@withandeo/cli 0.13.0 → 0.14.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.
@@ -0,0 +1,1785 @@
1
+ // src/platform/customer-auth/hostname-policy.ts
2
+ import { createHash as createHash2 } from "node:crypto";
3
+
4
+ // src/platform/runtime-hostname.ts
5
+ import { createHash } from "node:crypto";
6
+ function canonicalApplicationHostname(input) {
7
+ return `a-${shortDigest(input.projectId, 14)}--t-${shortDigest(input.tenantId, 14)}.${normalizedDomain(input.baseDomain)}`;
8
+ }
9
+ function immutablePreviewHostname(input) {
10
+ return [
11
+ `r-${shortDigest(input.releaseId, 12)}`,
12
+ `p-${shortDigest(input.projectId, 10)}`,
13
+ `t-${shortDigest(input.tenantId, 10)}`
14
+ ].join("--") + `.${normalizedDomain(input.baseDomain)}`;
15
+ }
16
+ function previewAliasHostname(input) {
17
+ const readable = input.alias.toLowerCase().replace(/[^a-z0-9-]+/gu, "-").replace(/^-+|-+$/gu, "").slice(0, 12);
18
+ if (readable.length < 2) throw new Error("The preview alias is invalid.");
19
+ return [
20
+ `b-${readable}-${rawDigest(input.alias, 8)}`,
21
+ `p-${shortDigest(input.projectId, 10)}`,
22
+ `t-${shortDigest(input.tenantId, 10)}`
23
+ ].join("--") + `.${normalizedDomain(input.baseDomain)}`;
24
+ }
25
+ function shortDigest(value, length) {
26
+ if (!/^[A-Za-z0-9][A-Za-z0-9_-]{1,127}$/u.test(value)) throw new Error("The runtime identity is invalid.");
27
+ return rawDigest(value, length);
28
+ }
29
+ function rawDigest(value, length) {
30
+ return createHash("sha256").update(value, "utf8").digest("hex").slice(0, length);
31
+ }
32
+ function normalizedDomain(value) {
33
+ const domain = value.toLowerCase().replace(/\.$/u, "");
34
+ if (!/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/u.test(domain)) {
35
+ throw new Error("The runtime base domain is invalid.");
36
+ }
37
+ return domain;
38
+ }
39
+
40
+ // src/platform/customer-auth/hostname-policy.ts
41
+ function createTenderRuntimeHostnamePolicy(input) {
42
+ const canonicalHostname = canonicalApplicationHostname(input);
43
+ const domain = canonicalHostname.slice(canonicalHostname.indexOf(".") + 1);
44
+ const projectDigest = shortDigest2(input.projectId, 10);
45
+ const tenantDigest = shortDigest2(input.tenantId, 10);
46
+ const immutablePattern = new RegExp(
47
+ `^r-[a-f0-9]{12}--p-${projectDigest}--t-${tenantDigest}\\.${escaped(domain)}$`
48
+ );
49
+ const aliasPattern = new RegExp(
50
+ `^b-[a-z0-9][a-z0-9-]{1,11}-[a-f0-9]{8}--p-${projectDigest}--t-${tenantDigest}\\.${escaped(domain)}$`
51
+ );
52
+ const additionalHostnames = new Set((input.additionalHostnames ?? []).map(normalizedHostname));
53
+ return ({ projectId, hostname }) => projectId === input.projectId && (hostname === canonicalHostname || additionalHostnames.has(hostname) || immutablePattern.test(hostname) || aliasPattern.test(hostname));
54
+ }
55
+ function normalizedHostname(value) {
56
+ const hostname = value.trim().toLowerCase().replace(/\.$/u, "");
57
+ if (!/^(?=.{4,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/u.test(hostname)) {
58
+ throw new Error("The additional runtime hostname is invalid.");
59
+ }
60
+ return hostname;
61
+ }
62
+ var PUBLIC_CUSTOMER_AUTH_RUNTIME_HOST_CONSTRUCTORS = Object.freeze({
63
+ canonicalApplicationHostname,
64
+ immutablePreviewHostname,
65
+ previewAliasHostname
66
+ });
67
+ function shortDigest2(value, length) {
68
+ return createHash2("sha256").update(value).digest("hex").slice(0, length);
69
+ }
70
+ function escaped(value) {
71
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
72
+ }
73
+
74
+ // src/platform/customer-auth/session-store.ts
75
+ var FLOW_MAX_TTL_SECONDS = 10 * 60;
76
+ var HANDOFF_MAX_TTL_SECONDS = 5 * 60;
77
+ var SESSION_MAX_TTL_SECONDS = 31 * 24 * 60 * 60;
78
+ var EXPIRED_STATE_DELETE_LIMIT = 256;
79
+ var DEFAULT_MAX_RECENT_AUTHORIZATION_ATTEMPTS_PER_PROJECT = 512;
80
+ var DEFAULT_MAX_RECENT_AUTHORIZATION_ATTEMPTS_PER_CLIENT = 20;
81
+ var MAX_CONFIGURED_AUTHORIZATION_ATTEMPTS = 1e5;
82
+ var OPAQUE_VALUE_BYTES = 32;
83
+ var SESSION_DATA_MAX_LENGTH = 65536;
84
+ var SESSION_SEAL_MAX_LENGTH = 1e5;
85
+ var CustomerAuthFlowCapacityError = class extends Error {
86
+ constructor() {
87
+ super("The customer authentication flow capacity has been reached.");
88
+ }
89
+ };
90
+ var PublicCustomerAuthSessionStore = class {
91
+ database;
92
+ hostnamePolicy;
93
+ maxRecentAuthorizationAttemptsPerProject;
94
+ maxRecentAuthorizationAttemptsPerClient;
95
+ constructor(database, hostnamePolicy, options = {}) {
96
+ if (typeof hostnamePolicy !== "function") {
97
+ throw new Error("A customer authentication hostname policy is required.");
98
+ }
99
+ this.database = database;
100
+ this.hostnamePolicy = hostnamePolicy;
101
+ const maxRecentAuthorizationAttemptsPerProject = options.maxRecentAuthorizationAttemptsPerProject ?? DEFAULT_MAX_RECENT_AUTHORIZATION_ATTEMPTS_PER_PROJECT;
102
+ const maxRecentAuthorizationAttemptsPerClient = options.maxRecentAuthorizationAttemptsPerClient ?? DEFAULT_MAX_RECENT_AUTHORIZATION_ATTEMPTS_PER_CLIENT;
103
+ if (!validAuthorizationAttemptLimit(maxRecentAuthorizationAttemptsPerProject) || !validAuthorizationAttemptLimit(maxRecentAuthorizationAttemptsPerClient) || maxRecentAuthorizationAttemptsPerClient > maxRecentAuthorizationAttemptsPerProject) {
104
+ throw new Error("The customer authentication flow capacity is invalid.");
105
+ }
106
+ this.maxRecentAuthorizationAttemptsPerProject = maxRecentAuthorizationAttemptsPerProject;
107
+ this.maxRecentAuthorizationAttemptsPerClient = maxRecentAuthorizationAttemptsPerClient;
108
+ }
109
+ async createAuthorizationFlow(input) {
110
+ const projectId = requiredProjectId(input.projectId);
111
+ const pkceVerifier = requiredPkceVerifier(input.pkceVerifier);
112
+ const returnHostname = normalizedHostname2(input.returnHostname);
113
+ const clientKey = requiredClientKey(input.clientKey);
114
+ const interactionMode = requiredInteractionMode(input.interactionMode ?? "interactive");
115
+ const timing = expiration(input.nowSeconds, input.ttlSeconds, FLOW_MAX_TTL_SECONDS, "authorization flow");
116
+ await this.requireAllowedHostname(projectId, returnHostname);
117
+ await this.deleteExpiredState({ projectId, nowSeconds: timing.createdAt });
118
+ const state = randomOpaqueValue();
119
+ const browserBinding = randomOpaqueValue();
120
+ const stateDigest = await opaqueDigest(state);
121
+ const browserBindingDigest = await opaqueDigest(browserBinding);
122
+ const clientKeyDigest = await opaqueDigest(clientKey);
123
+ const result = await this.database.prepare(
124
+ `INSERT INTO public_customer_auth_flows (
125
+ project_id, state_digest, browser_binding_digest, client_key_digest, interaction_mode,
126
+ pkce_verifier, return_hostname, created_at, expires_at
127
+ )
128
+ SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?
129
+ WHERE (
130
+ SELECT COUNT(*) FROM public_customer_auth_flows
131
+ WHERE project_id = ? AND expires_at > ?
132
+ ) < ?
133
+ AND (
134
+ SELECT COUNT(*) FROM public_customer_auth_flows
135
+ WHERE project_id = ? AND client_key_digest = ? AND expires_at > ?
136
+ ) < ?`
137
+ ).bind(
138
+ projectId,
139
+ stateDigest,
140
+ browserBindingDigest,
141
+ clientKeyDigest,
142
+ interactionMode,
143
+ pkceVerifier,
144
+ returnHostname,
145
+ timing.createdAt,
146
+ timing.expiresAt,
147
+ projectId,
148
+ timing.createdAt,
149
+ this.maxRecentAuthorizationAttemptsPerProject,
150
+ projectId,
151
+ clientKeyDigest,
152
+ timing.createdAt,
153
+ this.maxRecentAuthorizationAttemptsPerClient
154
+ ).run();
155
+ if (result.success === false) {
156
+ throw new Error("The customer authentication state was not stored.");
157
+ }
158
+ if (result.meta?.changes === 0) throw new CustomerAuthFlowCapacityError();
159
+ if (result.meta?.changes !== 1) {
160
+ throw new Error("The customer authentication state was not stored.");
161
+ }
162
+ return { state, browserBinding, returnHostname, expiresAt: timing.expiresAt };
163
+ }
164
+ async consumeAuthorizationFlow(input) {
165
+ const projectId = requiredProjectId(input.projectId);
166
+ const stateDigest = await opaqueDigest(requiredOpaqueValue(input.state, "authorization state"));
167
+ const browserBindingDigest = await opaqueDigest(
168
+ requiredOpaqueValue(input.browserBinding, "authorization browser binding")
169
+ );
170
+ const now = requiredNow(input.nowSeconds);
171
+ const row = await this.database.prepare(
172
+ `UPDATE public_customer_auth_flows
173
+ SET consumed_at = ?
174
+ WHERE project_id = ? AND state_digest = ? AND browser_binding_digest = ?
175
+ AND consumed_at IS NULL AND expires_at > ?
176
+ RETURNING pkce_verifier, return_hostname, interaction_mode, expires_at`
177
+ ).bind(now, projectId, stateDigest, browserBindingDigest, now).first();
178
+ if (!row) return null;
179
+ return {
180
+ pkceVerifier: requiredPkceVerifier(row.pkce_verifier),
181
+ returnHostname: normalizedHostname2(row.return_hostname),
182
+ interactionMode: requiredInteractionMode(row.interaction_mode),
183
+ expiresAt: requiredExpiry(row.expires_at, now)
184
+ };
185
+ }
186
+ async createShopperSessionHandoff(input) {
187
+ const projectId = requiredProjectId(input.projectId);
188
+ const returnHostname = normalizedHostname2(input.returnHostname);
189
+ const sessionData = requiredSessionData(input.sessionData);
190
+ const now = requiredNow(input.nowSeconds);
191
+ const timing = expiration(now, input.handoffTtlSeconds, HANDOFF_MAX_TTL_SECONDS, "handoff");
192
+ const sessionTiming = expiration(now, input.sessionTtlSeconds, SESSION_MAX_TTL_SECONDS, "shopper session");
193
+ if (input.handoffTtlSeconds > input.sessionTtlSeconds) {
194
+ throw new Error("The handoff cannot outlive its shopper session.");
195
+ }
196
+ await this.requireAllowedHostname(projectId, returnHostname);
197
+ await this.deleteExpiredState({ projectId, nowSeconds: now });
198
+ const sessionId = randomOpaqueValue();
199
+ const sessionDigest = await opaqueDigest(sessionId);
200
+ const handoff = randomOpaqueValue();
201
+ const handoffDigest = await opaqueDigest(handoff);
202
+ const sessionSeal = await sealPendingSessionPayload({
203
+ sessionId,
204
+ sessionExpiresAt: sessionTiming.expiresAt,
205
+ sessionData,
206
+ handoff,
207
+ projectId,
208
+ returnHostname,
209
+ handoffDigest
210
+ });
211
+ await requireInsert(this.database.prepare(
212
+ `INSERT INTO public_customer_auth_handoffs (
213
+ project_id, handoff_digest, return_hostname, session_digest, session_seal, created_at, expires_at
214
+ ) VALUES (?, ?, ?, ?, ?, ?, ?)`
215
+ ).bind(
216
+ projectId,
217
+ handoffDigest,
218
+ returnHostname,
219
+ sessionDigest,
220
+ sessionSeal,
221
+ timing.createdAt,
222
+ timing.expiresAt
223
+ ));
224
+ return { handoff, returnHostname, expiresAt: timing.expiresAt };
225
+ }
226
+ async consumeHandoff(input) {
227
+ const projectId = requiredProjectId(input.projectId);
228
+ const handoff = requiredOpaqueValue(input.handoff, "handoff");
229
+ const handoffDigest = await opaqueDigest(handoff);
230
+ const requestHostname = normalizedHostname2(input.requestHostname);
231
+ const now = requiredNow(input.nowSeconds);
232
+ const readHandoff = () => this.database.prepare(
233
+ `SELECT session_seal, session_digest, return_hostname, expires_at, claimed_at
234
+ FROM public_customer_auth_handoffs
235
+ WHERE project_id = ? AND handoff_digest = ? AND return_hostname = ? AND expires_at > ?`
236
+ ).bind(projectId, handoffDigest, requestHostname, now).first();
237
+ const row = await readHandoff();
238
+ if (!row) return null;
239
+ const returnHostname = normalizedHostname2(row.return_hostname);
240
+ const handoffExpiresAt = requiredExpiry(row.expires_at, now);
241
+ const sessionDigest = requiredDigest(row.session_digest, "shopper session");
242
+ const sessionSeal = requiredSessionSeal(row.session_seal);
243
+ const claimedAt = optionalClaimedAt(row.claimed_at, now);
244
+ if (claimedAt !== null) {
245
+ return this.readClaimedHandoff({
246
+ projectId,
247
+ handoff,
248
+ handoffDigest,
249
+ requestHostname,
250
+ handoffExpiresAt,
251
+ sessionDigest,
252
+ sessionSeal,
253
+ now
254
+ });
255
+ }
256
+ const payload = await unsealPendingSessionPayload({
257
+ sessionSeal,
258
+ handoff,
259
+ projectId,
260
+ returnHostname,
261
+ handoffDigest
262
+ });
263
+ if (await opaqueDigest(payload.sessionId) !== sessionDigest) {
264
+ throw new Error("The customer authentication handoff is invalid.");
265
+ }
266
+ const sessionExpiresAt = requiredExpiry(payload.sessionExpiresAt, now);
267
+ const claimedSeal = await sealClaimedSessionId({
268
+ sessionId: payload.sessionId,
269
+ handoff,
270
+ projectId,
271
+ returnHostname,
272
+ handoffDigest
273
+ });
274
+ const [claimResult, insertResult] = await this.database.batch([
275
+ this.database.prepare(
276
+ `UPDATE public_customer_auth_handoffs
277
+ SET claimed_at = ?, session_seal = ?
278
+ WHERE project_id = ? AND handoff_digest = ? AND return_hostname = ?
279
+ AND expires_at > ? AND claimed_at IS NULL AND session_digest = ? AND session_seal = ?`
280
+ ).bind(
281
+ now,
282
+ claimedSeal,
283
+ projectId,
284
+ handoffDigest,
285
+ returnHostname,
286
+ now,
287
+ sessionDigest,
288
+ sessionSeal
289
+ ),
290
+ this.database.prepare(
291
+ `INSERT INTO public_customer_auth_sessions (
292
+ project_id, session_digest, audience_hostname, session_data, created_at, expires_at
293
+ )
294
+ SELECT ?, ?, ?, ?, ?, ?
295
+ WHERE EXISTS (
296
+ SELECT 1 FROM public_customer_auth_handoffs
297
+ WHERE project_id = ? AND handoff_digest = ? AND return_hostname = ?
298
+ AND expires_at > ? AND claimed_at = ? AND session_digest = ? AND session_seal = ?
299
+ )`
300
+ ).bind(
301
+ projectId,
302
+ sessionDigest,
303
+ returnHostname,
304
+ payload.sessionData,
305
+ now,
306
+ sessionExpiresAt,
307
+ projectId,
308
+ handoffDigest,
309
+ returnHostname,
310
+ now,
311
+ now,
312
+ sessionDigest,
313
+ claimedSeal
314
+ )
315
+ ]);
316
+ if (!claimResult || !insertResult || claimResult.success === false || insertResult.success === false) {
317
+ throw new Error("The customer authentication handoff could not be consumed.");
318
+ }
319
+ const claimed = claimResult.meta?.changes ?? 0;
320
+ const inserted = insertResult.meta?.changes ?? 0;
321
+ if (claimed === 0 && inserted === 0) {
322
+ const racedRow = await readHandoff();
323
+ if (!racedRow || optionalClaimedAt(racedRow.claimed_at, now) === null) {
324
+ throw new Error("The customer authentication handoff could not be consumed.");
325
+ }
326
+ return this.readClaimedHandoff({
327
+ projectId,
328
+ handoff,
329
+ handoffDigest,
330
+ requestHostname,
331
+ handoffExpiresAt: requiredExpiry(racedRow.expires_at, now),
332
+ sessionDigest: requiredDigest(racedRow.session_digest, "shopper session"),
333
+ sessionSeal: requiredSessionSeal(racedRow.session_seal),
334
+ now
335
+ });
336
+ }
337
+ if (claimed !== 1 || inserted !== 1) {
338
+ throw new Error("The customer authentication handoff could not be consumed.");
339
+ }
340
+ return {
341
+ sessionId: payload.sessionId,
342
+ returnHostname,
343
+ expiresAt: handoffExpiresAt
344
+ };
345
+ }
346
+ async readClaimedHandoff(input) {
347
+ const sessionId = await unsealClaimedSessionId({
348
+ sessionSeal: input.sessionSeal,
349
+ handoff: input.handoff,
350
+ projectId: input.projectId,
351
+ returnHostname: input.requestHostname,
352
+ handoffDigest: input.handoffDigest
353
+ });
354
+ if (await opaqueDigest(sessionId) !== input.sessionDigest) {
355
+ throw new Error("The customer authentication handoff is invalid.");
356
+ }
357
+ const session = await this.database.prepare(
358
+ `SELECT expires_at FROM public_customer_auth_sessions
359
+ WHERE project_id = ? AND session_digest = ? AND audience_hostname = ? AND expires_at > ?`
360
+ ).bind(
361
+ input.projectId,
362
+ input.sessionDigest,
363
+ input.requestHostname,
364
+ input.now
365
+ ).first();
366
+ if (!session) throw new Error("The customer authentication handoff is invalid.");
367
+ requiredExpiry(session.expires_at, input.now);
368
+ return {
369
+ sessionId,
370
+ returnHostname: input.requestHostname,
371
+ expiresAt: input.handoffExpiresAt
372
+ };
373
+ }
374
+ async createShopperSession(input) {
375
+ const projectId = requiredProjectId(input.projectId);
376
+ const audienceHostname = normalizedHostname2(input.audienceHostname);
377
+ const sessionData = requiredSessionData(input.sessionData);
378
+ const timing = expiration(input.nowSeconds, input.ttlSeconds, SESSION_MAX_TTL_SECONDS, "shopper session");
379
+ await this.requireAllowedHostname(projectId, audienceHostname);
380
+ await this.deleteExpiredState({ projectId, nowSeconds: timing.createdAt });
381
+ return this.insertShopperSession({
382
+ projectId,
383
+ audienceHostname,
384
+ sessionData,
385
+ timing
386
+ });
387
+ }
388
+ async deleteExpiredState(input) {
389
+ const projectId = requiredProjectId(input.projectId);
390
+ const now = requiredNow(input.nowSeconds);
391
+ const deleted = {
392
+ flows: 0,
393
+ handoffs: 0,
394
+ sessions: 0
395
+ };
396
+ for (const [key, table] of [
397
+ ["flows", "public_customer_auth_flows"],
398
+ ["handoffs", "public_customer_auth_handoffs"],
399
+ ["sessions", "public_customer_auth_sessions"]
400
+ ]) {
401
+ const statement = this.database.prepare(
402
+ `DELETE FROM ${table}
403
+ WHERE rowid IN (
404
+ SELECT rowid FROM ${table}
405
+ WHERE project_id = ? AND expires_at <= ?
406
+ ORDER BY expires_at
407
+ LIMIT ?
408
+ )`
409
+ ).bind(projectId, now, EXPIRED_STATE_DELETE_LIMIT);
410
+ const result = await statement.run();
411
+ if (result.success === false) {
412
+ throw new Error("Expired customer authentication state could not be deleted.");
413
+ }
414
+ deleted[key] = result.meta?.changes ?? 0;
415
+ }
416
+ return deleted;
417
+ }
418
+ /**
419
+ * Bounded maintenance entry point. Opportunistic request cleanup is enough
420
+ * for the first proof; a future trusted first-party scheduler can invoke a
421
+ * protected merchant route to drain inactive projects. A User Worker
422
+ * `scheduled` export is not itself a trigger in a dispatch namespace.
423
+ */
424
+ async runScheduledCleanup(input) {
425
+ const maxBatches = input.maxBatches ?? 4;
426
+ if (!Number.isSafeInteger(maxBatches) || maxBatches < 1 || maxBatches > 16) {
427
+ throw new Error("The scheduled customer authentication cleanup bound is invalid.");
428
+ }
429
+ const total = { flows: 0, handoffs: 0, sessions: 0 };
430
+ let batches = 0;
431
+ let mayHaveMore = false;
432
+ while (batches < maxBatches) {
433
+ const deleted = await this.deleteExpiredState({
434
+ projectId: input.projectId,
435
+ nowSeconds: input.nowSeconds
436
+ });
437
+ batches += 1;
438
+ total.flows += deleted.flows;
439
+ total.handoffs += deleted.handoffs;
440
+ total.sessions += deleted.sessions;
441
+ mayHaveMore = Object.values(deleted).some((count) => count === EXPIRED_STATE_DELETE_LIMIT);
442
+ if (!mayHaveMore) break;
443
+ }
444
+ return { ...total, batches, mayHaveMore };
445
+ }
446
+ async insertShopperSession(input) {
447
+ const sessionId = input.sessionId === void 0 ? randomOpaqueValue() : requiredOpaqueValue(input.sessionId, "shopper session");
448
+ const sessionDigest = await opaqueDigest(sessionId);
449
+ await requireInsert(this.database.prepare(
450
+ `INSERT INTO public_customer_auth_sessions (
451
+ project_id, session_digest, audience_hostname, session_data, created_at, expires_at
452
+ ) VALUES (?, ?, ?, ?, ?, ?)`
453
+ ).bind(
454
+ input.projectId,
455
+ sessionDigest,
456
+ input.audienceHostname,
457
+ input.sessionData,
458
+ input.timing.createdAt,
459
+ input.timing.expiresAt
460
+ ));
461
+ return {
462
+ sessionId,
463
+ audienceHostname: input.audienceHostname,
464
+ expiresAt: input.timing.expiresAt
465
+ };
466
+ }
467
+ async readShopperSession(input) {
468
+ const projectId = requiredProjectId(input.projectId);
469
+ const sessionDigest = await opaqueDigest(requiredOpaqueValue(input.sessionId, "shopper session"));
470
+ const requestHostname = normalizedHostname2(input.requestHostname);
471
+ const now = requiredNow(input.nowSeconds);
472
+ const row = await this.database.prepare(
473
+ `SELECT session_data, audience_hostname, expires_at
474
+ FROM public_customer_auth_sessions
475
+ WHERE project_id = ? AND session_digest = ? AND audience_hostname = ?
476
+ AND expires_at > ?`
477
+ ).bind(projectId, sessionDigest, requestHostname, now).first();
478
+ if (!row) return null;
479
+ return {
480
+ sessionData: requiredSessionData(row.session_data),
481
+ audienceHostname: normalizedHostname2(row.audience_hostname),
482
+ expiresAt: requiredExpiry(row.expires_at, now)
483
+ };
484
+ }
485
+ async deleteShopperSession(input) {
486
+ const projectId = requiredProjectId(input.projectId);
487
+ const sessionDigest = await opaqueDigest(requiredOpaqueValue(input.sessionId, "shopper session"));
488
+ const requestHostname = normalizedHostname2(input.requestHostname);
489
+ const result = await this.database.prepare(
490
+ `DELETE FROM public_customer_auth_sessions
491
+ WHERE project_id = ? AND session_digest = ? AND audience_hostname = ?`
492
+ ).bind(projectId, sessionDigest, requestHostname).run();
493
+ if (result.success === false) throw new Error("The shopper session deletion failed.");
494
+ return result.meta?.changes === 1;
495
+ }
496
+ async requireAllowedHostname(projectId, hostname) {
497
+ let allowed = false;
498
+ try {
499
+ allowed = await this.hostnamePolicy({ projectId, hostname });
500
+ } catch {
501
+ throw new Error("The customer authentication hostname is not allowed for this project.");
502
+ }
503
+ if (allowed !== true) {
504
+ throw new Error("The customer authentication hostname is not allowed for this project.");
505
+ }
506
+ }
507
+ };
508
+ async function requireInsert(statement) {
509
+ const result = await statement.run();
510
+ if (result.success === false || result.meta?.changes !== void 0 && result.meta.changes !== 1) {
511
+ throw new Error("The customer authentication state was not stored.");
512
+ }
513
+ }
514
+ function expiration(nowSeconds, ttlSeconds, maximumTtlSeconds, label) {
515
+ const createdAt = requiredNow(nowSeconds);
516
+ if (!Number.isSafeInteger(ttlSeconds) || ttlSeconds < 1 || ttlSeconds > maximumTtlSeconds) {
517
+ throw new Error(`The ${label} lifetime is invalid.`);
518
+ }
519
+ return { createdAt, expiresAt: createdAt + ttlSeconds };
520
+ }
521
+ function requiredNow(value) {
522
+ const now = value ?? Math.floor(Date.now() / 1e3);
523
+ if (!Number.isSafeInteger(now) || now < 0) throw new Error("The current time is invalid.");
524
+ return now;
525
+ }
526
+ function requiredExpiry(value, now) {
527
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= now) {
528
+ throw new Error("The stored expiration is invalid.");
529
+ }
530
+ return value;
531
+ }
532
+ function optionalClaimedAt(value, now) {
533
+ if (value === null) return null;
534
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0 || value > now) {
535
+ throw new Error("The customer authentication handoff claim is invalid.");
536
+ }
537
+ return value;
538
+ }
539
+ function requiredProjectId(value) {
540
+ if (!/^[A-Za-z0-9][A-Za-z0-9_-]{1,127}$/.test(value)) {
541
+ throw new Error("The customer authentication project is invalid.");
542
+ }
543
+ return value;
544
+ }
545
+ function requiredPkceVerifier(value) {
546
+ if (typeof value !== "string" || !/^[A-Za-z0-9._~-]{43,128}$/.test(value)) {
547
+ throw new Error("The PKCE verifier is invalid.");
548
+ }
549
+ return value;
550
+ }
551
+ function requiredSessionData(value) {
552
+ if (typeof value !== "string" || value.length < 1 || new TextEncoder().encode(value).byteLength > SESSION_DATA_MAX_LENGTH) {
553
+ throw new Error("The customer session data is invalid.");
554
+ }
555
+ return value;
556
+ }
557
+ function requiredClientKey(value) {
558
+ if (typeof value !== "string" || value.length < 1 || value.length > 512 || /[\u0000-\u001f\u007f]/.test(value)) {
559
+ throw new Error("The customer authentication client key is invalid.");
560
+ }
561
+ return value;
562
+ }
563
+ function requiredInteractionMode(value) {
564
+ if (value !== "interactive" && value !== "silent") {
565
+ throw new Error("The customer authentication interaction mode is invalid.");
566
+ }
567
+ return value;
568
+ }
569
+ function validAuthorizationAttemptLimit(value) {
570
+ return Number.isSafeInteger(value) && value >= 1 && value <= MAX_CONFIGURED_AUTHORIZATION_ATTEMPTS;
571
+ }
572
+ function requiredDigest(value, label) {
573
+ if (typeof value !== "string" || !/^[a-f0-9]{64}$/.test(value)) {
574
+ throw new Error(`The ${label} digest is invalid.`);
575
+ }
576
+ return value;
577
+ }
578
+ function requiredSessionSeal(value) {
579
+ if (typeof value !== "string" || value.length > SESSION_SEAL_MAX_LENGTH || !/^[A-Za-z0-9_-]{16}\.[A-Za-z0-9_-]{40,99980}$/.test(value)) {
580
+ throw new Error("The customer authentication session seal is invalid.");
581
+ }
582
+ return value;
583
+ }
584
+ function requiredOpaqueValue(value, label) {
585
+ if (typeof value !== "string" || !/^[A-Za-z0-9_-]{43}$/.test(value)) {
586
+ throw new Error(`The ${label} is invalid.`);
587
+ }
588
+ return value;
589
+ }
590
+ function normalizedHostname2(value) {
591
+ if (typeof value !== "string") throw new Error("The customer authentication hostname is invalid.");
592
+ const hostname = value.toLowerCase().replace(/\.$/, "");
593
+ if (!/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/.test(hostname) || hostname.length > 253) {
594
+ throw new Error("The customer authentication hostname is invalid.");
595
+ }
596
+ return hostname;
597
+ }
598
+ function randomOpaqueValue() {
599
+ const bytes = crypto.getRandomValues(new Uint8Array(OPAQUE_VALUE_BYTES));
600
+ return base64UrlEncode(bytes);
601
+ }
602
+ async function opaqueDigest(value) {
603
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value));
604
+ return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
605
+ }
606
+ async function sealPendingSessionPayload(input) {
607
+ const key = await handoffKey(input.handoff, ["encrypt"]);
608
+ const nonce = crypto.getRandomValues(new Uint8Array(12));
609
+ const plaintext = new TextEncoder().encode(
610
+ `${requiredOpaqueValue(input.sessionId, "shopper session")}
611
+ ${requiredStoredExpiry(input.sessionExpiresAt)}
612
+ ${requiredSessionData(input.sessionData)}`
613
+ );
614
+ const ciphertext = await crypto.subtle.encrypt({
615
+ name: "AES-GCM",
616
+ iv: nonce,
617
+ additionalData: handoffContext(input, "pending")
618
+ }, key, plaintext);
619
+ return requiredSessionSeal(`${base64UrlEncode(nonce)}.${base64UrlEncode(new Uint8Array(ciphertext))}`);
620
+ }
621
+ async function unsealPendingSessionPayload(input) {
622
+ const [encodedNonce, encodedCiphertext] = input.sessionSeal.split(".");
623
+ if (!encodedNonce || !encodedCiphertext) throw new Error("The customer authentication session seal is invalid.");
624
+ try {
625
+ const key = await handoffKey(input.handoff, ["decrypt"]);
626
+ const plaintext = await crypto.subtle.decrypt({
627
+ name: "AES-GCM",
628
+ iv: base64UrlDecode(encodedNonce),
629
+ additionalData: handoffContext(input, "pending")
630
+ }, key, base64UrlDecode(encodedCiphertext));
631
+ const decoded = new TextDecoder("utf-8", { fatal: true }).decode(plaintext);
632
+ const firstSeparator = decoded.indexOf("\n");
633
+ const secondSeparator = decoded.indexOf("\n", firstSeparator + 1);
634
+ if (firstSeparator < 0 || secondSeparator < 0) {
635
+ throw new Error("The customer authentication session seal is invalid.");
636
+ }
637
+ return {
638
+ sessionId: requiredOpaqueValue(decoded.slice(0, firstSeparator), "shopper session"),
639
+ sessionExpiresAt: requiredStoredExpiry(decoded.slice(firstSeparator + 1, secondSeparator)),
640
+ sessionData: requiredSessionData(decoded.slice(secondSeparator + 1))
641
+ };
642
+ } catch {
643
+ throw new Error("The customer authentication session seal is invalid.");
644
+ }
645
+ }
646
+ async function sealClaimedSessionId(input) {
647
+ const key = await handoffKey(input.handoff, ["encrypt"]);
648
+ const nonce = crypto.getRandomValues(new Uint8Array(12));
649
+ const ciphertext = await crypto.subtle.encrypt({
650
+ name: "AES-GCM",
651
+ iv: nonce,
652
+ additionalData: handoffContext(input, "claimed")
653
+ }, key, new TextEncoder().encode(requiredOpaqueValue(input.sessionId, "shopper session")));
654
+ return requiredSessionSeal(`${base64UrlEncode(nonce)}.${base64UrlEncode(new Uint8Array(ciphertext))}`);
655
+ }
656
+ async function unsealClaimedSessionId(input) {
657
+ const [encodedNonce, encodedCiphertext] = input.sessionSeal.split(".");
658
+ if (!encodedNonce || !encodedCiphertext) throw new Error("The customer authentication session seal is invalid.");
659
+ try {
660
+ const key = await handoffKey(input.handoff, ["decrypt"]);
661
+ const plaintext = await crypto.subtle.decrypt({
662
+ name: "AES-GCM",
663
+ iv: base64UrlDecode(encodedNonce),
664
+ additionalData: handoffContext(input, "claimed")
665
+ }, key, base64UrlDecode(encodedCiphertext));
666
+ return requiredOpaqueValue(
667
+ new TextDecoder("utf-8", { fatal: true }).decode(plaintext),
668
+ "shopper session"
669
+ );
670
+ } catch {
671
+ throw new Error("The customer authentication session seal is invalid.");
672
+ }
673
+ }
674
+ function requiredStoredExpiry(value) {
675
+ const parsed = typeof value === "number" ? value : typeof value === "string" && /^(?:0|[1-9][0-9]{0,15})$/.test(value) ? Number(value) : Number.NaN;
676
+ if (!Number.isSafeInteger(parsed) || parsed < 1) {
677
+ throw new Error("The stored expiration is invalid.");
678
+ }
679
+ return parsed;
680
+ }
681
+ async function handoffKey(handoff, usages) {
682
+ const keyMaterial = await crypto.subtle.digest(
683
+ "SHA-256",
684
+ new TextEncoder().encode(`public-customer-auth-handoff:${handoff}`)
685
+ );
686
+ return crypto.subtle.importKey("raw", keyMaterial, "AES-GCM", false, usages);
687
+ }
688
+ function handoffContext(input, state) {
689
+ return new TextEncoder().encode(
690
+ `${input.projectId}\0${input.returnHostname}\0${input.handoffDigest}\0${state}`
691
+ );
692
+ }
693
+ function base64UrlEncode(value) {
694
+ const binary = Array.from(value, (byte) => String.fromCharCode(byte)).join("");
695
+ return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
696
+ }
697
+ function base64UrlDecode(value) {
698
+ if (!/^[A-Za-z0-9_-]+$/.test(value)) throw new Error("The encoded customer authentication value is invalid.");
699
+ const padding = "=".repeat((4 - value.length % 4) % 4);
700
+ const binary = atob(value.replaceAll("-", "+").replaceAll("_", "/") + padding);
701
+ return Uint8Array.from(binary, (character) => character.charCodeAt(0));
702
+ }
703
+
704
+ // src/platform/customer-auth/shopify-route-adapter.ts
705
+ var DEFAULT_LOGIN_PATH = "/auth/login";
706
+ var DEFAULT_CALLBACK_PATH = "/auth/callback";
707
+ var DEFAULT_HANDOFF_PATH = "/auth/handoff";
708
+ var DEFAULT_SESSION_PATH = "/api/session";
709
+ var DEFAULT_LOGOUT_PATH = "/auth/logout";
710
+ var DEFAULT_POST_LOGOUT_PATH = "/auth/logout/callback";
711
+ var DEFAULT_SIGNED_IN_PATH = "/account";
712
+ var DEFAULT_SIGNED_OUT_PATH = "/";
713
+ var FLOW_TTL_SECONDS = 5 * 60;
714
+ var HANDOFF_TTL_SECONDS = 60;
715
+ var MAX_REQUEST_URL_LENGTH = 8192;
716
+ var MAX_FORM_BODY_BYTES = 1024;
717
+ var MAX_COOKIE_HEADER_LENGTH = 8192;
718
+ var MAX_DISCOVERY_BYTES = 16384;
719
+ var MAX_TOKEN_BYTES = 65536;
720
+ var MAX_GRAPHQL_BYTES = 65536;
721
+ var MAX_ENDPOINT_LENGTH = 2048;
722
+ var MAX_ACCESS_TOKEN_LENGTH = 32768;
723
+ var MAX_ID_TOKEN_LENGTH = 16384;
724
+ var MAX_SESSION_TTL_SECONDS = 31 * 24 * 60 * 60;
725
+ var SHOPPER_SESSION_RECORD_TTL_SECONDS = MAX_SESSION_TTL_SECONDS;
726
+ var PROVIDER_FETCH_TIMEOUT_MS = 1e4;
727
+ var DEFAULT_PROVIDER_USER_AGENT = "TenderAccountsCustomerAuth/0.1";
728
+ var PROVIDER_REDIRECT_MODE = "manual";
729
+ var PUBLIC_CUSTOMER_AUTH_SESSION_COOKIE = "__Host-tender_accounts_session";
730
+ var PUBLIC_CUSTOMER_AUTH_FLOW_COOKIE = "__Host-tender_accounts_auth_flow";
731
+ var SHOPIFY_CUSTOMER_IDENTITY_QUERY = `query CustomerIdentity {
732
+ customer {
733
+ id
734
+ displayName
735
+ emailAddress {
736
+ emailAddress
737
+ }
738
+ }
739
+ }`;
740
+ function createShopifyCustomerAccountRouteAdapter(input) {
741
+ if (!(input.sessionStore instanceof PublicCustomerAuthSessionStore)) {
742
+ throw new Error("A public customer authentication session store is required.");
743
+ }
744
+ if (typeof input.hostnamePolicy !== "function") {
745
+ throw new Error("A public customer authentication hostname policy is required.");
746
+ }
747
+ const config = validateConfig(input.config);
748
+ const providerFetch = input.providerFetch ?? fetch;
749
+ if (typeof providerFetch !== "function") {
750
+ throw new Error("A Shopify provider fetch implementation is required.");
751
+ }
752
+ const authorizationClientKey = input.authorizationClientKey ?? cloudflareAuthorizationClientKey;
753
+ if (typeof authorizationClientKey !== "function") {
754
+ throw new Error("A customer authentication client-key implementation is required.");
755
+ }
756
+ const routeInput = {
757
+ sessionStore: input.sessionStore,
758
+ hostnamePolicy: input.hostnamePolicy,
759
+ authorizationClientKey
760
+ };
761
+ return {
762
+ async handle(request) {
763
+ try {
764
+ const routeUrl = parsedRequestUrl(request);
765
+ const route = routeFor(config, routeUrl.pathname);
766
+ if (!route) return null;
767
+ const requestUrl = validatedRequestUrl(routeUrl);
768
+ if (request.method !== route.method) return methodNotAllowed(route.method);
769
+ switch (route.name) {
770
+ case "login":
771
+ return await handleLogin({ request, requestUrl, config, input: routeInput, providerFetch });
772
+ case "callback":
773
+ return await handleCallback({ request, requestUrl, config, input: routeInput, providerFetch });
774
+ case "handoff":
775
+ return await handleHandoff({ request, requestUrl, config, input: routeInput });
776
+ case "session":
777
+ return await handleSession({ request, requestUrl, config, input: routeInput, providerFetch });
778
+ case "logout":
779
+ return await handleLogout({ request, requestUrl, config, input: routeInput });
780
+ case "postLogout":
781
+ return handlePostLogout({ requestUrl, config });
782
+ }
783
+ } catch (error) {
784
+ if (error instanceof SafeHttpError) return safeError(error.status, error.code);
785
+ if (error instanceof CustomerAuthFlowCapacityError) {
786
+ return safeError(429, "customer_authentication_busy");
787
+ }
788
+ if (error instanceof ProviderFailure) return safeError(502, "customer_authentication_unavailable");
789
+ return safeError(500, "customer_authentication_failed");
790
+ }
791
+ }
792
+ };
793
+ }
794
+ async function handleLogin(context) {
795
+ const callbackHostname2 = requireCallbackHostname(context.requestUrl, context.config);
796
+ const returnHostname = requiredSingleQueryValue(context.requestUrl, "return_host", 253);
797
+ const prompt = optionalSingleQueryValue(context.requestUrl, "prompt", 4, /^(?:none)$/);
798
+ if (!await isAllowedHostname(context.input, context.config.projectId, returnHostname)) {
799
+ throw new SafeHttpError(400, "invalid_return_host");
800
+ }
801
+ const verifier = randomBase64Url(32);
802
+ const challenge = await sha256Base64Url(verifier);
803
+ const flow = await context.input.sessionStore.createAuthorizationFlow({
804
+ projectId: context.config.projectId,
805
+ pkceVerifier: verifier,
806
+ returnHostname,
807
+ clientKey: await requiredAuthorizationClientKey(context.input.authorizationClientKey, context.request),
808
+ interactionMode: prompt === "none" ? "silent" : "interactive",
809
+ ttlSeconds: FLOW_TTL_SECONDS
810
+ });
811
+ let discovery;
812
+ try {
813
+ discovery = await discoverOpenId(context.config, context.providerFetch);
814
+ } catch (error) {
815
+ await context.input.sessionStore.consumeAuthorizationFlow({
816
+ projectId: context.config.projectId,
817
+ state: flow.state,
818
+ browserBinding: flow.browserBinding
819
+ });
820
+ throw error;
821
+ }
822
+ const authorizationUrl = new URL(discovery.authorizationEndpoint);
823
+ authorizationUrl.searchParams.set("scope", "openid email customer-account-api:full");
824
+ authorizationUrl.searchParams.set("client_id", context.config.clientId);
825
+ authorizationUrl.searchParams.set("response_type", "code");
826
+ authorizationUrl.searchParams.set("redirect_uri", callbackUrl(context.config, callbackHostname2));
827
+ authorizationUrl.searchParams.set("state", flow.state);
828
+ authorizationUrl.searchParams.set("code_challenge", challenge);
829
+ authorizationUrl.searchParams.set("code_challenge_method", "S256");
830
+ if (prompt) authorizationUrl.searchParams.set("prompt", prompt);
831
+ return redirectResponse(authorizationUrl.toString(), 302, {
832
+ "Set-Cookie": authorizationFlowCookie(flow.state, flow.browserBinding)
833
+ });
834
+ }
835
+ async function handleCallback(context) {
836
+ const callbackHostname2 = requireCallbackHostname(context.requestUrl, context.config);
837
+ const state = requiredSingleQueryValue(context.requestUrl, "state", 43, /^[A-Za-z0-9_-]{43}$/);
838
+ const browserBinding = readOpaqueCookie(
839
+ context.request.headers.get("Cookie"),
840
+ authorizationFlowCookieName(state)
841
+ );
842
+ if (!browserBinding) throw new SafeHttpError(400, "invalid_authorization_state");
843
+ if (context.requestUrl.searchParams.has("error")) {
844
+ const providerError = requiredSingleQueryValue(
845
+ context.requestUrl,
846
+ "error",
847
+ 256,
848
+ /^[A-Za-z0-9._-]+$/
849
+ );
850
+ const deniedFlow = await context.input.sessionStore.consumeAuthorizationFlow({
851
+ projectId: context.config.projectId,
852
+ state,
853
+ browserBinding
854
+ });
855
+ if (!deniedFlow) throw new SafeHttpError(400, "invalid_authorization_state");
856
+ if (providerError === "login_required" && deniedFlow.interactionMode === "silent") {
857
+ const interactiveUrl = new URL(`https://${callbackHostname2}${context.config.loginPath}`);
858
+ interactiveUrl.searchParams.set("return_host", deniedFlow.returnHostname);
859
+ return handleLogin({
860
+ request: new Request(interactiveUrl, { headers: context.request.headers }),
861
+ requestUrl: interactiveUrl,
862
+ config: context.config,
863
+ input: context.input,
864
+ providerFetch: context.providerFetch
865
+ });
866
+ }
867
+ return safeError(400, "customer_authentication_denied");
868
+ }
869
+ const code = requiredSingleQueryValue(context.requestUrl, "code", 4096, /^[A-Za-z0-9._~-]+$/);
870
+ const flow = await context.input.sessionStore.consumeAuthorizationFlow({
871
+ projectId: context.config.projectId,
872
+ state,
873
+ browserBinding
874
+ });
875
+ if (!flow) throw new SafeHttpError(400, "invalid_authorization_state");
876
+ if (!await isAllowedHostname(context.input, context.config.projectId, flow.returnHostname)) {
877
+ return safeError(400, "invalid_return_host");
878
+ }
879
+ try {
880
+ const openId = await discoverOpenId(context.config, context.providerFetch);
881
+ const token = await exchangeAuthorizationCode({
882
+ config: context.config,
883
+ providerFetch: context.providerFetch,
884
+ tokenEndpoint: openId.tokenEndpoint,
885
+ code,
886
+ verifier: flow.pkceVerifier,
887
+ callbackHostname: callbackHostname2
888
+ });
889
+ const customerApi = await discoverCustomerApi(context.config, context.providerFetch);
890
+ const tokenIssuedAt = currentSeconds();
891
+ const handoff = await context.input.sessionStore.createShopperSessionHandoff({
892
+ projectId: context.config.projectId,
893
+ returnHostname: flow.returnHostname,
894
+ sessionData: JSON.stringify({
895
+ version: 1,
896
+ accessToken: token.accessToken,
897
+ idToken: token.idToken,
898
+ graphqlEndpoint: customerApi.graphqlEndpoint,
899
+ endSessionEndpoint: openId.endSessionEndpoint,
900
+ accessTokenExpiresAt: tokenIssuedAt + token.expiresIn
901
+ }),
902
+ sessionTtlSeconds: SHOPPER_SESSION_RECORD_TTL_SECONDS,
903
+ handoffTtlSeconds: Math.min(HANDOFF_TTL_SECONDS, token.expiresIn)
904
+ });
905
+ if (handoff.returnHostname === callbackHostname2) {
906
+ return finalizeHandoff(context, handoff.handoff, handoff.returnHostname);
907
+ }
908
+ return handoffPostResponse(context.config, handoff.returnHostname, handoff.handoff);
909
+ } catch (error) {
910
+ if (error instanceof ProviderFailure) {
911
+ return safeError(502, "customer_authentication_unavailable");
912
+ }
913
+ throw error;
914
+ }
915
+ }
916
+ async function handleHandoff(context) {
917
+ const requestHostname = normalizedHostname3(context.requestUrl.hostname);
918
+ if (!await isAllowedHostname(context.input, context.config.projectId, requestHostname)) {
919
+ throw new SafeHttpError(400, "invalid_handoff");
920
+ }
921
+ requireOrigin(context.request, [
922
+ `https://${context.config.canonicalHostname}`,
923
+ ...Array.from(context.config.additionalCallbackHostnames, (hostname) => `https://${hostname}`),
924
+ `https://${requestHostname}`
925
+ ]);
926
+ const form = await boundedForm(context.request);
927
+ const handoffValue = requiredSingleFormValue(form, "handoff", 43, /^[A-Za-z0-9_-]{43}$/);
928
+ if (Array.from(form.keys()).some((key) => key !== "handoff")) {
929
+ throw new SafeHttpError(400, "invalid_handoff");
930
+ }
931
+ return finalizeHandoff(context, handoffValue, requestHostname);
932
+ }
933
+ async function finalizeHandoff(context, handoffValue, requestHostname) {
934
+ const handoff = await context.input.sessionStore.consumeHandoff({
935
+ projectId: context.config.projectId,
936
+ handoff: handoffValue,
937
+ requestHostname
938
+ });
939
+ if (!handoff) throw new SafeHttpError(400, "invalid_handoff");
940
+ const previousSessionId = readSessionCookie(context.request.headers.get("Cookie"));
941
+ if (previousSessionId && previousSessionId !== handoff.sessionId) {
942
+ try {
943
+ await context.input.sessionStore.deleteShopperSession({
944
+ projectId: context.config.projectId,
945
+ sessionId: previousSessionId,
946
+ requestHostname
947
+ });
948
+ } catch {
949
+ }
950
+ }
951
+ return new Response(null, {
952
+ status: 303,
953
+ headers: secureHeaders({
954
+ Location: context.config.signedInPath,
955
+ "Set-Cookie": sessionCookie(handoff.sessionId)
956
+ })
957
+ });
958
+ }
959
+ async function handleSession(context) {
960
+ const requestHostname = normalizedHostname3(context.requestUrl.hostname);
961
+ if (!await isAllowedHostname(context.input, context.config.projectId, requestHostname)) {
962
+ throw new SafeHttpError(404, "not_found");
963
+ }
964
+ const sessionId = readSessionCookie(context.request.headers.get("Cookie"));
965
+ if (!sessionId) return unauthenticatedResponse(true);
966
+ const stored = await context.input.sessionStore.readShopperSession({
967
+ projectId: context.config.projectId,
968
+ sessionId,
969
+ requestHostname
970
+ });
971
+ if (!stored) return unauthenticatedResponse(true);
972
+ const session = parseStoredShopifySession(stored.sessionData, context.config);
973
+ const renewalUrl = customerRenewalUrl(context.config, requestHostname);
974
+ if (session.accessTokenExpiresAt <= currentSeconds()) {
975
+ return jsonResponse(200, {
976
+ authenticated: false,
977
+ renewalRequired: true,
978
+ renewalUrl
979
+ });
980
+ }
981
+ const response = await fetchProvider(context.providerFetch, session.graphqlEndpoint, {
982
+ method: "POST",
983
+ headers: providerRequestHeaders(context.config, {
984
+ Accept: "application/json",
985
+ Authorization: session.accessToken,
986
+ "Content-Type": "application/json"
987
+ }, providerOrigin(context.config, requestHostname)),
988
+ body: JSON.stringify({
989
+ operationName: "CustomerIdentity",
990
+ query: SHOPIFY_CUSTOMER_IDENTITY_QUERY,
991
+ variables: {}
992
+ }),
993
+ redirect: PROVIDER_REDIRECT_MODE
994
+ });
995
+ if (response.status === 401 || response.status === 403) {
996
+ return jsonResponse(200, {
997
+ authenticated: false,
998
+ renewalRequired: true,
999
+ renewalUrl
1000
+ });
1001
+ }
1002
+ if (!response.ok) throw new ProviderFailure();
1003
+ const payload = await readBoundedJson(response, MAX_GRAPHQL_BYTES);
1004
+ const customer = parseCustomerIdentity(payload);
1005
+ return jsonResponse(200, {
1006
+ authenticated: true,
1007
+ customer,
1008
+ expiresAt: session.accessTokenExpiresAt,
1009
+ renewalUrl
1010
+ });
1011
+ }
1012
+ async function handleLogout(context) {
1013
+ const requestHostname = normalizedHostname3(context.requestUrl.hostname);
1014
+ if (!await isAllowedHostname(context.input, context.config.projectId, requestHostname)) {
1015
+ throw new SafeHttpError(404, "not_found");
1016
+ }
1017
+ requireOrigin(context.request, `https://${requestHostname}`);
1018
+ const sessionId = readSessionCookie(context.request.headers.get("Cookie"));
1019
+ if (!sessionId) return localLogoutResponse();
1020
+ const stored = await context.input.sessionStore.readShopperSession({
1021
+ projectId: context.config.projectId,
1022
+ sessionId,
1023
+ requestHostname
1024
+ });
1025
+ await context.input.sessionStore.deleteShopperSession({
1026
+ projectId: context.config.projectId,
1027
+ sessionId,
1028
+ requestHostname
1029
+ });
1030
+ if (!stored) return localLogoutResponse();
1031
+ let session;
1032
+ try {
1033
+ session = parseStoredShopifySession(stored.sessionData, context.config);
1034
+ } catch {
1035
+ return localLogoutResponse();
1036
+ }
1037
+ const providerLogout = new URL(session.endSessionEndpoint);
1038
+ providerLogout.searchParams.set("id_token_hint", session.idToken);
1039
+ providerLogout.searchParams.set(
1040
+ "post_logout_redirect_uri",
1041
+ `https://${callbackHostname(context.config, requestHostname)}${context.config.postLogoutPath}`
1042
+ );
1043
+ return redirectResponse(providerLogout.toString(), 303, {
1044
+ "Set-Cookie": expiredSessionCookie()
1045
+ });
1046
+ }
1047
+ function handlePostLogout(context) {
1048
+ requireCallbackHostname(context.requestUrl, context.config);
1049
+ return redirectResponse(context.config.signedOutPath, 303);
1050
+ }
1051
+ function routeFor(config, pathname) {
1052
+ if (pathname === config.loginPath) return { name: "login", method: "GET" };
1053
+ if (pathname === config.callbackPath) return { name: "callback", method: "GET" };
1054
+ if (pathname === config.handoffPath) return { name: "handoff", method: "POST" };
1055
+ if (pathname === config.sessionPath) return { name: "session", method: "GET" };
1056
+ if (pathname === config.logoutPath) return { name: "logout", method: "POST" };
1057
+ if (pathname === config.postLogoutPath) return { name: "postLogout", method: "GET" };
1058
+ return null;
1059
+ }
1060
+ function validateConfig(config) {
1061
+ if (!config || typeof config !== "object") throw new Error("Customer authentication config is required.");
1062
+ const allowedConfigKeys = /* @__PURE__ */ new Set([
1063
+ "projectId",
1064
+ "canonicalHostname",
1065
+ "additionalCallbackHostnames",
1066
+ "storefrontDomain",
1067
+ "clientId",
1068
+ "providerUserAgent",
1069
+ "additionalProviderHostnames",
1070
+ "loginPath",
1071
+ "callbackPath",
1072
+ "handoffPath",
1073
+ "sessionPath",
1074
+ "logoutPath",
1075
+ "postLogoutPath",
1076
+ "signedInPath",
1077
+ "signedOutPath"
1078
+ ]);
1079
+ if (Object.keys(config).some((key) => !allowedConfigKeys.has(key))) {
1080
+ throw new Error("The customer authentication config contains an unsupported field.");
1081
+ }
1082
+ if (!/^[A-Za-z0-9][A-Za-z0-9_-]{1,127}$/.test(config.projectId)) {
1083
+ throw new Error("The customer authentication project is invalid.");
1084
+ }
1085
+ if (typeof config.clientId !== "string" || !/^[A-Za-z0-9_-]{8,256}$/.test(config.clientId)) {
1086
+ throw new Error("The Shopify Customer Account client ID is invalid.");
1087
+ }
1088
+ const canonicalHostname = normalizedHostname3(config.canonicalHostname);
1089
+ const additionalCallbackHostnames = validatedHostnameSet(
1090
+ config.additionalCallbackHostnames,
1091
+ "Shopify callback hostname allowlist"
1092
+ );
1093
+ additionalCallbackHostnames.delete(canonicalHostname);
1094
+ const storefrontDomain = normalizedHostname3(config.storefrontDomain);
1095
+ const providerUserAgent = config.providerUserAgent ?? DEFAULT_PROVIDER_USER_AGENT;
1096
+ if (typeof providerUserAgent !== "string" || providerUserAgent.length < 3 || providerUserAgent.length > 128 || /[^\u0020-\u007e]/.test(providerUserAgent)) {
1097
+ throw new Error("The Shopify provider User-Agent is invalid.");
1098
+ }
1099
+ const additionalProviderHostnames = /* @__PURE__ */ new Set();
1100
+ if (config.additionalProviderHostnames !== void 0) {
1101
+ if (!Array.isArray(config.additionalProviderHostnames) || config.additionalProviderHostnames.length > 8) {
1102
+ throw new Error("The Shopify provider hostname allowlist is invalid.");
1103
+ }
1104
+ for (const hostname of config.additionalProviderHostnames) {
1105
+ additionalProviderHostnames.add(normalizedHostname3(hostname));
1106
+ }
1107
+ }
1108
+ const routePaths = {
1109
+ loginPath: validatedPath(config.loginPath ?? DEFAULT_LOGIN_PATH, "login"),
1110
+ callbackPath: validatedPath(config.callbackPath ?? DEFAULT_CALLBACK_PATH, "callback"),
1111
+ handoffPath: validatedPath(config.handoffPath ?? DEFAULT_HANDOFF_PATH, "handoff"),
1112
+ sessionPath: validatedPath(config.sessionPath ?? DEFAULT_SESSION_PATH, "session"),
1113
+ logoutPath: validatedPath(config.logoutPath ?? DEFAULT_LOGOUT_PATH, "logout"),
1114
+ postLogoutPath: validatedPath(config.postLogoutPath ?? DEFAULT_POST_LOGOUT_PATH, "post-logout")
1115
+ };
1116
+ if (new Set(Object.values(routePaths)).size !== Object.keys(routePaths).length) {
1117
+ throw new Error("Customer authentication route paths must be distinct.");
1118
+ }
1119
+ const signedInPath = validatedPath(config.signedInPath ?? DEFAULT_SIGNED_IN_PATH, "signed-in");
1120
+ const signedOutPath = validatedPath(config.signedOutPath ?? DEFAULT_SIGNED_OUT_PATH, "signed-out");
1121
+ if (signedOutPath === routePaths.postLogoutPath) {
1122
+ throw new Error("The signed-out path cannot be the post-logout callback path.");
1123
+ }
1124
+ return {
1125
+ projectId: config.projectId,
1126
+ canonicalHostname,
1127
+ additionalCallbackHostnames,
1128
+ storefrontDomain,
1129
+ clientId: config.clientId,
1130
+ providerUserAgent,
1131
+ additionalProviderHostnames,
1132
+ ...routePaths,
1133
+ signedInPath,
1134
+ signedOutPath
1135
+ };
1136
+ }
1137
+ function parsedRequestUrl(request) {
1138
+ if (!(request instanceof Request) || request.url.length > MAX_REQUEST_URL_LENGTH) {
1139
+ throw new SafeHttpError(400, "invalid_request");
1140
+ }
1141
+ try {
1142
+ return new URL(request.url);
1143
+ } catch {
1144
+ throw new SafeHttpError(400, "invalid_request");
1145
+ }
1146
+ }
1147
+ function validatedRequestUrl(url) {
1148
+ if (url.protocol !== "https:" || url.username || url.password || url.port) {
1149
+ throw new SafeHttpError(400, "invalid_request");
1150
+ }
1151
+ normalizedHostname3(url.hostname);
1152
+ return url;
1153
+ }
1154
+ function validatedPath(value, label) {
1155
+ if (typeof value !== "string" || !/^\/[A-Za-z0-9/_-]{0,127}$/.test(value) || value.includes("//")) {
1156
+ throw new Error(`The customer authentication ${label} path is invalid.`);
1157
+ }
1158
+ return value;
1159
+ }
1160
+ function callbackUrl(config, hostname = config.canonicalHostname) {
1161
+ return `https://${hostname}${config.callbackPath}`;
1162
+ }
1163
+ function customerRenewalUrl(config, returnHostname) {
1164
+ return `https://${callbackHostname(config, returnHostname)}${config.loginPath}?return_host=${encodeURIComponent(returnHostname)}&prompt=none`;
1165
+ }
1166
+ function currentSeconds() {
1167
+ return Math.floor(Date.now() / 1e3);
1168
+ }
1169
+ function requireCallbackHostname(url, config) {
1170
+ const hostname = normalizedHostname3(url.hostname);
1171
+ if (callbackHostname(config, hostname) !== hostname) {
1172
+ throw new SafeHttpError(404, "not_found");
1173
+ }
1174
+ return hostname;
1175
+ }
1176
+ function callbackHostname(config, hostname) {
1177
+ return hostname === config.canonicalHostname || config.additionalCallbackHostnames.has(hostname) ? hostname : config.canonicalHostname;
1178
+ }
1179
+ async function isAllowedHostname(input, projectId, hostnameValue) {
1180
+ let hostname;
1181
+ try {
1182
+ hostname = normalizedHostname3(hostnameValue);
1183
+ } catch {
1184
+ return false;
1185
+ }
1186
+ try {
1187
+ return await input.hostnamePolicy({ projectId, hostname }) === true;
1188
+ } catch {
1189
+ return false;
1190
+ }
1191
+ }
1192
+ async function discoverOpenId(config, providerFetch) {
1193
+ const response = await fetchProvider(
1194
+ providerFetch,
1195
+ `https://${config.storefrontDomain}/.well-known/openid-configuration`,
1196
+ {
1197
+ method: "GET",
1198
+ headers: providerRequestHeaders(config, { Accept: "application/json" }),
1199
+ redirect: PROVIDER_REDIRECT_MODE
1200
+ }
1201
+ );
1202
+ if (!response.ok) throw new ProviderFailure();
1203
+ const value = objectValue(await readBoundedJson(response, MAX_DISCOVERY_BYTES));
1204
+ return {
1205
+ authorizationEndpoint: providerEndpoint(value.authorization_endpoint, config),
1206
+ tokenEndpoint: providerEndpoint(value.token_endpoint, config),
1207
+ endSessionEndpoint: providerEndpoint(value.end_session_endpoint, config)
1208
+ };
1209
+ }
1210
+ async function discoverCustomerApi(config, providerFetch) {
1211
+ const response = await fetchProvider(
1212
+ providerFetch,
1213
+ `https://${config.storefrontDomain}/.well-known/customer-account-api`,
1214
+ {
1215
+ method: "GET",
1216
+ headers: providerRequestHeaders(config, { Accept: "application/json" }),
1217
+ redirect: PROVIDER_REDIRECT_MODE
1218
+ }
1219
+ );
1220
+ if (!response.ok) throw new ProviderFailure();
1221
+ const value = objectValue(await readBoundedJson(response, MAX_DISCOVERY_BYTES));
1222
+ return { graphqlEndpoint: providerEndpoint(value.graphql_api, config) };
1223
+ }
1224
+ async function exchangeAuthorizationCode(input) {
1225
+ const response = await fetchProvider(input.providerFetch, input.tokenEndpoint, {
1226
+ method: "POST",
1227
+ headers: providerRequestHeaders(input.config, {
1228
+ Accept: "application/json",
1229
+ "Content-Type": "application/x-www-form-urlencoded"
1230
+ }, `https://${input.callbackHostname}`),
1231
+ body: new URLSearchParams({
1232
+ grant_type: "authorization_code",
1233
+ client_id: input.config.clientId,
1234
+ redirect_uri: callbackUrl(input.config, input.callbackHostname),
1235
+ code: input.code,
1236
+ code_verifier: input.verifier
1237
+ }),
1238
+ redirect: PROVIDER_REDIRECT_MODE
1239
+ });
1240
+ if (!response.ok) throw new ProviderFailure();
1241
+ const value = objectValue(await readBoundedJson(response, MAX_TOKEN_BYTES));
1242
+ if (typeof value.access_token !== "string" || value.access_token.length < 1 || value.access_token.length > MAX_ACCESS_TOKEN_LENGTH || /[\u0000-\u001f\u007f]/.test(value.access_token)) {
1243
+ throw new ProviderFailure();
1244
+ }
1245
+ if (!Number.isSafeInteger(value.expires_in) || value.expires_in < 1 || value.expires_in > MAX_SESSION_TTL_SECONDS) {
1246
+ throw new ProviderFailure();
1247
+ }
1248
+ return {
1249
+ accessToken: value.access_token,
1250
+ idToken: requiredIdToken(value.id_token),
1251
+ expiresIn: value.expires_in
1252
+ };
1253
+ }
1254
+ function providerRequestHeaders(config, values, origin = `https://${config.canonicalHostname}`) {
1255
+ const headers = new Headers(values);
1256
+ headers.set("Origin", origin);
1257
+ headers.set("User-Agent", config.providerUserAgent);
1258
+ return headers;
1259
+ }
1260
+ function providerEndpoint(value, config) {
1261
+ if (typeof value !== "string" || value.length < 1 || value.length > MAX_ENDPOINT_LENGTH) {
1262
+ throw new ProviderFailure();
1263
+ }
1264
+ let url;
1265
+ try {
1266
+ url = new URL(value);
1267
+ } catch {
1268
+ throw new ProviderFailure();
1269
+ }
1270
+ if (url.protocol !== "https:" || url.username || url.password || url.hash || url.port && url.port !== "443") {
1271
+ throw new ProviderFailure();
1272
+ }
1273
+ let hostname;
1274
+ try {
1275
+ hostname = normalizedHostname3(url.hostname);
1276
+ } catch {
1277
+ throw new ProviderFailure();
1278
+ }
1279
+ if (!isSameOrSubdomain(hostname, config.storefrontDomain) && !isSameOrSubdomain(hostname, "shopify.com") && !isSameOrSubdomain(hostname, "myshopify.com") && !config.additionalProviderHostnames.has(hostname)) {
1280
+ throw new ProviderFailure();
1281
+ }
1282
+ return url.toString();
1283
+ }
1284
+ function parseStoredShopifySession(value, config) {
1285
+ if (typeof value !== "string" || value.length > 65536) throw new ProviderFailure();
1286
+ let parsed;
1287
+ try {
1288
+ parsed = JSON.parse(value);
1289
+ } catch {
1290
+ throw new ProviderFailure();
1291
+ }
1292
+ const object = objectValue(parsed);
1293
+ if (object.version !== 1 || typeof object.accessToken !== "string" || object.accessToken.length < 1 || object.accessToken.length > MAX_ACCESS_TOKEN_LENGTH || /[\u0000-\u001f\u007f]/.test(object.accessToken)) {
1294
+ throw new ProviderFailure();
1295
+ }
1296
+ return {
1297
+ version: 1,
1298
+ accessToken: object.accessToken,
1299
+ idToken: requiredIdToken(object.idToken),
1300
+ graphqlEndpoint: providerEndpoint(object.graphqlEndpoint, config),
1301
+ endSessionEndpoint: providerEndpoint(object.endSessionEndpoint, config),
1302
+ accessTokenExpiresAt: requiredStoredExpiry2(object.accessTokenExpiresAt)
1303
+ };
1304
+ }
1305
+ function requiredIdToken(value) {
1306
+ if (typeof value !== "string" || value.length < 3 || value.length > MAX_ID_TOKEN_LENGTH || !/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.test(value)) {
1307
+ throw new ProviderFailure();
1308
+ }
1309
+ return value;
1310
+ }
1311
+ function requiredStoredExpiry2(value) {
1312
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 1) {
1313
+ throw new ProviderFailure();
1314
+ }
1315
+ return value;
1316
+ }
1317
+ function parseCustomerIdentity(value) {
1318
+ const root = objectValue(value);
1319
+ if (root.errors !== void 0 || !root.data) throw new ProviderFailure();
1320
+ const data = objectValue(root.data);
1321
+ const customer = objectValue(data.customer);
1322
+ const id = boundedString(customer.id, 512);
1323
+ const displayName = boundedString(customer.displayName, 1024);
1324
+ let emailAddress = null;
1325
+ if (customer.emailAddress !== null && customer.emailAddress !== void 0) {
1326
+ const email = objectValue(customer.emailAddress);
1327
+ emailAddress = { emailAddress: boundedString(email.emailAddress, 2048) };
1328
+ }
1329
+ return { id, displayName, emailAddress };
1330
+ }
1331
+ async function fetchProvider(providerFetch, input, init) {
1332
+ try {
1333
+ return await providerFetch(input, {
1334
+ ...init,
1335
+ signal: AbortSignal.timeout(PROVIDER_FETCH_TIMEOUT_MS)
1336
+ });
1337
+ } catch {
1338
+ throw new ProviderFailure();
1339
+ }
1340
+ }
1341
+ async function readBoundedJson(response, maximumBytes) {
1342
+ const contentType = response.headers.get("Content-Type")?.split(";", 1)[0]?.trim().toLowerCase();
1343
+ if (contentType !== "application/json" && !contentType?.endsWith("+json")) throw new ProviderFailure();
1344
+ const contentLength = response.headers.get("Content-Length");
1345
+ if (contentLength && (!/^\d+$/.test(contentLength) || Number(contentLength) > maximumBytes)) {
1346
+ throw new ProviderFailure();
1347
+ }
1348
+ if (!response.body) throw new ProviderFailure();
1349
+ const reader = response.body.getReader();
1350
+ const decoder = new TextDecoder("utf-8", { fatal: true });
1351
+ let bytesRead = 0;
1352
+ let text = "";
1353
+ try {
1354
+ while (true) {
1355
+ const result = await reader.read();
1356
+ if (result.done) break;
1357
+ bytesRead += result.value.byteLength;
1358
+ if (bytesRead > maximumBytes) throw new ProviderFailure();
1359
+ text += decoder.decode(result.value, { stream: true });
1360
+ }
1361
+ text += decoder.decode();
1362
+ } catch (error) {
1363
+ if (error instanceof ProviderFailure) throw error;
1364
+ throw new ProviderFailure();
1365
+ }
1366
+ try {
1367
+ return JSON.parse(text);
1368
+ } catch {
1369
+ throw new ProviderFailure();
1370
+ }
1371
+ }
1372
+ async function boundedForm(request) {
1373
+ const contentType = request.headers.get("Content-Type")?.split(";", 1)[0]?.trim().toLowerCase();
1374
+ if (contentType !== "application/x-www-form-urlencoded") {
1375
+ throw new SafeHttpError(400, "invalid_handoff");
1376
+ }
1377
+ const contentLength = request.headers.get("Content-Length");
1378
+ if (contentLength && (!/^\d+$/.test(contentLength) || Number(contentLength) > MAX_FORM_BODY_BYTES)) {
1379
+ throw new SafeHttpError(400, "invalid_handoff");
1380
+ }
1381
+ if (!request.body) throw new SafeHttpError(400, "invalid_handoff");
1382
+ const reader = request.body.getReader();
1383
+ const chunks = [];
1384
+ let byteLength = 0;
1385
+ try {
1386
+ while (true) {
1387
+ const result = await reader.read();
1388
+ if (result.done) break;
1389
+ byteLength += result.value.byteLength;
1390
+ if (byteLength > MAX_FORM_BODY_BYTES) {
1391
+ await reader.cancel();
1392
+ throw new SafeHttpError(400, "invalid_handoff");
1393
+ }
1394
+ chunks.push(result.value);
1395
+ }
1396
+ } catch (error) {
1397
+ if (error instanceof SafeHttpError) throw error;
1398
+ throw new SafeHttpError(400, "invalid_handoff");
1399
+ }
1400
+ const bodyBytes = new Uint8Array(byteLength);
1401
+ let offset = 0;
1402
+ for (const chunk of chunks) {
1403
+ bodyBytes.set(chunk, offset);
1404
+ offset += chunk.byteLength;
1405
+ }
1406
+ let body;
1407
+ try {
1408
+ body = new TextDecoder("utf-8", { fatal: true }).decode(bodyBytes);
1409
+ } catch {
1410
+ throw new SafeHttpError(400, "invalid_handoff");
1411
+ }
1412
+ return new URLSearchParams(body);
1413
+ }
1414
+ function requiredSingleQueryValue(url, name, maximumLength, pattern) {
1415
+ const values = url.searchParams.getAll(name);
1416
+ if (values.length !== 1 || values[0].length < 1 || values[0].length > maximumLength || pattern && !pattern.test(values[0])) {
1417
+ throw new SafeHttpError(400, "invalid_request");
1418
+ }
1419
+ return values[0];
1420
+ }
1421
+ function optionalSingleQueryValue(url, name, maximumLength, pattern) {
1422
+ const values = url.searchParams.getAll(name);
1423
+ if (values.length === 0) return null;
1424
+ if (values.length !== 1 || values[0].length < 1 || values[0].length > maximumLength || !pattern.test(values[0])) {
1425
+ throw new SafeHttpError(400, "invalid_request");
1426
+ }
1427
+ return values[0];
1428
+ }
1429
+ function requiredSingleFormValue(form, name, maximumLength, pattern) {
1430
+ const values = form.getAll(name);
1431
+ if (values.length !== 1 || values[0].length < 1 || values[0].length > maximumLength || !pattern.test(values[0])) {
1432
+ throw new SafeHttpError(400, "invalid_handoff");
1433
+ }
1434
+ return values[0];
1435
+ }
1436
+ function readSessionCookie(cookieHeader) {
1437
+ return readOpaqueCookie(cookieHeader, PUBLIC_CUSTOMER_AUTH_SESSION_COOKIE);
1438
+ }
1439
+ function readOpaqueCookie(cookieHeader, expectedName) {
1440
+ if (!cookieHeader || cookieHeader.length > MAX_COOKIE_HEADER_LENGTH) return null;
1441
+ const matches = [];
1442
+ for (const part of cookieHeader.split(";")) {
1443
+ const separator = part.indexOf("=");
1444
+ if (separator < 1) continue;
1445
+ const name = part.slice(0, separator).trim();
1446
+ if (name === expectedName) {
1447
+ matches.push(part.slice(separator + 1).trim());
1448
+ }
1449
+ }
1450
+ if (matches.length !== 1 || !/^[A-Za-z0-9_-]{43}$/.test(matches[0])) return null;
1451
+ return matches[0];
1452
+ }
1453
+ async function requiredAuthorizationClientKey(resolver, request) {
1454
+ try {
1455
+ const key = await resolver(request);
1456
+ if (typeof key !== "string" || key.length < 1 || key.length > 512 || /[\u0000-\u001f\u007f]/.test(key)) {
1457
+ throw new Error("invalid");
1458
+ }
1459
+ return key;
1460
+ } catch {
1461
+ throw new SafeHttpError(503, "customer_authentication_unavailable");
1462
+ }
1463
+ }
1464
+ function cloudflareAuthorizationClientKey(request) {
1465
+ const address = request.headers.get("CF-Connecting-IP")?.trim().toLowerCase();
1466
+ if (!address || address.length > 64 || !/^[0-9a-f:.]+$/.test(address)) {
1467
+ throw new Error("The client address is unavailable.");
1468
+ }
1469
+ return `cloudflare-ip:${address}`;
1470
+ }
1471
+ function requireOrigin(request, expectedOrigin) {
1472
+ const expectedOrigins = typeof expectedOrigin === "string" ? [expectedOrigin] : expectedOrigin;
1473
+ if (!expectedOrigins.includes(request.headers.get("Origin") ?? "")) {
1474
+ throw new SafeHttpError(403, "invalid_origin");
1475
+ }
1476
+ }
1477
+ function providerOrigin(config, requestHostname) {
1478
+ return `https://${callbackHostname(config, requestHostname)}`;
1479
+ }
1480
+ function validatedHostnameSet(value, label) {
1481
+ if (value === void 0) return /* @__PURE__ */ new Set();
1482
+ if (!Array.isArray(value) || value.length > 8) throw new Error(`The ${label} is invalid.`);
1483
+ return new Set(value.map(normalizedHostname3));
1484
+ }
1485
+ function normalizedHostname3(value) {
1486
+ if (typeof value !== "string") throw new Error("The customer authentication hostname is invalid.");
1487
+ const hostname = value.toLowerCase().replace(/\.$/, "");
1488
+ if (!/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/.test(hostname) || hostname.length > 253) {
1489
+ throw new Error("The customer authentication hostname is invalid.");
1490
+ }
1491
+ return hostname;
1492
+ }
1493
+ function isSameOrSubdomain(hostname, parent) {
1494
+ return hostname === parent || hostname.endsWith(`.${parent}`);
1495
+ }
1496
+ function objectValue(value) {
1497
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new ProviderFailure();
1498
+ return value;
1499
+ }
1500
+ function boundedString(value, maximumLength) {
1501
+ if (typeof value !== "string" || value.length > maximumLength) throw new ProviderFailure();
1502
+ return value;
1503
+ }
1504
+ function randomBase64Url(bytes) {
1505
+ const value = crypto.getRandomValues(new Uint8Array(bytes));
1506
+ return base64Url(value);
1507
+ }
1508
+ async function sha256Base64Url(value) {
1509
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value));
1510
+ return base64Url(new Uint8Array(digest));
1511
+ }
1512
+ function base64Url(value) {
1513
+ const binary = Array.from(value, (byte) => String.fromCharCode(byte)).join("");
1514
+ return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
1515
+ }
1516
+ function handoffPostResponse(config, returnHostname, handoff) {
1517
+ const action = `https://${returnHostname}${config.handoffPath}`;
1518
+ const nonce = randomBase64Url(18);
1519
+ const html = `<!doctype html>
1520
+ <html lang="en">
1521
+ <head><meta charset="utf-8"><meta name="referrer" content="no-referrer"><title>Signing in</title></head>
1522
+ <body>
1523
+ <form id="customer-auth-handoff" method="post" action="${escapeHtml(action)}">
1524
+ <input type="hidden" name="handoff" value="${escapeHtml(handoff)}">
1525
+ <noscript><button type="submit">Continue</button></noscript>
1526
+ </form>
1527
+ <script nonce="${nonce}">document.getElementById("customer-auth-handoff").requestSubmit();</script>
1528
+ </body>
1529
+ </html>`;
1530
+ return new Response(html, {
1531
+ status: 200,
1532
+ headers: secureHeaders({
1533
+ "Content-Type": "text/html; charset=utf-8",
1534
+ "Content-Security-Policy": `default-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action https://${returnHostname}; script-src 'nonce-${nonce}'`,
1535
+ "Referrer-Policy": "no-referrer"
1536
+ })
1537
+ });
1538
+ }
1539
+ function escapeHtml(value) {
1540
+ return value.replaceAll("&", "&amp;").replaceAll('"', "&quot;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
1541
+ }
1542
+ function sessionCookie(sessionId) {
1543
+ return `${PUBLIC_CUSTOMER_AUTH_SESSION_COOKIE}=${sessionId}; Path=/; Secure; HttpOnly; SameSite=Lax`;
1544
+ }
1545
+ function authorizationFlowCookie(state, browserBinding) {
1546
+ return `${authorizationFlowCookieName(state)}=${browserBinding}; Max-Age=${FLOW_TTL_SECONDS}; Path=/; Secure; HttpOnly; SameSite=Lax`;
1547
+ }
1548
+ function authorizationFlowCookieName(state) {
1549
+ if (!/^[A-Za-z0-9_-]{43}$/.test(state)) {
1550
+ throw new Error("The customer authentication state is invalid.");
1551
+ }
1552
+ return `${PUBLIC_CUSTOMER_AUTH_FLOW_COOKIE}_${state}`;
1553
+ }
1554
+ function expiredSessionCookie() {
1555
+ return `${PUBLIC_CUSTOMER_AUTH_SESSION_COOKIE}=; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=0`;
1556
+ }
1557
+ function localLogoutResponse() {
1558
+ return new Response(null, {
1559
+ status: 204,
1560
+ headers: secureHeaders({ "Set-Cookie": expiredSessionCookie() })
1561
+ });
1562
+ }
1563
+ function unauthenticatedResponse(clearCookie) {
1564
+ return jsonResponse(200, { authenticated: false }, clearCookie ? { "Set-Cookie": expiredSessionCookie() } : void 0);
1565
+ }
1566
+ function jsonResponse(status, value, headers) {
1567
+ return new Response(JSON.stringify(value), {
1568
+ status,
1569
+ headers: secureHeaders({ "Content-Type": "application/json; charset=utf-8", ...headers })
1570
+ });
1571
+ }
1572
+ function safeError(status, code, headers) {
1573
+ return jsonResponse(status, { error: code }, headers);
1574
+ }
1575
+ function redirectResponse(location, status, headers) {
1576
+ const responseHeaders = new Headers(headers);
1577
+ responseHeaders.set("Location", location);
1578
+ return new Response(null, {
1579
+ status,
1580
+ headers: secureHeaders(responseHeaders)
1581
+ });
1582
+ }
1583
+ function methodNotAllowed(method) {
1584
+ return jsonResponse(405, { error: "method_not_allowed" }, { Allow: method });
1585
+ }
1586
+ function secureHeaders(extra) {
1587
+ const headers = new Headers(extra);
1588
+ headers.set("Cache-Control", "no-store");
1589
+ headers.set("Referrer-Policy", "no-referrer");
1590
+ headers.set("X-Content-Type-Options", "nosniff");
1591
+ return headers;
1592
+ }
1593
+ var SafeHttpError = class extends Error {
1594
+ constructor(status, code) {
1595
+ super(code);
1596
+ this.status = status;
1597
+ this.code = code;
1598
+ }
1599
+ status;
1600
+ code;
1601
+ };
1602
+ var ProviderFailure = class extends Error {
1603
+ };
1604
+
1605
+ // src/portal-service.ts
1606
+ var FORWARDED_HEADERS = [
1607
+ "accept",
1608
+ "accept-language",
1609
+ "cache-control",
1610
+ "if-modified-since",
1611
+ "if-none-match",
1612
+ "if-range",
1613
+ "pragma",
1614
+ "range",
1615
+ "user-agent"
1616
+ ];
1617
+ var PORTAL_CONTENT_SECURITY_POLICY = [
1618
+ "default-src 'self'",
1619
+ "base-uri 'none'",
1620
+ "connect-src 'self'",
1621
+ "frame-ancestors 'none'",
1622
+ "img-src 'self' data:",
1623
+ "object-src 'none'",
1624
+ "script-src 'self'",
1625
+ "style-src 'self'"
1626
+ ].join("; ");
1627
+ async function forwardPortalRequest(request, portal) {
1628
+ if (!portal || typeof portal.fetch !== "function") return gatewayError(503, "portal_ui_unavailable");
1629
+ try {
1630
+ const response = await portal.fetch(portalRequest(request));
1631
+ const headers = new Headers(response.headers);
1632
+ for (const name of [...headers.keys()]) {
1633
+ const normalized = name.toLowerCase();
1634
+ if (normalized === "set-cookie" || normalized === "set-cookie2" || normalized === "clear-site-data" || normalized.startsWith("cf-") || normalized.startsWith("cloudflare-") || normalized.startsWith("x-tender-")) {
1635
+ headers.delete(name);
1636
+ }
1637
+ }
1638
+ headers.set("Content-Security-Policy", PORTAL_CONTENT_SECURITY_POLICY);
1639
+ headers.set("Permissions-Policy", "camera=(), geolocation=(), microphone=(), payment=()");
1640
+ headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
1641
+ headers.set("X-Content-Type-Options", "nosniff");
1642
+ headers.set("X-Frame-Options", "DENY");
1643
+ return new Response(request.method === "HEAD" ? null : response.body, {
1644
+ status: response.status,
1645
+ statusText: response.statusText,
1646
+ headers
1647
+ });
1648
+ } catch {
1649
+ return gatewayError(502, "portal_ui_failed");
1650
+ }
1651
+ }
1652
+ function portalRequest(request) {
1653
+ const headers = new Headers();
1654
+ for (const name of FORWARDED_HEADERS) {
1655
+ const value = request.headers.get(name);
1656
+ if (value !== null) headers.set(name, value);
1657
+ }
1658
+ return new Request(request.url, { method: request.method, headers, redirect: "manual" });
1659
+ }
1660
+ function gatewayError(status, code) {
1661
+ return Response.json({ error: { code } }, {
1662
+ status,
1663
+ headers: {
1664
+ "Cache-Control": "no-store",
1665
+ "Content-Security-Policy": "default-src 'none'; frame-ancestors 'none'",
1666
+ "Referrer-Policy": "no-referrer",
1667
+ "X-Content-Type-Options": "nosniff"
1668
+ }
1669
+ });
1670
+ }
1671
+
1672
+ // src/runtime-config.ts
1673
+ var PUBLIC_CLIENT_ID = /^[A-Za-z0-9_-]{8,256}$/u;
1674
+ var RUNTIME_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{1,127}$/u;
1675
+ var HOSTNAME = /^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/u;
1676
+ function readRuntimeConfig(env) {
1677
+ const tenantId = requiredMatch(env.ACCOUNT_TENANT_ID, RUNTIME_ID, "tenant identity");
1678
+ const projectId = requiredMatch(env.ACCOUNT_PROJECT_ID, RUNTIME_ID, "project identity");
1679
+ const runtimeBaseDomain = requiredHostname(env.ACCOUNT_RUNTIME_BASE_DOMAIN, "runtime base domain");
1680
+ return {
1681
+ tenantId,
1682
+ projectId,
1683
+ runtimeBaseDomain,
1684
+ canonicalHostname: canonicalApplicationHostname({ tenantId, projectId, baseDomain: runtimeBaseDomain }),
1685
+ storefrontDomain: requiredHostname(env.SHOPIFY_STOREFRONT_DOMAIN, "Shopify storefront domain"),
1686
+ customerAccountClientId: requiredMatch(
1687
+ env.SHOPIFY_CUSTOMER_ACCOUNT_CLIENT_ID,
1688
+ PUBLIC_CLIENT_ID,
1689
+ "Shopify public customer-account client ID"
1690
+ ),
1691
+ customerAccountDomain: requiredHostname(env.SHOPIFY_CUSTOMER_ACCOUNT_DOMAIN, "Shopify customer-account domain"),
1692
+ vanityHostnames: optionalHostnameList(env.ACCOUNT_VANITY_HOSTNAMES)
1693
+ };
1694
+ }
1695
+ function optionalHostnameList(value) {
1696
+ if (value === void 0) return [];
1697
+ if (value.trim() === "") return [];
1698
+ const hostnames = [...new Set(value.split(",").map((hostname) => requiredHostname(hostname, "vanity hostname")))];
1699
+ if (hostnames.length > 8) throw new Error("Too many vanity hostnames are configured.");
1700
+ return hostnames;
1701
+ }
1702
+ function requiredHostname(value, label) {
1703
+ const hostname = value.trim().toLowerCase().replace(/\.$/u, "");
1704
+ if (!HOSTNAME.test(hostname)) throw new Error(`The ${label} is invalid.`);
1705
+ return hostname;
1706
+ }
1707
+ function requiredMatch(value, pattern, label) {
1708
+ const normalized = value.trim();
1709
+ if (!pattern.test(normalized)) throw new Error(`The ${label} is invalid.`);
1710
+ return normalized;
1711
+ }
1712
+
1713
+ // src/worker.ts
1714
+ var LOOPBACK_HOSTNAMES = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "[::1]"]);
1715
+ var worker_default = {
1716
+ async fetch(request, env) {
1717
+ let config;
1718
+ try {
1719
+ config = readRuntimeConfig(env);
1720
+ } catch {
1721
+ return jsonResponse2(503, "application_not_configured");
1722
+ }
1723
+ const url = new URL(request.url);
1724
+ if (request.method === "GET" && url.pathname === "/api/health") {
1725
+ return Response.json({ ok: true, service: "shopify-customer-account-gateway" });
1726
+ }
1727
+ const localDevelopment = (url.protocol === "http:" || url.protocol === "https:") && LOOPBACK_HOSTNAMES.has(url.hostname.toLowerCase());
1728
+ if (localDevelopment && request.method === "GET" && url.pathname === "/api/bootstrap") {
1729
+ return Response.json({ canonicalHostname: config.canonicalHostname, loginUrl: null, localDevelopment: true });
1730
+ }
1731
+ if (localDevelopment && request.method === "GET" && url.pathname === "/api/session") {
1732
+ return Response.json({ authenticated: false, localDevelopment: true });
1733
+ }
1734
+ if (localDevelopment && url.pathname.startsWith("/auth/")) {
1735
+ return jsonResponse2(409, "authentication_requires_https_preview");
1736
+ }
1737
+ const hostnamePolicy = createTenderRuntimeHostnamePolicy({
1738
+ tenantId: config.tenantId,
1739
+ projectId: config.projectId,
1740
+ baseDomain: config.runtimeBaseDomain,
1741
+ additionalHostnames: config.vanityHostnames
1742
+ });
1743
+ const auth = createShopifyCustomerAccountRouteAdapter({
1744
+ config: {
1745
+ projectId: config.projectId,
1746
+ canonicalHostname: config.canonicalHostname,
1747
+ additionalCallbackHostnames: config.vanityHostnames,
1748
+ storefrontDomain: config.storefrontDomain,
1749
+ clientId: config.customerAccountClientId,
1750
+ additionalProviderHostnames: [config.customerAccountDomain]
1751
+ },
1752
+ sessionStore: new PublicCustomerAuthSessionStore(env.APP_DB, hostnamePolicy),
1753
+ hostnamePolicy
1754
+ });
1755
+ const authResponse = await auth.handle(request);
1756
+ if (authResponse) return authResponse;
1757
+ if (request.method === "GET" && url.pathname === "/api/bootstrap") {
1758
+ const hostname = url.hostname.toLowerCase().replace(/\.$/u, "");
1759
+ if (!await hostnamePolicy({ projectId: config.projectId, hostname })) return jsonResponse2(404, "not_found");
1760
+ const loginHostname = config.vanityHostnames.includes(hostname) ? hostname : config.canonicalHostname;
1761
+ const loginUrl = new URL(`https://${loginHostname}/auth/login`);
1762
+ loginUrl.searchParams.set("return_host", hostname);
1763
+ return Response.json({ canonicalHostname: config.canonicalHostname, loginUrl: loginUrl.toString() });
1764
+ }
1765
+ if (url.pathname.startsWith("/api/") || url.pathname.startsWith("/auth/")) return jsonResponse2(404, "not_found");
1766
+ if (request.method !== "GET" && request.method !== "HEAD") {
1767
+ return new Response(null, { status: 405, headers: { Allow: "GET, HEAD" } });
1768
+ }
1769
+ return await forwardPortalRequest(request, env.PORTAL_UI);
1770
+ }
1771
+ };
1772
+ function jsonResponse2(status, code) {
1773
+ return Response.json({ error: { code } }, {
1774
+ status,
1775
+ headers: {
1776
+ "Cache-Control": "no-store",
1777
+ "Content-Security-Policy": "default-src 'none'; frame-ancestors 'none'",
1778
+ "Referrer-Policy": "no-referrer",
1779
+ "X-Content-Type-Options": "nosniff"
1780
+ }
1781
+ });
1782
+ }
1783
+ export {
1784
+ worker_default as default
1785
+ };