flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t/apps/status/src/subscribers.ts

81 lines3,509 bytesCodeBlame
1/**
2 * Email subscribers: double opt-in and one-click leaving. Only hashes are
3 * kept. A confirmation token is random and stored as its SHA-256; an
4 * unsubscribe link is the subscriber's id signed with STATUS_SECRET, so it
5 * is never stored at all and a copy of the database cannot unsubscribe
6 * anyone. Web Crypto only, so it is tested under Node.
7 */
8
9/** How long a confirmation link works. */
10export const CONFIRM_TTL_MS = 24 * 60 * 60 * 1000;
11/** No second confirmation email to one address sooner than this. */
12export const RESEND_AFTER_MS = 10 * 60 * 1000;
13
14const EMAIL = /^[^\s@<>"(),;:]{1,64}@[a-z0-9.-]{1,190}\.[a-z]{2,}$/;
15
16/** An address as kept: trimmed and lowercased; null when it is not one. */
17export function normalizeEmail(raw: unknown): string | null {
18 const email = typeof raw === "string" ? raw.trim().toLowerCase() : "";
19 return email.length <= 254 && EMAIL.test(email) ? email : null;
20}
21
22function base64url(bytes: Uint8Array): string {
23 let text = "";
24 for (const b of bytes) text += String.fromCharCode(b);
25 return btoa(text).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
26}
27
28/** A new random token, as it goes in a link. */
29export function newToken(): string {
30 return base64url(crypto.getRandomValues(new Uint8Array(32)));
31}
32
33/** What is kept of a token: its SHA-256, hex. */
34export async function hashToken(token: string): Promise<string> {
35 const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(token));
36 return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
37}
38
39async function hmac(secret: string, message: string): Promise<string> {
40 const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
41 return base64url(new Uint8Array(await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(message))));
42}
43
44/** The token in a subscriber's unsubscribe link: `<id>.<signature>`. */
45export async function unsubscribeToken(secret: string, id: string): Promise<string> {
46 return `${id}.${await hmac(secret, `unsubscribe:${id}`)}`;
47}
48
49/** The subscriber an unsubscribe token is for, or null when it is not genuine. */
50export async function readUnsubscribeToken(secret: string, token: string): Promise<string | null> {
51 const dot = token.lastIndexOf(".");
52 if (dot <= 0) return null;
53 const id = token.slice(0, dot);
54 const expected = await unsubscribeToken(secret, id);
55 if (expected.length !== token.length) return null;
56 let diff = 0;
57 for (let i = 0; i < token.length; i++) diff |= expected.charCodeAt(i) ^ token.charCodeAt(i);
58 return diff === 0 ? id : null;
59}
60
61/** A subscriber's parts: null for everything. */
62export function parseParts(json: string | null): string[] | null {
63 if (!json) return null;
64 try {
65 const value = JSON.parse(json);
66 return Array.isArray(value) && value.length ? value.map(String) : null;
67 } catch {
68 return null;
69 }
70}
71
72/** Whether a subscriber wants news about these parts. */
73export function wants(parts: string[] | null, about: string[]): boolean {
74 return parts == null || about.length === 0 || about.some((key) => parts.includes(key));
75}
76
77/** The parts chosen in the form, known ones only; null (everything) when none or all are. */
78export function chosenParts(raw: string[], known: string[]): string[] | null {
79 const parts = [...new Set(raw.filter((key) => known.includes(key)))];
80 return parts.length === 0 || parts.length === known.length ? null : parts;
81}