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.

63 lines · 3.1 KBRaw History
1import { devAuth } from "../auth/dev.js";
2import type { Env } from "../env.js";
3import { hmacSha256, matchesAnyKey, signingSecrets } from "../lib/crypto.js";
4import { b64url, fromB64url } from "../lib/util.js";
5import type { Category } from "./types.js";
6
7/**
8 * Unsubscribe links work without signing in, so each one carries a signed
9 * capability: whose settings to change and what to turn off. The key is
10 * derived from MASTER_KEY, so links stay valid across deploys (and across a
11 * rotation, while the old key is MASTER_KEY_PREV), and each one expires about
12 * a year after it was sent.
13 */
14export type UnsubScope =
15 /** Stop email for a settings group. */
16 | { t: "category"; category: Exclude<Category, "security" | "account"> }
17 /** Mute one issue or pull request. */
18 | { t: "thread"; repoId: string; number: number; repo: string }
19 /** Stop watching a repository. */
20 | { t: "repo"; repoId: string; repo: string };
21
22export interface UnsubToken {
23 accountId: string;
24 scope: UnsubScope;
25}
26
27function secrets(env: Env): string[] {
28 // Local development has no MASTER_KEY unless one is passed; production always does.
29 return signingSecrets(env, devAuth(env) ? "diffinery-dev-unsubscribe" : null, "MASTER_KEY is not set, so unsubscribe links can't be signed");
30}
31
32const signWith = async (secret: string, payload: string): Promise<string> => b64url(await hmacSha256(`diffinery-unsubscribe-v1:${secret}`, payload));
33
34/** How long an unsubscribe link works: about a year, so old emails in someone's inbox stop being a capability. */
35export const UNSUB_TTL_MS = 365 * 24 * 60 * 60 * 1000;
36/** Links signed before they carried an expiry keep working until this date (a year after expiries were added). */
37export const UNSUB_LEGACY_CUTOFF_MS = Date.UTC(2027, 9, 16);
38
39export async function unsubToken(env: Env, t: UnsubToken, now = Date.now()): Promise<string> {
40 // `e` is the expiry in seconds since the epoch.
41 const payload = b64url(new TextEncoder().encode(JSON.stringify({ a: t.accountId, s: t.scope, e: Math.floor((now + UNSUB_TTL_MS) / 1000) })));
42 return `${payload}.${await signWith(secrets(env)[0]!, payload)}`;
43}
44
45export async function readUnsubToken(env: Env, token: string, now = Date.now()): Promise<UnsubToken | null> {
46 const [payload, sig] = token.split(".");
47 if (!payload || !sig || token.length > 2000) return null;
48 if (!(await matchesAnyKey(secrets(env), sig, (s) => signWith(s, payload)))) return null;
49 try {
50 const v = JSON.parse(new TextDecoder().decode(fromB64url(payload))) as { a?: unknown; s?: UnsubScope; e?: unknown };
51 if (typeof v.a !== "string" || !v.s || typeof v.s !== "object") return null;
52 // A link without an expiry was sent before links had one; those are honoured only until the cutoff.
53 const expires = v.e === undefined ? UNSUB_LEGACY_CUTOFF_MS : typeof v.e === "number" ? v.e * 1000 : 0;
54 if (now >= expires) return null;
55 return { accountId: v.a, scope: v.s };
56 } catch {
57 return null;
58 }
59}
60
61export async function unsubUrl(env: Env, t: UnsubToken): Promise<string> {
62 return `${env.PUBLIC_URL}/-/unsubscribe/${await unsubToken(env, t)}`;
63}