This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

web/settings: wire pipeline secrets to the spindle xrpc

Adds a spindle service-auth client and wires the pipelines secrets section
(list/add/remove) to sh.tangled.repo.*Secret; spindle selection stays a
read-only display (it is a repo-record edit, not a spindle call).

Signed-off-by: Anirudh Oppiliappan <anirudh@tangled.org>

author
Anirudh Oppiliappan
date (Aug 4, 2026, 5:09 PM +0300) commit 458ea847 parent 540a9c8c change-id wvnktstu
+187 -44
+58
web/src/lib/api/spindle.ts
··· 1 + import type { OAuthUserAgent } from "@atcute/oauth-browser-client"; 2 + import { 3 + createAppviewClient, 4 + authedGet, 5 + authedPost, 6 + type AppviewContext 7 + } from "./appview"; 8 + import type { XrpcRequestInit } from "./client"; 9 + 10 + // The spindle is a service-auth'd xrpc host just like the appview: the browser 11 + // mints a per-call token with aud = did:web:<spindle host> and lxm = the method. 12 + // Secrets live on the spindle (the CI runner needs them) and are keyed by the 13 + // repo's at-uri, which the spindle resolves to a repoDid. 14 + export type SpindleContext = AppviewContext; 15 + 16 + export const createSpindleClient = ( 17 + host: string, 18 + agent: OAuthUserAgent, 19 + fetch?: typeof globalThis.fetch 20 + ): SpindleContext => createAppviewClient({ apiUrl: `https://${host}`, agent, fetch }); 21 + 22 + // mirrors sh.tangled.repo.listSecrets#secret — values are never returned. 23 + export interface Secret { 24 + repo: string; 25 + key: string; 26 + createdAt: string; 27 + createdBy: string; 28 + } 29 + 30 + const LIST_SECRETS = "sh.tangled.repo.listSecrets"; 31 + const ADD_SECRET = "sh.tangled.repo.addSecret"; 32 + const REMOVE_SECRET = "sh.tangled.repo.removeSecret"; 33 + 34 + export const listSecrets = async ( 35 + ctx: SpindleContext, 36 + repoUri: string, 37 + init?: XrpcRequestInit 38 + ): Promise<Secret[]> => { 39 + const res = await authedGet<{ secrets?: Secret[] }>(ctx, LIST_SECRETS, { repo: repoUri }, init); 40 + return res.secrets ?? []; 41 + }; 42 + 43 + export const addSecret = ( 44 + ctx: SpindleContext, 45 + repoUri: string, 46 + key: string, 47 + value: string, 48 + init?: XrpcRequestInit 49 + ): Promise<void> => 50 + authedPost(ctx, ADD_SECRET, { repo: repoUri, key, value }, init).then(() => undefined); 51 + 52 + export const removeSecret = ( 53 + ctx: SpindleContext, 54 + repoUri: string, 55 + key: string, 56 + init?: XrpcRequestInit 57 + ): Promise<void> => 58 + authedPost(ctx, REMOVE_SECRET, { repo: repoUri, key }, init).then(() => undefined);
+100 -40
web/src/routes/[handle]/[repo]/settings/pipelines/+page.svelte
··· 1 1 <script lang="ts"> 2 - import { untrack } from "svelte"; 3 2 import { resolve } from "$app/paths"; 3 + import { getAuth } from "$lib/auth.svelte"; 4 + import { createSpindleClient, listSecrets, removeSecret, type Secret } from "$lib/api/spindle"; 5 + import { createLoad, createAction } from "$lib/action.svelte"; 4 6 import Avatar from "$lib/components/ui/Avatar.svelte"; 5 7 import Button from "$lib/components/ui/Button.svelte"; 6 8 import Select from "$lib/components/ui/Select.svelte"; 9 + import Skeleton from "$lib/components/ui/Skeleton.svelte"; 10 + import ErrorAlert from "$lib/components/ui/Error.svelte"; 11 + import TimeAgo from "$lib/components/ui/TimeAgo.svelte"; 7 12 import SettingsBlock from "$lib/components/settings/SettingsBlock.svelte"; 13 + import SettingsEmpty from "$lib/components/settings/SettingsEmpty.svelte"; 8 14 import SettingsList from "$lib/components/settings/SettingsList.svelte"; 9 15 import SettingsRow from "$lib/components/settings/SettingsRow.svelte"; 10 16 import DocsButton from "$lib/components/settings/DocsButton.svelte"; ··· 13 19 14 20 let { data } = $props(); 15 21 22 + const auth = getAuth(); 16 23 const base = $derived(`/${data.repo.ownerHandle}/${data.repo.name}/settings`); 17 24 const path = (p: string) => resolve(p as "/"); 18 25 19 - // mocked — spindle selection and secrets are not wired up yet 20 - let spindle = $state(untrack(() => data.repo.spindle) ?? "spindle.tangled.sh"); 21 - const spindles = $derived([spindle, "spindle.user.tld"]); 26 + const spindleHost = $derived(data.repo.spindle); 27 + const repoUri = $derived(data.repo.uri); 28 + 29 + // spindle selection is stored on the repo record, not the spindle — changing 30 + // it is a pds write, out of scope here. show the configured spindle read-only. 31 + const spindleOptions = $derived(spindleHost ? [{ value: spindleHost }] : []); 22 32 23 - let secrets = $state([ 24 - { 25 - name: "generic_secret", 26 - added: "Added 5 hours ago by", 27 - by: untrack(() => data.repo.ownerHandle) 28 - } 29 - ]); 33 + // secrets live on the spindle; load them if one is configured. 34 + const loaded = createLoad(async (): Promise<Secret[]> => { 35 + const agent = auth.agent; 36 + const host = spindleHost; 37 + const uri = repoUri; 38 + if (!agent || !host || !uri) return []; 39 + return listSecrets(createSpindleClient(host, agent), uri); 40 + }); 30 41 31 - const remove = (name: string) => { 32 - secrets = secrets.filter((s) => s.name !== name); 42 + const remove = createAction(async (secret: Secret) => { 43 + const agent = auth.agent; 44 + const host = spindleHost; 45 + const uri = repoUri; 46 + if (!agent || !host || !uri) throw new Error("No spindle is configured for this repository."); 47 + await removeSecret(createSpindleClient(host, agent), uri, secret.key); 48 + loaded.update((list) => list.filter((s) => s.key !== secret.key)); 49 + }); 50 + 51 + const confirmRemove = (secret: Secret) => { 52 + if (remove.loading) return; 53 + if (!confirm(`Delete the secret ${secret.key}?`)) return; 54 + void remove.run(secret); 33 55 }; 34 56 </script> 35 57 ··· 51 73 52 74 <SettingsList> 53 75 <SettingsRow title="Spindle" stack> 54 - <Select 55 - bind:value={spindle} 56 - options={spindles.map((option) => ({ value: option }))} 57 - label="Spindle" 58 - class="w-full sm:w-80" 59 - /> 76 + {#if spindleHost} 77 + <Select 78 + value={spindleHost} 79 + options={spindleOptions} 80 + label="Spindle" 81 + disabled 82 + class="w-full sm:w-80" 83 + /> 84 + {:else} 85 + <SettingsEmpty message="No spindle configured for this repository." /> 86 + {/if} 60 87 </SettingsRow> 61 88 </SettingsList> 62 89 </SettingsBlock> ··· 70 97 separator 71 98 > 72 99 {#snippet action()} 73 - <Button icon={Plus} href={path(`${base}/pipelines/secrets/new`)}>Add secret</Button> 100 + <Button icon={Plus} href={path(`${base}/pipelines/secrets/new`)} disabled={!spindleHost}> 101 + Add secret 102 + </Button> 74 103 {/snippet} 75 104 76 - <SettingsList> 77 - {#each secrets as secret (secret.name)} 78 - <SettingsRow stack> 79 - {#snippet label()} 80 - <span class="flex min-w-0 flex-col gap-1"> 81 - <span class="font-mono typography-monospace-regular break-all text-foreground-default"> 82 - {secret.name} 83 - </span> 84 - <span 85 - class="flex items-center gap-1.5 typography-paragraph-regular text-foreground-muted" 86 - > 87 - {secret.added} 88 - <Avatar handle={secret.by} size="small" /> 89 - {secret.by} 105 + {#if loaded.error} 106 + <ErrorAlert label={loaded.error} /> 107 + {/if} 108 + {#if remove.error} 109 + <ErrorAlert label={remove.error} /> 110 + {/if} 111 + 112 + {#if !spindleHost} 113 + <SettingsEmpty message="Configure a spindle to manage secrets." /> 114 + {:else if loaded.loading} 115 + <SettingsList> 116 + {#each [0, 1] as row (row)} 117 + <div class="flex w-full items-center justify-between gap-4"> 118 + <div class="flex flex-col gap-1.5"> 119 + <Skeleton class="h-4 w-40 rounded-sm" /> 120 + <Skeleton class="h-3 w-48 rounded-sm" /> 121 + </div> 122 + <Skeleton class="h-8 w-20 shrink-0 rounded-sm" /> 123 + </div> 124 + {/each} 125 + </SettingsList> 126 + {:else if (loaded.data?.length ?? 0) === 0} 127 + <SettingsEmpty message="No secrets yet" /> 128 + {:else if loaded.data} 129 + <SettingsList> 130 + {#each loaded.data as secret (secret.key)} 131 + <SettingsRow stack> 132 + {#snippet label()} 133 + <span class="flex min-w-0 flex-col gap-1"> 134 + <span class="typography-monospace-regular font-mono break-all text-foreground-default"> 135 + {secret.key} 136 + </span> 137 + <span 138 + class="flex items-center gap-1.5 typography-paragraph-regular text-foreground-muted" 139 + > 140 + Added <TimeAgo value={secret.createdAt} variant="full" /> 141 + <Avatar did={secret.createdBy} size="small" /> 142 + </span> 90 143 </span> 91 - </span> 92 - {/snippet} 93 - <Button variant="danger" icon={Trash} onclick={() => remove(secret.name)}>Delete</Button> 94 - </SettingsRow> 95 - {/each} 96 - </SettingsList> 144 + {/snippet} 145 + <Button 146 + variant="danger" 147 + icon={Trash} 148 + loading={remove.loading && remove.args?.[0].key === secret.key} 149 + onclick={() => confirmRemove(secret)} 150 + > 151 + Delete 152 + </Button> 153 + </SettingsRow> 154 + {/each} 155 + </SettingsList> 156 + {/if} 97 157 </SettingsBlock>
+29 -4
web/src/routes/[handle]/[repo]/settings/pipelines/secrets/new/+page.svelte
··· 1 1 <script lang="ts"> 2 2 import { goto } from "$app/navigation"; 3 3 import { resolve } from "$app/paths"; 4 + import { getAuth } from "$lib/auth.svelte"; 5 + import { createSpindleClient, addSecret } from "$lib/api/spindle"; 6 + import { createAction } from "$lib/action.svelte"; 4 7 import Button from "$lib/components/ui/Button.svelte"; 5 8 import Input from "$lib/components/ui/Input.svelte"; 9 + import ErrorAlert from "$lib/components/ui/Error.svelte"; 6 10 import DrillDown from "$lib/components/settings/DrillDown.svelte"; 7 11 import SettingsList from "$lib/components/settings/SettingsList.svelte"; 8 12 import FormRow from "$lib/components/settings/FormRow.svelte"; ··· 12 16 13 17 let { data } = $props(); 14 18 19 + const auth = getAuth(); 15 20 const back = $derived(`/${data.repo.ownerHandle}/${data.repo.name}/settings/pipelines`); 21 + const spindleHost = $derived(data.repo.spindle); 22 + const repoUri = $derived(data.repo.uri); 16 23 17 24 let name = $state(""); 18 25 let value = $state(""); 19 - const valid = $derived(name.trim().length > 0 && value.length > 0); 26 + const valid = $derived(name.trim().length > 0 && value.length > 0 && !!spindleHost); 20 27 21 - // mocked — secrets are not wired up yet 22 - const submit = () => goto(resolve(back as "/")); 28 + const create = createAction(async () => { 29 + const agent = auth.agent; 30 + const host = spindleHost; 31 + const uri = repoUri; 32 + if (!agent || !host || !uri) throw new Error("No spindle is configured for this repository."); 33 + await addSecret(createSpindleClient(host, agent), uri, name.trim(), value); 34 + await goto(resolve(back as "/")); 35 + }); 23 36 </script> 24 37 25 38 <DrillDown ··· 28 41 title="Add secret" 29 42 description="Secrets are available as environment variables in the workflow." 30 43 > 44 + {#if create.error} 45 + <ErrorAlert label={create.error} /> 46 + {/if} 47 + 31 48 <SettingsList padding="tight"> 32 49 <FormRow label="Name" for="secret-name"> 33 50 <Input id="secret-name" bind:value={name} placeholder="SECRET_NAME" /> ··· 39 56 40 57 <FormActions> 41 58 <Button href={resolve(back as "/")} icon={X}>Cancel</Button> 42 - <Button variant="primary" icon={Plus} disabled={!valid} onclick={submit}>Add</Button> 59 + <Button 60 + variant="primary" 61 + icon={Plus} 62 + loading={create.loading} 63 + disabled={!valid} 64 + onclick={create.run} 65 + > 66 + Add 67 + </Button> 43 68 </FormActions> 44 69 </DrillDown>