@tenderprompt/accounts 0.2.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 (90) hide show
  1. package/README.md +84 -0
  2. package/dist/api.d.ts +27 -0
  3. package/dist/api.d.ts.map +1 -0
  4. package/dist/api.js +73 -0
  5. package/dist/api.js.map +1 -0
  6. package/dist/artifact.d.ts +8 -0
  7. package/dist/artifact.d.ts.map +1 -0
  8. package/dist/artifact.js +205 -0
  9. package/dist/artifact.js.map +1 -0
  10. package/dist/cli.d.ts +3 -0
  11. package/dist/cli.d.ts.map +1 -0
  12. package/dist/cli.js +4 -0
  13. package/dist/cli.js.map +1 -0
  14. package/dist/contracts.d.ts +96 -0
  15. package/dist/contracts.d.ts.map +1 -0
  16. package/dist/contracts.js +5 -0
  17. package/dist/contracts.js.map +1 -0
  18. package/dist/errors.d.ts +10 -0
  19. package/dist/errors.d.ts.map +1 -0
  20. package/dist/errors.js +19 -0
  21. package/dist/errors.js.map +1 -0
  22. package/dist/files.d.ts +21 -0
  23. package/dist/files.d.ts.map +1 -0
  24. package/dist/files.js +263 -0
  25. package/dist/files.js.map +1 -0
  26. package/dist/index.d.ts +4 -0
  27. package/dist/index.d.ts.map +1 -0
  28. package/dist/index.js +4 -0
  29. package/dist/index.js.map +1 -0
  30. package/dist/main.d.ts +15 -0
  31. package/dist/main.d.ts.map +1 -0
  32. package/dist/main.js +565 -0
  33. package/dist/main.js.map +1 -0
  34. package/dist/processes.d.ts +12 -0
  35. package/dist/processes.d.ts.map +1 -0
  36. package/dist/processes.js +44 -0
  37. package/dist/processes.js.map +1 -0
  38. package/dist/starter.d.ts +20 -0
  39. package/dist/starter.d.ts.map +1 -0
  40. package/dist/starter.js +159 -0
  41. package/dist/starter.js.map +1 -0
  42. package/package.json +32 -0
  43. package/skill/tender-accounts/SKILL.md +111 -0
  44. package/templates/shopify-customer-account/AGENTS.md +14 -0
  45. package/templates/shopify-customer-account/README.md +44 -0
  46. package/templates/shopify-customer-account/apps/gateway/AGENTS.md +9 -0
  47. package/templates/shopify-customer-account/apps/gateway/README.md +9 -0
  48. package/templates/shopify-customer-account/apps/gateway/database-schema.json +49 -0
  49. package/templates/shopify-customer-account/apps/gateway/dot.dev.vars.example +11 -0
  50. package/templates/shopify-customer-account/apps/gateway/migrations/0001_customer_auth.sql +76 -0
  51. package/templates/shopify-customer-account/apps/gateway/package.json +25 -0
  52. package/templates/shopify-customer-account/apps/gateway/scripts/build-worker.mjs +15 -0
  53. package/templates/shopify-customer-account/apps/gateway/scripts/pack-tender-artifact.mjs +84 -0
  54. package/templates/shopify-customer-account/apps/gateway/src/platform/customer-auth/hostname-policy.ts +66 -0
  55. package/templates/shopify-customer-account/apps/gateway/src/platform/customer-auth/schema.ts +90 -0
  56. package/templates/shopify-customer-account/apps/gateway/src/platform/customer-auth/session-store.ts +864 -0
  57. package/templates/shopify-customer-account/apps/gateway/src/platform/customer-auth/shopify-route-adapter.ts +1157 -0
  58. package/templates/shopify-customer-account/apps/gateway/src/platform/runtime-hostname.ts +46 -0
  59. package/templates/shopify-customer-account/apps/gateway/src/portal-service.test.ts +18 -0
  60. package/templates/shopify-customer-account/apps/gateway/src/portal-service.ts +51 -0
  61. package/templates/shopify-customer-account/apps/gateway/src/runtime-config.test.ts +30 -0
  62. package/templates/shopify-customer-account/apps/gateway/src/runtime-config.ts +56 -0
  63. package/templates/shopify-customer-account/apps/gateway/src/worker.ts +85 -0
  64. package/templates/shopify-customer-account/apps/gateway/tender-accounts.json +10 -0
  65. package/templates/shopify-customer-account/apps/gateway/tender-app.config.json +40 -0
  66. package/templates/shopify-customer-account/apps/gateway/tsconfig.json +13 -0
  67. package/templates/shopify-customer-account/apps/gateway/vitest.config.ts +8 -0
  68. package/templates/shopify-customer-account/apps/gateway/worker-configuration.d.ts +26 -0
  69. package/templates/shopify-customer-account/apps/gateway/wrangler.jsonc +42 -0
  70. package/templates/shopify-customer-account/apps/portal/AGENTS.md +9 -0
  71. package/templates/shopify-customer-account/apps/portal/README.md +5 -0
  72. package/templates/shopify-customer-account/apps/portal/index.html +13 -0
  73. package/templates/shopify-customer-account/apps/portal/package.json +33 -0
  74. package/templates/shopify-customer-account/apps/portal/scripts/build-worker.mjs +11 -0
  75. package/templates/shopify-customer-account/apps/portal/scripts/pack-tender-artifact.mjs +63 -0
  76. package/templates/shopify-customer-account/apps/portal/scripts/portal-worker.mjs +84 -0
  77. package/templates/shopify-customer-account/apps/portal/scripts/portal-worker.test.mjs +14 -0
  78. package/templates/shopify-customer-account/apps/portal/src/App.tsx +111 -0
  79. package/templates/shopify-customer-account/apps/portal/src/main.tsx +10 -0
  80. package/templates/shopify-customer-account/apps/portal/src/styles.css +54 -0
  81. package/templates/shopify-customer-account/apps/portal/tender-accounts.json +10 -0
  82. package/templates/shopify-customer-account/apps/portal/tender-app.config.json +19 -0
  83. package/templates/shopify-customer-account/apps/portal/tsconfig.json +14 -0
  84. package/templates/shopify-customer-account/apps/portal/vite.config.ts +7 -0
  85. package/templates/shopify-customer-account/apps/portal/worker-configuration.d.ts +11 -0
  86. package/templates/shopify-customer-account/apps/portal/wrangler.jsonc +22 -0
  87. package/templates/shopify-customer-account/dot.dev.vars.example +3 -0
  88. package/templates/shopify-customer-account/dot.gitignore +9 -0
  89. package/templates/shopify-customer-account/package.json +22 -0
  90. package/templates/shopify-customer-account/scripts/artifact-contract.mjs +109 -0
@@ -0,0 +1,864 @@
1
+ const FLOW_MAX_TTL_SECONDS = 10 * 60;
2
+ const HANDOFF_MAX_TTL_SECONDS = 5 * 60;
3
+ const SESSION_MAX_TTL_SECONDS = 31 * 24 * 60 * 60;
4
+ const EXPIRED_STATE_DELETE_LIMIT = 256;
5
+ const DEFAULT_MAX_RECENT_AUTHORIZATION_ATTEMPTS_PER_PROJECT = 512;
6
+ const DEFAULT_MAX_RECENT_AUTHORIZATION_ATTEMPTS_PER_CLIENT = 20;
7
+ const MAX_CONFIGURED_AUTHORIZATION_ATTEMPTS = 100_000;
8
+ const OPAQUE_VALUE_BYTES = 32;
9
+ const SESSION_DATA_MAX_LENGTH = 65_536;
10
+ const SESSION_SEAL_MAX_LENGTH = 100_000;
11
+
12
+ type CustomerAuthD1Value = string | number | null;
13
+
14
+ export interface CustomerAuthD1Result {
15
+ success?: boolean;
16
+ meta?: { changes?: number };
17
+ }
18
+
19
+ export interface CustomerAuthD1BoundStatement {
20
+ first<T = Record<string, unknown>>(): Promise<T | null>;
21
+ run(): Promise<CustomerAuthD1Result>;
22
+ }
23
+
24
+ export interface CustomerAuthD1PreparedStatement {
25
+ bind(...values: CustomerAuthD1Value[]): CustomerAuthD1BoundStatement;
26
+ }
27
+
28
+ export interface CustomerAuthD1Database {
29
+ prepare(sql: string): CustomerAuthD1PreparedStatement;
30
+ batch(statements: CustomerAuthD1BoundStatement[]): Promise<CustomerAuthD1Result[]>;
31
+ }
32
+
33
+ export type CustomerAuthHostnamePolicy = (input: {
34
+ projectId: string;
35
+ hostname: string;
36
+ }) => boolean | Promise<boolean>;
37
+
38
+ export class CustomerAuthFlowCapacityError extends Error {
39
+ constructor() {
40
+ super("The customer authentication flow capacity has been reached.");
41
+ }
42
+ }
43
+
44
+ export interface ConsumedAuthorizationFlow {
45
+ pkceVerifier: string;
46
+ returnHostname: string;
47
+ interactionMode: "interactive" | "silent";
48
+ expiresAt: number;
49
+ }
50
+
51
+ export interface ConsumedCustomerAuthHandoff {
52
+ sessionId: string;
53
+ returnHostname: string;
54
+ expiresAt: number;
55
+ }
56
+
57
+ export interface StoredShopperSession {
58
+ sessionData: string;
59
+ audienceHostname: string;
60
+ expiresAt: number;
61
+ }
62
+
63
+ export class PublicCustomerAuthSessionStore {
64
+ private readonly database: CustomerAuthD1Database;
65
+ private readonly hostnamePolicy: CustomerAuthHostnamePolicy;
66
+ private readonly maxRecentAuthorizationAttemptsPerProject: number;
67
+ private readonly maxRecentAuthorizationAttemptsPerClient: number;
68
+
69
+ constructor(
70
+ database: CustomerAuthD1Database,
71
+ hostnamePolicy: CustomerAuthHostnamePolicy,
72
+ options: {
73
+ maxRecentAuthorizationAttemptsPerProject?: number;
74
+ maxRecentAuthorizationAttemptsPerClient?: number;
75
+ } = {},
76
+ ) {
77
+ if (typeof hostnamePolicy !== "function") {
78
+ throw new Error("A customer authentication hostname policy is required.");
79
+ }
80
+ this.database = database;
81
+ this.hostnamePolicy = hostnamePolicy;
82
+ const maxRecentAuthorizationAttemptsPerProject = options.maxRecentAuthorizationAttemptsPerProject
83
+ ?? DEFAULT_MAX_RECENT_AUTHORIZATION_ATTEMPTS_PER_PROJECT;
84
+ const maxRecentAuthorizationAttemptsPerClient = options.maxRecentAuthorizationAttemptsPerClient
85
+ ?? DEFAULT_MAX_RECENT_AUTHORIZATION_ATTEMPTS_PER_CLIENT;
86
+ if (!validAuthorizationAttemptLimit(maxRecentAuthorizationAttemptsPerProject)
87
+ || !validAuthorizationAttemptLimit(maxRecentAuthorizationAttemptsPerClient)
88
+ || maxRecentAuthorizationAttemptsPerClient > maxRecentAuthorizationAttemptsPerProject) {
89
+ throw new Error("The customer authentication flow capacity is invalid.");
90
+ }
91
+ this.maxRecentAuthorizationAttemptsPerProject = maxRecentAuthorizationAttemptsPerProject;
92
+ this.maxRecentAuthorizationAttemptsPerClient = maxRecentAuthorizationAttemptsPerClient;
93
+ }
94
+
95
+ async createAuthorizationFlow(input: {
96
+ projectId: string;
97
+ pkceVerifier: string;
98
+ returnHostname: string;
99
+ clientKey: string;
100
+ interactionMode?: "interactive" | "silent";
101
+ ttlSeconds: number;
102
+ nowSeconds?: number;
103
+ }): Promise<{
104
+ state: string;
105
+ browserBinding: string;
106
+ returnHostname: string;
107
+ expiresAt: number;
108
+ }> {
109
+ const projectId = requiredProjectId(input.projectId);
110
+ const pkceVerifier = requiredPkceVerifier(input.pkceVerifier);
111
+ const returnHostname = normalizedHostname(input.returnHostname);
112
+ const clientKey = requiredClientKey(input.clientKey);
113
+ const interactionMode = requiredInteractionMode(input.interactionMode ?? "interactive");
114
+ const timing = expiration(input.nowSeconds, input.ttlSeconds, FLOW_MAX_TTL_SECONDS, "authorization flow");
115
+ await this.requireAllowedHostname(projectId, returnHostname);
116
+ await this.deleteExpiredState({ projectId, nowSeconds: timing.createdAt });
117
+ const state = randomOpaqueValue();
118
+ const browserBinding = randomOpaqueValue();
119
+ const stateDigest = await opaqueDigest(state);
120
+ const browserBindingDigest = await opaqueDigest(browserBinding);
121
+ const clientKeyDigest = await opaqueDigest(clientKey);
122
+ const result = await this.database.prepare(
123
+ `INSERT INTO public_customer_auth_flows (
124
+ project_id, state_digest, browser_binding_digest, client_key_digest, interaction_mode,
125
+ pkce_verifier, return_hostname, created_at, expires_at
126
+ )
127
+ SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?
128
+ WHERE (
129
+ SELECT COUNT(*) FROM public_customer_auth_flows
130
+ WHERE project_id = ? AND expires_at > ?
131
+ ) < ?
132
+ AND (
133
+ SELECT COUNT(*) FROM public_customer_auth_flows
134
+ WHERE project_id = ? AND client_key_digest = ? AND expires_at > ?
135
+ ) < ?`,
136
+ ).bind(
137
+ projectId,
138
+ stateDigest,
139
+ browserBindingDigest,
140
+ clientKeyDigest,
141
+ interactionMode,
142
+ pkceVerifier,
143
+ returnHostname,
144
+ timing.createdAt,
145
+ timing.expiresAt,
146
+ projectId,
147
+ timing.createdAt,
148
+ this.maxRecentAuthorizationAttemptsPerProject,
149
+ projectId,
150
+ clientKeyDigest,
151
+ timing.createdAt,
152
+ this.maxRecentAuthorizationAttemptsPerClient,
153
+ ).run();
154
+ if (result.success === false) {
155
+ throw new Error("The customer authentication state was not stored.");
156
+ }
157
+ if (result.meta?.changes === 0) throw new CustomerAuthFlowCapacityError();
158
+ if (result.meta?.changes !== 1) {
159
+ throw new Error("The customer authentication state was not stored.");
160
+ }
161
+ return { state, browserBinding, returnHostname, expiresAt: timing.expiresAt };
162
+ }
163
+
164
+ async consumeAuthorizationFlow(input: {
165
+ projectId: string;
166
+ state: string;
167
+ browserBinding: string;
168
+ nowSeconds?: number;
169
+ }): Promise<ConsumedAuthorizationFlow | null> {
170
+ const projectId = requiredProjectId(input.projectId);
171
+ const stateDigest = await opaqueDigest(requiredOpaqueValue(input.state, "authorization state"));
172
+ const browserBindingDigest = await opaqueDigest(
173
+ requiredOpaqueValue(input.browserBinding, "authorization browser binding"),
174
+ );
175
+ const now = requiredNow(input.nowSeconds);
176
+ const row = await this.database.prepare(
177
+ `UPDATE public_customer_auth_flows
178
+ SET consumed_at = ?
179
+ WHERE project_id = ? AND state_digest = ? AND browser_binding_digest = ?
180
+ AND consumed_at IS NULL AND expires_at > ?
181
+ RETURNING pkce_verifier, return_hostname, interaction_mode, expires_at`,
182
+ ).bind(now, projectId, stateDigest, browserBindingDigest, now).first<AuthorizationFlowRow>();
183
+ if (!row) return null;
184
+ return {
185
+ pkceVerifier: requiredPkceVerifier(row.pkce_verifier),
186
+ returnHostname: normalizedHostname(row.return_hostname),
187
+ interactionMode: requiredInteractionMode(row.interaction_mode),
188
+ expiresAt: requiredExpiry(row.expires_at, now),
189
+ };
190
+ }
191
+
192
+ async createShopperSessionHandoff(input: {
193
+ projectId: string;
194
+ returnHostname: string;
195
+ sessionData: string;
196
+ sessionTtlSeconds: number;
197
+ handoffTtlSeconds: number;
198
+ nowSeconds?: number;
199
+ }): Promise<{ handoff: string; returnHostname: string; expiresAt: number }> {
200
+ const projectId = requiredProjectId(input.projectId);
201
+ const returnHostname = normalizedHostname(input.returnHostname);
202
+ const sessionData = requiredSessionData(input.sessionData);
203
+ const now = requiredNow(input.nowSeconds);
204
+ const timing = expiration(now, input.handoffTtlSeconds, HANDOFF_MAX_TTL_SECONDS, "handoff");
205
+ const sessionTiming = expiration(now, input.sessionTtlSeconds, SESSION_MAX_TTL_SECONDS, "shopper session");
206
+ if (input.handoffTtlSeconds > input.sessionTtlSeconds) {
207
+ throw new Error("The handoff cannot outlive its shopper session.");
208
+ }
209
+ await this.requireAllowedHostname(projectId, returnHostname);
210
+ await this.deleteExpiredState({ projectId, nowSeconds: now });
211
+ const sessionId = randomOpaqueValue();
212
+ const sessionDigest = await opaqueDigest(sessionId);
213
+ const handoff = randomOpaqueValue();
214
+ const handoffDigest = await opaqueDigest(handoff);
215
+ const sessionSeal = await sealPendingSessionPayload({
216
+ sessionId,
217
+ sessionExpiresAt: sessionTiming.expiresAt,
218
+ sessionData,
219
+ handoff,
220
+ projectId,
221
+ returnHostname,
222
+ handoffDigest,
223
+ });
224
+ await requireInsert(this.database.prepare(
225
+ `INSERT INTO public_customer_auth_handoffs (
226
+ project_id, handoff_digest, return_hostname, session_digest, session_seal, created_at, expires_at
227
+ ) VALUES (?, ?, ?, ?, ?, ?, ?)`,
228
+ ).bind(
229
+ projectId,
230
+ handoffDigest,
231
+ returnHostname,
232
+ sessionDigest,
233
+ sessionSeal,
234
+ timing.createdAt,
235
+ timing.expiresAt,
236
+ ));
237
+ return { handoff, returnHostname, expiresAt: timing.expiresAt };
238
+ }
239
+
240
+ async consumeHandoff(input: {
241
+ projectId: string;
242
+ handoff: string;
243
+ requestHostname: string;
244
+ nowSeconds?: number;
245
+ }): Promise<ConsumedCustomerAuthHandoff | null> {
246
+ const projectId = requiredProjectId(input.projectId);
247
+ const handoff = requiredOpaqueValue(input.handoff, "handoff");
248
+ const handoffDigest = await opaqueDigest(handoff);
249
+ const requestHostname = normalizedHostname(input.requestHostname);
250
+ const now = requiredNow(input.nowSeconds);
251
+ const readHandoff = () => this.database.prepare(
252
+ `SELECT session_seal, session_digest, return_hostname, expires_at, claimed_at
253
+ FROM public_customer_auth_handoffs
254
+ WHERE project_id = ? AND handoff_digest = ? AND return_hostname = ? AND expires_at > ?`,
255
+ ).bind(projectId, handoffDigest, requestHostname, now).first<HandoffRow>();
256
+ const row = await readHandoff();
257
+ if (!row) return null;
258
+ const returnHostname = normalizedHostname(row.return_hostname);
259
+ const handoffExpiresAt = requiredExpiry(row.expires_at, now);
260
+ const sessionDigest = requiredDigest(row.session_digest, "shopper session");
261
+ const sessionSeal = requiredSessionSeal(row.session_seal);
262
+ const claimedAt = optionalClaimedAt(row.claimed_at, now);
263
+ if (claimedAt !== null) {
264
+ return this.readClaimedHandoff({
265
+ projectId,
266
+ handoff,
267
+ handoffDigest,
268
+ requestHostname,
269
+ handoffExpiresAt,
270
+ sessionDigest,
271
+ sessionSeal,
272
+ now,
273
+ });
274
+ }
275
+ const payload = await unsealPendingSessionPayload({
276
+ sessionSeal,
277
+ handoff,
278
+ projectId,
279
+ returnHostname,
280
+ handoffDigest,
281
+ });
282
+ if (await opaqueDigest(payload.sessionId) !== sessionDigest) {
283
+ throw new Error("The customer authentication handoff is invalid.");
284
+ }
285
+ const sessionExpiresAt = requiredExpiry(payload.sessionExpiresAt, now);
286
+ const claimedSeal = await sealClaimedSessionId({
287
+ sessionId: payload.sessionId,
288
+ handoff,
289
+ projectId,
290
+ returnHostname,
291
+ handoffDigest,
292
+ });
293
+ const [claimResult, insertResult] = await this.database.batch([
294
+ this.database.prepare(
295
+ `UPDATE public_customer_auth_handoffs
296
+ SET claimed_at = ?, session_seal = ?
297
+ WHERE project_id = ? AND handoff_digest = ? AND return_hostname = ?
298
+ AND expires_at > ? AND claimed_at IS NULL AND session_digest = ? AND session_seal = ?`,
299
+ ).bind(
300
+ now,
301
+ claimedSeal,
302
+ projectId,
303
+ handoffDigest,
304
+ returnHostname,
305
+ now,
306
+ sessionDigest,
307
+ sessionSeal,
308
+ ),
309
+ this.database.prepare(
310
+ `INSERT INTO public_customer_auth_sessions (
311
+ project_id, session_digest, audience_hostname, session_data, created_at, expires_at
312
+ )
313
+ SELECT ?, ?, ?, ?, ?, ?
314
+ WHERE EXISTS (
315
+ SELECT 1 FROM public_customer_auth_handoffs
316
+ WHERE project_id = ? AND handoff_digest = ? AND return_hostname = ?
317
+ AND expires_at > ? AND claimed_at = ? AND session_digest = ? AND session_seal = ?
318
+ )`,
319
+ ).bind(
320
+ projectId,
321
+ sessionDigest,
322
+ returnHostname,
323
+ payload.sessionData,
324
+ now,
325
+ sessionExpiresAt,
326
+ projectId,
327
+ handoffDigest,
328
+ returnHostname,
329
+ now,
330
+ now,
331
+ sessionDigest,
332
+ claimedSeal,
333
+ ),
334
+ ]);
335
+ if (!claimResult || !insertResult || claimResult.success === false || insertResult.success === false) {
336
+ throw new Error("The customer authentication handoff could not be consumed.");
337
+ }
338
+ const claimed = claimResult.meta?.changes ?? 0;
339
+ const inserted = insertResult.meta?.changes ?? 0;
340
+ if (claimed === 0 && inserted === 0) {
341
+ const racedRow = await readHandoff();
342
+ if (!racedRow || optionalClaimedAt(racedRow.claimed_at, now) === null) {
343
+ throw new Error("The customer authentication handoff could not be consumed.");
344
+ }
345
+ return this.readClaimedHandoff({
346
+ projectId,
347
+ handoff,
348
+ handoffDigest,
349
+ requestHostname,
350
+ handoffExpiresAt: requiredExpiry(racedRow.expires_at, now),
351
+ sessionDigest: requiredDigest(racedRow.session_digest, "shopper session"),
352
+ sessionSeal: requiredSessionSeal(racedRow.session_seal),
353
+ now,
354
+ });
355
+ }
356
+ if (claimed !== 1 || inserted !== 1) {
357
+ throw new Error("The customer authentication handoff could not be consumed.");
358
+ }
359
+ return {
360
+ sessionId: payload.sessionId,
361
+ returnHostname,
362
+ expiresAt: handoffExpiresAt,
363
+ };
364
+ }
365
+
366
+ private async readClaimedHandoff(input: {
367
+ projectId: string;
368
+ handoff: string;
369
+ handoffDigest: string;
370
+ requestHostname: string;
371
+ handoffExpiresAt: number;
372
+ sessionDigest: string;
373
+ sessionSeal: string;
374
+ now: number;
375
+ }): Promise<ConsumedCustomerAuthHandoff> {
376
+ const sessionId = await unsealClaimedSessionId({
377
+ sessionSeal: input.sessionSeal,
378
+ handoff: input.handoff,
379
+ projectId: input.projectId,
380
+ returnHostname: input.requestHostname,
381
+ handoffDigest: input.handoffDigest,
382
+ });
383
+ if (await opaqueDigest(sessionId) !== input.sessionDigest) {
384
+ throw new Error("The customer authentication handoff is invalid.");
385
+ }
386
+ const session = await this.database.prepare(
387
+ `SELECT expires_at FROM public_customer_auth_sessions
388
+ WHERE project_id = ? AND session_digest = ? AND audience_hostname = ? AND expires_at > ?`,
389
+ ).bind(
390
+ input.projectId,
391
+ input.sessionDigest,
392
+ input.requestHostname,
393
+ input.now,
394
+ ).first<{ expires_at: unknown }>();
395
+ if (!session) throw new Error("The customer authentication handoff is invalid.");
396
+ requiredExpiry(session.expires_at, input.now);
397
+ return {
398
+ sessionId,
399
+ returnHostname: input.requestHostname,
400
+ expiresAt: input.handoffExpiresAt,
401
+ };
402
+ }
403
+
404
+ async createShopperSession(input: {
405
+ projectId: string;
406
+ audienceHostname: string;
407
+ sessionData: string;
408
+ ttlSeconds: number;
409
+ nowSeconds?: number;
410
+ }): Promise<{ sessionId: string; audienceHostname: string; expiresAt: number }> {
411
+ const projectId = requiredProjectId(input.projectId);
412
+ const audienceHostname = normalizedHostname(input.audienceHostname);
413
+ const sessionData = requiredSessionData(input.sessionData);
414
+ const timing = expiration(input.nowSeconds, input.ttlSeconds, SESSION_MAX_TTL_SECONDS, "shopper session");
415
+ await this.requireAllowedHostname(projectId, audienceHostname);
416
+ await this.deleteExpiredState({ projectId, nowSeconds: timing.createdAt });
417
+ return this.insertShopperSession({
418
+ projectId,
419
+ audienceHostname,
420
+ sessionData,
421
+ timing,
422
+ });
423
+ }
424
+
425
+ async deleteExpiredState(input: {
426
+ projectId: string;
427
+ nowSeconds?: number;
428
+ }): Promise<{ flows: number; handoffs: number; sessions: number }> {
429
+ const projectId = requiredProjectId(input.projectId);
430
+ const now = requiredNow(input.nowSeconds);
431
+ const deleted: Record<"flows" | "handoffs" | "sessions", number> = {
432
+ flows: 0,
433
+ handoffs: 0,
434
+ sessions: 0,
435
+ };
436
+ for (const [key, table] of [
437
+ ["flows", "public_customer_auth_flows"],
438
+ ["handoffs", "public_customer_auth_handoffs"],
439
+ ["sessions", "public_customer_auth_sessions"],
440
+ ] as const) {
441
+ const statement = this.database.prepare(
442
+ `DELETE FROM ${table}
443
+ WHERE rowid IN (
444
+ SELECT rowid FROM ${table}
445
+ WHERE project_id = ? AND expires_at <= ?
446
+ ORDER BY expires_at
447
+ LIMIT ?
448
+ )`,
449
+ ).bind(projectId, now, EXPIRED_STATE_DELETE_LIMIT);
450
+ const result = await statement.run();
451
+ if (result.success === false) {
452
+ throw new Error("Expired customer authentication state could not be deleted.");
453
+ }
454
+ deleted[key] = result.meta?.changes ?? 0;
455
+ }
456
+ return deleted;
457
+ }
458
+
459
+ /**
460
+ * Bounded maintenance entry point. Opportunistic request cleanup is enough
461
+ * for the first proof; a future trusted first-party scheduler can invoke a
462
+ * protected merchant route to drain inactive projects. A User Worker
463
+ * `scheduled` export is not itself a trigger in a dispatch namespace.
464
+ */
465
+ async runScheduledCleanup(input: {
466
+ projectId: string;
467
+ nowSeconds?: number;
468
+ maxBatches?: number;
469
+ }): Promise<{
470
+ flows: number;
471
+ handoffs: number;
472
+ sessions: number;
473
+ batches: number;
474
+ mayHaveMore: boolean;
475
+ }> {
476
+ const maxBatches = input.maxBatches ?? 4;
477
+ if (!Number.isSafeInteger(maxBatches) || maxBatches < 1 || maxBatches > 16) {
478
+ throw new Error("The scheduled customer authentication cleanup bound is invalid.");
479
+ }
480
+ const total = { flows: 0, handoffs: 0, sessions: 0 };
481
+ let batches = 0;
482
+ let mayHaveMore = false;
483
+ while (batches < maxBatches) {
484
+ const deleted = await this.deleteExpiredState({
485
+ projectId: input.projectId,
486
+ nowSeconds: input.nowSeconds,
487
+ });
488
+ batches += 1;
489
+ total.flows += deleted.flows;
490
+ total.handoffs += deleted.handoffs;
491
+ total.sessions += deleted.sessions;
492
+ mayHaveMore = Object.values(deleted).some((count) => count === EXPIRED_STATE_DELETE_LIMIT);
493
+ if (!mayHaveMore) break;
494
+ }
495
+ return { ...total, batches, mayHaveMore };
496
+ }
497
+
498
+ private async insertShopperSession(input: {
499
+ projectId: string;
500
+ audienceHostname: string;
501
+ sessionData: string;
502
+ timing: { createdAt: number; expiresAt: number };
503
+ sessionId?: string;
504
+ }): Promise<{ sessionId: string; audienceHostname: string; expiresAt: number }> {
505
+ const sessionId = input.sessionId === undefined
506
+ ? randomOpaqueValue()
507
+ : requiredOpaqueValue(input.sessionId, "shopper session");
508
+ const sessionDigest = await opaqueDigest(sessionId);
509
+ await requireInsert(this.database.prepare(
510
+ `INSERT INTO public_customer_auth_sessions (
511
+ project_id, session_digest, audience_hostname, session_data, created_at, expires_at
512
+ ) VALUES (?, ?, ?, ?, ?, ?)`,
513
+ ).bind(
514
+ input.projectId,
515
+ sessionDigest,
516
+ input.audienceHostname,
517
+ input.sessionData,
518
+ input.timing.createdAt,
519
+ input.timing.expiresAt,
520
+ ));
521
+ return {
522
+ sessionId,
523
+ audienceHostname: input.audienceHostname,
524
+ expiresAt: input.timing.expiresAt,
525
+ };
526
+ }
527
+
528
+ async readShopperSession(input: {
529
+ projectId: string;
530
+ sessionId: string;
531
+ requestHostname: string;
532
+ nowSeconds?: number;
533
+ }): Promise<StoredShopperSession | null> {
534
+ const projectId = requiredProjectId(input.projectId);
535
+ const sessionDigest = await opaqueDigest(requiredOpaqueValue(input.sessionId, "shopper session"));
536
+ const requestHostname = normalizedHostname(input.requestHostname);
537
+ const now = requiredNow(input.nowSeconds);
538
+ const row = await this.database.prepare(
539
+ `SELECT session_data, audience_hostname, expires_at
540
+ FROM public_customer_auth_sessions
541
+ WHERE project_id = ? AND session_digest = ? AND audience_hostname = ?
542
+ AND expires_at > ?`,
543
+ ).bind(projectId, sessionDigest, requestHostname, now).first<SessionRow>();
544
+ if (!row) return null;
545
+ return {
546
+ sessionData: requiredSessionData(row.session_data),
547
+ audienceHostname: normalizedHostname(row.audience_hostname),
548
+ expiresAt: requiredExpiry(row.expires_at, now),
549
+ };
550
+ }
551
+
552
+ async deleteShopperSession(input: {
553
+ projectId: string;
554
+ sessionId: string;
555
+ requestHostname: string;
556
+ }): Promise<boolean> {
557
+ const projectId = requiredProjectId(input.projectId);
558
+ const sessionDigest = await opaqueDigest(requiredOpaqueValue(input.sessionId, "shopper session"));
559
+ const requestHostname = normalizedHostname(input.requestHostname);
560
+ const result = await this.database.prepare(
561
+ `DELETE FROM public_customer_auth_sessions
562
+ WHERE project_id = ? AND session_digest = ? AND audience_hostname = ?`,
563
+ ).bind(projectId, sessionDigest, requestHostname).run();
564
+ if (result.success === false) throw new Error("The shopper session deletion failed.");
565
+ return result.meta?.changes === 1;
566
+ }
567
+
568
+ private async requireAllowedHostname(projectId: string, hostname: string): Promise<void> {
569
+ let allowed = false;
570
+ try {
571
+ allowed = await this.hostnamePolicy({ projectId, hostname });
572
+ } catch {
573
+ throw new Error("The customer authentication hostname is not allowed for this project.");
574
+ }
575
+ if (allowed !== true) {
576
+ throw new Error("The customer authentication hostname is not allowed for this project.");
577
+ }
578
+ }
579
+ }
580
+
581
+ interface AuthorizationFlowRow {
582
+ pkce_verifier: unknown;
583
+ return_hostname: unknown;
584
+ interaction_mode: unknown;
585
+ expires_at: unknown;
586
+ }
587
+
588
+ interface HandoffRow {
589
+ session_seal: unknown;
590
+ session_digest: unknown;
591
+ return_hostname: unknown;
592
+ expires_at: unknown;
593
+ claimed_at: unknown;
594
+ }
595
+
596
+ interface SessionRow {
597
+ session_data: unknown;
598
+ audience_hostname: unknown;
599
+ expires_at: unknown;
600
+ }
601
+
602
+ async function requireInsert(statement: CustomerAuthD1BoundStatement): Promise<void> {
603
+ const result = await statement.run();
604
+ if (result.success === false || (result.meta?.changes !== undefined && result.meta.changes !== 1)) {
605
+ throw new Error("The customer authentication state was not stored.");
606
+ }
607
+ }
608
+
609
+ function expiration(
610
+ nowSeconds: number | undefined,
611
+ ttlSeconds: number,
612
+ maximumTtlSeconds: number,
613
+ label: string,
614
+ ): { createdAt: number; expiresAt: number } {
615
+ const createdAt = requiredNow(nowSeconds);
616
+ if (!Number.isSafeInteger(ttlSeconds) || ttlSeconds < 1 || ttlSeconds > maximumTtlSeconds) {
617
+ throw new Error(`The ${label} lifetime is invalid.`);
618
+ }
619
+ return { createdAt, expiresAt: createdAt + ttlSeconds };
620
+ }
621
+
622
+ function requiredNow(value: number | undefined): number {
623
+ const now = value ?? Math.floor(Date.now() / 1000);
624
+ if (!Number.isSafeInteger(now) || now < 0) throw new Error("The current time is invalid.");
625
+ return now;
626
+ }
627
+
628
+ function requiredExpiry(value: unknown, now: number): number {
629
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= now) {
630
+ throw new Error("The stored expiration is invalid.");
631
+ }
632
+ return value;
633
+ }
634
+
635
+ function optionalClaimedAt(value: unknown, now: number): number | null {
636
+ if (value === null) return null;
637
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0 || value > now) {
638
+ throw new Error("The customer authentication handoff claim is invalid.");
639
+ }
640
+ return value;
641
+ }
642
+
643
+ function requiredProjectId(value: string): string {
644
+ if (!/^[A-Za-z0-9][A-Za-z0-9_-]{1,127}$/.test(value)) {
645
+ throw new Error("The customer authentication project is invalid.");
646
+ }
647
+ return value;
648
+ }
649
+
650
+ function requiredPkceVerifier(value: unknown): string {
651
+ if (typeof value !== "string" || !/^[A-Za-z0-9._~-]{43,128}$/.test(value)) {
652
+ throw new Error("The PKCE verifier is invalid.");
653
+ }
654
+ return value;
655
+ }
656
+
657
+ function requiredSessionData(value: unknown): string {
658
+ if (typeof value !== "string" || value.length < 1
659
+ || new TextEncoder().encode(value).byteLength > SESSION_DATA_MAX_LENGTH) {
660
+ throw new Error("The customer session data is invalid.");
661
+ }
662
+ return value;
663
+ }
664
+
665
+ function requiredClientKey(value: unknown): string {
666
+ if (typeof value !== "string" || value.length < 1 || value.length > 512
667
+ || /[\u0000-\u001f\u007f]/.test(value)) {
668
+ throw new Error("The customer authentication client key is invalid.");
669
+ }
670
+ return value;
671
+ }
672
+
673
+ function requiredInteractionMode(value: unknown): "interactive" | "silent" {
674
+ if (value !== "interactive" && value !== "silent") {
675
+ throw new Error("The customer authentication interaction mode is invalid.");
676
+ }
677
+ return value;
678
+ }
679
+
680
+ function validAuthorizationAttemptLimit(value: number): boolean {
681
+ return Number.isSafeInteger(value)
682
+ && value >= 1
683
+ && value <= MAX_CONFIGURED_AUTHORIZATION_ATTEMPTS;
684
+ }
685
+
686
+ function requiredDigest(value: unknown, label: string): string {
687
+ if (typeof value !== "string" || !/^[a-f0-9]{64}$/.test(value)) {
688
+ throw new Error(`The ${label} digest is invalid.`);
689
+ }
690
+ return value;
691
+ }
692
+
693
+ function requiredSessionSeal(value: unknown): string {
694
+ if (typeof value !== "string" || value.length > SESSION_SEAL_MAX_LENGTH
695
+ || !/^[A-Za-z0-9_-]{16}\.[A-Za-z0-9_-]{40,99980}$/.test(value)) {
696
+ throw new Error("The customer authentication session seal is invalid.");
697
+ }
698
+ return value;
699
+ }
700
+
701
+ function requiredOpaqueValue(value: unknown, label: string): string {
702
+ if (typeof value !== "string" || !/^[A-Za-z0-9_-]{43}$/.test(value)) {
703
+ throw new Error(`The ${label} is invalid.`);
704
+ }
705
+ return value;
706
+ }
707
+
708
+ function normalizedHostname(value: unknown): string {
709
+ if (typeof value !== "string") throw new Error("The customer authentication hostname is invalid.");
710
+ const hostname = value.toLowerCase().replace(/\.$/, "");
711
+ if (!/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/.test(hostname) ||
712
+ hostname.length > 253) {
713
+ throw new Error("The customer authentication hostname is invalid.");
714
+ }
715
+ return hostname;
716
+ }
717
+
718
+ function randomOpaqueValue(): string {
719
+ const bytes = crypto.getRandomValues(new Uint8Array(OPAQUE_VALUE_BYTES));
720
+ return base64UrlEncode(bytes);
721
+ }
722
+
723
+ async function opaqueDigest(value: string): Promise<string> {
724
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value));
725
+ return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
726
+ }
727
+
728
+ async function sealPendingSessionPayload(input: {
729
+ sessionId: string;
730
+ sessionExpiresAt: number;
731
+ sessionData: string;
732
+ handoff: string;
733
+ projectId: string;
734
+ returnHostname: string;
735
+ handoffDigest: string;
736
+ }): Promise<string> {
737
+ const key = await handoffKey(input.handoff, ["encrypt"]);
738
+ const nonce = crypto.getRandomValues(new Uint8Array(12));
739
+ const plaintext = new TextEncoder().encode(
740
+ `${requiredOpaqueValue(input.sessionId, "shopper session")}\n${requiredStoredExpiry(input.sessionExpiresAt)}\n${requiredSessionData(input.sessionData)}`,
741
+ );
742
+ const ciphertext = await crypto.subtle.encrypt({
743
+ name: "AES-GCM",
744
+ iv: nonce,
745
+ additionalData: handoffContext(input, "pending"),
746
+ }, key, plaintext);
747
+ return requiredSessionSeal(`${base64UrlEncode(nonce)}.${base64UrlEncode(new Uint8Array(ciphertext))}`);
748
+ }
749
+
750
+ async function unsealPendingSessionPayload(input: {
751
+ sessionSeal: string;
752
+ handoff: string;
753
+ projectId: string;
754
+ returnHostname: string;
755
+ handoffDigest: string;
756
+ }): Promise<{ sessionId: string; sessionExpiresAt: number; sessionData: string }> {
757
+ const [encodedNonce, encodedCiphertext] = input.sessionSeal.split(".");
758
+ if (!encodedNonce || !encodedCiphertext) throw new Error("The customer authentication session seal is invalid.");
759
+ try {
760
+ const key = await handoffKey(input.handoff, ["decrypt"]);
761
+ const plaintext = await crypto.subtle.decrypt({
762
+ name: "AES-GCM",
763
+ iv: base64UrlDecode(encodedNonce),
764
+ additionalData: handoffContext(input, "pending"),
765
+ }, key, base64UrlDecode(encodedCiphertext));
766
+ const decoded = new TextDecoder("utf-8", { fatal: true }).decode(plaintext);
767
+ const firstSeparator = decoded.indexOf("\n");
768
+ const secondSeparator = decoded.indexOf("\n", firstSeparator + 1);
769
+ if (firstSeparator < 0 || secondSeparator < 0) {
770
+ throw new Error("The customer authentication session seal is invalid.");
771
+ }
772
+ return {
773
+ sessionId: requiredOpaqueValue(decoded.slice(0, firstSeparator), "shopper session"),
774
+ sessionExpiresAt: requiredStoredExpiry(decoded.slice(firstSeparator + 1, secondSeparator)),
775
+ sessionData: requiredSessionData(decoded.slice(secondSeparator + 1)),
776
+ };
777
+ } catch {
778
+ throw new Error("The customer authentication session seal is invalid.");
779
+ }
780
+ }
781
+
782
+ async function sealClaimedSessionId(input: {
783
+ sessionId: string;
784
+ handoff: string;
785
+ projectId: string;
786
+ returnHostname: string;
787
+ handoffDigest: string;
788
+ }): Promise<string> {
789
+ const key = await handoffKey(input.handoff, ["encrypt"]);
790
+ const nonce = crypto.getRandomValues(new Uint8Array(12));
791
+ const ciphertext = await crypto.subtle.encrypt({
792
+ name: "AES-GCM",
793
+ iv: nonce,
794
+ additionalData: handoffContext(input, "claimed"),
795
+ }, key, new TextEncoder().encode(requiredOpaqueValue(input.sessionId, "shopper session")));
796
+ return requiredSessionSeal(`${base64UrlEncode(nonce)}.${base64UrlEncode(new Uint8Array(ciphertext))}`);
797
+ }
798
+
799
+ async function unsealClaimedSessionId(input: {
800
+ sessionSeal: string;
801
+ handoff: string;
802
+ projectId: string;
803
+ returnHostname: string;
804
+ handoffDigest: string;
805
+ }): Promise<string> {
806
+ const [encodedNonce, encodedCiphertext] = input.sessionSeal.split(".");
807
+ if (!encodedNonce || !encodedCiphertext) throw new Error("The customer authentication session seal is invalid.");
808
+ try {
809
+ const key = await handoffKey(input.handoff, ["decrypt"]);
810
+ const plaintext = await crypto.subtle.decrypt({
811
+ name: "AES-GCM",
812
+ iv: base64UrlDecode(encodedNonce),
813
+ additionalData: handoffContext(input, "claimed"),
814
+ }, key, base64UrlDecode(encodedCiphertext));
815
+ return requiredOpaqueValue(
816
+ new TextDecoder("utf-8", { fatal: true }).decode(plaintext),
817
+ "shopper session",
818
+ );
819
+ } catch {
820
+ throw new Error("The customer authentication session seal is invalid.");
821
+ }
822
+ }
823
+
824
+ function requiredStoredExpiry(value: unknown): number {
825
+ const parsed = typeof value === "number"
826
+ ? value
827
+ : typeof value === "string" && /^(?:0|[1-9][0-9]{0,15})$/.test(value)
828
+ ? Number(value)
829
+ : Number.NaN;
830
+ if (!Number.isSafeInteger(parsed) || parsed < 1) {
831
+ throw new Error("The stored expiration is invalid.");
832
+ }
833
+ return parsed;
834
+ }
835
+
836
+ async function handoffKey(handoff: string, usages: KeyUsage[]): Promise<CryptoKey> {
837
+ const keyMaterial = await crypto.subtle.digest(
838
+ "SHA-256",
839
+ new TextEncoder().encode(`public-customer-auth-handoff:${handoff}`),
840
+ );
841
+ return crypto.subtle.importKey("raw", keyMaterial, "AES-GCM", false, usages);
842
+ }
843
+
844
+ function handoffContext(input: {
845
+ projectId: string;
846
+ returnHostname: string;
847
+ handoffDigest: string;
848
+ }, state: "pending" | "claimed"): Uint8Array<ArrayBuffer> {
849
+ return new TextEncoder().encode(
850
+ `${input.projectId}\0${input.returnHostname}\0${input.handoffDigest}\0${state}`,
851
+ );
852
+ }
853
+
854
+ function base64UrlEncode(value: Uint8Array): string {
855
+ const binary = Array.from(value, (byte) => String.fromCharCode(byte)).join("");
856
+ return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
857
+ }
858
+
859
+ function base64UrlDecode(value: string): Uint8Array<ArrayBuffer> {
860
+ if (!/^[A-Za-z0-9_-]+$/.test(value)) throw new Error("The encoded customer authentication value is invalid.");
861
+ const padding = "=".repeat((4 - (value.length % 4)) % 4);
862
+ const binary = atob(value.replaceAll("-", "+").replaceAll("_", "/") + padding);
863
+ return Uint8Array.from(binary, (character) => character.charCodeAt(0));
864
+ }