@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.
- package/README.md +92 -0
- package/migrations/0001_init.sql +69 -0
- package/migrations/0002_identity.sql +24 -0
- package/migrations/0003_federation_peers.sql +21 -0
- package/migrations/0003_users_rbac.sql +8 -0
- package/migrations/0004_oidc_op.sql +61 -0
- package/migrations/0005_upstream_providers.sql +34 -0
- package/migrations/0006_op_accounts.sql +28 -0
- package/migrations/0007_org_join_requests.sql +26 -0
- package/migrations/0008_op_client_roles.sql +19 -0
- package/migrations/0009_account_console.sql +32 -0
- package/migrations/0009_sso_states.sql +13 -0
- package/migrations/0010_notify_events.sql +23 -0
- package/migrations/0011_op_launch.sql +18 -0
- package/migrations/0011_org_memberships.sql +62 -0
- package/migrations/0012_notify_subscriptions.sql +57 -0
- package/migrations/0012_strong_auth.sql +109 -0
- package/migrations/0013_org_registry.sql +53 -0
- package/migrations/0014_notify_inbox.sql +34 -0
- package/migrations/0015_certificate_holder_attribution.sql +63 -0
- package/migrations/0016_instrument_registrations.sql +78 -0
- package/package.json +52 -0
- package/src/client-info.ts +25 -0
- package/src/context.ts +31 -0
- package/src/github.ts +284 -0
- package/src/mailer.ts +309 -0
- package/src/oidc.ts +369 -0
- package/src/profile/node.ts +83 -0
- package/src/profile.ts +582 -0
- package/src/rbac/node.ts +42 -0
- package/src/rbac.ts +53 -0
- package/src/session.ts +45 -0
- package/src/store/d1.ts +2850 -0
- package/src/store/sqlite/entities.ts +71 -0
- package/src/store/sqlite/events.ts +82 -0
- package/src/store/sqlite/factors-store.ts +348 -0
- package/src/store/sqlite/notify.ts +247 -0
- package/src/store/sqlite/op-accounts-store.ts +470 -0
- package/src/store/sqlite/op-store.ts +280 -0
- package/src/store/sqlite/schema.sql +745 -0
- package/src/store/sqlite/store.ts +1390 -0
- package/src/store/sqlite/upstream-store.ts +148 -0
- package/src/store/sqlite.ts +1027 -0
- package/src/store.ts +1826 -0
- package/src/vocab/index.ts +12 -0
- package/src/vocab/permissions.ts +398 -0
- package/src/vocab/rbac.ts +281 -0
- 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
|
+
}
|