~ knotless Search/
↑↓ move↵ open⇧↵ all results

johnandersen777.bsky.social/knotless

git that lives in your Atmosphere account

Forked from chadtmiller.com/knotless

0
0
221 lines · 7.0 KB Raw History
<script lang="ts">
	import type { ActorView } from '#lib/lexicons/dev/knotless/defs.ts';
	import type { RunnerKey } from '#lib/lexicons/dev/knotless/listRunners.defs.ts';
	import type { Secret } from '#lib/lexicons/dev/knotless/listSecrets.defs.ts';
	import type * as putSecret from '#lib/lexicons/dev/knotless/putSecret.ts';
	import type * as deleteSecret from '#lib/lexicons/dev/knotless/deleteSecret.ts';
	import type { DidString } from '@atproto/lex';
	import { untrack } from 'svelte';
	import { invalidateAll } from '$app/navigation';
	import { call } from '#lib/data.ts';
	import { seal } from '#lib/sealed.ts';
	import Avatar from '#lib/atoms/Avatar.svelte';
	import Time from '#lib/atoms/Time.svelte';
	import Empty from '#lib/molecules/Empty.svelte';
	/**
	 * The secrets the repository of `owner` with the key `repo` gives its
	 * `runners`, as `knotless secret` does: each sealed in this browser to
	 * the key a runner publishes (`keys`), so only that runner reads it and
	 * the server holds only ciphertext. Listed by name, never a value.
	 */
	let {
		owner,
		repo,
		runners,
		keys,
		secrets
	}: { owner: string; repo: string; runners: ActorView[]; keys: RunnerKey[]; secrets: Secret[] } =
		$props();
	const keyOf = (did: string) => keys.find((k) => k.runner === did)?.key;
	const actorOf = (did: string) => runners.find((r) => r.did === did);
	/** Each name once, with the runners it is sealed to and when it was last set. */
	const named = $derived.by(() => {
		const all = new Map<string, { refs: string[]; runners: string[]; at: string }>();
		for (const s of secrets) {
			const one = all.get(s.name) ?? {
				refs: (s.refs ?? []).map((r) => r.pattern),
				runners: [],
				at: s.updatedAt
			};
			one.runners.push(s.runner);
			if (s.updatedAt > one.at) one.at = s.updatedAt;
			all.set(s.name, one);
		}
		return [...all].map(([name, one]) => ({ name, ...one }));
	});
	let name = $state('');
	let value = $state('');
	let refs = $state('');
	const sealable = $derived(runners.filter((r) => keyOf(r.did)));
	// Every runner it can be sealed to, until the owner picks.
	let chosen = $state<string[]>(untrack(() => sealable.map((r) => r.did)));
	const targets = $derived(runners.length > 1 ? chosen : sealable.map((r) => r.did));
	let problem = $state('');
	// What is under way: 'save', or the name being removed.
	let busy = $state('');
	async function doing(action: string, act: () => Promise<unknown>) {
		busy = action;
		problem = '';
		try {
			await act();
			await invalidateAll();
		} catch (err) {
			problem = (err as Error).message;
		}
		busy = '';
	}
	function save(e: SubmitEvent) {
		e.preventDefault();
		const limits = refs.split(/[\s,]+/).filter(Boolean);
		// Without the one line ending a paste or a file leaves, as the command line takes it.
		const text = value.replace(/\r?\n$/, '');
		doing('save', async () => {
			for (const runner of targets) {
				const key = keyOf(runner) as DidString;
				const sealed = await seal(key, { owner, repo, runner, name, refs: limits }, text);
				const put = {
					owner: owner as DidString,
					repo,
					runner: runner as DidString,
					name,
					refs: limits.map((pattern) => ({ pattern })),
					key,
					sealed
				};
				await call<typeof putSecret.main>('dev.knotless.putSecret', put);
			}
			const had = named.find((n) => n.name === name)?.runners ?? [];
			for (const runner of had.filter((r) => !targets.includes(r)))
				await call<typeof deleteSecret.main>('dev.knotless.deleteSecret', {
					owner: owner as DidString,
					repo,
					name,
					runner: runner as DidString
				});
			name = value = refs = '';
		});
	}
	function remove(secret: string) {
		if (!confirm(`Remove ${secret}? Workflows that list it get it no more.`)) return;
		doing(`remove ${secret}`, () =>
			call<typeof deleteSecret.main>('dev.knotless.deleteSecret', {
				owner: owner as DidString,
				repo,
				name: secret
			})
		);
	}
</script>

<div class="box secret-list">
	<div class="boxhead">
		<b>Secrets</b><span class="mute"
			>Each is sealed in this browser to the runners' keys, so only they can read it. Workflows get
			those they list in <code>secrets</code>, on pushes alone.</span
		>
	</div>
	{#if named.length}
		<ul class="list">
			{#each named as s (s.name)}
				<li>
					<span class="secret-what"
						><code>{s.name}</code><span class="mute"
							>{s.refs.length ? s.refs.join(', ') : 'Every push'} · for {s.runners
								.map((did) => actorOf(did)?.handle ?? did)
								.join(', ')} · <Time at={s.at} /></span
						></span
					>
					<button
						class="btn end"
						type="button"
						disabled={!!busy}
						aria-busy={busy === `remove ${s.name}`}
						onclick={() => remove(s.name)}
						>{busy === `remove ${s.name}` ? 'Removing…' : 'Remove'}</button
					>
				</li>
			{/each}
		</ul>
	{:else}
		<Empty title="No secrets yet">Workflows that list secrets get none until one is added.</Empty>
	{/if}
	{#if !runners.length}
		<p class="boxfoot note mute">Add a runner above to give it secrets.</p>
	{:else if !sealable.length}
		<p class="boxfoot note mute">
			No runner here publishes a key to seal secrets to. Update knotless-run on one, and it does.
		</p>
	{:else}
		<form class="boxfoot form secret-form" onsubmit={save}>
			<label for="secret-name"
				>{named.some((n) => n.name === name) ? `Replace ${name}` : 'New secret'}</label
			>
			<input
				id="secret-name"
				name="name"
				maxlength="64"
				pattern="[A-Za-z_][A-Za-z0-9_]*"
				placeholder="DEPLOY_TOKEN"
				autocapitalize="none"
				autocomplete="off"
				spellcheck="false"
				required
				disabled={!!busy}
				bind:value={name}
			/>
			<label for="secret-value">Value</label>
			<textarea
				id="secret-value"
				class="secret-value"
				name="value"
				rows="3"
				maxlength="16384"
				autocomplete="off"
				spellcheck="false"
				required
				disabled={!!busy}
				bind:value></textarea>
			<span class="hint mute">Once added, it can be replaced or removed but never read again.</span>
			<label for="secret-refs">Refs <span class="mute">(optional)</span></label>
			<input
				id="secret-refs"
				name="refs"
				placeholder="refs/heads/main"
				autocapitalize="none"
				spellcheck="false"
				disabled={!!busy}
				bind:value={refs}
			/>
			<span class="hint mute">Only pushes to these refs get it. Leave it empty for every push.</span
			>
			{#if runners.length > 1}
				<fieldset class="secret-runners">
					<legend class="label-like">Runners</legend>
					{#each runners as r (r.did)}
						<label class="check"
							><input
								type="checkbox"
								value={r.did}
								disabled={!!busy || !keyOf(r.did)}
								bind:group={chosen}
							/><Avatar
								name={r.handle}
								did={r.did}
								src={r.avatar}
							/>{r.handle}{#if !keyOf(r.did)}<span class="mute">publishes no key</span>{/if}</label
						>
					{/each}
				</fieldset>
			{/if}
			<div class="actions">
				<button
					class="btn"
					type="submit"
					disabled={!!busy || !targets.length}
					aria-busy={busy === 'save'}>{busy === 'save' ? 'Sealing…' : 'Add secret'}</button
				>
			</div>
		</form>
	{/if}
	{#if problem}<p class="problem" role="alert">{problem}</p>{/if}
</div>