Webhooks for the AT Protocol airglow.run
atproto atprotocol automation webhook
12
fork

Configure Feed

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

feat: encrypted user secrets for webhook actions

Add AES-256-GCM encrypted secret storage so users can store API keys,
auth tokens, and JWTs that are injected into webhook HTTP headers at
dispatch time. Secrets are never returned in API responses or logs.

Backend:
- Per-user key derivation via HKDF-SHA256 (SECRETS_KEY env var)
- CRUD API at /api/secrets with audit logging
- {{secret:name}} references in webhook custom headers
- Header validation, sanitization, template namespace guard
- Graceful degradation when SECRETS_KEY is not set

UI:
- Dashboard page at /dashboard/secrets with SecretsManager island
- Custom headers section in webhook action editor
- Nav link to Secrets in header

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Hugo 035f09ea f059cc6c

+1570 -27
+1
.env.example
··· 4 4 PDS_URL=http://localhost:3000 5 5 JETSTREAM_URL=wss://jetstream2.us-east.bsky.network/subscribe 6 6 COOKIE_SECRET= # openssl rand -base64 32 7 + SECRETS_KEY= # optional, enables encrypted user secrets. openssl rand -base64 32 7 8 NSID_ALLOWLIST= 8 9 NSID_BLOCKLIST=
+3
app/components/Layout/Header/index.tsx
··· 19 19 <a href="/dashboard" class={s.navLink}> 20 20 Dashboard 21 21 </a> 22 + <a href="/dashboard/secrets" class={s.navLink}> 23 + Secrets 24 + </a> 22 25 <span class={s.userInfo}>@{user.handle}</span> 23 26 <form method="post" action="/auth/signout"> 24 27 <button type="submit" class={s.signOutButton}>
+90 -17
app/islands/AutomationForm.tsx
··· 20 20 21 21 type FetchDraft = { name: string; uri: string; comment: string }; 22 22 23 - type WebhookDraft = { type: "webhook"; callbackUrl: string; comment: string }; 23 + type HeaderDraft = { key: string; value: string }; 24 + type WebhookDraft = { 25 + type: "webhook"; 26 + callbackUrl: string; 27 + headers: HeaderDraft[]; 28 + comment: string; 29 + }; 24 30 type RecordDraft = { 25 31 type: "record"; 26 32 targetCollection: string; ··· 86 92 action: WebhookDraft; 87 93 onChange: (a: WebhookDraft) => void; 88 94 }) { 95 + const updateHeader = (index: number, key: "key" | "value", val: string) => { 96 + const headers = action.headers.map((h, i) => (i === index ? { ...h, [key]: val } : h)); 97 + onChange({ ...action, headers }); 98 + }; 99 + const addHeader = () => { 100 + onChange({ ...action, headers: [...action.headers, { key: "", value: "" }] }); 101 + }; 102 + const removeHeader = (index: number) => { 103 + onChange({ ...action, headers: action.headers.filter((_, i) => i !== index) }); 104 + }; 105 + 89 106 return ( 90 - <div class={s.fieldGroup}> 91 - <label class={s.label}>Callback URL</label> 92 - <input 93 - class={s.input} 94 - type="url" 95 - placeholder="https://example.com/hooks/events" 96 - value={action.callbackUrl} 97 - onInput={(e: Event) => 98 - onChange({ ...action, callbackUrl: (e.target as HTMLInputElement).value }) 99 - } 100 - required 101 - /> 102 - </div> 107 + <> 108 + <div class={s.fieldGroup}> 109 + <label class={s.label}>Callback URL</label> 110 + <input 111 + class={s.input} 112 + type="url" 113 + placeholder="https://example.com/hooks/events" 114 + value={action.callbackUrl} 115 + onInput={(e: Event) => 116 + onChange({ ...action, callbackUrl: (e.target as HTMLInputElement).value }) 117 + } 118 + required 119 + /> 120 + </div> 121 + <div class={s.fieldGroup}> 122 + <label class={s.label}>Custom Headers</label> 123 + <span class={s.hint}> 124 + Use <code>{"{{secret:name}}"}</code> to reference stored secrets 125 + </span> 126 + {action.headers.map((header, i) => ( 127 + <div key={i} class={s.conditionRow}> 128 + <div class={s.conditionField}> 129 + <input 130 + class={s.input} 131 + type="text" 132 + placeholder="Authorization" 133 + value={header.key} 134 + onInput={(e: Event) => updateHeader(i, "key", (e.target as HTMLInputElement).value)} 135 + /> 136 + </div> 137 + <div class={s.conditionValue}> 138 + <input 139 + class={s.input} 140 + type="text" 141 + placeholder="Bearer {{secret:my-token}}" 142 + value={header.value} 143 + onInput={(e: Event) => 144 + updateHeader(i, "value", (e.target as HTMLInputElement).value) 145 + } 146 + /> 147 + </div> 148 + <button type="button" class={s.removeBtn} onClick={() => removeHeader(i)}> 149 + Remove 150 + </button> 151 + </div> 152 + ))} 153 + <button type="button" class={s.addBtn} onClick={addHeader}> 154 + + Add Header 155 + </button> 156 + </div> 157 + </> 103 158 ); 104 159 } 105 160 ··· 495 550 function toActionDrafts(actions: Action[]): ActionDraft[] { 496 551 return actions.map((a) => { 497 552 if (a.$type === "webhook") { 498 - return { type: "webhook", callbackUrl: a.callbackUrl, comment: a.comment ?? "" }; 553 + const headers: HeaderDraft[] = a.headers 554 + ? Object.entries(a.headers).map(([key, value]) => ({ key, value })) 555 + : []; 556 + return { type: "webhook", callbackUrl: a.callbackUrl, headers, comment: a.comment ?? "" }; 499 557 } 500 558 if (a.$type === "bsky-post") { 501 559 return { ··· 701 759 702 760 const addAction = useCallback((type: "webhook" | "record" | "bsky-post" | "patch-record") => { 703 761 if (type === "webhook") { 704 - setActions((prev) => [...prev, { type: "webhook", callbackUrl: "", comment: "" }]); 762 + setActions((prev) => [ 763 + ...prev, 764 + { type: "webhook", callbackUrl: "", headers: [], comment: "" }, 765 + ]); 705 766 } else if (type === "bsky-post") { 706 767 setActions((prev) => [ 707 768 ...prev, ··· 756 817 } 757 818 payload.actions = actions.map((a) => { 758 819 const comment = a.comment ? { comment: a.comment } : {}; 759 - if (a.type === "webhook") return { type: "webhook", callbackUrl: a.callbackUrl, ...comment }; 820 + if (a.type === "webhook") { 821 + const filtered = a.headers.filter((h) => h.key.trim() && h.value.trim()); 822 + const headers = 823 + filtered.length > 0 824 + ? Object.fromEntries(filtered.map((h) => [h.key.trim(), h.value.trim()])) 825 + : undefined; 826 + return { 827 + type: "webhook", 828 + callbackUrl: a.callbackUrl, 829 + ...(headers ? { headers } : {}), 830 + ...comment, 831 + }; 832 + } 760 833 if (a.type === "bsky-post") { 761 834 return { 762 835 type: "bsky-post",
+159
app/islands/SecretsManager.css.ts
··· 1 + import { style } from "@vanilla-extract/css"; 2 + import { vars } from "../styles/theme.css.ts"; 3 + import { space } from "../styles/tokens/spacing.ts"; 4 + import { fontSize, fontWeight } from "../styles/tokens/typography.ts"; 5 + import { radii } from "../styles/tokens/radii.ts"; 6 + 7 + export const wrapper = style({ 8 + display: "flex", 9 + flexDirection: "column", 10 + gap: space[5], 11 + }); 12 + 13 + export const form = style({ 14 + display: "flex", 15 + flexWrap: "wrap", 16 + gap: space[2], 17 + alignItems: "flex-end", 18 + }); 19 + 20 + export const fieldGroup = style({ 21 + display: "flex", 22 + flexDirection: "column", 23 + gap: space[1], 24 + flex: "1 1 180px", 25 + }); 26 + 27 + export const label = style({ 28 + fontSize: fontSize.sm, 29 + fontWeight: fontWeight.medium, 30 + color: vars.color.text, 31 + }); 32 + 33 + export const input = style({ 34 + width: "100%", 35 + paddingBlock: space[2], 36 + paddingInline: space[3], 37 + fontSize: fontSize.base, 38 + color: vars.color.text, 39 + backgroundColor: vars.color.bg, 40 + border: `1px solid ${vars.color.border}`, 41 + borderRadius: radii.md, 42 + "::placeholder": { 43 + color: vars.color.textMuted, 44 + }, 45 + ":focus": { 46 + borderColor: vars.color.accent, 47 + outline: "none", 48 + boxShadow: `0 0 0 3px ${vars.focus.ring}`, 49 + }, 50 + }); 51 + 52 + export const submitBtn = style({ 53 + paddingBlock: space[2], 54 + paddingInline: space[4], 55 + fontSize: fontSize.base, 56 + fontWeight: fontWeight.medium, 57 + color: vars.color.accentText, 58 + backgroundColor: vars.color.accent, 59 + border: "1px solid transparent", 60 + borderRadius: radii.md, 61 + cursor: "pointer", 62 + whiteSpace: "nowrap", 63 + ":hover": { 64 + backgroundColor: vars.color.accentHover, 65 + }, 66 + selectors: { 67 + "&:disabled": { 68 + opacity: 0.5, 69 + cursor: "not-allowed", 70 + }, 71 + }, 72 + }); 73 + 74 + export const secretsList = style({ 75 + display: "flex", 76 + flexDirection: "column", 77 + gap: 0, 78 + }); 79 + 80 + export const secretRow = style({ 81 + display: "flex", 82 + justifyContent: "space-between", 83 + alignItems: "center", 84 + paddingBlock: space[3], 85 + paddingInline: space[4], 86 + selectors: { 87 + "& + &": { 88 + borderBlockStart: `1px solid ${vars.color.borderSubtle}`, 89 + }, 90 + }, 91 + }); 92 + 93 + export const secretInfo = style({ 94 + display: "flex", 95 + flexDirection: "column", 96 + gap: space[1], 97 + }); 98 + 99 + export const secretName = style({ 100 + fontSize: fontSize.base, 101 + fontWeight: fontWeight.medium, 102 + fontFamily: "monospace", 103 + color: vars.color.text, 104 + }); 105 + 106 + export const secretMeta = style({ 107 + fontSize: fontSize.xs, 108 + color: vars.color.textMuted, 109 + }); 110 + 111 + export const deleteBtn = style({ 112 + paddingBlock: space[1], 113 + paddingInline: space[3], 114 + fontSize: fontSize.sm, 115 + color: vars.color.error, 116 + backgroundColor: "transparent", 117 + border: `1px solid ${vars.color.border}`, 118 + borderRadius: radii.md, 119 + cursor: "pointer", 120 + whiteSpace: "nowrap", 121 + ":hover": { 122 + backgroundColor: vars.color.errorSubtle, 123 + borderColor: vars.color.error, 124 + }, 125 + }); 126 + 127 + export const alertError = style({ 128 + paddingBlock: space[3], 129 + paddingInline: space[4], 130 + borderRadius: radii.md, 131 + fontSize: fontSize.sm, 132 + borderInlineStart: "3px solid", 133 + backgroundColor: vars.color.errorSubtle, 134 + color: vars.color.error, 135 + borderColor: vars.color.error, 136 + }); 137 + 138 + export const alertSuccess = style({ 139 + paddingBlock: space[3], 140 + paddingInline: space[4], 141 + borderRadius: radii.md, 142 + fontSize: fontSize.sm, 143 + borderInlineStart: "3px solid", 144 + backgroundColor: vars.color.successSubtle, 145 + color: vars.color.success, 146 + borderColor: vars.color.success, 147 + }); 148 + 149 + export const emptyState = style({ 150 + textAlign: "center", 151 + paddingBlock: space[6], 152 + color: vars.color.textMuted, 153 + fontSize: fontSize.sm, 154 + }); 155 + 156 + export const hint = style({ 157 + fontSize: fontSize.xs, 158 + color: vars.color.textMuted, 159 + });
+163
app/islands/SecretsManager.tsx
··· 1 + import { useState, useCallback } from "hono/jsx"; 2 + import * as s from "./SecretsManager.css.ts"; 3 + 4 + type SecretEntry = { 5 + name: string; 6 + createdAt: number; 7 + updatedAt: number; 8 + }; 9 + 10 + function formatDate(ts: number): string { 11 + return new Date(ts).toLocaleDateString(undefined, { 12 + year: "numeric", 13 + month: "short", 14 + day: "numeric", 15 + hour: "2-digit", 16 + minute: "2-digit", 17 + }); 18 + } 19 + 20 + export default function SecretsManager({ initial }: { initial: SecretEntry[] }) { 21 + const [secrets, setSecrets] = useState<SecretEntry[]>(initial); 22 + const [name, setName] = useState(""); 23 + const [value, setValue] = useState(""); 24 + const [submitting, setSubmitting] = useState(false); 25 + const [error, setError] = useState(""); 26 + const [success, setSuccess] = useState(""); 27 + const [deleting, setDeleting] = useState<string | null>(null); 28 + 29 + const handleSubmit = useCallback( 30 + async (e: Event) => { 31 + e.preventDefault(); 32 + setError(""); 33 + setSuccess(""); 34 + setSubmitting(true); 35 + try { 36 + const res = await fetch("/api/secrets", { 37 + method: "POST", 38 + headers: { "Content-Type": "application/json" }, 39 + body: JSON.stringify({ name: name.trim(), value }), 40 + }); 41 + const data = await res.json(); 42 + if (!res.ok) { 43 + setError(data.error || "Failed to save secret"); 44 + } else { 45 + const isUpdate = secrets.some((s) => s.name === name.trim()); 46 + if (isUpdate) { 47 + setSecrets((prev) => 48 + prev.map((s) => (s.name === name.trim() ? { ...s, updatedAt: Date.now() } : s)), 49 + ); 50 + setSuccess(`Secret "${name.trim()}" updated`); 51 + } else { 52 + setSecrets((prev) => 53 + [...prev, { name: name.trim(), createdAt: Date.now(), updatedAt: Date.now() }].sort( 54 + (a, b) => a.name.localeCompare(b.name), 55 + ), 56 + ); 57 + setSuccess(`Secret "${name.trim()}" created`); 58 + } 59 + setName(""); 60 + setValue(""); 61 + } 62 + } catch { 63 + setError("Request failed"); 64 + } finally { 65 + setSubmitting(false); 66 + } 67 + }, 68 + [name, value, secrets], 69 + ); 70 + 71 + const handleDelete = useCallback(async (secretName: string) => { 72 + setError(""); 73 + setSuccess(""); 74 + setDeleting(secretName); 75 + try { 76 + const res = await fetch(`/api/secrets/${encodeURIComponent(secretName)}`, { 77 + method: "DELETE", 78 + }); 79 + if (!res.ok) { 80 + const data = await res.json(); 81 + setError(data.error || "Failed to delete secret"); 82 + } else { 83 + setSecrets((prev) => prev.filter((s) => s.name !== secretName)); 84 + setSuccess(`Secret "${secretName}" deleted`); 85 + } 86 + } catch { 87 + setError("Request failed"); 88 + } finally { 89 + setDeleting(null); 90 + } 91 + }, []); 92 + 93 + return ( 94 + <div class={s.wrapper}> 95 + {error && <div class={s.alertError}>{error}</div>} 96 + {success && <div class={s.alertSuccess}>{success}</div>} 97 + 98 + <form class={s.form} onSubmit={handleSubmit}> 99 + <div class={s.fieldGroup}> 100 + <label class={s.label} for="secret-name"> 101 + Name 102 + </label> 103 + <input 104 + id="secret-name" 105 + class={s.input} 106 + type="text" 107 + placeholder="my-api-key" 108 + value={name} 109 + onInput={(e: Event) => setName((e.target as HTMLInputElement).value)} 110 + pattern="^[a-zA-Z][a-zA-Z0-9_-]*$" 111 + maxLength={64} 112 + required 113 + /> 114 + </div> 115 + <div class={s.fieldGroup}> 116 + <label class={s.label} for="secret-value"> 117 + Value 118 + </label> 119 + <input 120 + id="secret-value" 121 + class={s.input} 122 + type="password" 123 + placeholder="sk-..." 124 + value={value} 125 + onInput={(e: Event) => setValue((e.target as HTMLInputElement).value)} 126 + required 127 + /> 128 + </div> 129 + <button type="submit" class={s.submitBtn} disabled={submitting || !name.trim() || !value}> 130 + {submitting ? "Saving..." : "Save secret"} 131 + </button> 132 + </form> 133 + 134 + <p class={s.hint}> 135 + Reference secrets in webhook headers as <code>{"{{secret:name}}"}</code>. Values are 136 + encrypted and never shown again. 137 + </p> 138 + 139 + {secrets.length === 0 ? ( 140 + <div class={s.emptyState}>No secrets yet.</div> 141 + ) : ( 142 + <div class={s.secretsList}> 143 + {secrets.map((secret) => ( 144 + <div key={secret.name} class={s.secretRow}> 145 + <div class={s.secretInfo}> 146 + <span class={s.secretName}>{secret.name}</span> 147 + <span class={s.secretMeta}>Updated {formatDate(secret.updatedAt)}</span> 148 + </div> 149 + <button 150 + type="button" 151 + class={s.deleteBtn} 152 + disabled={deleting === secret.name} 153 + onClick={() => handleDelete(secret.name)} 154 + > 155 + {deleting === secret.name ? "Deleting..." : "Delete"} 156 + </button> 157 + </div> 158 + ))} 159 + </div> 160 + )} 161 + </div> 162 + ); 163 + }
+13
app/routes/api/automations/[rkey].ts
··· 34 34 VALID_OPERATIONS, 35 35 VALID_BSKY_LABELS, 36 36 BCP47_RE, 37 + validateWebhookHeaders, 37 38 } from "@/actions/validation.js"; 38 39 import { notifyAutomationChange } from "@/jetstream/consumer.js"; 39 40 ··· 70 71 ? { 71 72 $type: a.$type, 72 73 callbackUrl: a.callbackUrl, 74 + ...(a.headers ? { headers: a.headers } : {}), 73 75 verified: a.verified ?? false, 74 76 comment: a.comment, 75 77 } ··· 226 228 return c.json({ error: message }, 400); 227 229 } 228 230 231 + // Validate custom headers if provided 232 + if (input.headers && Object.keys(input.headers).length > 0) { 233 + const headersValidation = validateWebhookHeaders(input.headers); 234 + if (!headersValidation.valid) { 235 + return c.json({ error: headersValidation.error }, 400); 236 + } 237 + } 238 + 229 239 // Verify callback (non-blocking — stores verified status) 230 240 const verification = await verifyCallback(input.callbackUrl, auto.lexicon); 231 241 ··· 234 244 (a): a is WebhookAction => a.$type === "webhook" && a.callbackUrl === input.callbackUrl, 235 245 ); 236 246 const secret = existing?.secret ?? nanoid(32); 247 + const headers = 248 + input.headers && Object.keys(input.headers).length > 0 ? input.headers : undefined; 237 249 238 250 newLocalActions.push({ 239 251 $type: "webhook", 240 252 callbackUrl: input.callbackUrl, 241 253 secret, 254 + ...(headers ? { headers } : {}), 242 255 verified: verification.ok, 243 256 ...(input.comment ? { comment: input.comment } : {}), 244 257 } satisfies WebhookAction);
+13
app/routes/api/automations/index.ts
··· 33 33 VALID_OPERATIONS, 34 34 VALID_BSKY_LABELS, 35 35 BCP47_RE, 36 + validateWebhookHeaders, 36 37 } from "@/actions/validation.js"; 37 38 import { notifyAutomationChange } from "@/jetstream/consumer.js"; 38 39 ··· 54 55 ? { 55 56 $type: a.$type, 56 57 callbackUrl: a.callbackUrl, 58 + ...(a.headers ? { headers: a.headers } : {}), 57 59 verified: a.verified ?? false, 58 60 comment: a.comment, 59 61 } ··· 187 189 return c.json({ error: message }, 400); 188 190 } 189 191 192 + // Validate custom headers if provided 193 + if (input.headers && Object.keys(input.headers).length > 0) { 194 + const headersValidation = validateWebhookHeaders(input.headers); 195 + if (!headersValidation.valid) { 196 + return c.json({ error: headersValidation.error }, 400); 197 + } 198 + } 199 + 190 200 const verification = await verifyCallback(input.callbackUrl, body.lexicon); 191 201 192 202 const secret = nanoid(32); 203 + const headers = 204 + input.headers && Object.keys(input.headers).length > 0 ? input.headers : undefined; 193 205 localActions.push({ 194 206 $type: "webhook", 195 207 callbackUrl: input.callbackUrl, 196 208 secret, 209 + ...(headers ? { headers } : {}), 197 210 verified: verification.ok, 198 211 ...(input.comment ? { comment: input.comment } : {}), 199 212 } satisfies WebhookAction);
+19
app/routes/api/secrets/[name].ts
··· 1 + import { createRoute } from "honox/factory"; 2 + import { config } from "@/config.js"; 3 + import { remove } from "@/secrets/store.js"; 4 + 5 + export const DELETE = createRoute(async (c) => { 6 + if (!config.secretsKey) { 7 + return c.json({ error: "Secrets feature is not configured on this instance" }, 501); 8 + } 9 + 10 + const user = c.get("user"); 11 + const name = c.req.param("name")!; 12 + 13 + const existed = await remove(user.did, name); 14 + if (!existed) { 15 + return c.json({ error: "Secret not found" }, 404); 16 + } 17 + 18 + return c.json({ ok: true }); 19 + });
+11
app/routes/api/secrets/_middleware.ts
··· 1 + import { createMiddleware } from "hono/factory"; 2 + import { getSessionUser } from "@/auth/middleware.js"; 3 + 4 + export default [ 5 + createMiddleware(async (c, next) => { 6 + const user = await getSessionUser(c); 7 + if (!user) return c.json({ error: "Unauthorized" }, 401); 8 + c.set("user", user); 9 + return next(); 10 + }), 11 + ];
+51
app/routes/api/secrets/index.ts
··· 1 + import { createRoute } from "honox/factory"; 2 + import { config } from "@/config.js"; 3 + import { createOrUpdate, list, validateSecretName } from "@/secrets/store.js"; 4 + 5 + export const GET = createRoute(async (c) => { 6 + if (!config.secretsKey) { 7 + return c.json({ error: "Secrets feature is not configured on this instance" }, 501); 8 + } 9 + 10 + const user = c.get("user"); 11 + const secrets = await list(user.did); 12 + 13 + return c.json( 14 + secrets.map((s) => ({ 15 + name: s.name, 16 + createdAt: s.createdAt.getTime(), 17 + updatedAt: s.updatedAt.getTime(), 18 + })), 19 + ); 20 + }); 21 + 22 + export const POST = createRoute(async (c) => { 23 + if (!config.secretsKey) { 24 + return c.json({ error: "Secrets feature is not configured on this instance" }, 501); 25 + } 26 + 27 + const user = c.get("user"); 28 + const body = await c.req.json<{ name: string; value: string }>(); 29 + 30 + if (!body.name || typeof body.name !== "string") { 31 + return c.json({ error: "name is required" }, 400); 32 + } 33 + const nameError = validateSecretName(body.name); 34 + if (nameError) { 35 + return c.json({ error: nameError }, 400); 36 + } 37 + if (!body.value || typeof body.value !== "string") { 38 + return c.json({ error: "value is required" }, 400); 39 + } 40 + if (Buffer.byteLength(body.value, "utf8") > 8192) { 41 + return c.json({ error: "Secret value must be 8KB or less" }, 400); 42 + } 43 + 44 + try { 45 + const result = await createOrUpdate(user.did, body.name, body.value); 46 + return c.json({ ok: true, name: body.name }, result === "created" ? 201 : 200); 47 + } catch (err) { 48 + const message = err instanceof Error ? err.message : "Failed to save secret"; 49 + return c.json({ error: message }, 400); 50 + } 51 + });
+57
app/routes/dashboard/secrets.tsx
··· 1 + import { createRoute } from "honox/factory"; 2 + import { config } from "@/config.js"; 3 + import { list } from "@/secrets/store.js"; 4 + import { AppShell } from "../../components/Layout/AppShell/index.js"; 5 + import { Header } from "../../components/Layout/Header/index.js"; 6 + import { Container } from "../../components/Layout/Container/index.js"; 7 + import { PageHeader } from "../../components/Layout/PageHeader/index.js"; 8 + import { Card } from "../../components/Card/index.js"; 9 + import ThemeToggle from "../../islands/ThemeToggle.js"; 10 + import SecretsManager from "../../islands/SecretsManager.js"; 11 + import { centerTextSm } from "../../styles/utilities.css.js"; 12 + 13 + export default createRoute(async (c) => { 14 + const user = c.get("user"); 15 + 16 + if (!config.secretsKey) { 17 + return c.render( 18 + <AppShell header={<Header user={user} actions={<ThemeToggle />} />}> 19 + <Container> 20 + <PageHeader title="Secrets" description="Encrypted storage for API keys and tokens" /> 21 + <Card variant="flat"> 22 + <div class={centerTextSm}> 23 + <p>Secrets are not configured on this instance.</p> 24 + <p> 25 + The instance administrator needs to set the <code>SECRETS_KEY</code> environment 26 + variable. 27 + </p> 28 + </div> 29 + </Card> 30 + </Container> 31 + </AppShell>, 32 + { title: "Secrets — Airglow" }, 33 + ); 34 + } 35 + 36 + const secrets = await list(user.did); 37 + const initial = secrets.map((s) => ({ 38 + name: s.name, 39 + createdAt: s.createdAt.getTime(), 40 + updatedAt: s.updatedAt.getTime(), 41 + })); 42 + 43 + return c.render( 44 + <AppShell header={<Header user={user} actions={<ThemeToggle />} />}> 45 + <Container> 46 + <PageHeader 47 + title="Secrets" 48 + description={`${secrets.length} secret${secrets.length !== 1 ? "s" : ""} — encrypted, never shown after creation`} 49 + /> 50 + <Card variant="flat"> 51 + <SecretsManager initial={initial} /> 52 + </Card> 53 + </Container> 54 + </AppShell>, 55 + { title: "Secrets — Airglow" }, 56 + ); 57 + });
+2
app/server.ts
··· 52 52 53 53 app.use("/auth/*", authLimiter); 54 54 app.use("/api/automations", apiLimiter); 55 + app.use("/api/secrets/*", apiLimiter); 56 + app.use("/api/secrets", apiLimiter); 55 57 56 58 // Start Jetstream consumer — routes matched events to action handlers 57 59 startJetstream(handleMatchedEvent);
+1 -1
lib/actions/template.ts
··· 169 169 170 170 const FETCH_NAME_RE = /^[a-zA-Z_][a-zA-Z0-9_]*$/; 171 171 const ACTION_NAME_RE = /^action\d+$/; 172 - const RESERVED_FETCH_NAMES = new Set(["event", "now", "self"]); 172 + const RESERVED_FETCH_NAMES = new Set(["event", "now", "self", "secret"]); 173 173 174 174 /** Validate a single fetch step name + URI. */ 175 175 export function validateFetchStep(
+75 -1
lib/actions/validation.ts
··· 1 + import { SECRET_NAME_RE } from "../secrets/store.js"; 2 + 1 3 export type ActionInput = 2 - | { type: "webhook"; callbackUrl: string; comment?: string } 4 + | { 5 + type: "webhook"; 6 + callbackUrl: string; 7 + headers?: Record<string, string>; 8 + comment?: string; 9 + } 3 10 | { type: "record"; targetCollection: string; recordTemplate: string; comment?: string } 4 11 | { 5 12 type: "bsky-post"; ··· 20 27 export const VALID_OPERATIONS = new Set(["create", "update", "delete"]); 21 28 export const VALID_BSKY_LABELS = new Set(["sexual", "nudity", "porn", "graphic-media"]); 22 29 export const BCP47_RE = /^[a-z]{2,3}(-[A-Za-z0-9]{1,8})*$/; 30 + 31 + // System headers that cannot be overridden by custom webhook headers 32 + const SYSTEM_HEADERS = new Set([ 33 + "content-type", 34 + "x-airglow-signature", 35 + "x-airglow-automation", 36 + "x-airglow-timestamp", 37 + ]); 38 + 39 + const HEADER_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9-]{0,127}$/; 40 + const SECRET_REF_RE = /\{\{secret:([^}]+)\}\}/g; 41 + const ANY_PLACEHOLDER_RE = /\{\{([^}]+)\}\}/g; 42 + 43 + /** Validate custom headers for a webhook action. */ 44 + export function validateWebhookHeaders( 45 + headers: Record<string, string>, 46 + ): { valid: true } | { valid: false; error: string } { 47 + const entries = Object.entries(headers); 48 + 49 + if (entries.length > 20) { 50 + return { valid: false, error: "Maximum 20 custom headers allowed" }; 51 + } 52 + 53 + for (const [key, value] of entries) { 54 + if (!HEADER_NAME_RE.test(key)) { 55 + return { 56 + valid: false, 57 + error: `Invalid header name: "${key}". Must be alphanumeric with hyphens, 1-128 chars`, 58 + }; 59 + } 60 + if (SYSTEM_HEADERS.has(key.toLowerCase())) { 61 + return { 62 + valid: false, 63 + error: `Header "${key}" is reserved and cannot be overridden`, 64 + }; 65 + } 66 + if (typeof value !== "string") { 67 + return { valid: false, error: `Header "${key}" value must be a string` }; 68 + } 69 + if (value.length > 2048) { 70 + return { valid: false, error: `Header "${key}" value must be 2048 chars or less` }; 71 + } 72 + 73 + // Only {{secret:name}} references are allowed in headers — no other placeholders 74 + const allPlaceholders = [...value.matchAll(ANY_PLACEHOLDER_RE)]; 75 + const secretRefs = [...value.matchAll(SECRET_REF_RE)]; 76 + 77 + if (allPlaceholders.length !== secretRefs.length) { 78 + return { 79 + valid: false, 80 + error: `Header "${key}" contains invalid placeholders. Only {{secret:name}} references are allowed in headers`, 81 + }; 82 + } 83 + 84 + for (const match of secretRefs) { 85 + const secretName = match[1]!.trim(); 86 + if (!SECRET_NAME_RE.test(secretName)) { 87 + return { 88 + valid: false, 89 + error: `Invalid secret reference in header "${key}": {{secret:${secretName}}}`, 90 + }; 91 + } 92 + } 93 + } 94 + 95 + return { valid: true }; 96 + }
+9 -1
lib/automations/sanitize.ts
··· 1 1 import type { Action } from "../db/schema.ts"; 2 2 3 3 export type PublicAction = 4 - | { $type: "webhook"; callbackDomain: string; verified?: boolean; comment?: string } 4 + | { 5 + $type: "webhook"; 6 + callbackDomain: string; 7 + headerNames?: string[]; 8 + verified?: boolean; 9 + comment?: string; 10 + } 5 11 | { $type: "record"; targetCollection: string; recordTemplate: string; comment?: string } 6 12 | { 7 13 $type: "bsky-post"; ··· 28 34 } catch { 29 35 callbackDomain = "unknown"; 30 36 } 37 + const headerNames = a.headers ? Object.keys(a.headers) : undefined; 31 38 return { 32 39 $type: "webhook" as const, 33 40 callbackDomain, 41 + ...(headerNames && headerNames.length > 0 ? { headerNames } : {}), 34 42 verified: a.verified, 35 43 comment: a.comment, 36 44 };
+15
lib/config.ts
··· 20 20 ); 21 21 } 22 22 23 + // Optional encryption key for user secrets (API keys, tokens). 24 + // When set, enables the secrets feature. Must be exactly 32 bytes, base64-encoded. 25 + // Generate with: openssl rand -base64 32 26 + const secretsKeyRaw = env("SECRETS_KEY", ""); 27 + let secretsKey: Buffer | null = null; 28 + if (secretsKeyRaw) { 29 + secretsKey = Buffer.from(secretsKeyRaw, "base64"); 30 + if (secretsKey.length !== 32) { 31 + throw new Error( 32 + "SECRETS_KEY must be exactly 32 bytes (base64-encoded). Generate one with: openssl rand -base64 32", 33 + ); 34 + } 35 + } 36 + 23 37 export const config = { 24 38 port: Number(env("PORT", "5175")), 25 39 databasePath: env("DATABASE_PATH", "./data/airglow.db"), ··· 27 41 pdsUrl: process.env.PDS_URL?.replace(/\/$/, "") || "", 28 42 jetstreamUrl: env("JETSTREAM_URL", "wss://jetstream2.us-east.bsky.network/subscribe"), 29 43 cookieSecret, 44 + secretsKey, 30 45 nsidAllowlist: env("NSID_ALLOWLIST", "").split(",").filter(Boolean), 31 46 nsidBlocklist: env("NSID_BLOCKLIST", "").split(",").filter(Boolean), 32 47 } as const;
+19
lib/db/migrations/0003_familiar_hellfire_club.sql
··· 1 + CREATE TABLE `secret_events` ( 2 + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, 3 + `did` text NOT NULL, 4 + `name` text NOT NULL, 5 + `action` text NOT NULL, 6 + `created_at` integer NOT NULL 7 + ); 8 + --> statement-breakpoint 9 + CREATE TABLE `user_secrets` ( 10 + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, 11 + `did` text NOT NULL, 12 + `name` text NOT NULL, 13 + `encrypted_value` blob NOT NULL, 14 + `created_at` integer NOT NULL, 15 + `updated_at` integer NOT NULL, 16 + FOREIGN KEY (`did`) REFERENCES `users`(`did`) ON UPDATE no action ON DELETE cascade 17 + ); 18 + --> statement-breakpoint 19 + CREATE UNIQUE INDEX `user_secrets_did_name_unique` ON `user_secrets` (`did`,`name`);
+526
lib/db/migrations/meta/0003_snapshot.json
··· 1 + { 2 + "version": "6", 3 + "dialect": "sqlite", 4 + "id": "540ef8f8-f159-4c01-bef8-ecb6ceb13ae5", 5 + "prevId": "96ed2bef-4b39-4079-99e1-e2c0a52b50bb", 6 + "tables": { 7 + "automations": { 8 + "name": "automations", 9 + "columns": { 10 + "uri": { 11 + "name": "uri", 12 + "type": "text", 13 + "primaryKey": true, 14 + "notNull": true, 15 + "autoincrement": false 16 + }, 17 + "did": { 18 + "name": "did", 19 + "type": "text", 20 + "primaryKey": false, 21 + "notNull": true, 22 + "autoincrement": false 23 + }, 24 + "rkey": { 25 + "name": "rkey", 26 + "type": "text", 27 + "primaryKey": false, 28 + "notNull": true, 29 + "autoincrement": false 30 + }, 31 + "name": { 32 + "name": "name", 33 + "type": "text", 34 + "primaryKey": false, 35 + "notNull": true, 36 + "autoincrement": false 37 + }, 38 + "description": { 39 + "name": "description", 40 + "type": "text", 41 + "primaryKey": false, 42 + "notNull": false, 43 + "autoincrement": false 44 + }, 45 + "lexicon": { 46 + "name": "lexicon", 47 + "type": "text", 48 + "primaryKey": false, 49 + "notNull": true, 50 + "autoincrement": false 51 + }, 52 + "operation": { 53 + "name": "operation", 54 + "type": "text", 55 + "primaryKey": false, 56 + "notNull": true, 57 + "autoincrement": false, 58 + "default": "'[\"create\"]'" 59 + }, 60 + "actions": { 61 + "name": "actions", 62 + "type": "text", 63 + "primaryKey": false, 64 + "notNull": true, 65 + "autoincrement": false, 66 + "default": "'[]'" 67 + }, 68 + "fetches": { 69 + "name": "fetches", 70 + "type": "text", 71 + "primaryKey": false, 72 + "notNull": true, 73 + "autoincrement": false, 74 + "default": "'[]'" 75 + }, 76 + "conditions": { 77 + "name": "conditions", 78 + "type": "text", 79 + "primaryKey": false, 80 + "notNull": true, 81 + "autoincrement": false, 82 + "default": "'[]'" 83 + }, 84 + "active": { 85 + "name": "active", 86 + "type": "integer", 87 + "primaryKey": false, 88 + "notNull": true, 89 + "autoincrement": false, 90 + "default": false 91 + }, 92 + "dry_run": { 93 + "name": "dry_run", 94 + "type": "integer", 95 + "primaryKey": false, 96 + "notNull": true, 97 + "autoincrement": false, 98 + "default": false 99 + }, 100 + "indexed_at": { 101 + "name": "indexed_at", 102 + "type": "integer", 103 + "primaryKey": false, 104 + "notNull": true, 105 + "autoincrement": false 106 + } 107 + }, 108 + "indexes": {}, 109 + "foreignKeys": {}, 110 + "compositePrimaryKeys": {}, 111 + "uniqueConstraints": {}, 112 + "checkConstraints": {} 113 + }, 114 + "delivery_logs": { 115 + "name": "delivery_logs", 116 + "columns": { 117 + "id": { 118 + "name": "id", 119 + "type": "integer", 120 + "primaryKey": true, 121 + "notNull": true, 122 + "autoincrement": true 123 + }, 124 + "automation_uri": { 125 + "name": "automation_uri", 126 + "type": "text", 127 + "primaryKey": false, 128 + "notNull": true, 129 + "autoincrement": false 130 + }, 131 + "action_index": { 132 + "name": "action_index", 133 + "type": "integer", 134 + "primaryKey": false, 135 + "notNull": true, 136 + "autoincrement": false, 137 + "default": 0 138 + }, 139 + "event_time_us": { 140 + "name": "event_time_us", 141 + "type": "integer", 142 + "primaryKey": false, 143 + "notNull": true, 144 + "autoincrement": false 145 + }, 146 + "payload": { 147 + "name": "payload", 148 + "type": "text", 149 + "primaryKey": false, 150 + "notNull": false, 151 + "autoincrement": false 152 + }, 153 + "status_code": { 154 + "name": "status_code", 155 + "type": "integer", 156 + "primaryKey": false, 157 + "notNull": false, 158 + "autoincrement": false 159 + }, 160 + "message": { 161 + "name": "message", 162 + "type": "text", 163 + "primaryKey": false, 164 + "notNull": false, 165 + "autoincrement": false 166 + }, 167 + "error": { 168 + "name": "error", 169 + "type": "text", 170 + "primaryKey": false, 171 + "notNull": false, 172 + "autoincrement": false 173 + }, 174 + "dry_run": { 175 + "name": "dry_run", 176 + "type": "integer", 177 + "primaryKey": false, 178 + "notNull": true, 179 + "autoincrement": false, 180 + "default": false 181 + }, 182 + "attempt": { 183 + "name": "attempt", 184 + "type": "integer", 185 + "primaryKey": false, 186 + "notNull": true, 187 + "autoincrement": false, 188 + "default": 1 189 + }, 190 + "created_at": { 191 + "name": "created_at", 192 + "type": "integer", 193 + "primaryKey": false, 194 + "notNull": true, 195 + "autoincrement": false 196 + } 197 + }, 198 + "indexes": {}, 199 + "foreignKeys": { 200 + "delivery_logs_automation_uri_automations_uri_fk": { 201 + "name": "delivery_logs_automation_uri_automations_uri_fk", 202 + "tableFrom": "delivery_logs", 203 + "tableTo": "automations", 204 + "columnsFrom": [ 205 + "automation_uri" 206 + ], 207 + "columnsTo": [ 208 + "uri" 209 + ], 210 + "onDelete": "cascade", 211 + "onUpdate": "no action" 212 + } 213 + }, 214 + "compositePrimaryKeys": {}, 215 + "uniqueConstraints": {}, 216 + "checkConstraints": {} 217 + }, 218 + "favicon_cache": { 219 + "name": "favicon_cache", 220 + "columns": { 221 + "domain": { 222 + "name": "domain", 223 + "type": "text", 224 + "primaryKey": true, 225 + "notNull": true, 226 + "autoincrement": false 227 + }, 228 + "data": { 229 + "name": "data", 230 + "type": "text", 231 + "primaryKey": false, 232 + "notNull": true, 233 + "autoincrement": false 234 + }, 235 + "content_type": { 236 + "name": "content_type", 237 + "type": "text", 238 + "primaryKey": false, 239 + "notNull": true, 240 + "autoincrement": false 241 + }, 242 + "fetched_at": { 243 + "name": "fetched_at", 244 + "type": "integer", 245 + "primaryKey": false, 246 + "notNull": true, 247 + "autoincrement": false 248 + } 249 + }, 250 + "indexes": {}, 251 + "foreignKeys": {}, 252 + "compositePrimaryKeys": {}, 253 + "uniqueConstraints": {}, 254 + "checkConstraints": {} 255 + }, 256 + "lexicon_cache": { 257 + "name": "lexicon_cache", 258 + "columns": { 259 + "nsid": { 260 + "name": "nsid", 261 + "type": "text", 262 + "primaryKey": true, 263 + "notNull": true, 264 + "autoincrement": false 265 + }, 266 + "schema": { 267 + "name": "schema", 268 + "type": "text", 269 + "primaryKey": false, 270 + "notNull": true, 271 + "autoincrement": false 272 + }, 273 + "fetched_at": { 274 + "name": "fetched_at", 275 + "type": "integer", 276 + "primaryKey": false, 277 + "notNull": true, 278 + "autoincrement": false 279 + } 280 + }, 281 + "indexes": {}, 282 + "foreignKeys": {}, 283 + "compositePrimaryKeys": {}, 284 + "uniqueConstraints": {}, 285 + "checkConstraints": {} 286 + }, 287 + "oauth_sessions": { 288 + "name": "oauth_sessions", 289 + "columns": { 290 + "key": { 291 + "name": "key", 292 + "type": "text", 293 + "primaryKey": true, 294 + "notNull": true, 295 + "autoincrement": false 296 + }, 297 + "value": { 298 + "name": "value", 299 + "type": "text", 300 + "primaryKey": false, 301 + "notNull": true, 302 + "autoincrement": false 303 + }, 304 + "expires_at": { 305 + "name": "expires_at", 306 + "type": "integer", 307 + "primaryKey": false, 308 + "notNull": false, 309 + "autoincrement": false 310 + } 311 + }, 312 + "indexes": {}, 313 + "foreignKeys": {}, 314 + "compositePrimaryKeys": {}, 315 + "uniqueConstraints": {}, 316 + "checkConstraints": {} 317 + }, 318 + "oauth_states": { 319 + "name": "oauth_states", 320 + "columns": { 321 + "key": { 322 + "name": "key", 323 + "type": "text", 324 + "primaryKey": true, 325 + "notNull": true, 326 + "autoincrement": false 327 + }, 328 + "value": { 329 + "name": "value", 330 + "type": "text", 331 + "primaryKey": false, 332 + "notNull": true, 333 + "autoincrement": false 334 + }, 335 + "expires_at": { 336 + "name": "expires_at", 337 + "type": "integer", 338 + "primaryKey": false, 339 + "notNull": false, 340 + "autoincrement": false 341 + } 342 + }, 343 + "indexes": {}, 344 + "foreignKeys": {}, 345 + "compositePrimaryKeys": {}, 346 + "uniqueConstraints": {}, 347 + "checkConstraints": {} 348 + }, 349 + "secret_events": { 350 + "name": "secret_events", 351 + "columns": { 352 + "id": { 353 + "name": "id", 354 + "type": "integer", 355 + "primaryKey": true, 356 + "notNull": true, 357 + "autoincrement": true 358 + }, 359 + "did": { 360 + "name": "did", 361 + "type": "text", 362 + "primaryKey": false, 363 + "notNull": true, 364 + "autoincrement": false 365 + }, 366 + "name": { 367 + "name": "name", 368 + "type": "text", 369 + "primaryKey": false, 370 + "notNull": true, 371 + "autoincrement": false 372 + }, 373 + "action": { 374 + "name": "action", 375 + "type": "text", 376 + "primaryKey": false, 377 + "notNull": true, 378 + "autoincrement": false 379 + }, 380 + "created_at": { 381 + "name": "created_at", 382 + "type": "integer", 383 + "primaryKey": false, 384 + "notNull": true, 385 + "autoincrement": false 386 + } 387 + }, 388 + "indexes": {}, 389 + "foreignKeys": {}, 390 + "compositePrimaryKeys": {}, 391 + "uniqueConstraints": {}, 392 + "checkConstraints": {} 393 + }, 394 + "user_secrets": { 395 + "name": "user_secrets", 396 + "columns": { 397 + "id": { 398 + "name": "id", 399 + "type": "integer", 400 + "primaryKey": true, 401 + "notNull": true, 402 + "autoincrement": true 403 + }, 404 + "did": { 405 + "name": "did", 406 + "type": "text", 407 + "primaryKey": false, 408 + "notNull": true, 409 + "autoincrement": false 410 + }, 411 + "name": { 412 + "name": "name", 413 + "type": "text", 414 + "primaryKey": false, 415 + "notNull": true, 416 + "autoincrement": false 417 + }, 418 + "encrypted_value": { 419 + "name": "encrypted_value", 420 + "type": "blob", 421 + "primaryKey": false, 422 + "notNull": true, 423 + "autoincrement": false 424 + }, 425 + "created_at": { 426 + "name": "created_at", 427 + "type": "integer", 428 + "primaryKey": false, 429 + "notNull": true, 430 + "autoincrement": false 431 + }, 432 + "updated_at": { 433 + "name": "updated_at", 434 + "type": "integer", 435 + "primaryKey": false, 436 + "notNull": true, 437 + "autoincrement": false 438 + } 439 + }, 440 + "indexes": { 441 + "user_secrets_did_name_unique": { 442 + "name": "user_secrets_did_name_unique", 443 + "columns": [ 444 + "did", 445 + "name" 446 + ], 447 + "isUnique": true 448 + } 449 + }, 450 + "foreignKeys": { 451 + "user_secrets_did_users_did_fk": { 452 + "name": "user_secrets_did_users_did_fk", 453 + "tableFrom": "user_secrets", 454 + "tableTo": "users", 455 + "columnsFrom": [ 456 + "did" 457 + ], 458 + "columnsTo": [ 459 + "did" 460 + ], 461 + "onDelete": "cascade", 462 + "onUpdate": "no action" 463 + } 464 + }, 465 + "compositePrimaryKeys": {}, 466 + "uniqueConstraints": {}, 467 + "checkConstraints": {} 468 + }, 469 + "users": { 470 + "name": "users", 471 + "columns": { 472 + "id": { 473 + "name": "id", 474 + "type": "integer", 475 + "primaryKey": true, 476 + "notNull": true, 477 + "autoincrement": true 478 + }, 479 + "did": { 480 + "name": "did", 481 + "type": "text", 482 + "primaryKey": false, 483 + "notNull": true, 484 + "autoincrement": false 485 + }, 486 + "handle": { 487 + "name": "handle", 488 + "type": "text", 489 + "primaryKey": false, 490 + "notNull": true, 491 + "autoincrement": false 492 + }, 493 + "created_at": { 494 + "name": "created_at", 495 + "type": "integer", 496 + "primaryKey": false, 497 + "notNull": true, 498 + "autoincrement": false 499 + } 500 + }, 501 + "indexes": { 502 + "users_did_unique": { 503 + "name": "users_did_unique", 504 + "columns": [ 505 + "did" 506 + ], 507 + "isUnique": true 508 + } 509 + }, 510 + "foreignKeys": {}, 511 + "compositePrimaryKeys": {}, 512 + "uniqueConstraints": {}, 513 + "checkConstraints": {} 514 + } 515 + }, 516 + "views": {}, 517 + "enums": {}, 518 + "_meta": { 519 + "schemas": {}, 520 + "tables": {}, 521 + "columns": {} 522 + }, 523 + "internal": { 524 + "indexes": {} 525 + } 526 + }
+7
lib/db/migrations/meta/_journal.json
··· 22 22 "when": 1776156786266, 23 23 "tag": "0002_minor_skin", 24 24 "breakpoints": true 25 + }, 26 + { 27 + "idx": 3, 28 + "version": "6", 29 + "when": 1776255747958, 30 + "tag": "0003_familiar_hellfire_club", 31 + "breakpoints": true 25 32 } 26 33 ] 27 34 }
+29 -2
lib/db/schema.ts
··· 1 - import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core"; 1 + import { sqliteTable, text, integer, blob, uniqueIndex } from "drizzle-orm/sqlite-core"; 2 2 3 3 export const users = sqliteTable("users", { 4 4 id: integer("id").primaryKey({ autoIncrement: true }), ··· 11 11 export type WebhookAction = { 12 12 $type: "webhook"; 13 13 callbackUrl: string; 14 - secret: string; // instance-local HMAC secret, not stored on PDS 14 + secret: string; // instance-local HMAC secret (encrypted when SECRETS_KEY is set) 15 + headers?: Record<string, string>; // custom HTTP headers, values may reference {{secret:name}} 15 16 verified?: boolean; // true if /.well-known/airglow manifest matched 16 17 comment?: string; 17 18 }; ··· 114 115 contentType: text("content_type").notNull(), 115 116 fetchedAt: integer("fetched_at", { mode: "timestamp_ms" }).notNull(), 116 117 }); 118 + 119 + // Encrypted user secrets (API keys, tokens) for webhook action headers. 120 + // Values are AES-256-GCM encrypted blobs — plaintext never stored. 121 + export const userSecrets = sqliteTable( 122 + "user_secrets", 123 + { 124 + id: integer("id").primaryKey({ autoIncrement: true }), 125 + did: text("did") 126 + .notNull() 127 + .references(() => users.did, { onDelete: "cascade" }), 128 + name: text("name").notNull(), 129 + encryptedValue: blob("encrypted_value", { mode: "buffer" }).notNull(), 130 + createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(), 131 + updatedAt: integer("updated_at", { mode: "timestamp_ms" }).notNull(), 132 + }, 133 + (table) => [uniqueIndex("user_secrets_did_name_unique").on(table.did, table.name)], 134 + ); 135 + 136 + // Audit log for secret lifecycle events — no values, just metadata. 137 + export const secretEvents = sqliteTable("secret_events", { 138 + id: integer("id").primaryKey({ autoIncrement: true }), 139 + did: text("did").notNull(), 140 + name: text("name").notNull(), 141 + action: text("action").notNull(), // "create" | "update" | "delete" 142 + createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(), 143 + });
+3 -1
lib/jetstream/handler.ts
··· 96 96 if (failedFetches.length > 0) { 97 97 error = `Fetch failed: ${failedFetches.join(", ")}`; 98 98 } else if (action.$type === "webhook") { 99 - message = `Would POST to ${action.callbackUrl}`; 99 + const headerCount = action.headers ? Object.keys(action.headers).length : 0; 100 + const headerNote = headerCount > 0 ? ` with ${headerCount} custom header(s)` : ""; 101 + message = `Would POST to ${action.callbackUrl}${headerNote}`; 100 102 payload = JSON.stringify(buildPayload(match, fetchContext)); 101 103 } else if (action.$type === "bsky-post") { 102 104 try {
+18
lib/secrets/audit.ts
··· 1 + import { db } from "../db/index.js"; 2 + import { secretEvents } from "../db/schema.js"; 3 + 4 + type SecretAction = "create" | "update" | "delete"; 5 + 6 + /** Log a secret lifecycle event (no values, just metadata). */ 7 + export async function logSecretEvent( 8 + did: string, 9 + name: string, 10 + action: SecretAction, 11 + ): Promise<void> { 12 + await db.insert(secretEvents).values({ 13 + did, 14 + name, 15 + action, 16 + createdAt: new Date(), 17 + }); 18 + }
+80
lib/secrets/crypto.ts
··· 1 + import { hkdfSync, createCipheriv, createDecipheriv, randomBytes } from "node:crypto"; 2 + import { config } from "../config.js"; 3 + 4 + const HKDF_HASH = "sha256"; 5 + const KEY_LENGTH = 32; 6 + const NONCE_LENGTH = 12; 7 + const TAG_LENGTH = 16; 8 + const ALGORITHM = "aes-256-gcm"; 9 + 10 + // Per-user derived key cache (module-scoped, cleared on process restart) 11 + const keyCache = new Map<string, Buffer>(); 12 + 13 + function getMasterKey(): Buffer { 14 + if (!config.secretsKey) { 15 + throw new Error("Secrets feature is not configured (SECRETS_KEY not set)"); 16 + } 17 + return config.secretsKey; 18 + } 19 + 20 + /** 21 + * Derive a per-user encryption key using HKDF-SHA256. 22 + * - IKM: master SECRETS_KEY 23 + * - Salt: empty (HKDF defaults to zero-filled hash-length per RFC 5869) 24 + * - Info: "airglow-secrets-v1:<userDID>" for contextual binding 25 + */ 26 + function deriveKey(did: string): Buffer { 27 + const cached = keyCache.get(did); 28 + if (cached) return cached; 29 + 30 + const masterKey = getMasterKey(); 31 + const info = `airglow-secrets-v1:${did}`; 32 + const derived = Buffer.from(hkdfSync(HKDF_HASH, masterKey, Buffer.alloc(0), info, KEY_LENGTH)); 33 + 34 + keyCache.set(did, derived); 35 + return derived; 36 + } 37 + 38 + /** 39 + * Encrypt a plaintext string using AES-256-GCM with a per-user derived key. 40 + * Returns a single Buffer: nonce (12B) || ciphertext || authTag (16B). 41 + */ 42 + export function encrypt(did: string, plaintext: string): Buffer { 43 + const key = deriveKey(did); 44 + const nonce = randomBytes(NONCE_LENGTH); 45 + 46 + const cipher = createCipheriv(ALGORITHM, key, nonce); 47 + const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]); 48 + const tag = cipher.getAuthTag(); 49 + 50 + return Buffer.concat([nonce, encrypted, tag]); 51 + } 52 + 53 + /** 54 + * Decrypt a blob produced by encrypt(). 55 + * Throws on tampered data or wrong key — error messages never include ciphertext. 56 + */ 57 + export function decrypt(did: string, blob: Buffer): string { 58 + if (blob.length < NONCE_LENGTH + TAG_LENGTH + 1) { 59 + throw new Error("Invalid encrypted secret: blob too short"); 60 + } 61 + 62 + const key = deriveKey(did); 63 + const nonce = blob.subarray(0, NONCE_LENGTH); 64 + const tag = blob.subarray(-TAG_LENGTH); 65 + const ciphertext = blob.subarray(NONCE_LENGTH, -TAG_LENGTH); 66 + 67 + const decipher = createDecipheriv(ALGORITHM, key, nonce); 68 + decipher.setAuthTag(tag); 69 + 70 + try { 71 + return decipher.update(ciphertext).toString("utf8") + decipher.final("utf8"); 72 + } catch { 73 + throw new Error("Failed to decrypt secret: authentication failed"); 74 + } 75 + } 76 + 77 + /** Clear the derived key cache. Exported for testing. */ 78 + export function clearDerivedKeyCache(): void { 79 + keyCache.clear(); 80 + }
+112
lib/secrets/store.ts
··· 1 + import { eq, and, inArray, count } from "drizzle-orm"; 2 + import { db } from "../db/index.js"; 3 + import { userSecrets } from "../db/schema.js"; 4 + import { encrypt, decrypt } from "./crypto.js"; 5 + import { logSecretEvent } from "./audit.js"; 6 + 7 + export const SECRET_NAME_RE = /^[a-zA-Z][a-zA-Z0-9_-]{0,63}$/; 8 + const MAX_SECRETS_PER_USER = 50; 9 + const MAX_VALUE_BYTES = 8192; 10 + 11 + export function validateSecretName(name: string): string | null { 12 + if (!name || !SECRET_NAME_RE.test(name)) { 13 + return "Secret name must start with a letter and contain only letters, digits, hyphens, or underscores (max 64 chars)"; 14 + } 15 + return null; 16 + } 17 + 18 + /** Create or update an encrypted secret for a user. */ 19 + export async function createOrUpdate( 20 + did: string, 21 + name: string, 22 + plaintext: string, 23 + ): Promise<"created" | "updated"> { 24 + const nameError = validateSecretName(name); 25 + if (nameError) throw new Error(nameError); 26 + if (!plaintext) throw new Error("Secret value must not be empty"); 27 + if (Buffer.byteLength(plaintext, "utf8") > MAX_VALUE_BYTES) { 28 + throw new Error("Secret value must be 8KB or less"); 29 + } 30 + 31 + const existing = await db.query.userSecrets.findFirst({ 32 + where: and(eq(userSecrets.did, did), eq(userSecrets.name, name)), 33 + columns: { id: true }, 34 + }); 35 + 36 + if (!existing) { 37 + // Check per-user limit before inserting 38 + const rows = await db 39 + .select({ total: count() }) 40 + .from(userSecrets) 41 + .where(eq(userSecrets.did, did)); 42 + if ((rows[0]?.total ?? 0) >= MAX_SECRETS_PER_USER) { 43 + throw new Error(`Maximum ${MAX_SECRETS_PER_USER} secrets per user`); 44 + } 45 + } 46 + 47 + const encryptedValue = encrypt(did, plaintext); 48 + const now = new Date(); 49 + const action = existing ? "update" : "create"; 50 + 51 + await db 52 + .insert(userSecrets) 53 + .values({ did, name, encryptedValue, createdAt: now, updatedAt: now }) 54 + .onConflictDoUpdate({ 55 + target: [userSecrets.did, userSecrets.name], 56 + set: { encryptedValue, updatedAt: now }, 57 + }); 58 + 59 + await logSecretEvent(did, name, action); 60 + return action === "create" ? "created" : "updated"; 61 + } 62 + 63 + /** List secrets for a user (metadata only, never values). */ 64 + export async function list( 65 + did: string, 66 + ): Promise<Array<{ name: string; createdAt: Date; updatedAt: Date }>> { 67 + const rows = await db.query.userSecrets.findMany({ 68 + where: eq(userSecrets.did, did), 69 + columns: { name: true, createdAt: true, updatedAt: true }, 70 + orderBy: (t, { asc }) => asc(t.name), 71 + }); 72 + return rows; 73 + } 74 + 75 + /** Remove a secret. Returns true if it existed. */ 76 + export async function remove(did: string, name: string): Promise<boolean> { 77 + const result = await db 78 + .delete(userSecrets) 79 + .where(and(eq(userSecrets.did, did), eq(userSecrets.name, name))) 80 + .returning({ id: userSecrets.id }); 81 + 82 + if (result.length > 0) { 83 + await logSecretEvent(did, name, "delete"); 84 + return true; 85 + } 86 + return false; 87 + } 88 + 89 + /** 90 + * Batch-decrypt secrets by name for a user. 91 + * Used at webhook dispatch time. Returns a map of name → plaintext. 92 + * Missing secrets are silently omitted from the result. 93 + */ 94 + export async function resolve(did: string, names: string[]): Promise<Map<string, string>> { 95 + if (names.length === 0) return new Map(); 96 + 97 + const rows = await db.query.userSecrets.findMany({ 98 + where: and(eq(userSecrets.did, did), inArray(userSecrets.name, names)), 99 + columns: { name: true, encryptedValue: true }, 100 + }); 101 + 102 + const result = new Map<string, string>(); 103 + for (const row of rows) { 104 + try { 105 + result.set(row.name, decrypt(did, Buffer.from(row.encryptedValue))); 106 + } catch { 107 + // Decryption failure (key rotated, corrupted) — skip, do not leak details 108 + console.error(`Failed to decrypt secret "${row.name}" for ${did}`); 109 + } 110 + } 111 + return result; 112 + }
+11
lib/webhooks/dispatcher.test.ts
··· 10 10 UrlGuardError: class extends Error {}, 11 11 })); 12 12 13 + vi.mock("@/config.js", () => ({ 14 + config: { 15 + secretsKey: null, 16 + cookieSecret: "x".repeat(32), 17 + }, 18 + })); 19 + 20 + vi.mock("@/secrets/store.js", () => ({ 21 + resolve: vi.fn(async () => new Map()), 22 + })); 23 + 13 24 import { dispatch } from "./dispatcher.js"; 14 25 import { sign } from "./signer.js"; 15 26 import { db } from "../db/index.js";
+83 -4
lib/webhooks/dispatcher.ts
··· 2 2 import { sign } from "./signer.js"; 3 3 import { assertPublicUrl } from "../url-guard.js"; 4 4 import { RETRY_DELAYS, isSuccess, isRetryable, logDelivery } from "../actions/delivery.js"; 5 + import { resolve as resolveSecrets } from "../secrets/store.js"; 6 + import { config } from "../config.js"; 5 7 import type { ActionResult } from "../actions/executor.js"; 6 8 import type { MatchedEvent } from "../jetstream/consumer.js"; 7 9 import type { FetchContext } from "../actions/template.js"; 10 + 11 + const SECRET_REF_RE = /\{\{secret:([^}]+)\}\}/g; 8 12 9 13 type WebhookPayload = { 10 14 automation: string; ··· 54 58 body: string, 55 59 secret: string, 56 60 automationUri: string, 61 + customHeaders?: Record<string, string>, 57 62 ): Promise<{ statusCode: number; error?: string }> { 58 63 const timestamp = Date.now(); 59 64 const signature = sign(body, secret); ··· 65 70 const res = await fetch(callbackUrl, { 66 71 method: "POST", 67 72 headers: { 73 + // Custom headers first, then system headers override 74 + ...customHeaders, 68 75 "Content-Type": "application/json", 69 76 "X-Airglow-Signature": `sha256=${signature}`, 70 77 "X-Airglow-Automation": automationUri, ··· 75 82 }); 76 83 return { statusCode: res.status }; 77 84 } catch (err) { 78 - console.error(`Webhook delivery error to ${callbackUrl} for ${automationUri}:`, err); 79 - console.error(`Request body was:`, body); 85 + console.error(`Webhook delivery error to ${callbackUrl} for ${automationUri}`); 80 86 return { statusCode: 0, error: String(err) }; 81 87 } 82 88 } 83 89 90 + /** 91 + * Resolve {{secret:name}} references in webhook action headers. 92 + * Returns resolved headers with plaintext values, or empty object if no headers. 93 + */ 94 + async function resolveHeaders( 95 + action: WebhookAction, 96 + ownerDid: string, 97 + ): Promise<{ resolved: Record<string, string>; missing: string[] }> { 98 + if (!action.headers || !config.secretsKey) { 99 + return { resolved: {}, missing: [] }; 100 + } 101 + 102 + // Collect all unique secret names from header values 103 + const secretNames = new Set<string>(); 104 + for (const value of Object.values(action.headers)) { 105 + for (const match of value.matchAll(SECRET_REF_RE)) { 106 + secretNames.add(match[1]!.trim()); 107 + } 108 + } 109 + 110 + if (secretNames.size === 0) { 111 + // Headers with no secret refs — return as-is 112 + return { resolved: { ...action.headers }, missing: [] }; 113 + } 114 + 115 + const secrets = await resolveSecrets(ownerDid, [...secretNames]); 116 + const resolved: Record<string, string> = {}; 117 + const missing: string[] = []; 118 + 119 + for (const [key, template] of Object.entries(action.headers)) { 120 + let hasUnresolved = false; 121 + const value = template.replace(SECRET_REF_RE, (_, name: string) => { 122 + const plaintext = secrets.get(name.trim()); 123 + if (plaintext === undefined) { 124 + hasUnresolved = true; 125 + missing.push(name.trim()); 126 + return ""; 127 + } 128 + return plaintext; 129 + }); 130 + 131 + // Skip headers with unresolved secret references 132 + if (!hasUnresolved) { 133 + resolved[key] = value; 134 + } 135 + } 136 + 137 + return { resolved, missing: [...new Set(missing)] }; 138 + } 139 + 84 140 function scheduleRetry( 85 141 automationUri: string, 86 142 actionIndex: number, ··· 89 145 eventTimeUs: number, 90 146 body: string, 91 147 retryIndex: number, 148 + customHeaders?: Record<string, string>, 92 149 ) { 93 150 if (retryIndex >= RETRY_DELAYS.length) return; 94 151 95 152 setTimeout(async () => { 96 153 try { 97 - const result = await deliver(callbackUrl, body, secret, automationUri); 154 + const result = await deliver(callbackUrl, body, secret, automationUri, customHeaders); 98 155 99 156 await logDelivery( 100 157 automationUri, ··· 115 172 eventTimeUs, 116 173 body, 117 174 retryIndex + 1, 175 + customHeaders, 118 176 ); 119 177 } 120 178 } catch (err) { ··· 134 192 const payload = buildPayload(match, fetchContext); 135 193 const body = JSON.stringify(payload); 136 194 137 - const result = await deliver(action.callbackUrl, body, action.secret, automation.uri); 195 + // Resolve {{secret:name}} references in custom headers (once, reused for retries) 196 + const { resolved: customHeaders, missing } = await resolveHeaders(action, automation.did); 197 + if (missing.length > 0) { 198 + await logDelivery( 199 + automation.uri, 200 + actionIndex, 201 + event.time_us, 202 + null, 203 + 0, 204 + `Missing secrets: ${missing.join(", ")}`, 205 + 1, 206 + ); 207 + } 208 + 209 + const result = await deliver( 210 + action.callbackUrl, 211 + body, 212 + action.secret, 213 + automation.uri, 214 + customHeaders, 215 + ); 138 216 139 217 await logDelivery( 140 218 automation.uri, ··· 156 234 event.time_us, 157 235 body, 158 236 0, 237 + customHeaders, 159 238 ); 160 239 } 161 240