File size: 6,498 Bytes
20f83d9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
/**
 * HMAC-signed URL helpers for the WorldMonitor Brief magazine route.
 *
 * The hosted magazine at /api/brief/{userId}/{issueSlot} is auth-less
 * in the traditional sense (no Clerk session, no cookie). The signed
 * token IS the credential: a recipient with the URL can read the
 * magazine; without it, no. This matches the push / email delivery
 * model where the token is delivered to the user through an already-
 * authenticated channel.
 *
 * Secret rotation is supported: set BRIEF_URL_SIGNING_SECRET_PREV to
 * the outgoing secret for the overlap window. `verifyBriefToken` will
 * accept a token signed with either, so producers can roll the primary
 * secret without invalidating in-flight notifications.
 *
 * Rotation runbook:
 *   - Normal roll: set PREV = current, then replace SECRET with a
 *     fresh value. Keep PREV set for at least the envelope TTL
 *     (7 days) plus the push/email-delivery window so in-flight
 *     notifications remain valid.
 *   - Emergency kill switch (suspected secret leak): rotate SECRET
 *     and do NOT set PREV. This invalidates every outstanding token
 *     immediately. Accept the breakage of in-flight notifications
 *     as the cost of containment.
 *
 * All crypto goes through Web Crypto (`crypto.subtle`) so this module
 * runs unchanged in Vercel Edge, Node 18+, and Tauri.
 */

const USER_ID_RE = /^[A-Za-z0-9_-]{1,128}$/;
// YYYY-MM-DD-HHMM issue slot — hour+minute of the compose run in the
// user's tz. The token binds userId + slot so each digest dispatch
// gets its own frozen magazine URL.
const ISSUE_DATE_RE = /^\d{4}-\d{2}-\d{2}-\d{4}$/;
const TOKEN_RE = /^[A-Za-z0-9_-]{43}$/; // base64url(sha256) = 43 chars, no padding

export class BriefUrlError extends Error {
  readonly code: 'invalid_user_id' | 'invalid_issue_date' | 'missing_secret';

  constructor(code: BriefUrlError['code'], message: string) {
    super(message);
    this.code = code;
    this.name = 'BriefUrlError';
  }
}

function assertShape(userId: string, issueDate: string): void {
  if (!USER_ID_RE.test(userId)) {
    throw new BriefUrlError('invalid_user_id', 'userId must match [A-Za-z0-9_-]{1,128}');
  }
  if (!ISSUE_DATE_RE.test(issueDate)) {
    throw new BriefUrlError('invalid_issue_date', 'issueDate must match YYYY-MM-DD-HHMM');
  }
}

function base64url(bytes: Uint8Array): string {
  let bin = '';
  for (const b of bytes) bin += String.fromCharCode(b);
  return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}

async function hmacSha256(secret: string, message: string): Promise<Uint8Array> {
  const key = await crypto.subtle.importKey(
    'raw',
    new TextEncoder().encode(secret),
    { name: 'HMAC', hash: 'SHA-256' },
    false,
    ['sign'],
  );
  const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(message));
  return new Uint8Array(sig);
}

/** Constant-time byte comparison. Returns false on length mismatch. */
function constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean {
  if (a.length !== b.length) return false;
  let diff = 0;
  for (let i = 0; i < a.length; i++) diff |= (a[i] ?? 0) ^ (b[i] ?? 0);
  return diff === 0;
}

/**
 * Deterministically sign `${userId}:${issueSlot}` and return a
 * base64url-encoded token. The parameter is still named issueDate for
 * compatibility with earlier callers, but the accepted value is the
 * frozen issue slot. Throws BriefUrlError on malformed inputs or missing
 * secret.
 */
export async function signBriefToken(
  userId: string,
  issueDate: string,
  secret: string,
): Promise<string> {
  assertShape(userId, issueDate);
  if (!secret) {
    throw new BriefUrlError('missing_secret', 'BRIEF_URL_SIGNING_SECRET is not configured');
  }
  const sig = await hmacSha256(secret, `${userId}:${issueDate}`);
  return base64url(sig);
}

/**
 * Verify a token against userId + issueSlot. Accepts the primary
 * secret and (if provided) a previous secret during rotation. Returns
 * `true` only on a byte-for-byte match under either secret.
 *
 * The token is rejected without ever touching crypto if its shape is
 * invalid (wrong length, illegal chars). userId and the slot-shaped
 * issueDate parameter are shape-validated before any HMAC computation to
 * prevent probing.
 */
export async function verifyBriefToken(
  userId: string,
  issueDate: string,
  token: string,
  secret: string,
  prevSecret?: string,
): Promise<boolean> {
  if (typeof token !== 'string' || !TOKEN_RE.test(token)) return false;
  try {
    assertShape(userId, issueDate);
  } catch {
    return false;
  }
  if (!secret) {
    throw new BriefUrlError('missing_secret', 'BRIEF_URL_SIGNING_SECRET is not configured');
  }

  const tokenBytes = base64urlDecode(token);
  if (!tokenBytes) return false;

  const message = `${userId}:${issueDate}`;
  const primary = await hmacSha256(secret, message);
  if (constantTimeEqual(primary, tokenBytes)) return true;

  if (prevSecret) {
    const legacy = await hmacSha256(prevSecret, message);
    if (constantTimeEqual(legacy, tokenBytes)) return true;
  }
  return false;
}

function base64urlDecode(token: string): Uint8Array | null {
  try {
    const b64 = token.replace(/-/g, '+').replace(/_/g, '/');
    const padded = b64 + '==='.slice((b64.length + 3) % 4);
    const bin = atob(padded);
    const out = new Uint8Array(bin.length);
    for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
    return out;
  } catch {
    return null;
  }
}

/**
 * Compose the full magazine URL with signed token.
 *
 * Producers should always go through this helper rather than string-
 * concatenating URLs by hand. `issueDate` is the legacy property name
 * for the issueSlot-shaped value (`YYYY-MM-DD-HHMM`). Example:
 *
 *   const url = await signBriefUrl({
 *     userId: 'user_abc',
 *     issueDate: '2026-04-17-0800',
 *     baseUrl: 'https://worldmonitor.app',
 *     secret: process.env.BRIEF_URL_SIGNING_SECRET!,
 *   });
 */
export async function signBriefUrl({
  userId,
  issueDate,
  baseUrl,
  secret,
}: {
  userId: string;
  /** Legacy name for the frozen issueSlot (`YYYY-MM-DD-HHMM`). */
  issueDate: string;
  baseUrl: string;
  secret: string;
}): Promise<string> {
  const token = await signBriefToken(userId, issueDate, secret);
  const encodedUser = encodeURIComponent(userId);
  const encodedDate = encodeURIComponent(issueDate);
  const trimmedBase = baseUrl.replace(/\/+$/, '');
  return `${trimmedBase}/api/brief/${encodedUser}/${encodedDate}?t=${token}`;
}