@oimlsmart/platform-server 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +92 -0
  2. package/migrations/0001_init.sql +69 -0
  3. package/migrations/0002_identity.sql +24 -0
  4. package/migrations/0003_federation_peers.sql +21 -0
  5. package/migrations/0003_users_rbac.sql +8 -0
  6. package/migrations/0004_oidc_op.sql +61 -0
  7. package/migrations/0005_upstream_providers.sql +34 -0
  8. package/migrations/0006_op_accounts.sql +28 -0
  9. package/migrations/0007_org_join_requests.sql +26 -0
  10. package/migrations/0008_op_client_roles.sql +19 -0
  11. package/migrations/0009_account_console.sql +32 -0
  12. package/migrations/0009_sso_states.sql +13 -0
  13. package/migrations/0010_notify_events.sql +23 -0
  14. package/migrations/0011_op_launch.sql +18 -0
  15. package/migrations/0011_org_memberships.sql +62 -0
  16. package/migrations/0012_notify_subscriptions.sql +57 -0
  17. package/migrations/0012_strong_auth.sql +109 -0
  18. package/migrations/0013_org_registry.sql +53 -0
  19. package/migrations/0014_notify_inbox.sql +34 -0
  20. package/migrations/0015_certificate_holder_attribution.sql +63 -0
  21. package/migrations/0016_instrument_registrations.sql +78 -0
  22. package/package.json +52 -0
  23. package/src/client-info.ts +25 -0
  24. package/src/context.ts +31 -0
  25. package/src/github.ts +284 -0
  26. package/src/mailer.ts +309 -0
  27. package/src/oidc.ts +369 -0
  28. package/src/profile/node.ts +83 -0
  29. package/src/profile.ts +582 -0
  30. package/src/rbac/node.ts +42 -0
  31. package/src/rbac.ts +53 -0
  32. package/src/session.ts +45 -0
  33. package/src/store/d1.ts +2850 -0
  34. package/src/store/sqlite/entities.ts +71 -0
  35. package/src/store/sqlite/events.ts +82 -0
  36. package/src/store/sqlite/factors-store.ts +348 -0
  37. package/src/store/sqlite/notify.ts +247 -0
  38. package/src/store/sqlite/op-accounts-store.ts +470 -0
  39. package/src/store/sqlite/op-store.ts +280 -0
  40. package/src/store/sqlite/schema.sql +745 -0
  41. package/src/store/sqlite/store.ts +1390 -0
  42. package/src/store/sqlite/upstream-store.ts +148 -0
  43. package/src/store/sqlite.ts +1027 -0
  44. package/src/store.ts +1826 -0
  45. package/src/vocab/index.ts +12 -0
  46. package/src/vocab/permissions.ts +398 -0
  47. package/src/vocab/rbac.ts +281 -0
  48. package/src/vocab/roles.ts +162 -0
package/src/github.ts ADDED
@@ -0,0 +1,284 @@
1
+ // ═══════════════════════════════════════════════════════════════════
2
+ // GitHub OAuth — the deployment's authorized sign-in
3
+ // (docs/deployment/identity.md, "GitHub OAuth"). TWO concerns live here:
4
+ //
5
+ // 1. THE STATELESS OAUTH STATE. The old flow kept a per-process
6
+ // `Map` jar, which a sibling Worker isolate never sees — the
7
+ // callback then failed the state check intermittently. The state
8
+ // parameter is now SELF-PROVING: `<nonce>.<issuedAt>.<hmac>` where
9
+ // the hmac is HMAC-SHA256 over `nonce:issuedAt` keyed by the
10
+ // GITHUB_CLIENT_SECRET (already a deployed secret — no new secret,
11
+ // no D1 round trip), compared in constant time, with a 10-minute
12
+ // TTL. It verifies identically on node and on the Worker, so the
13
+ // in-memory path is GONE — this is the only path.
14
+ //
15
+ // 2. THE AUTHORIZED-USERS DECLARATION. Which GitHub accounts may sign
16
+ // in, and with which initial role, is instance policy declared in
17
+ // the ENV (the same env seam the OIDC config uses — hono/adapter
18
+ // reads process.env on node, the Worker bindings on Cloudflare):
19
+ //
20
+ // GITHUB_ADMIN_LOGINS comma-separated logins (case-insensitive)
21
+ // → role `admin`.
22
+ // GITHUB_ALLOWED_LOGINS comma-separated logins → the default
23
+ // allowed role (`cs_admin`).
24
+ // GITHUB_ROLE_MAP `login:role,login2:role2` — fine-grained
25
+ // initial roles. Roles validate against the
26
+ // platform vocabulary (src/auth/roles.ts);
27
+ // an unknown role FAILS CLOSED: the entry is
28
+ // dropped, the resolution logs the problem
29
+ // once, and the login falls through to the
30
+ // other rules — a role is never invented.
31
+ // GITHUB_ALLOWED_ORG one org slug → the sign-in checks LIVE
32
+ // membership (GET /user/memberships/orgs/
33
+ // {org} with the user's own token; state
34
+ // `active` counts, `pending` does not) and
35
+ // members get the default allowed role. The
36
+ // authorization request adds the `read:org`
37
+ // scope when this is set (private membership
38
+ // is invisible without it).
39
+ //
40
+ // Precedence is the declaration order above: admin list → role map
41
+ // → allowed list → org membership → denied. With NO allowlist env
42
+ // declared the instance is OPEN ENROLLMENT: any GitHub account signs
43
+ // in with role `user` (the historical behavior) and the resolution
44
+ // logs a boot warning — fine for a personal evaluation, unsuitable
45
+ // for a shared deployment.
46
+ //
47
+ // The allowlist is ADMISSION CONTROL + the INITIAL role. An
48
+ // existing account keeps its locally assigned role and org (the
49
+ // admin refines them in the users section, TODO.federation/12) —
50
+ // but a login struck off every list is refused at the gate,
51
+ // whatever account it holds. Org binding is NOT derived from
52
+ // GitHub: every GitHub sign-in provisions with org NULL (item 3 of
53
+ // the work order — binding happens through the admin's user
54
+ // management afterwards).
55
+ //
56
+ // GitHub Enterprise Server: GITHUB_OAUTH_BASE_URL /
57
+ // GITHUB_API_BASE_URL override the github.com endpoints (also the
58
+ // in-process test seam — the stub fixture rides them).
59
+ //
60
+ // WORKER-SAFE: WebCrypto only (crypto.subtle / getRandomValues), no
61
+ // node built-ins — the Worker bundle carries this module.
62
+ // ═══════════════════════════════════════════════════════════════════
63
+
64
+ import { APP_ROLES } from './vocab/roles'
65
+
66
+ type EnvLike = Record<string, string | undefined>
67
+
68
+ // ── the stateless OAuth state ───────────────────────────────────────
69
+
70
+ /** The state parameter's lifetime (both OAuth flows share the value). */
71
+ export const OAUTH_STATE_TTL_MS = 10 * 60 * 1000
72
+
73
+ /** Tolerance for a future-dated `issuedAt` (clock skew between the
74
+ * sign-in and the callback — the signature covers the timestamp, so
75
+ * only the secret holder could mint one; a wildly future state is
76
+ * still refused honestly). */
77
+ const STATE_CLOCK_SKEW_MS = 60_000
78
+
79
+ function base64url(bytes: Uint8Array): string {
80
+ let bin = ''
81
+ for (const b of bytes) bin += String.fromCharCode(b)
82
+ return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
83
+ }
84
+
85
+ async function hmacSha256(key: string, message: string): Promise<string> {
86
+ const cryptoKey = await crypto.subtle.importKey(
87
+ 'raw',
88
+ new TextEncoder().encode(key),
89
+ { name: 'HMAC', hash: 'SHA-256' },
90
+ false,
91
+ ['sign'],
92
+ )
93
+ const sig = await crypto.subtle.sign('HMAC', cryptoKey, new TextEncoder().encode(message))
94
+ return base64url(new Uint8Array(sig))
95
+ }
96
+
97
+ /** Constant-time string equality (Workers have no
98
+ * crypto.timingSafeEqual — the length check leaks only what the
99
+ * attacker already knows, their own input's length). */
100
+ function timingSafeEqual(a: string, b: string): boolean {
101
+ if (a.length !== b.length) return false
102
+ let diff = 0
103
+ for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i)
104
+ return diff === 0
105
+ }
106
+
107
+ /** Mint the state parameter: a random nonce + issue time, self-proving
108
+ * under HMAC. `now` is injectable for the expiry tests. */
109
+ export async function signOAuthState(secret: string, now: number = Date.now()): Promise<string> {
110
+ const nonce = base64url(crypto.getRandomValues(new Uint8Array(24)))
111
+ const sig = await hmacSha256(secret, `${nonce}:${now}`)
112
+ return `${nonce}.${now}.${sig}`
113
+ }
114
+
115
+ /** Verify a presented state parameter: well-formed, inside the TTL
116
+ * (± skew), and carrying the signature of `nonce:issuedAt` under the
117
+ * secret — all three, constant-time on the signature. */
118
+ export async function verifyOAuthState(
119
+ secret: string,
120
+ presented: string,
121
+ opts?: { now?: number; ttlMs?: number },
122
+ ): Promise<boolean> {
123
+ const parts = presented.split('.')
124
+ if (parts.length !== 3) return false
125
+ const [nonce, issuedAtRaw, sig] = parts as [string, string, string]
126
+ if (!nonce || !sig) return false
127
+ const issuedAt = Number(issuedAtRaw)
128
+ if (!Number.isFinite(issuedAt)) return false
129
+ const now = opts?.now ?? Date.now()
130
+ const ttl = opts?.ttlMs ?? OAUTH_STATE_TTL_MS
131
+ if (now - issuedAt > ttl) return false
132
+ if (issuedAt - now > STATE_CLOCK_SKEW_MS) return false
133
+ // The signature recomputes over the PRESENTED strings (never the
134
+ // re-parsed number) so no canonicalization gap opens.
135
+ const expected = await hmacSha256(secret, `${nonce}:${issuedAtRaw}`)
136
+ return timingSafeEqual(expected, sig)
137
+ }
138
+
139
+ // ── the authorized-users declaration ────────────────────────────────
140
+
141
+ /** The initial role for GITHUB_ALLOWED_LOGINS members and
142
+ * GITHUB_ALLOWED_ORG members (the role map refines it per login). */
143
+ export const GITHUB_DEFAULT_ALLOWED_ROLE = 'cs_admin'
144
+
145
+ export interface GitHubAuthorizationConfig {
146
+ /** Lowercased logins → `admin`. */
147
+ admins: ReadonlySet<string>
148
+ /** Lowercased login → validated platform role. */
149
+ roleMap: ReadonlyMap<string, string>
150
+ /** Lowercased logins → the default allowed role. */
151
+ allowed: ReadonlySet<string>
152
+ /** The org slug whose active members may sign in (null = no org check). */
153
+ org: string | null
154
+ /** TRUE when no allowlist env is declared at all: any GitHub account
155
+ * signs in with role `user` (the historical posture — the resolution
156
+ * logs the open-enrollment warning once). */
157
+ openEnrollment: boolean
158
+ /** The declaration's validation problems (unknown roles, malformed
159
+ * pairs) — every problem means an entry was DROPPED (fail closed),
160
+ * never guessed. The route logs them once per process/isolate. */
161
+ problems: string[]
162
+ }
163
+
164
+ function parseLoginList(raw: string | undefined): string[] {
165
+ return (raw ?? '').split(',').map(s => s.trim().toLowerCase()).filter(Boolean)
166
+ }
167
+
168
+ /** Resolve + validate the allowlist declaration from the env. Pure. */
169
+ export function resolveGitHubAuthorizationConfig(env: EnvLike): GitHubAuthorizationConfig {
170
+ const problems: string[] = []
171
+ const admins = new Set(parseLoginList(env.GITHUB_ADMIN_LOGINS))
172
+ const allowed = new Set(parseLoginList(env.GITHUB_ALLOWED_LOGINS))
173
+ const roleMap = new Map<string, string>()
174
+
175
+ const rawMap = env.GITHUB_ROLE_MAP?.trim()
176
+ if (rawMap) {
177
+ for (const pair of rawMap.split(',')) {
178
+ const entry = pair.trim()
179
+ if (!entry) continue
180
+ const idx = entry.indexOf(':')
181
+ const login = idx > 0 ? entry.slice(0, idx).trim().toLowerCase() : ''
182
+ const role = idx > 0 ? entry.slice(idx + 1).trim() : ''
183
+ if (!login || !role) {
184
+ problems.push(`GITHUB_ROLE_MAP entry ${JSON.stringify(entry)} is not a 'login:role' pair — the entry is ignored`)
185
+ continue
186
+ }
187
+ if (!(APP_ROLES as readonly string[]).includes(role)) {
188
+ problems.push(
189
+ `GITHUB_ROLE_MAP entry for ${JSON.stringify(login)} names the unknown role ${JSON.stringify(role)} — `
190
+ + `the entry is ignored (fail closed; the platform roles are ${APP_ROLES.join(', ')})`,
191
+ )
192
+ continue
193
+ }
194
+ roleMap.set(login, role)
195
+ }
196
+ }
197
+
198
+ const org = env.GITHUB_ALLOWED_ORG?.trim() || null
199
+ // Open enrollment keys on the DECLARATION, not the post-validation
200
+ // set: a deployment that declares only a broken role map fails
201
+ // CLOSED (every login denied), never silently open.
202
+ const declared = !!(
203
+ env.GITHUB_ADMIN_LOGINS?.trim()
204
+ || env.GITHUB_ALLOWED_LOGINS?.trim()
205
+ || rawMap
206
+ || org
207
+ )
208
+ return { admins, allowed, roleMap, org, openEnrollment: !declared, problems }
209
+ }
210
+
211
+ /** Where a login stands BEFORE the live org-membership check. */
212
+ export type GitHubListResolution =
213
+ | { kind: 'listed'; role: string; via: 'admin_logins' | 'role_map' | 'allowed_logins' }
214
+ /** Not in any list, but GITHUB_ALLOWED_ORG is declared — the live
215
+ * membership check decides. */
216
+ | { kind: 'org_check' }
217
+ /** Open enrollment: any GitHub account, role `user`. */
218
+ | { kind: 'open' }
219
+ | { kind: 'denied' }
220
+
221
+ /** The pure per-login resolution: admin list → role map → allowed list
222
+ * → org check → denied (open enrollment short-circuits to 'open').
223
+ * Logins compare case-insensitively (GitHub treats them so). */
224
+ export function resolveGitHubLogin(config: GitHubAuthorizationConfig, login: string): GitHubListResolution {
225
+ const l = login.trim().toLowerCase()
226
+ if (config.admins.has(l)) return { kind: 'listed', role: 'admin', via: 'admin_logins' }
227
+ const mapped = config.roleMap.get(l)
228
+ if (mapped) return { kind: 'listed', role: mapped, via: 'role_map' }
229
+ if (config.allowed.has(l)) return { kind: 'listed', role: GITHUB_DEFAULT_ALLOWED_ROLE, via: 'allowed_logins' }
230
+ if (config.org) return { kind: 'org_check' }
231
+ if (config.openEnrollment) return { kind: 'open' }
232
+ return { kind: 'denied' }
233
+ }
234
+
235
+ // ── the GitHub endpoints (github.com, or GHES / the test stub) ──────
236
+
237
+ export interface GitHubEndpoints {
238
+ /** The OAuth web flow base (https://github.com). */
239
+ oauthBase: string
240
+ /** The REST API base (https://api.github.com). */
241
+ apiBase: string
242
+ }
243
+
244
+ /** The endpoints this instance talks to — github.com by default, the
245
+ * GITHUB_*_BASE_URL overrides for GitHub Enterprise Server (and the
246
+ * in-process tests' stub). */
247
+ export function gitHubEndpoints(env: EnvLike): GitHubEndpoints {
248
+ const trim = (v: string | undefined) => v?.trim().replace(/\/+$/, '') || ''
249
+ return {
250
+ oauthBase: trim(env.GITHUB_OAUTH_BASE_URL) || 'https://github.com',
251
+ apiBase: trim(env.GITHUB_API_BASE_URL) || 'https://api.github.com',
252
+ }
253
+ }
254
+
255
+ /** The OAuth scope request: the base identity scopes, plus `read:org`
256
+ * when an org gate is declared (a private membership is invisible
257
+ * without it — the check would fail closed against real members). */
258
+ export function gitHubScopes(env: EnvLike): string {
259
+ const base = 'read:user user:email'
260
+ return env.GITHUB_ALLOWED_ORG?.trim() ? `${base} read:org` : base
261
+ }
262
+
263
+ /** The LIVE org-membership check: the user's own membership record in
264
+ * the declared org. `active` counts; `pending` (an invitation not yet
265
+ * accepted) does not. Any non-200 — not a member, an org that does not
266
+ * exist, a token that cannot see the membership — is NOT a member:
267
+ * fail closed. */
268
+ export async function checkGitHubOrgMembership(
269
+ apiBase: string,
270
+ org: string,
271
+ accessToken: string,
272
+ fetchImpl: typeof fetch = fetch,
273
+ ): Promise<boolean> {
274
+ try {
275
+ const res = await fetchImpl(`${apiBase}/user/memberships/orgs/${encodeURIComponent(org)}`, {
276
+ headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/vnd.github+json' },
277
+ })
278
+ if (!res.ok) return false
279
+ const body = await res.json() as { state?: string }
280
+ return body.state === 'active'
281
+ } catch {
282
+ return false
283
+ }
284
+ }
package/src/mailer.ts ADDED
@@ -0,0 +1,309 @@
1
+ // ═══════════════════════════════════════════════════════════════════
2
+ // The transactional mailer (TODO.identity/09) — ONE send interface with
3
+ // three postures, resolved from the environment, best first:
4
+ //
5
+ // 1. send_email — the Cloudflare Email Service (public beta): the
6
+ // Worker's `send_email` binding (env.EMAIL) + EMAIL_FROM. The
7
+ // binding's builder form carries { from, to, subject, text, html }
8
+ // — no raw MIME to compose. Domain authentication (SPF/DKIM/DMARC
9
+ // on oimlsmart.org) is the operator act documented in
10
+ // docs/deployment/cloudflare.md; without it the binding sends
11
+ // nothing deliverable, which is why it ships commented-out in
12
+ // wrangler.toml.
13
+ // 2. https — a small HTTPS mail provider (Resend is the documented
14
+ // choice: a simple JSON POST API, a free tier, the key a Worker
15
+ // secret). MAIL_PROVIDER_URL + MAIL_PROVIDER_KEY + EMAIL_FROM; the
16
+ // request shape is Resend's POST /emails ({ from, to, subject,
17
+ // text, html } with a Bearer key), so any provider speaking that
18
+ // shape plugs in by URL alone.
19
+ // 3. console — the honest no-op when no provider is configured: the
20
+ // message is LOGGED in full (the setup link lands in the deploy
21
+ // log, the OP_ACCOUNT_SEED posture) and the result says not-sent,
22
+ // so the triggering flow keeps SHOWING the link (the invite card's
23
+ // copy path). NEVER a silent drop.
24
+ //
25
+ // Every send — sent, failed, logged, rate-limited — writes an audit
26
+ // event (entity_type 'email': recipient, template, posture, result).
27
+ // Sends are RATE-LIMITED per recipient (a token bucket, in-memory per
28
+ // process/isolate — the federation limiter's documented posture; a
29
+ // global limit needs a durable counter, deliberately out of scope):
30
+ //
31
+ // MAIL_RATE_LIMIT_CAPACITY messages per window per recipient
32
+ // (default 5; 0 disables the limiter,
33
+ // honestly)
34
+ // MAIL_RATE_LIMIT_WINDOW_MS the window (default 3_600_000 — 1 h)
35
+ //
36
+ // The mailer NEVER throws from send(): a transport failure is a result
37
+ // ({ ok: false, error }), never an exception into the triggering flow.
38
+ //
39
+ // WORKER-SAFE: fetch + the store seam only, no node built-ins.
40
+ // ═══════════════════════════════════════════════════════════════════
41
+
42
+ import { getStore } from './store'
43
+
44
+ /** The env as the mailer reads it: strings on node, plus the Worker's
45
+ * object bindings on Cloudflare (the EMAIL send_email binding). */
46
+ export type MailEnv = Record<string, unknown>
47
+
48
+ export interface MailMessage {
49
+ to: string
50
+ subject: string
51
+ text: string
52
+ html?: string
53
+ }
54
+
55
+ export type MailPosture = 'send_email' | 'https' | 'console'
56
+
57
+ export interface MailSendResult {
58
+ ok: boolean
59
+ posture: MailPosture
60
+ /** Named when ok is false (the provider's answer, bounded; the
61
+ * no-provider posture's 'not configured'; the rate-limit refusal). */
62
+ error?: string
63
+ rateLimited?: boolean
64
+ }
65
+
66
+ /** The minimal shape of the Cloudflare Email Service binding (the
67
+ * workers-types `SendEmail`'s builder form), declared structurally so
68
+ * this module never imports the worker types. */
69
+ export interface SendEmailBinding {
70
+ send(message: {
71
+ from: string
72
+ to: string
73
+ subject: string
74
+ text?: string
75
+ html?: string
76
+ }): Promise<unknown>
77
+ }
78
+
79
+ export interface MailerConfig {
80
+ posture: MailPosture
81
+ /** The From address (EMAIL_FROM) — required by both real postures. */
82
+ from: string | null
83
+ binding: SendEmailBinding | null
84
+ providerUrl: string | null
85
+ providerKey: string | null
86
+ rateLimit: { capacity: number; windowMs: number }
87
+ /** Every configuration problem, named (logged once per config change —
88
+ * a misdeclared mailer must never silently degrade). */
89
+ problems: string[]
90
+ }
91
+
92
+ export const MAIL_RATE_LIMIT_DEFAULTS = { capacity: 5, windowMs: 3_600_000 }
93
+
94
+ /** The provider call's ceiling — a hung provider must never hold the
95
+ * triggering request. */
96
+ export const MAIL_TIMEOUT_MS = 10_000
97
+
98
+ /** Read the env into the effective configuration. PURE (the tests drive
99
+ * every posture through it). */
100
+ export function resolveMailerConfig(env: MailEnv): MailerConfig {
101
+ const problems: string[] = []
102
+ const str = (name: string): string | null => {
103
+ const v = env[name]
104
+ return typeof v === 'string' && v.trim() ? v.trim() : null
105
+ }
106
+ const bindingCandidate = env.EMAIL as SendEmailBinding | undefined
107
+ const binding = bindingCandidate && typeof bindingCandidate.send === 'function' ? bindingCandidate : null
108
+ const from = str('EMAIL_FROM')
109
+ const providerUrl = str('MAIL_PROVIDER_URL')
110
+ const providerKey = str('MAIL_PROVIDER_KEY')
111
+
112
+ if (binding && !from) problems.push('the EMAIL send_email binding is present but EMAIL_FROM is unset — the binding posture is skipped')
113
+ if ((providerUrl || providerKey) && !(providerUrl && providerKey)) {
114
+ problems.push('MAIL_PROVIDER_URL and MAIL_PROVIDER_KEY must be set together — the HTTPS provider posture is skipped')
115
+ } else if (providerUrl && providerKey && !from) {
116
+ problems.push('MAIL_PROVIDER_URL is set but EMAIL_FROM is unset — the HTTPS provider posture is skipped')
117
+ }
118
+ if (!binding && !(providerUrl && providerKey && from)) {
119
+ problems.push('no mail provider is configured (no EMAIL binding, no MAIL_PROVIDER_URL+MAIL_PROVIDER_KEY) — messages are logged, never delivered; the flows keep showing their links')
120
+ }
121
+
122
+ let capacity = MAIL_RATE_LIMIT_DEFAULTS.capacity
123
+ const rawCapacity = str('MAIL_RATE_LIMIT_CAPACITY')
124
+ if (rawCapacity !== null) {
125
+ const parsed = Number(rawCapacity)
126
+ if (!Number.isInteger(parsed) || parsed < 0) {
127
+ problems.push(`MAIL_RATE_LIMIT_CAPACITY is not a non-negative integer: ${JSON.stringify(rawCapacity)} — the default ${MAIL_RATE_LIMIT_DEFAULTS.capacity} applies`)
128
+ } else {
129
+ capacity = parsed
130
+ }
131
+ }
132
+ let windowMs = MAIL_RATE_LIMIT_DEFAULTS.windowMs
133
+ const rawWindow = str('MAIL_RATE_LIMIT_WINDOW_MS')
134
+ if (rawWindow !== null) {
135
+ const parsed = Number(rawWindow)
136
+ if (!Number.isInteger(parsed) || parsed <= 0) {
137
+ problems.push(`MAIL_RATE_LIMIT_WINDOW_MS is not a positive integer: ${JSON.stringify(rawWindow)} — the default ${MAIL_RATE_LIMIT_DEFAULTS.windowMs} applies`)
138
+ } else {
139
+ windowMs = parsed
140
+ }
141
+ }
142
+
143
+ const posture: MailPosture = binding && from ? 'send_email' : providerUrl && providerKey && from ? 'https' : 'console'
144
+ return { posture, from, binding, providerUrl, providerKey, rateLimit: { capacity, windowMs }, problems }
145
+ }
146
+
147
+ export interface Mailer {
148
+ readonly config: MailerConfig
149
+ send(message: MailMessage, meta?: { template?: string }): Promise<MailSendResult>
150
+ }
151
+
152
+ /** The audit trail on every send outcome (the spec's invariant) —
153
+ * logged, never thrown (the audit never blocks the path). */
154
+ async function auditSend(
155
+ action: 'email.sent' | 'email.failed' | 'email.logged' | 'email.rate_limited',
156
+ message: MailMessage,
157
+ meta: { template?: string; posture: MailPosture; error?: string },
158
+ ): Promise<void> {
159
+ try {
160
+ const id = crypto.randomUUID()
161
+ await getStore().putEntity('auditEvents', id, null, JSON.stringify({
162
+ id,
163
+ timestamp: new Date().toISOString(),
164
+ standard_id: '',
165
+ entity_type: 'email',
166
+ entity_id: message.to,
167
+ action,
168
+ metadata: {
169
+ subject: message.subject,
170
+ template: meta.template ?? null,
171
+ posture: meta.posture,
172
+ error: meta.error ?? null,
173
+ },
174
+ }))
175
+ } catch (err) {
176
+ console.error('[mail] the send audit failed to persist:', (err as Error).message)
177
+ }
178
+ }
179
+
180
+ interface Bucket {
181
+ tokens: number
182
+ resetAt: number
183
+ }
184
+
185
+ /** Build the mailer over a resolved configuration. `now`/`fetcher` are
186
+ * the tests' seams (the clock, the stubbed provider). */
187
+ export function createMailer(config: MailerConfig, deps?: { now?: () => number; fetcher?: typeof fetch }): Mailer {
188
+ const now = deps?.now ?? (() => Date.now())
189
+ const fetcher = deps?.fetcher ?? fetch
190
+ const buckets = new Map<string, Bucket>()
191
+
192
+ async function transport(message: MailMessage): Promise<MailSendResult> {
193
+ if (config.posture === 'send_email') {
194
+ await config.binding!.send({
195
+ from: config.from!,
196
+ to: message.to,
197
+ subject: message.subject,
198
+ text: message.text,
199
+ ...(message.html ? { html: message.html } : {}),
200
+ })
201
+ return { ok: true, posture: 'send_email' }
202
+ }
203
+ if (config.posture === 'https') {
204
+ const res = await fetcher(config.providerUrl!, {
205
+ method: 'POST',
206
+ headers: {
207
+ 'content-type': 'application/json',
208
+ authorization: `Bearer ${config.providerKey}`,
209
+ },
210
+ body: JSON.stringify({
211
+ from: config.from,
212
+ to: message.to,
213
+ subject: message.subject,
214
+ text: message.text,
215
+ ...(message.html ? { html: message.html } : {}),
216
+ }),
217
+ signal: AbortSignal.timeout(MAIL_TIMEOUT_MS),
218
+ })
219
+ if (!res.ok) {
220
+ const body = (await res.text().catch(() => '')).slice(0, 300)
221
+ return { ok: false, posture: 'https', error: `the mail provider answered ${res.status}${body ? `: ${body}` : ''}` }
222
+ }
223
+ return { ok: true, posture: 'https' }
224
+ }
225
+ // The console posture: the honest no-op. The message is logged in
226
+ // full (the link survives in the deploy log) and the result says
227
+ // not-sent, so the triggering flow keeps showing its own link.
228
+ console.warn(
229
+ `[mail] no provider configured — the message for ${message.to} is NOT delivered (the flow shows its link instead).\n`
230
+ + ` subject: ${message.subject}\n`
231
+ + message.text.split('\n').map(l => ` ${l}`).join('\n'),
232
+ )
233
+ return { ok: false, posture: 'console', error: 'no mail provider is configured on this deployment' }
234
+ }
235
+
236
+ return {
237
+ config,
238
+ async send(message, meta) {
239
+ const recipient = message.to.trim().toLowerCase()
240
+ const { capacity, windowMs } = config.rateLimit
241
+ if (capacity > 0) {
242
+ const at = now()
243
+ let bucket = buckets.get(recipient)
244
+ if (!bucket || at >= bucket.resetAt) {
245
+ bucket = { tokens: capacity, resetAt: at + windowMs }
246
+ buckets.set(recipient, bucket)
247
+ }
248
+ if (bucket.tokens <= 0) {
249
+ const error = `rate limited — this recipient already received ${capacity} message(s) within the window`
250
+ await auditSend('email.rate_limited', message, { template: meta?.template, posture: config.posture, error })
251
+ return { ok: false, posture: config.posture, error, rateLimited: true }
252
+ }
253
+ bucket.tokens -= 1
254
+ }
255
+
256
+ let result: MailSendResult
257
+ try {
258
+ result = await transport(message)
259
+ } catch (err) {
260
+ result = { ok: false, posture: config.posture, error: (err as Error).message }
261
+ }
262
+ await auditSend(
263
+ result.ok ? 'email.sent' : config.posture === 'console' ? 'email.logged' : 'email.failed',
264
+ message,
265
+ { template: meta?.template, posture: result.posture, error: result.error },
266
+ )
267
+ return result
268
+ },
269
+ }
270
+ }
271
+
272
+ // ── the per-process mailer slot ──────────────────────────────────────
273
+ // The rate-limit buckets must survive across requests (a mailer rebuilt
274
+ // per request would reset them), so the composition roots resolve
275
+ // through this slot: ONE mailer per effective configuration, rebuilt
276
+ // when the env's mail surface changes (a test mutating process.env, an
277
+ // isolate booting with the binding). Per-ISOLATE on the Worker — the
278
+ // rate limiter's documented posture, same as the federation one.
279
+
280
+ let cached: { fingerprint: string; mailer: Mailer } | null = null
281
+
282
+ function fingerprintOf(config: MailerConfig): string {
283
+ return JSON.stringify([
284
+ config.posture,
285
+ config.from,
286
+ config.providerUrl,
287
+ config.providerKey ? 'key-set' : null, // never the key itself
288
+ !!config.binding,
289
+ config.rateLimit.capacity,
290
+ config.rateLimit.windowMs,
291
+ ])
292
+ }
293
+
294
+ /** The process's mailer for this env (built once per configuration;
295
+ * the configuration problems log once per build, not per request). */
296
+ export function mailerFor(env: MailEnv): Mailer {
297
+ const config = resolveMailerConfig(env)
298
+ const fingerprint = fingerprintOf(config)
299
+ if (!cached || cached.fingerprint !== fingerprint) {
300
+ for (const problem of config.problems) console.warn(`[mail] ${problem}`)
301
+ cached = { fingerprint, mailer: createMailer(config) }
302
+ }
303
+ return cached.mailer
304
+ }
305
+
306
+ /** Test seam: drop the cached mailer (the next mailerFor re-resolves). */
307
+ export function resetMailerForTest(): void {
308
+ cached = null
309
+ }