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.

66 lines · 3.1 KBRaw History
1import { devAuth } from "../auth/dev.js";
2import type { Env } from "../env.js";
3import { b64url, fromB64url, safeEqual } from "../lib/util.js";
4import type { Category } from "./types.js";
5
6/**
7 * Unsubscribe links work without signing in, so each one carries a signed
8 * capability: whose settings to change and what to turn off. The key is
9 * derived from MASTER_KEY, so links stay valid across deploys, and each one
10 * expires about a year after it was sent.
11 */
12export type UnsubScope =
13 /** Stop email for a settings group. */
14 | { t: "category"; category: Exclude<Category, "security" | "account"> }
15 /** Mute one issue or pull request. */
16 | { t: "thread"; repoId: string; number: number; repo: string }
17 /** Stop watching a repository. */
18 | { t: "repo"; repoId: string; repo: string };
19
20export interface UnsubToken {
21 accountId: string;
22 scope: UnsubScope;
23}
24
25async function key(env: Env): Promise<CryptoKey> {
26 // Local development has no MASTER_KEY unless one is passed; production always does.
27 const secret = env.MASTER_KEY ?? (devAuth(env) ? "diffinery-dev-unsubscribe" : null);
28 if (!secret) throw new Error("MASTER_KEY is not set, so unsubscribe links can't be signed");
29 return crypto.subtle.importKey("raw", new TextEncoder().encode(`diffinery-unsubscribe-v1:${secret}`), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
30}
31
32async function sign(env: Env, payload: string): Promise<string> {
33 const sig = await crypto.subtle.sign("HMAC", await key(env), new TextEncoder().encode(payload));
34 return b64url(new Uint8Array(sig));
35}
36
37/** How long an unsubscribe link works: about a year, so old emails in someone's inbox stop being a capability. */
38export 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). */
40export const UNSUB_LEGACY_CUTOFF_MS = Date.UTC(2027, 9, 16);
41
42export 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) })));
45 return `${payload}.${await sign(env, payload)}`;
46}
47
48export async function readUnsubToken(env: Env, token: string, now = Date.now()): Promise<UnsubToken | null> {
49 const [payload, sig] = token.split(".");
50 if (!payload || !sig || token.length > 2000) return null;
51 if (!safeEqual(sig, await sign(env, payload))) return null;
52 try {
53 const v = JSON.parse(new TextDecoder().decode(fromB64url(payload))) as { a?: unknown; s?: UnsubScope; e?: unknown };
54 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;
58 return { accountId: v.a, scope: v.s };
59 } catch {
60 return null;
61 }
62}
63
64export async function unsubUrl(env: Env, t: UnsubToken): Promise<string> {
65 return `${env.PUBLIC_URL}/-/unsubscribe/${await unsubToken(env, t)}`;
66}