johnandersen777.bsky.social/knotless
git that lives in your Atmosphere account
Forked from chadtmiller.com/knotless
0
0
<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>