Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

Security audit, part 3 Lows: bounded commit paging and anonymous search, expiring unsubscribe links, encrypted webhook secrets, job token lifetime (#202)

* Commit listings page at most 10,000 commits deep
* Anonymous issue searches fan out to at most 10 repositories
* Unsubscribe links expire after a year and don't show the email address
* Docs: unsubscribe links expire after a year and don't show the email address
* Webhook secrets are encrypted at rest
* Job tokens expire 30 minutes after the job's timeout
test-bot authored 1 parent 26c09b1commit c74f242352b9c6271ed267578eb3ec312700c90dBrowse files
20 files changed+408−31
Files (20)
modifiedapps/diffinery/docs/accounts/notifications.md+1−1View file
@@ -191,7 +191,7 @@
191191
192192 The mail app's one-click button (`List-Unsubscribe`) turns off email for the group the email belongs to, the same as **Unsubscribe from emails like this**: for an issue or pull request email, that's every Participating email (or every Watching one, when you got it because you watch the repository). To mute just one thread, use **Mute this thread** in the footer instead. You can turn the group back on in notification settings.
193193
194-The links are signed, so they work without signing in and can't be altered to change someone else's settings. Opening one shows a page naming the change and the account (for example "Mute acme/website#42") with a button to confirm; after confirming you can click **Undo**. Security and account emails have no unsubscribe links.
194+The links are signed, so they work without signing in and can't be altered to change someone else's settings. Each link works for a year after the email was sent (links from before links carried an expiry work until October 16, 2027); after that, change what you get in notification settings. Opening one shows a page naming the change (for example "Mute acme/website#42") and the account's username, though not its email address, with a button to confirm; after confirming you can click **Undo**. Security and account emails have no unsubscribe links.
195195
196196 ## Email headers
197197
modifiedapps/diffinery/docs/actions/workflow-permissions.md+1−1View file
@@ -15,7 +15,7 @@
1515 GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
1616 ```
1717
18-- It works only while the job runs. When the job completes, the token stops working.
18+- It works only while the job runs. When the job completes, the token stops working, and it never works more than 30 minutes past the job's timeout (counted from when the job started), even if the job is never marked complete.
1919 - It acts in the repository as `diffinery-actions[bot]`, so issues, comments and labels it creates show that name.
2020 - Its role is `write` when any scope other than `id-token` is `write`, and `read` otherwise. It never has the `maintain` or `admin` role, so [branch protection](/docs/repositories/branch-protection) always applies to it.
2121 - Each request is then checked against the scope its endpoint needs (see [What each endpoint needs](#what-each-endpoint-needs)).
modifiedapps/diffinery/docs/self-hosting/configuration.md+1−1View file
@@ -159,7 +159,7 @@
159159
160160 | Secret | Format | Created by | What it does | If it is missing |
161161 | --- | --- | --- | --- | --- |
162-| `MASTER_KEY` | 32 random bytes, base64 | `ensure-secrets.sh`, once | Root key for envelope encryption of Actions secrets: each repository, organization and deployment environment gets its own AES-GCM key derived from it with HKDF. The GitHub token of a running import is encrypted the same way. It also signs the unsubscribe links in emails, the codes and OAuth state for linking GitHub accounts, keys the stored hashes of sign-up codes, and a key derived from it (HKDF) signs each job's `ACTIONS_RUNTIME_TOKEN` and the upload and download URLs of the Actions cache and artifact services. | Repository, organization and environment secrets can't be stored or read (`could not store the secret: ... (is MASTER_KEY configured?)`). Notification emails that carry unsubscribe links can't be built, so they end up in the email log as failed; security and account emails still send. Jobs get no `ACTIONS_RUNTIME_TOKEN`: `actions/cache` and the built-in cache step skip caching (`The cache service is not available to this job; skipping.`), and uploading or downloading artifacts fails (`The artifact service is not available to this job.`). Imports that come with a GitHub token can't be started. Open sign-up can't send codes. |
162+| `MASTER_KEY` | 32 random bytes, base64 | `ensure-secrets.sh`, once | Root key for envelope encryption of Actions secrets: each repository, organization and deployment environment gets its own AES-GCM key derived from it with HKDF. The GitHub token of a running import and each webhook's signing secret are encrypted the same way. It also signs the unsubscribe links in emails, the codes and OAuth state for linking GitHub accounts, keys the stored hashes of sign-up codes, and a key derived from it (HKDF) signs each job's `ACTIONS_RUNTIME_TOKEN` and the upload and download URLs of the Actions cache and artifact services. | Repository, organization and environment secrets can't be stored or read (`could not store the secret: ... (is MASTER_KEY configured?)`). Notification emails that carry unsubscribe links can't be built, so they end up in the email log as failed; security and account emails still send. Jobs get no `ACTIONS_RUNTIME_TOKEN`: `actions/cache` and the built-in cache step skip caching (`The cache service is not available to this job; skipping.`), and uploading or downloading artifacts fails (`The artifact service is not available to this job.`). Imports that come with a GitHub token can't be started. Webhooks can't be created or delivered. Open sign-up can't send codes. |
163163 | `OIDC_PRIVATE_KEY` | An ECDSA P-256 private key as a JWK (JSON), with a `kid` | `ensure-secrets.sh`, once | Signs the ID tokens that workflow jobs request with `permissions: id-token: write` (ES256). Its public half is served at `/.well-known/jwks`. | Jobs that request an ID token fail with `OIDC is not configured on this Diffinery instance`, `/.well-known/jwks` returns no keys, and `GET /api/v3/meta` returns `"oidc": false`. |
164164 | `GITHUB_OAUTH_CLIENT_SECRET` | The client secret of a GitHub OAuth app | You, optionally | With `GITHUB_OAUTH_CLIENT_ID`, turns on the **Link with GitHub** option on **Settings → Linked accounts**. | People link GitHub accounts with the gist code only. |
165165 | `MAIL_EVENTS_SECRET` | A random string, at least 32 characters | You, optionally | Turns on `POST /-/email/events`, the signed intake for bounces and complaints from anything other than Cloudflare's own event subscriptions. See [Bounces, complaints and the suppression list](/docs/admin/site-administration#bounces-complaints-and-the-suppression-list). | The endpoint answers 404. Bounces and complaints still arrive through the `diffinery-mail-events` queue. |
modifiedapps/diffinery/src/app.ts+2−2View file
@@ -159,7 +159,7 @@
159159
160160 async function deliverWebhooks(env: Env, ev: Exclude<DiffineryEvent, { type: "audit" }>): Promise<void> {
161161 const stub = repoStub(env, ev.repoId);
162- const hooks = (await stub.webhooks()).filter((h) => h.active && (h.events.includes("*") || h.events.includes(GITHUB_EVENT[ev.type])));
162+ const hooks = (await stub.deliverableWebhooks()).filter((h) => h.events.includes("*") || h.events.includes(GITHUB_EVENT[ev.type]));
163163 if (!hooks.length) return;
164164 const body = JSON.stringify(webhookPayload(env.PUBLIC_URL, ev, await new Directory(env).repoById(ev.repoId)));
165165 // Each hook retries on its own (deliverWebhook requeues it). Once a hook may have been sent to,
@@ -170,7 +170,7 @@
170170
171171 const webhookDeps = (env: Env): WebhookDeps => ({
172172 ownHost: new URL(env.PUBLIC_URL).hostname,
173- hook: async (repoId, hookId) => (await repoStub(env, repoId).webhooks()).find((h) => h.id === hookId && h.active) ?? null,
173+ hook: async (repoId, hookId) => (await repoStub(env, repoId).deliverableWebhooks()).find((h) => h.id === hookId) ?? null,
174174 record: (repoId, hookId, status) => repoStub(env, repoId).recordDelivery(hookId, status),
175175 requeue: async (r, delaySeconds) => {
176176 await env.EVENTS.send(r as unknown as DiffineryEvent, { delaySeconds });
modifiedapps/diffinery/src/http/api.ts+5−3View file
@@ -11,7 +11,7 @@
1111 import { publicJwk } from "../lib/jwt.js";
1212 import { visibleTo } from "../lib/moderation.js";
1313 import { HttpError, b64, fromB64 } from "../lib/util.js";
14-import { atLeast, ROLE_LEVEL, type ProtectionRules, type Role } from "../repo/types.js";
14+import { MAX_LOG_DEPTH, atLeast, ROLE_LEVEL, type ProtectionRules, type Role } from "../repo/types.js";
1515 import type { JobInfo, RunInfo } from "../repo/actions.js";
1616 import type { CommitInfo } from "../repo/RepoDO.js";
1717 import type { QueueEntry } from "../repo/queue.js";
@@ -631,6 +631,8 @@
631631 api.get("/repos/:owner/:repo/commits", async (c) => {
632632 const { stub } = await loadRepo(c, P(c).owner, P(c).repo);
633633 const p = pageOf(c);
634+ // GitHub's answer when a listing is paged deeper than it allows; the history walk stops at MAX_LOG_DEPTH commits.
635+ if (p.offset >= MAX_LOG_DEPTH) throw new HttpError(422, "In order to keep the API fast for everyone, pagination is limited for this resource.");
634636 const rev = c.req.query("sha") ?? "HEAD";
635637 if (!(await stub.resolve(rev))) {
636638 if (!(await stub.resolve("HEAD"))) throw new HttpError(409, "Git Repository is empty.");
@@ -1297,7 +1299,7 @@
12971299
12981300 api.get("/repos/:owner/:repo/hooks", async (c) => {
12991301 const { stub } = await loadRepo(c, P(c).owner, P(c).repo, "admin");
1300- return c.json(paginate(c, (await stub.webhooks()).map(({ secret: _s, ...h }) => hookJson(c, h))));
1302+ return c.json(paginate(c, (await stub.webhooks()).map((h) => hookJson(c, h))));
13011303 });
13021304 api.post("/repos/:owner/:repo/hooks", async (c) => {
13031305 const { stub, caller, repo } = await loadRepo(c, P(c).owner, P(c).repo, "admin");
@@ -1307,7 +1309,7 @@
13071309 const hook = (await stub.webhooks()).find((h) => h.id === created.id)!;
13081310 auditRepo(c, caller, repo, "repo.webhook.create", { id: created.id, url: b.config.url });
13091311 // The generated secret is shown once, here (GitHub never returns secrets; Diffinery makes one when none is given).
1310- return c.json({ ...hookJson(c, { ...hook, secret: undefined }), secret: b.config.secret ? undefined : created.secret }, 201);
1312+ return c.json({ ...hookJson(c, hook), secret: b.config.secret ? undefined : created.secret }, 201);
13111313 });
13121314 api.delete("/repos/:owner/:repo/hooks/:id", async (c) => {
13131315 const { stub, caller, repo } = await loadRepo(c, P(c).owner, P(c).repo, "admin");
modifiedapps/diffinery/src/http/search.ts+7−2View file
@@ -1,6 +1,6 @@
11 import { Hono } from "hono";
22 import type { Env } from "../env.js";
3-import { callerFor, type AppContext, type AppVars } from "../auth/principal.js";
3+import { callerFor, type AppContext, type AppVars, type Principal } from "../auth/principal.js";
44 import type { Repo } from "../lib/directory.js";
55 import { matchesText, parseIssueQuery, parseRepoQuery } from "../lib/search-query.js";
66 import { HttpError } from "../lib/util.js";
@@ -14,6 +14,11 @@
1414
1515 /** Searches fan out to at most this many repositories; more sets `incomplete_results`. */
1616 const MAX_REPOS = 50;
17+/** Anonymous searches fan out to fewer: each repository searched is a Durable Object request, and anyone can send them. */
18+const MAX_ANONYMOUS_REPOS = 10;
19+
20+/** How many repositories one issue search may fan out to for this caller. */
21+export const searchFanOut = (p: Principal): number => (p.type === "anonymous" ? MAX_ANONYMOUS_REPOS : MAX_REPOS);
1722 const MAX_RESULTS = 1000;
1823 /** GitHub's limit on the length of a search query. */
1924 const MAX_QUERY = 256;
@@ -78,7 +83,7 @@
7883 const owners = new Set(q.owners.map((o) => o.toLowerCase()));
7984 repos = repos.filter((r) => owners.has(r.owner_login.toLowerCase()));
8085 }
81- const allowed = await authorized(c, repos, MAX_REPOS);
86+ const allowed = await authorized(c, repos, searchFanOut(c.get("principal")));
8287 // Each repository returns at most `want` hits as (number, sort key); only the page is loaded afterwards.
8388 const want = Math.min(p.offset + p.perPage, MAX_RESULTS);
8489 const results = await Promise.all(allowed.repos.map(async (r) => ({ repo: r, ...(await repoStub(c.env, r.id).searchIssues(q, want)) })));
modifiedapps/diffinery/src/mail/unsubscribe.ts+15−5View file
@@ -6,7 +6,8 @@
66 /**
77 * Unsubscribe links work without signing in, so each one carries a signed
88 * capability: whose settings to change and what to turn off. The key is
9- * derived from MASTER_KEY, so links stay valid across deploys.
9+ * derived from MASTER_KEY, so links stay valid across deploys, and each one
10+ * expires about a year after it was sent.
1011 */
1112 export type UnsubScope =
1213 /** Stop email for a settings group. */
@@ -33,18 +34,27 @@
3334 return b64url(new Uint8Array(sig));
3435 }
3536
36-export async function unsubToken(env: Env, t: UnsubToken): Promise<string> {
37- const payload = b64url(new TextEncoder().encode(JSON.stringify({ a: t.accountId, s: t.scope })));
37+/** How long an unsubscribe link works: about a year, so old emails in someone's inbox stop being a capability. */
38+export const UNSUB_TTL_MS = 365 * 24 * 60 * 60 * 1000;
39+/** Links signed before they carried an expiry keep working until this date (a year after expiries were added). */
40+export const UNSUB_LEGACY_CUTOFF_MS = Date.UTC(2027, 9, 16);
41+
42+export async function unsubToken(env: Env, t: UnsubToken, now = Date.now()): Promise<string> {
43+ // `e` is the expiry in seconds since the epoch.
44+ const payload = b64url(new TextEncoder().encode(JSON.stringify({ a: t.accountId, s: t.scope, e: Math.floor((now + UNSUB_TTL_MS) / 1000) })));
3845 return `${payload}.${await sign(env, payload)}`;
3946 }
4047
41-export async function readUnsubToken(env: Env, token: string): Promise<UnsubToken | null> {
48+export async function readUnsubToken(env: Env, token: string, now = Date.now()): Promise<UnsubToken | null> {
4249 const [payload, sig] = token.split(".");
4350 if (!payload || !sig || token.length > 2000) return null;
4451 if (!safeEqual(sig, await sign(env, payload))) return null;
4552 try {
46- const v = JSON.parse(new TextDecoder().decode(fromB64url(payload))) as { a?: unknown; s?: UnsubScope };
53+ const v = JSON.parse(new TextDecoder().decode(fromB64url(payload))) as { a?: unknown; s?: UnsubScope; e?: unknown };
4754 if (typeof v.a !== "string" || !v.s || typeof v.s !== "object") return null;
55+ // A link without an expiry was sent before links had one; those are honoured only until the cutoff.
56+ const expires = v.e === undefined ? UNSUB_LEGACY_CUTOFF_MS : typeof v.e === "number" ? v.e * 1000 : 0;
57+ if (now >= expires) return null;
4858 return { accountId: v.a, scope: v.s };
4959 } catch {
5060 return null;
modifiedapps/diffinery/src/mail/web.tsx+1−1View file
@@ -538,7 +538,7 @@
538538 { title: "Unsubscribe" },
539539 <UnsubPage title={scopeText(x.t.scope)}>
540540 <p class="muted">
541- For <b>{x.account.login}</b> ({x.account.email}).
541+ For <b>{x.account.login}</b>.
542542 </p>
543543 <form method="post" action={c.req.path}>
544544 <button class="btn primary">{scopeText(x.t.scope)}</button>
modifiedapps/diffinery/src/repo/RepoDO.ts+9−4View file
@@ -72,7 +72,7 @@
7272 import { repoToday } from "../agentic/today-repo.js";
7373 import { Social } from "./social.js";
7474 import { Statuses } from "./statuses.js";
75-import { atLeast, globToRegExp, type Caller, type ProtectionRules, type RepoMeta } from "./types.js";
75+import { MAX_LOG_DEPTH, atLeast, globToRegExp, type Caller, type ProtectionRules, type RepoMeta } from "./types.js";
7676
7777 const MAX_PUSH_BYTES = 100 * 1024 * 1024;
7878 /** Pushed blobs this large are hashed, scanned and stored from their compressed bytes, never held inflated. */
@@ -134,7 +134,7 @@
134134 migrateRepoSchema(this.sql);
135135 const meta = this.readMeta();
136136 if (meta) this.store = new RepoObjectStore(this.sql, env.BUCKET, meta.repoId, () => this.storageChanged());
137- this.collab = new Collab(this);
137+ this.collab = new Collab(this, env);
138138 this.actions = new Actions(this, env);
139139 this.queue = new MergeQueue(this);
140140 this.autoMerge = new AutoMerge(this, env);
@@ -852,9 +852,11 @@
852852 const sha = await this.resolve(rev);
853853 if (!sha) return [];
854854 const limit = Math.min(opts.limit ?? 30, 100);
855- const skip = opts.skip ?? 0;
855+ const skip = Math.max(opts.skip ?? 0, 0);
856856 if (!opts.path) {
857- const commits = await walkCommits(this.store, [sha], { limit: limit + skip });
857+ // Paging past MAX_LOG_DEPTH lists nothing rather than walking the whole history for one page.
858+ if (skip >= MAX_LOG_DEPTH) return [];
859+ const commits = await walkCommits(this.store, [sha], { limit: Math.min(limit + skip, MAX_LOG_DEPTH) });
858860 return commits.slice(skip).map((c) => toInfo(c.oid, c.commit));
859861 }
860862 // Path history: commits whose tree entry at `path` differs from their first parent's.
@@ -1139,6 +1141,9 @@
11391141 webhooks(...a: Parameters<Collab["webhooks"]>) {
11401142 return this.collab.webhooks(...a);
11411143 }
1144+ deliverableWebhooks(...a: Parameters<Collab["deliverableWebhooks"]>) {
1145+ return this.collab.deliverableWebhooks(...a);
1146+ }
11421147 createWebhook(url: string, events: string[], secret?: string) {
11431148 return this.collab.createWebhook(url, events, secret, new URL(this.env.PUBLIC_URL).hostname);
11441149 }
modifiedapps/diffinery/src/repo/actions.ts+23−2View file
@@ -41,6 +41,22 @@
4141
4242 /** How long a sandbox token works after the sandbox starts. */
4343 export const SANDBOX_TOKEN_TTL_MS = 24 * 3600_000;
44+/** A job token works this long past the job's timeout, so a runner can still report a job that hit it. */
45+const JOB_TOKEN_MARGIN_MS = 30 * 60_000;
46+/** A job token never works longer than this after the job started (timeouts are at most 360 minutes). */
47+export const MAX_JOB_TOKEN_TTL_MS = 7 * 3600_000;
48+
49+/**
50+ * When a job's token stops working, in ms since the epoch: its timeout plus a margin after the job
51+ * started (or, before it starts, after the token was issued), at most MAX_JOB_TOKEN_TTL_MS. The
52+ * Workflow that times jobs out normally ends the token first; this holds even if it never does.
53+ */
54+export function jobTokenExpiry(j: Pick<JobRow, "started_at" | "token_at" | "timeout_minutes">): number | null {
55+ const from = Date.parse(j.started_at ?? j.token_at ?? "");
56+ if (!Number.isFinite(from)) return null;
57+ const minutes = Math.max(Number(j.timeout_minutes) || 0, 0);
58+ return from + Math.min(minutes * 60_000 + JOB_TOKEN_MARGIN_MS, MAX_JOB_TOKEN_TTL_MS);
59+}
4460 /** Sandboxes one person or agent may have open in a repository at once: their containers are shared by the whole instance. */
4561 export const MAX_OPEN_SANDBOXES = 5;
4662
@@ -182,6 +198,8 @@
182198 steps: string;
183199 failure: string | null;
184200 token_hash: string | null;
201+ /** When token_hash was issued (null for tokens issued before it was recorded, until the schema backfills it). */
202+ token_at: string | null;
185203 summary: string | null;
186204 container: string | null;
187205 timeout_minutes: number;
@@ -1237,7 +1255,7 @@
12371255 const { size, note } = this.runnerSize(job, run);
12381256 // A restarted job gets a new container: the replaced one may still be shutting down.
12391257 const container = containerName(jobId, job.restarts, size);
1240- this.sql.exec("UPDATE jobs SET status = 'waiting', token_hash = ?, container = ?, size_note = ? WHERE id = ?", await sha256(token), container, note, jobId);
1258+ this.sql.exec("UPDATE jobs SET status = 'waiting', token_hash = ?, token_at = ?, container = ?, size_note = ? WHERE id = ?", await sha256(token), now(), container, note, jobId);
12411259 await startCompute(this.env, { id: jobId, accountId: m.ownerId, repoId: m.repoId, kind: "job" }).catch((e: unknown) => console.error("compute usage failed", e));
12421260 this.repo.upsertCheck({ sha: run.head_sha ?? run.sha, name: this.checkName(run, job), status: "queued", runId: run.id, jobId, detailsUrl: `${this.env.PUBLIC_URL}/${m.owner}/${m.name}/actions/runs/${run.id}/job/${jobId}` });
12431261 return { jobId, container, token, egress: await egressFor(this.env, m.ownerId), vcpu: RUNNER_SIZES[size].vcpu };
@@ -1640,7 +1658,7 @@
16401658 const m = this.repo.meta;
16411659 const token = `djt_${m.repoId}_${job.id}_${randomToken(24)}`;
16421660 const container = `self-hosted:${runnerName.slice(0, 64)}`;
1643- this.sql.exec("UPDATE jobs SET token_hash = ?, container = ?, runner_id = ? WHERE id = ? AND container IS NULL", await sha256(token), container, runnerId, job.id);
1661+ this.sql.exec("UPDATE jobs SET token_hash = ?, token_at = ?, container = ?, runner_id = ? WHERE id = ? AND container IS NULL", await sha256(token), now(), container, runnerId, job.id);
16441662 this.repo.broadcast(`run:${job.run_id}`, { action: "job", jobId: job.id });
16451663 return { jobId: job.id, token, runId: job.run_id, name: job.name };
16461664 }
@@ -1941,6 +1959,9 @@
19411959 if (parts[0] === "djt") {
19421960 const j = this.sql.exec<JobRow>("SELECT * FROM jobs WHERE id = ?", parts[2]!).toArray()[0];
19431961 if (!j || j.token_hash !== hash || j.status === "completed") return null;
1962+ // Bounded in time too: a job whose Workflow died never completes, and its token mustn't live on.
1963+ const expires = jobTokenExpiry(j);
1964+ if (expires === null || Date.now() >= expires) return null;
19441965 const grant = this.repo.runtime.grantFor(j.id);
19451966 // Code an agent wrote, pushed or opened a pull request with runs as that agent (repo/token-policy.ts).
19461967 const run = this.sql.exec<{ agent_origin: number }>("SELECT agent_origin FROM runs WHERE id = ?", j.run_id).toArray()[0];
modifiedapps/diffinery/src/repo/collab.ts+62−6View file
@@ -1,5 +1,7 @@
11 import { ZERO_OID, isAncestor, mergeBase, mergeTrees, readCommit, serializeCommit, walkCommits } from "@diffinery/git-core";
22 import { checkPublicUrl } from "../imports/options.js";
3+import type { Env } from "../env.js";
4+import { decryptSecret, encryptSecret } from "../lib/crypto.js";
35 import type { QueueEntry } from "./queue.js";
46 import { HttpError, checkText, newId, now, randomToken } from "../lib/util.js";
57 import type { RepoDO } from "./RepoDO.js";
@@ -167,8 +169,22 @@
167169 /** Webhooks one repository may have, as on GitHub. */
168170 export const MAX_WEBHOOKS = 20;
169171
172+/** A webhook as listed: everything but its signing secret. */
173+export interface Webhook {
174+ id: string;
175+ url: string;
176+ events: string[];
177+ active: boolean;
178+ createdAt: string;
179+ lastStatus: number | null;
180+ lastDeliveryAt: string | null;
181+}
182+
170183 export class Collab {
171- constructor(private repo: RepoDO) {}
184+ constructor(
185+ private repo: RepoDO,
186+ private env: Env,
187+ ) {}
172188
173189 private get sql() {
174190 return this.repo.sql;
@@ -1038,9 +1054,10 @@
10381054
10391055 // ---------------------------------------------------------------- webhooks
10401056
1041- webhooks(): Array<{ id: string; url: string; events: string[]; active: boolean; createdAt: string; lastStatus: number | null; lastDeliveryAt: string | null; secret: string }> {
1057+ /** The repository's webhooks, without their signing secrets. */
1058+ webhooks(): Webhook[] {
10421059 return this.sql
1043- .exec<Record<string, string | number | null>>("SELECT * FROM webhooks ORDER BY created_at")
1060+ .exec<Record<string, string | number | null>>("SELECT id, url, events, active, created_at, last_status, last_delivery_at FROM webhooks ORDER BY created_at")
10441061 .toArray()
10451062 .map((w) => ({
10461063 id: w.id as string,
@@ -1050,11 +1067,45 @@
10501067 createdAt: w.created_at as string,
10511068 lastStatus: (w.last_status as number) ?? null,
10521069 lastDeliveryAt: (w.last_delivery_at as string) ?? null,
1053- secret: w.secret as string,
10541070 }));
10551071 }
10561072
1057- createWebhook(url: string, events: string[], secret: string | undefined, ownHost: string): { id: string; secret: string } {
1073+ /**
1074+ * The active webhooks with their signing secrets, for delivery. Secrets are stored encrypted; a hook
1075+ * saved before that (secret_encrypted = 0) is still signed with its plaintext secret, which is
1076+ * encrypted in place here so it doesn't stay in the clear.
1077+ */
1078+ async deliverableWebhooks(): Promise<Array<Webhook & { secret: string }>> {
1079+ const rows = new Map(
1080+ this.sql
1081+ .exec<{ id: string; secret: string; secret_encrypted: number }>("SELECT id, secret, secret_encrypted FROM webhooks WHERE active = 1")
1082+ .toArray()
1083+ .map((r) => [r.id, r]),
1084+ );
1085+ const out: Array<Webhook & { secret: string }> = [];
1086+ for (const h of this.webhooks()) {
1087+ const r = rows.get(h.id);
1088+ if (!r) continue;
1089+ if (r.secret_encrypted) {
1090+ // A secret that doesn't decrypt (a changed MASTER_KEY, a damaged row) leaves that one hook undelivered, not every hook.
1091+ const secret = await decryptSecret(this.env.MASTER_KEY, this.hookScope(h.id), r.secret).catch((e: Error) => void console.error("webhook secret unreadable", h.id, e.message));
1092+ if (secret !== undefined) out.push({ ...h, secret });
1093+ continue;
1094+ }
1095+ const sealed = await encryptSecret(this.env.MASTER_KEY, this.hookScope(h.id), r.secret);
1096+ // Only if the row is still the plaintext read above (no concurrent re-encryption or delete).
1097+ this.sql.exec("UPDATE webhooks SET secret = ?, secret_encrypted = 1 WHERE id = ? AND secret_encrypted = 0 AND secret = ?", sealed, h.id, r.secret);
1098+ out.push({ ...h, secret: r.secret });
1099+ }
1100+ return out;
1101+ }
1102+
1103+ /** Each hook's secret gets its own key, so a ciphertext can't be moved to another hook or into the repository's Actions secrets. */
1104+ private hookScope(id: string): string {
1105+ return `repo:${this.repo.meta.repoId}:webhook:${id}`;
1106+ }
1107+
1108+ async createWebhook(url: string, events: string[], secret: string | undefined, ownHost: string): Promise<{ id: string; secret: string }> {
10581109 // HTTPS to a public host other than Diffinery's own, at most 20 per repository (as on GitHub), so a
10591110 // repository can't aim deliveries at internal addresses or multiply every event into a flood.
10601111 checkPublicUrl(url, false, "webhook URL", ownHost);
@@ -1062,7 +1113,12 @@
10621113 if (count >= MAX_WEBHOOKS) throw new HttpError(422, `a repository can have at most ${MAX_WEBHOOKS} webhooks`);
10631114 const id = newId();
10641115 const s = secret ?? randomToken(24);
1065- this.sql.exec("INSERT INTO webhooks (id, url, secret, events, created_at) VALUES (?, ?, ?, ?, ?)", id, url, s, JSON.stringify(events.length ? events : ["*"]), now());
1116+ const sealed = await encryptSecret(this.env.MASTER_KEY, this.hookScope(id), s).catch((e: Error) => {
1117+ throw new HttpError(422, `could not store the webhook secret: ${e.message || "encryption unavailable"} (is MASTER_KEY configured?)`);
1118+ });
1119+ // Counted again after the await, so concurrent creates can't pass the limit together.
1120+ if (this.sql.exec<{ n: number }>("SELECT count(*) AS n FROM webhooks").one().n >= MAX_WEBHOOKS) throw new HttpError(422, `a repository can have at most ${MAX_WEBHOOKS} webhooks`);
1121+ this.sql.exec("INSERT INTO webhooks (id, url, secret, secret_encrypted, events, created_at) VALUES (?, ?, ?, 1, ?, ?)", id, url, sealed, JSON.stringify(events.length ? events : ["*"]), now());
10661122 return { id, secret: s };
10671123 }
10681124
modifiedapps/diffinery/src/repo/schema.ts+8−1View file
@@ -171,6 +171,8 @@
171171 // The commit a run reports its checks on: the PR head for pull_request runs, whose
172172 // GITHUB_SHA is the test merge commit. Null means the run's own sha.
173173 ["runs", "head_sha", "TEXT"],
174+ // 1 when a webhook's secret is an encrypted envelope (repo/collab.ts); older rows hold it in plaintext until their next delivery.
175+ ["webhooks", "secret_encrypted", "INTEGER NOT NULL DEFAULT 0"],
174176 // 1 when the code a run executes came from an agent (repo/actions.ts agentOrigin): its jobs act as an agent.
175177 ["runs", "agent_origin", "INTEGER NOT NULL DEFAULT 0"],
176178 // The repository a pull request's head branch lives in, when it is not this one (a fork).
@@ -250,12 +252,17 @@
250252 ["comments", "minimized_reason", "TEXT"],
251253 ["comments", "minimized_by", "TEXT"],
252254 ["comments", "minimized_at", "TEXT"],
255+ // When the job's current token was issued (repo/actions.ts jobTokenExpiry); a job not yet started counts from it.
256+ ["jobs", "token_at", "TEXT"],
253257 ];
254258
255259 export function migrateRepoSchema(sql: SqlStorage): void {
256260 for (const [table, column, definition] of ADDED_COLUMNS) {
257261 const has = sql.exec<{ n: number }>(`SELECT count(*) AS n FROM pragma_table_info('${table}') WHERE name = ?`, column).one().n > 0;
258- if (!has) sql.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
262+ if (has) continue;
263+ sql.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
264+ // Job tokens issued before token_at was recorded count from this upgrade, so they still expire.
265+ if (table === "jobs" && column === "token_at") sql.exec("UPDATE jobs SET token_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE token_hash IS NOT NULL");
259266 }
260267 // Older runners' artifacts were first recorded in `artifacts`, which the REST API didn't list: they
261268 // move to rt_artifacts, kept for 90 days from their upload. One shadowed by an artifact service
modifiedapps/diffinery/src/repo/types.ts+2−0View file
@@ -4,6 +4,8 @@
44 export type Role = "read" | "triage" | "write" | "maintain" | "admin";
55 export const ROLE_LEVEL: Record<Role, number> = { read: 1, triage: 2, write: 3, maintain: 4, admin: 5 };
66 export const atLeast = (role: Role | null | undefined, needed: Role): boolean => !!role && ROLE_LEVEL[role] >= ROLE_LEVEL[needed];
7+/** How far back a commit listing may page: one request walks at most this many commits, however deep the page asks to go. */
8+export const MAX_LOG_DEPTH = 10_000;
79
810 /** The authenticated caller, with their effective role on this repository. */
911 export interface Caller extends Actor {
modifiedapps/diffinery/test/e2e/email.test.mjs+4−1View file
@@ -242,7 +242,10 @@
242242 assert.ok(link.startsWith(`${base}/-/unsubscribe/`));
243243 assert.equal(reply.dev_copy.headers["List-Unsubscribe-Post"], "List-Unsubscribe=One-Click");
244244 const confirm = await fetch(link);
245- assert.match(await confirm.text(), /Turn off email about issues and pull requests you take part in/);
245+ const confirmPage = await confirm.text();
246+ assert.match(confirmPage, /Turn off email about issues and pull requests you take part in/);
247+ // Whoever holds the link doesn't learn the account's email address from it.
248+ assert.ok(reply.to_addr && !confirmPage.includes(reply.to_addr), "the unsubscribe page doesn't show the email address");
246249 const unsub = await fetch(link, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: "List-Unsubscribe=One-Click" });
247250 assert.equal(unsub.status, 200, await unsub.clone().text());
248251 assert.equal((await ok("GET", "/diffinery/notification-settings")).participating.email, false);
modifiedapps/diffinery/test/e2e/security.test.mjs+3−0View file
@@ -2232,6 +2232,9 @@
22322232 await status(201, "POST", `${R}/check-runs`, { name: "ext", head_sha: sha, details_url: "https://ci.example/run/1" });
22332233 // Paging that isn't a number falls back to the defaults instead of an odd query.
22342234 assert.equal((await ok("GET", `${R}/actions/runs?page=-3&per_page=abc`)).total_count, 0);
2235+ // Commit history pages only so deep: one request doesn't walk millions of commits.
2236+ assert.equal((await ok("GET", `${R}/commits?per_page=100&page=2`)).length, 0);
2237+ assert.match((await status(422, "GET", `${R}/commits?per_page=100&page=101`)).message, /pagination is limited/);
22352238 // One MCP request carries at most 20 calls.
22362239 const batch = Array.from({ length: 21 }, (_, i) => ({ jsonrpc: "2.0", id: i, method: "ping" }));
22372240 const r = await fetch(`${base}/mcp`, { method: "POST", headers: { authorization: `Bearer ${T.olive}`, "content-type": "application/json", accept: "application/json, text/event-stream" }, body: JSON.stringify(batch) });
addedapps/diffinery/test/unit/commit-log.test.ts+46−0View file
@@ -1,0 +1,46 @@
1+import { describe, expect, it } from "vitest";
2+import { MemoryStore, serializeCommit, serializeTree } from "@diffinery/git-core";
3+import { RepoDO } from "../../src/repo/RepoDO.js";
4+import { MAX_LOG_DEPTH } from "../../src/repo/types.js";
5+
6+/** A linear history of `n` commits, newest last; returns the store and the tip. */
7+async function history(n: number) {
8+ const store = new MemoryStore();
9+ const tree = await store.write("tree", serializeTree([]));
10+ let tip: string | null = null;
11+ for (let i = 0; i < n; i++) {
12+ const sig = { name: "a", email: "a@x.test", timestamp: 1_700_000_000 + i, timezone: "+0000" };
13+ tip = await store.write("commit", serializeCommit({ tree, parents: tip ? [tip] : [], author: sig, committer: sig, message: `c${i}\n` }));
14+ }
15+ return { store, tip: tip! };
16+}
17+
18+/** RepoDO.log run against a bare object store, with `resolve` answering the tip for any revision. */
19+function logOf(store: MemoryStore, tip: string, counter?: { reads: number }) {
20+ const counted = counter ? { read: (oid: string) => (counter.reads++, store.read(oid)) } : store;
21+ const self = { store: counted, resolve: async () => tip };
22+ return (opts: { limit?: number; skip?: number }) => RepoDO.prototype.log.call(self as unknown as RepoDO, "HEAD", opts);
23+}
24+
25+describe("commit listing depth", () => {
26+ it("pages through history as usual", async () => {
27+ const { store, tip } = await history(12);
28+ const log = logOf(store, tip);
29+ expect((await log({ limit: 5 })).map((c) => c.message.trim())).toEqual(["c11", "c10", "c9", "c8", "c7"]);
30+ expect((await log({ limit: 5, skip: 10 })).map((c) => c.message.trim())).toEqual(["c1", "c0"]);
31+ expect(await log({ limit: 5, skip: 20 })).toEqual([]);
32+ });
33+
34+ it("lists nothing past MAX_LOG_DEPTH and never walks further than it", async () => {
35+ const { store, tip } = await history(MAX_LOG_DEPTH + 50);
36+ const counter = { reads: 0 };
37+ const log = logOf(store, tip, counter);
38+ expect(await log({ limit: 100, skip: MAX_LOG_DEPTH })).toEqual([]);
39+ expect(await log({ limit: 100, skip: 10_000_000 })).toEqual([]);
40+ expect(counter.reads).toBe(0);
41+ // The last page inside the window is cut at the window's end, so it shows no further page.
42+ const last = await log({ limit: 101, skip: MAX_LOG_DEPTH - 100 });
43+ expect(last).toHaveLength(100);
44+ expect(counter.reads).toBeLessThanOrEqual(MAX_LOG_DEPTH + 1);
45+ });
46+});
modifiedapps/diffinery/test/unit/email.test.ts+21−1View file
@@ -6,7 +6,7 @@
66 import { buildMessage, senderAddress } from "../../src/mail/send.js";
77 import { compose, toHtml, toText } from "../../src/mail/templates.js";
88 import { categoryOf, isMandatory, type MailJob } from "../../src/mail/types.js";
9-import { readUnsubToken, unsubToken } from "../../src/mail/unsubscribe.js";
9+import { UNSUB_LEGACY_CUTOFF_MS, UNSUB_TTL_MS, readUnsubToken, unsubToken } from "../../src/mail/unsubscribe.js";
1010
1111 const PUB = "https://diffinery.example";
1212 const env = { PUBLIC_URL: PUB, MASTER_KEY: "test-master-key", MAIL_FROM: "notifications@diffinery.example" } as unknown as Env;
@@ -150,6 +150,26 @@
150150 expect(await readUnsubToken(env, `${payload}.${sig!.slice(0, -2)}xx`)).toBeNull();
151151 expect(await readUnsubToken({ ...env, MASTER_KEY: "another-key" } as Env, t)).toBeNull();
152152 });
153+
154+ it("expire about a year after they're sent", async () => {
155+ const sent = Date.UTC(2027, 0, 1);
156+ const t = await unsubToken(env, { accountId: "acc1", scope: { t: "category", category: "watching" } }, sent);
157+ expect(await readUnsubToken(env, t, sent + UNSUB_TTL_MS - 1000)).toEqual({ accountId: "acc1", scope: { t: "category", category: "watching" } });
158+ expect(await readUnsubToken(env, t, sent + UNSUB_TTL_MS)).toBeNull();
159+ });
160+
161+ it("keep links sent before expiries working only until the cutoff", async () => {
162+ // A link as they were signed before: no `e`, signed with the same key.
163+ const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(`diffinery-unsubscribe-v1:${env.MASTER_KEY}`), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
164+ const payload = Buffer.from(JSON.stringify({ a: "acc1", s: { t: "category", category: "watching" } })).toString("base64url");
165+ const old = `${payload}.${Buffer.from(await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(payload))).toString("base64url")}`;
166+ expect(await readUnsubToken(env, old, UNSUB_LEGACY_CUTOFF_MS - 1000)).toEqual({ accountId: "acc1", scope: { t: "category", category: "watching" } });
167+ expect(await readUnsubToken(env, old, UNSUB_LEGACY_CUTOFF_MS)).toBeNull();
168+ // An expiry that isn't a number never passes.
169+ const odd = Buffer.from(JSON.stringify({ a: "acc1", s: { t: "category", category: "watching" }, e: "9999999999" })).toString("base64url");
170+ const oddToken = `${odd}.${Buffer.from(await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(odd))).toString("base64url")}`;
171+ expect(await readUnsubToken(env, oddToken, UNSUB_LEGACY_CUTOFF_MS - 1000)).toBeNull();
172+ });
153173 });
154174
155175 describe("Markdown in email", () => {
addedapps/diffinery/test/unit/job-token.test.ts+100−0View file
@@ -1,0 +1,100 @@
1+import { DatabaseSync } from "node:sqlite";
2+import { afterEach, describe, expect, it, vi } from "vitest";
3+import type { Env } from "../../src/env.js";
4+import { sha256 } from "../../src/lib/util.js";
5+import { Actions, MAX_JOB_TOKEN_TTL_MS, jobTokenExpiry } from "../../src/repo/actions.js";
6+import type { RepoDO } from "../../src/repo/RepoDO.js";
7+import { REPO_SCHEMA, migrateRepoSchema } from "../../src/repo/schema.js";
8+
9+const HOUR = 3600_000;
10+const t0 = Date.parse("2026-10-16T00:00:00.000Z");
11+const at = (ms: number) => new Date(ms).toISOString();
12+
13+describe("when a job token expires", () => {
14+ it("counts the job's timeout plus half an hour from when it started", () => {
15+ expect(jobTokenExpiry({ started_at: at(t0), token_at: at(t0 - HOUR), timeout_minutes: 60 })).toBe(t0 + 1.5 * HOUR);
16+ // The longest timeout a job can have (360 minutes) still gets its margin.
17+ expect(jobTokenExpiry({ started_at: at(t0), token_at: null, timeout_minutes: 360 })).toBe(t0 + 6.5 * HOUR);
18+ });
19+
20+ it("counts from when the token was issued while the job hasn't started", () => {
21+ expect(jobTokenExpiry({ started_at: null, token_at: at(t0), timeout_minutes: 10 })).toBe(t0 + 40 * 60_000);
22+ });
23+
24+ it("never lasts more than seven hours, whatever the row says", () => {
25+ expect(jobTokenExpiry({ started_at: at(t0), token_at: null, timeout_minutes: 100_000 })).toBe(t0 + MAX_JOB_TOKEN_TTL_MS);
26+ expect(jobTokenExpiry({ started_at: at(t0), token_at: null, timeout_minutes: -5 })).toBe(t0 + 30 * 60_000);
27+ });
28+
29+ it("is unknown without a start or issue time", () => {
30+ expect(jobTokenExpiry({ started_at: null, token_at: null, timeout_minutes: 60 })).toBeNull();
31+ });
32+});
33+
34+/** A minimal SqlStorage over node:sqlite with the repository schema. */
35+function sqlStorage(): SqlStorage {
36+ const db = new DatabaseSync(":memory:");
37+ db.exec(REPO_SCHEMA);
38+ const sql = {
39+ exec(query: string, ...bindings: unknown[]) {
40+ const stmt = db.prepare(query);
41+ if (/^\s*select/i.test(query)) {
42+ const rows = stmt.all(...(bindings as never[]));
43+ return { toArray: () => rows, one: () => rows[0] } as unknown as SqlStorageCursor<Record<string, SqlStorageValue>>;
44+ }
45+ stmt.run(...(bindings as never[]));
46+ return { toArray: () => [], one: () => undefined } as unknown as SqlStorageCursor<Record<string, SqlStorageValue>>;
47+ },
48+ } as unknown as SqlStorage;
49+ migrateRepoSchema(sql);
50+ return sql;
51+}
52+
53+describe("job tokens", () => {
54+ afterEach(() => void vi.useRealTimers());
55+
56+ async function fixture(o: { started_at: string | null; token_at: string | null; status?: string; timeout?: number }) {
57+ const sql = sqlStorage();
58+ const token = "djt_r_j1_secretsecretsecret";
59+ sql.exec("INSERT INTO runs (id, run_number, workflow_path, workflow_name, event, ref, sha, actor, status, created_at, updated_at, event_payload) VALUES ('run1', 1, 'w.yml', 'w', 'push', 'refs/heads/main', 'abc', '{}', 'in_progress', ?, ?, '{}')", at(t0), at(t0));
60+ sql.exec(
61+ "INSERT INTO jobs (id, run_id, job_key, name, status, matrix, strategy, timeout_minutes, token_hash, token_at, started_at) VALUES ('j1', 'run1', 'build', 'build', ?, '{}', '{}', ?, ?, ?, ?)",
62+ o.status ?? "in_progress",
63+ o.timeout ?? 60,
64+ await sha256(token),
65+ o.token_at,
66+ o.started_at,
67+ );
68+ const repo = { sql, meta: { repoId: "r" }, runtime: { grantFor: () => ({ role: "write", permissions: {} }) } } as unknown as RepoDO;
69+ return { actions: new Actions(repo, {} as Env), token, sql };
70+ }
71+
72+ it("work while the job is within its timeout and margin, and not after, even if it never completes", async () => {
73+ const { actions, token } = await fixture({ started_at: at(t0), token_at: at(t0 - 60_000) });
74+ vi.useFakeTimers({ now: t0 + 89 * 60_000 });
75+ expect((await actions.runnerAuth(token))?.kind).toBe("job");
76+ vi.setSystemTime(t0 + 90 * 60_000);
77+ expect(await actions.runnerAuth(token)).toBeNull();
78+ });
79+
80+ it("issued for a job that never started stop working too", async () => {
81+ const { actions, token } = await fixture({ started_at: null, token_at: at(t0), status: "waiting" });
82+ vi.useFakeTimers({ now: t0 + 60_000 });
83+ expect((await actions.runnerAuth(token))?.kind).toBe("job");
84+ vi.setSystemTime(t0 + 2 * HOUR);
85+ expect(await actions.runnerAuth(token)).toBeNull();
86+ });
87+
88+ it("issued before token_at was recorded count from the upgrade", async () => {
89+ const sql = sqlStorage();
90+ // Drop the column and add it back, as an upgrade would, over a job holding a token.
91+ sql.exec("ALTER TABLE jobs DROP COLUMN token_at");
92+ sql.exec("INSERT INTO runs (id, run_number, workflow_path, workflow_name, event, ref, sha, actor, status, created_at, updated_at, event_payload) VALUES ('run1', 1, 'w.yml', 'w', 'push', 'refs/heads/main', 'abc', '{}', 'in_progress', ?, ?, '{}')", at(t0), at(t0));
93+ sql.exec("INSERT INTO jobs (id, run_id, job_key, name, status, matrix, strategy, token_hash) VALUES ('j1', 'run1', 'build', 'build', 'waiting', '{}', '{}', 'h')");
94+ sql.exec("INSERT INTO jobs (id, run_id, job_key, name, status, matrix, strategy) VALUES ('j2', 'run1', 'test', 'test', 'queued', '{}', '{}')");
95+ migrateRepoSchema(sql);
96+ const rows = sql.exec<{ id: string; token_at: string | null }>("SELECT id, token_at FROM jobs ORDER BY id").toArray();
97+ expect(Date.parse(rows[0]!.token_at!)).toBeGreaterThan(Date.now() - 60_000);
98+ expect(rows[1]!.token_at).toBeNull();
99+ });
100+});
addedapps/diffinery/test/unit/search-fanout.test.ts+11−0View file
@@ -1,0 +1,11 @@
1+import { describe, expect, it } from "vitest";
2+import type { Principal } from "../../src/auth/principal.js";
3+import { searchFanOut } from "../../src/http/search.js";
4+
5+describe("issue search fan-out", () => {
6+ it("searches fewer repositories for anonymous callers than for signed-in ones", () => {
7+ expect(searchFanOut({ type: "anonymous" })).toBe(10);
8+ expect(searchFanOut({ type: "account" } as Principal)).toBe(50);
9+ expect(searchFanOut({ type: "repo-token", repoId: "r", token: "t" })).toBe(50);
10+ });
11+});
modifiedapps/diffinery/test/unit/webhooks.test.ts+86−0View file
@@ -1,4 +1,9 @@
1+import { DatabaseSync } from "node:sqlite";
12 import { describe, expect, it } from "vitest";
3+import type { Env } from "../../src/env.js";
4+import { Collab } from "../../src/repo/collab.js";
5+import type { RepoDO } from "../../src/repo/RepoDO.js";
6+import { REPO_SCHEMA, migrateRepoSchema } from "../../src/repo/schema.js";
27 import { deliverWebhook, WEBHOOK_RETRY_DELAYS, type WebhookDeps, type WebhookRetry } from "../../src/lib/webhooks.js";
38
49 const hook = { id: "h1", url: "https://hooks.example/x", secret: "s3cret" };
@@ -70,3 +75,84 @@
7075 }
7176 });
7277 });
78+
79+// ---------------------------------------------------------------- signing secrets at rest
80+
81+/** A minimal SqlStorage over node:sqlite with the repository schema. */
82+function sqlStorage(): SqlStorage {
83+ const db = new DatabaseSync(":memory:");
84+ db.exec(REPO_SCHEMA);
85+ const sql = {
86+ exec(query: string, ...bindings: unknown[]) {
87+ const stmt = db.prepare(query);
88+ if (/^\s*select/i.test(query)) {
89+ const rows = stmt.all(...(bindings as never[]));
90+ return { toArray: () => rows, one: () => rows[0] } as unknown as SqlStorageCursor<Record<string, SqlStorageValue>>;
91+ }
92+ stmt.run(...(bindings as never[]));
93+ return { toArray: () => [], one: () => undefined } as unknown as SqlStorageCursor<Record<string, SqlStorageValue>>;
94+ },
95+ } as unknown as SqlStorage;
96+ migrateRepoSchema(sql);
97+ return sql;
98+}
99+
100+describe("webhook secrets", () => {
101+ const env = { MASTER_KEY: Buffer.alloc(32, 7).toString("base64") } as Env;
102+ const collab = () => {
103+ const sql = sqlStorage();
104+ return { sql, collab: new Collab({ sql, meta: { repoId: "r1" } } as unknown as RepoDO, env) };
105+ };
106+ const stored = (sql: SqlStorage, id: string) => sql.exec<{ secret: string; secret_encrypted: number }>("SELECT secret, secret_encrypted FROM webhooks WHERE id = ?", id).one();
107+
108+ it("are stored encrypted, never listed, and decrypted for delivery", async () => {
109+ const { sql, collab: c } = collab();
110+ const made = await c.createWebhook("https://hooks.example/x", ["push"], "s3cret", "diffinery.test");
111+ expect(made.secret).toBe("s3cret");
112+ const row = stored(sql, made.id);
113+ expect(row.secret_encrypted).toBe(1);
114+ expect(row.secret).toMatch(/^v1\./);
115+ expect(row.secret).not.toContain("s3cret");
116+ expect(c.webhooks()[0]).not.toHaveProperty("secret");
117+ expect((await c.deliverableWebhooks()).map((h) => h.secret)).toEqual(["s3cret"]);
118+ // A generated secret is shown once and encrypted the same way.
119+ const generated = await c.createWebhook("https://hooks.example/y", [], undefined, "diffinery.test");
120+ expect(stored(sql, generated.id).secret).not.toContain(generated.secret);
121+ expect((await c.deliverableWebhooks()).find((h) => h.id === generated.id)?.secret).toBe(generated.secret);
122+ });
123+
124+ it("can't be moved to another hook", async () => {
125+ const { sql, collab: c } = collab();
126+ const a = await c.createWebhook("https://hooks.example/a", [], "aaa", "diffinery.test");
127+ const b = await c.createWebhook("https://hooks.example/b", [], "bbb", "diffinery.test");
128+ sql.exec("UPDATE webhooks SET secret = ? WHERE id = ?", stored(sql, a.id).secret, b.id);
129+ // Only the hook whose secret doesn't decrypt goes undelivered.
130+ expect((await c.deliverableWebhooks()).map((h) => [h.id, h.secret])).toEqual([[a.id, "aaa"]]);
131+ });
132+
133+ it("saved in plaintext before keep signing, and are encrypted on their next delivery", async () => {
134+ const { sql, collab: c } = collab();
135+ // A row as older versions wrote it; one that looks like an envelope is still taken as plaintext.
136+ sql.exec("INSERT INTO webhooks (id, url, secret, events, created_at) VALUES ('old', 'https://hooks.example/old', 'v1.not.encrypted', '[\"*\"]', '2026-01-01T00:00:00Z')");
137+ expect((await c.deliverableWebhooks()).map((h) => h.secret)).toEqual(["v1.not.encrypted"]);
138+ const row = stored(sql, "old");
139+ expect(row.secret_encrypted).toBe(1);
140+ expect(row.secret).not.toBe("v1.not.encrypted");
141+ expect((await c.deliverableWebhooks()).map((h) => h.secret)).toEqual(["v1.not.encrypted"]);
142+ });
143+
144+ it("leave inactive hooks out of delivery", async () => {
145+ const { sql, collab: c } = collab();
146+ const h = await c.createWebhook("https://hooks.example/x", [], "s", "diffinery.test");
147+ sql.exec("UPDATE webhooks SET active = 0 WHERE id = ?", h.id);
148+ expect(await c.deliverableWebhooks()).toEqual([]);
149+ expect(c.webhooks()).toHaveLength(1);
150+ });
151+
152+ it("can't be stored without MASTER_KEY", async () => {
153+ const sql = sqlStorage();
154+ const c = new Collab({ sql, meta: { repoId: "r1" } } as unknown as RepoDO, {} as Env);
155+ await expect(c.createWebhook("https://hooks.example/x", [], "s", "diffinery.test")).rejects.toThrow(/is MASTER_KEY configured/);
156+ expect(c.webhooks()).toEqual([]);
157+ });
158+});