create-ailk 0.20.0 → 0.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -1
- package/component-catalog.json +220 -8
- package/component-catalog.md +21 -9
- package/dist/add-module.js +9 -5
- package/dist/apply-design.d.ts +9 -0
- package/dist/apply-design.js +78 -31
- package/dist/derive-architecture.d.ts +8 -1
- package/dist/derive-architecture.js +38 -3
- package/dist/design-home.d.ts +54 -11
- package/dist/design-home.js +160 -22
- package/dist/design-package.js +7 -2
- package/dist/lib/design-checks.d.ts +6 -1
- package/dist/lib/design-checks.js +23 -2
- package/dist/lib/env-catalog.js +5 -1
- package/dist/module-architecture.js +4 -2
- package/dist/programmatic.js +63 -25
- package/dist/site-chrome.d.ts +8 -0
- package/dist/site-chrome.js +17 -1
- package/dist/site-roster.d.ts +4 -2
- package/dist/site-roster.js +7 -5
- package/dist/spoke-checkout.d.ts +39 -18
- package/dist/spoke-checkout.js +177 -236
- package/dist/starter-grid.js +16 -0
- package/dist/surfaces.js +30 -14
- package/dist/sync-routes.d.ts +28 -0
- package/dist/sync-routes.js +124 -1
- package/dist/template-record.d.ts +4 -3
- package/dist/template-record.js +6 -3
- package/dist/topology.js +25 -1
- package/dist/update.d.ts +6 -1
- package/dist/update.js +125 -18
- package/package.json +3 -3
- package/site-templates/_base/ailk-template.json +2 -2
- package/site-templates/_base/content/en/WorkingAgreementPage/working-agreement.mdx +78 -0
- package/site-templates/designs/minimalism/DESIGN.md +1 -1
- package/site-templates/designs/minimalism/contact/local-business-service.mdx +23 -0
- package/site-templates/designs/minimalism/contact/organization-product.mdx +23 -0
- package/site-templates/designs/minimalism/contact/organization-service.mdx +23 -0
- package/site-templates/designs/minimalism/contact/person-service.mdx +23 -0
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +18 -1
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +11 -0
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +18 -1
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +34 -23
- package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.json +18 -1
- package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.md +33 -23
- package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.json +18 -1
- package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.md +33 -23
- package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.json +18 -1
- package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.md +31 -21
- package/templates/README.md +26 -25
- package/templates/apps/api/CLAUDE.md +20 -7
- package/templates/apps/api/jest.config.cjs +1 -0
- package/templates/apps/api/src/lib/__tests__/consent-store.test.ts +19 -0
- package/templates/apps/api/src/lib/__tests__/flow-completion-store.test.ts +149 -0
- package/templates/apps/api/src/lib/consent-store.ts +28 -0
- package/templates/apps/api/src/lib/flow-completion-store.ts +136 -0
- package/templates/apps/api/src/lib/hub-sign-in-store.ts +38 -2
- package/templates/apps/api/src/lib/proposal-paid-store.ts +21 -4
- package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +14 -3
- package/templates/apps/api/src/middleware/auth.ts +13 -6
- package/templates/apps/api/src/openapi/spec.ts +30 -3
- package/templates/apps/api/src/routes/auth/__tests__/hub-sign-in.test.ts +49 -0
- package/templates/apps/api/src/routes/auth/hub-sign-in.ts +41 -0
- package/templates/apps/api/src/routes/consent/__tests__/account.test.ts +386 -0
- package/templates/apps/api/src/routes/consent/account.ts +266 -0
- package/templates/apps/api/src/routes/consent/index.ts +15 -7
- package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +36 -0
- package/templates/apps/api/src/routes/content/get-page.ts +4 -0
- package/templates/apps/api/src/routes/leads/__tests__/magnet-completion.test.ts +515 -0
- package/templates/apps/api/src/routes/leads/index.ts +161 -7
- package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +26 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-checkout-consent.test.ts +649 -0
- package/templates/apps/api/src/routes/webhooks/stripe.ts +154 -19
- package/templates/apps/api/src/server.ts +28 -0
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +1038 -118
- package/templates/apps/api/src/services/__tests__/magnet-completion.test.ts +389 -0
- package/templates/apps/api/src/services/checkout-buyer.ts +100 -4
- package/templates/apps/api/src/services/consent.ts +95 -0
- package/templates/apps/api/src/services/lead-routing.ts +337 -8
- package/templates/apps/api/src/services/magnet-completion.ts +242 -0
- package/templates/apps/api/src/services/person.ts +40 -8
- package/templates/apps/api/src/services/plan-offerings.ts +59 -0
- package/templates/apps/api/src/services/project-listings.ts +6 -1
- package/templates/apps/api/src/services/public-site-resolver.ts +8 -1
- package/templates/apps/mcp/__tests__/catalog-drift.test.ts +69 -61
- package/templates/apps/mcp/jest.config.cjs +1 -0
- package/templates/apps/web/.env.example +14 -0
- package/templates/apps/web/CLAUDE.md +139 -3
- package/templates/apps/web/__tests__/__mocks__/next-intl-server.ts +6 -2
- package/templates/apps/web/app/[locale]/(auth)/__tests__/auth-pages.test.tsx +5 -4
- package/templates/apps/web/app/[locale]/(auth)/forgot-password/forgot-password-card.tsx +5 -1
- package/templates/apps/web/app/[locale]/(auth)/forgot-password/page.tsx +2 -0
- package/templates/apps/web/app/[locale]/(auth)/layout.tsx +3 -14
- package/templates/apps/web/app/[locale]/(auth)/reset-password/page.tsx +2 -0
- package/templates/apps/web/app/[locale]/(auth)/reset-password/reset-password-card.tsx +5 -1
- package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/page.test.tsx +160 -8
- package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/sign-in-card.test.tsx +12 -9
- package/templates/apps/web/app/[locale]/(auth)/sign-in/page.tsx +105 -2
- package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +36 -3
- package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-text.ts +47 -1
- package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/sign-up-card.test.tsx +60 -22
- package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +16 -8
- package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +85 -31
- package/templates/apps/web/app/[locale]/(auth)/use-auth-card-flow.ts +18 -4
- package/templates/apps/web/app/[locale]/(auth)/verify-email/page.tsx +3 -1
- package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +7 -4
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/agreements-prompt.test.tsx +227 -207
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout-sign-out.test.tsx +17 -1
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +143 -1
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-menu.test.tsx +4 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +385 -89
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/popups.test.tsx +5 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +44 -5
- package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +68 -34
- package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +0 -8
- package/templates/apps/web/app/[locale]/(authed)/account/agreements-actions.ts +39 -151
- package/templates/apps/web/app/[locale]/(authed)/account/agreements-gate.ts +61 -0
- package/templates/apps/web/app/[locale]/(authed)/account/agreements-prompt.tsx +143 -140
- package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +40 -6
- package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +68 -54
- package/templates/apps/web/app/[locale]/(authed)/account/privacy/page.tsx +5 -1
- package/templates/apps/web/app/[locale]/about/[slug]/page.tsx +46 -0
- package/templates/apps/web/app/[locale]/about/__tests__/about-routes.test.tsx +350 -0
- package/templates/apps/web/app/[locale]/about/page.tsx +83 -1
- package/templates/apps/web/app/[locale]/careers/[slug]/apply.actions.ts +293 -0
- package/templates/apps/web/app/[locale]/careers/[slug]/job-application-form.ts +115 -0
- package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +146 -1
- package/templates/apps/web/app/[locale]/careers/__tests__/apply.actions.test.ts +310 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/job-application-form.test.ts +143 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/job-apply-page.test.tsx +350 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +29 -3
- package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/checkout/__tests__/pages.test.tsx +139 -0
- package/templates/apps/web/app/[locale]/checkout/__tests__/thank-you-account.test.tsx +136 -0
- package/templates/apps/web/app/[locale]/checkout/thank-you/page.tsx +114 -0
- package/templates/apps/web/app/[locale]/checkout/unavailable/page.tsx +51 -0
- package/templates/apps/web/app/[locale]/clients/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/contact/ConversationalIntakeWrapper.tsx +24 -1
- package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +79 -4
- package/templates/apps/web/app/[locale]/contact/__tests__/contact-form-magnet.test.ts +138 -0
- package/templates/apps/web/app/[locale]/contact/__tests__/contact-form-text.test.ts +17 -0
- package/templates/apps/web/app/[locale]/contact/__tests__/contact-magnet-page.test.tsx +386 -0
- package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +504 -24
- package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +12 -3
- package/templates/apps/web/app/[locale]/contact/contact-form-magnet.ts +77 -0
- package/templates/apps/web/app/[locale]/contact/contact-form-text.ts +2 -0
- package/templates/apps/web/app/[locale]/contact/contact.actions.ts +184 -41
- package/templates/apps/web/app/[locale]/contact/intake-script.ts +26 -0
- package/templates/apps/web/app/[locale]/contact/page.tsx +120 -16
- package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/downloads/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +106 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.invite.test.tsx +257 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/account-invite.actions.test.ts +69 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/account-invite.actions.ts +35 -0
- package/templates/apps/web/app/[locale]/for/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/layout.tsx +23 -2
- package/templates/apps/web/app/[locale]/page.tsx +13 -1
- package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/testimonials/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/thanks/[slug]/AccountInviteLink.tsx +89 -0
- package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/AccountInviteLink.test.tsx +104 -0
- package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +38 -0
- package/templates/apps/web/app/[locale]/thanks/[slug]/page.tsx +9 -0
- package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/working-agreement/__tests__/page.test.tsx +63 -0
- package/templates/apps/web/app/[locale]/working-agreement/page.tsx +41 -0
- package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +114 -0
- package/templates/apps/web/app/api/auth/hub/authorize/route.ts +20 -3
- package/templates/apps/web/app/api/checkout/[offering]/route.ts +48 -0
- package/templates/apps/web/app/api/checkout/__tests__/route.test.ts +120 -0
- package/templates/apps/web/app/api/checkout/__tests__/success-path.test.ts +86 -0
- package/templates/apps/web/app/api/hub-session/__tests__/routes.test.ts +44 -1
- package/templates/apps/web/app/api/hub-session/sign-in/route.ts +22 -3
- package/templates/apps/web/app/api/webhooks/stripe/__tests__/route.test.ts +92 -0
- package/templates/apps/web/app/api/webhooks/stripe/route.ts +31 -0
- package/templates/apps/web/auth.ts +28 -2
- package/templates/apps/web/jest.config.cjs +1 -0
- package/templates/apps/web/lib/__tests__/account-invite.test.ts +63 -0
- package/templates/apps/web/lib/__tests__/checkout.test.ts +243 -0
- package/templates/apps/web/lib/__tests__/consent-policies.test.ts +23 -0
- package/templates/apps/web/lib/__tests__/deck-page.test.ts +249 -0
- package/templates/apps/web/lib/__tests__/structured-data.test.tsx +38 -19
- package/templates/apps/web/lib/account-invite.ts +37 -0
- package/templates/apps/web/lib/checkout-config.ts +27 -0
- package/templates/apps/web/lib/checkout.ts +93 -0
- package/templates/apps/web/lib/consent-policies.ts +22 -0
- package/templates/apps/web/lib/deck-page.ts +192 -0
- package/templates/apps/web/lib/hub-session/__tests__/sign-in-gate.test.tsx +111 -0
- package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +88 -32
- package/templates/apps/web/lib/prisma-for-auth.ts +8 -0
- package/templates/apps/web/lib/sign-up-agreements.ts +447 -0
- package/templates/apps/web/lib/site-chrome.ts +2 -0
- package/templates/apps/web/lib/telemetry-scrub.ts +5 -1
- package/templates/apps/web/messages/en.json +40 -12
- package/templates/apps/web/package.json +1 -0
- package/templates/apps/web/src/lib/__tests__/lead-write.test.ts +187 -0
- package/templates/apps/web/src/lib/__tests__/rate-limit.test.ts +67 -0
- package/templates/apps/web/src/lib/lead-write.ts +90 -0
- package/templates/apps/web/src/lib/rate-limit.ts +79 -0
- package/templates/database/CHANGELOG.md +47 -0
- package/templates/database/__tests__/consent-source-checkout-migration.test.ts +79 -0
- package/templates/database/__tests__/flow-completion-migration.test.ts +115 -0
- package/templates/database/__tests__/flow-completion.db.test.ts +136 -0
- package/templates/database/__tests__/seed.test.ts +72 -2
- package/templates/database/inbox/schema.prisma +17 -2
- package/templates/database/jest.config.cjs +1 -0
- package/templates/database/migrations/20261018120000_flow_completion/migration.sql +52 -0
- package/templates/database/migrations/20261019120000_consent_source_checkout/migration.sql +27 -0
- package/templates/database/package.json +1 -1
- package/templates/database/seed.ts +60 -0
- package/templates/tools/jest/pin-timezone.cjs +34 -0
- package/versions.json +34 -34
- package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up.actions.ts +0 -125
|
@@ -10,6 +10,10 @@
|
|
|
10
10
|
* are the two external boundaries; both are mocked. The route's own decisions
|
|
11
11
|
* are what is under test: which query it accepts, who it sends to sign in,
|
|
12
12
|
* where a minted code goes, and what it refuses to redirect to.
|
|
13
|
+
*
|
|
14
|
+
* #8757 D9 (AC-6): an optional `login_hint` reaches the sign-in page as
|
|
15
|
+
* `email` on the no-session branch only; it never enters the continuation ref
|
|
16
|
+
* or the code redirect, and a repeated one is a 400 like any repeated key.
|
|
13
17
|
*/
|
|
14
18
|
|
|
15
19
|
// --- Mocks set up BEFORE imports ---
|
|
@@ -345,3 +349,113 @@ describe("a signed-in person", () => {
|
|
|
345
349
|
},
|
|
346
350
|
);
|
|
347
351
|
});
|
|
352
|
+
|
|
353
|
+
describe("the login_hint (#8757 D9)", () => {
|
|
354
|
+
const HINT = "a@b.example";
|
|
355
|
+
|
|
356
|
+
/** The `next` value of the 303, and the ref inside it. */
|
|
357
|
+
function continuationOf(res: Response): { next: string; ref: string } {
|
|
358
|
+
const location = res.headers.get("location") ?? "";
|
|
359
|
+
const next = new URL(location, HUB).searchParams.get("next") ?? "";
|
|
360
|
+
return { next, ref: next.slice(`${CONTINUE}/`.length) };
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
it("no session + a valid login_hint: 303 to /sign-in?next=<continue path>&email=a%40b.example; the ref opens to the six-key canonical query alone", async () => {
|
|
364
|
+
mockGetSession.mockResolvedValue(null);
|
|
365
|
+
const canonical = query();
|
|
366
|
+
|
|
367
|
+
const res = await GET(
|
|
368
|
+
request(`${canonical}&login_hint=${encodeURIComponent(HINT)}`),
|
|
369
|
+
);
|
|
370
|
+
|
|
371
|
+
expect(res.status).toBe(303);
|
|
372
|
+
const { next, ref } = continuationOf(res);
|
|
373
|
+
expect(res.headers.get("location")).toBe(
|
|
374
|
+
`/sign-in?next=${encodeURIComponent(`${CONTINUE}/${ref}`)}&email=a%40b.example`,
|
|
375
|
+
);
|
|
376
|
+
expect(next).toBe(`${CONTINUE}/${ref}`);
|
|
377
|
+
const opened = openContinuation(ref, mockSecret);
|
|
378
|
+
expect(opened).toBe(canonical);
|
|
379
|
+
expect(opened).not.toContain("login_hint");
|
|
380
|
+
expect(opened).not.toContain("email");
|
|
381
|
+
expect(mockCreateCode).not.toHaveBeenCalled();
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
it.each([
|
|
385
|
+
["an invalid login_hint", "nope"],
|
|
386
|
+
["a login_hint carrying a separator", "a@b.example&next=/x"],
|
|
387
|
+
["an over-long login_hint", `${"a".repeat(250)}@b.example`],
|
|
388
|
+
])(
|
|
389
|
+
"no session + %s: dropped — the 303 carries no email",
|
|
390
|
+
async (_name, hint) => {
|
|
391
|
+
mockGetSession.mockResolvedValue(null);
|
|
392
|
+
const canonical = query();
|
|
393
|
+
|
|
394
|
+
const res = await GET(
|
|
395
|
+
request(`${canonical}&login_hint=${encodeURIComponent(hint)}`),
|
|
396
|
+
);
|
|
397
|
+
|
|
398
|
+
expect(res.status).toBe(303);
|
|
399
|
+
const { ref } = continuationOf(res);
|
|
400
|
+
expect(res.headers.get("location")).toBe(
|
|
401
|
+
`/sign-in?next=${encodeURIComponent(`${CONTINUE}/${ref}`)}`,
|
|
402
|
+
);
|
|
403
|
+
expect(openContinuation(ref, mockSecret)).toBe(canonical);
|
|
404
|
+
},
|
|
405
|
+
);
|
|
406
|
+
|
|
407
|
+
it.each([
|
|
408
|
+
["no session", null],
|
|
409
|
+
["a session", { user: { id: "u1" } }],
|
|
410
|
+
])(
|
|
411
|
+
"a repeated login_hint is a 400 invalid_request (%s)",
|
|
412
|
+
async (_name, session) => {
|
|
413
|
+
mockGetSession.mockResolvedValue(session);
|
|
414
|
+
|
|
415
|
+
const res = await GET(
|
|
416
|
+
request(`${query()}&login_hint=a%40b.example&login_hint=c%40d.example`),
|
|
417
|
+
);
|
|
418
|
+
|
|
419
|
+
expect(res.status).toBe(400);
|
|
420
|
+
expect(res.headers.get("location")).toBeNull();
|
|
421
|
+
expect(await res.json()).toEqual({ error: "invalid_request" });
|
|
422
|
+
expect(mockCreateCode).not.toHaveBeenCalled();
|
|
423
|
+
expect(mockGetSession).not.toHaveBeenCalled();
|
|
424
|
+
},
|
|
425
|
+
);
|
|
426
|
+
|
|
427
|
+
it("with a session: the code redirect is unchanged and carries no hint", async () => {
|
|
428
|
+
mockGetSession.mockResolvedValue({ user: { id: "u1" } });
|
|
429
|
+
mockCreateCode.mockResolvedValue({
|
|
430
|
+
ok: true,
|
|
431
|
+
body: { code: CODE, redirectUri: SPOKE_CALLBACK },
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
const withHint = await GET(
|
|
435
|
+
request(`${query()}&login_hint=${encodeURIComponent(HINT)}`, {
|
|
436
|
+
cookie: "better-auth.session_token=tok",
|
|
437
|
+
}),
|
|
438
|
+
);
|
|
439
|
+
const without = await GET(
|
|
440
|
+
request(query(), { cookie: "better-auth.session_token=tok" }),
|
|
441
|
+
);
|
|
442
|
+
|
|
443
|
+
expect(withHint.status).toBe(303);
|
|
444
|
+
const location = withHint.headers.get("location") ?? "";
|
|
445
|
+
expect(location).toBe(without.headers.get("location"));
|
|
446
|
+
expect(location).toBe(`${SPOKE_CALLBACK}?code=${CODE}&state=${STATE}`);
|
|
447
|
+
expect(location).not.toContain("login_hint");
|
|
448
|
+
expect(location).not.toContain("email");
|
|
449
|
+
expect(location).not.toContain("b.example");
|
|
450
|
+
// The hint is never handed to the API either.
|
|
451
|
+
expect(mockCreateCode).toHaveBeenNthCalledWith(
|
|
452
|
+
1,
|
|
453
|
+
{
|
|
454
|
+
clientId: "spoke_abc",
|
|
455
|
+
redirectUri: SPOKE_CALLBACK,
|
|
456
|
+
codeChallenge: CHALLENGE,
|
|
457
|
+
},
|
|
458
|
+
{ headers: { cookie: "better-auth.session_token=tok", origin: HUB } },
|
|
459
|
+
);
|
|
460
|
+
});
|
|
461
|
+
});
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* GET /api/auth/hub/authorize
|
|
12
12
|
* ?response_type=code&client_id=…&redirect_uri=…&state=…
|
|
13
13
|
* &code_challenge=…&code_challenge_method=S256
|
|
14
|
+
* [&login_hint=<email>] (optional, #8757 D9 — see below)
|
|
14
15
|
*
|
|
15
16
|
* - a malformed query is a 400 and NEVER a redirect;
|
|
16
17
|
* - no Better Auth session: the six validated keys are sealed into a short
|
|
@@ -32,6 +33,14 @@
|
|
|
32
33
|
* - any other refusal is a 400 with `{ "error": "invalid_request" }` and no
|
|
33
34
|
* visitor copy: only a misconfigured client reaches it.
|
|
34
35
|
*
|
|
36
|
+
* `login_hint` (#8757 D9) is optional and read beside the six keys with
|
|
37
|
+
* `accountEmailHintSchema` (`hubAuthorizeQuerySchema` is unchanged): repeated
|
|
38
|
+
* → 400 like any repeated key; refused → dropped. It acts only on the
|
|
39
|
+
* no-session branch, as the sign-in page's `email`, so the form opens with the
|
|
40
|
+
* address typed. It never enters the ref, a code or a cookie — the continue
|
|
41
|
+
* route rebuilds the query from the ref, so the hint is gone after sign-in —
|
|
42
|
+
* and with a session it is ignored.
|
|
43
|
+
*
|
|
35
44
|
* `BETTER_AUTH_URL` pins the `Origin` sent with the cookie-authenticated write
|
|
36
45
|
* (Node's `fetch` sets none). Unset, the step answers 503: it needs a pinned
|
|
37
46
|
* origin, exactly as the emailed link does.
|
|
@@ -42,7 +51,10 @@
|
|
|
42
51
|
* state, a challenge or a cookie.
|
|
43
52
|
*/
|
|
44
53
|
import { hubSignIn } from "@working-theory/api-client";
|
|
45
|
-
import {
|
|
54
|
+
import {
|
|
55
|
+
accountEmailHintSchema,
|
|
56
|
+
hubAuthorizeQuerySchema,
|
|
57
|
+
} from "@working-theory/validation";
|
|
46
58
|
|
|
47
59
|
import { auth, resolveSecret } from "../../../../../auth";
|
|
48
60
|
import {
|
|
@@ -109,9 +121,13 @@ export async function GET(request: Request): Promise<Response> {
|
|
|
109
121
|
if (values.length > 1) return json(400, "invalid_request");
|
|
110
122
|
raw[key] = values[0];
|
|
111
123
|
}
|
|
124
|
+
// #8757 D9 — the optional hint: the same once-only rule; refused → dropped.
|
|
125
|
+
const hints = url.searchParams.getAll("login_hint");
|
|
126
|
+
if (hints.length > 1) return json(400, "invalid_request");
|
|
112
127
|
const parsed = hubAuthorizeQuerySchema.safeParse(raw);
|
|
113
128
|
if (!parsed.success) return json(400, "invalid_request");
|
|
114
129
|
const query = parsed.data;
|
|
130
|
+
const hint = accountEmailHintSchema.safeParse(hints[0]);
|
|
115
131
|
|
|
116
132
|
const session = await auth.api.getSession({ headers: request.headers });
|
|
117
133
|
if (!session) {
|
|
@@ -121,8 +137,9 @@ export async function GET(request: Request): Promise<Response> {
|
|
|
121
137
|
for (const key of QUERY_KEYS) canonical.set(key, query[key]);
|
|
122
138
|
const ref = mintContinuation(canonical.toString(), resolveSecret());
|
|
123
139
|
if (ref.length > CONTINUATION_REF_MAX) return json(400, "invalid_request");
|
|
124
|
-
const
|
|
125
|
-
|
|
140
|
+
const signIn = new URLSearchParams({ next: `${CONTINUE_PATH}/${ref}` });
|
|
141
|
+
if (hint.success) signIn.set("email", hint.data);
|
|
142
|
+
return see(`/sign-in?${signIn.toString()}`);
|
|
126
143
|
}
|
|
127
144
|
|
|
128
145
|
const origin = pinnedOrigin();
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// The buy route — the standard spoke checkout (#8748), kept current by
|
|
2
|
+
// `create-ailk update`: do not edit it. Every guarantee lives in the
|
|
3
|
+
// spoke-checkout package and arrives by a package bump. This site's offerings
|
|
4
|
+
// are data in lib/checkout-config.ts, the one file to edit. It answers 404 on
|
|
5
|
+
// any build that is not a spoke.
|
|
6
|
+
//
|
|
7
|
+
// A buyer who is signed in when checkout starts returns to /account; everyone
|
|
8
|
+
// else returns to the thank-you page with Stripe's session id (#8757 D12). The
|
|
9
|
+
// session is read through the site's generated seam: Better Auth on a site,
|
|
10
|
+
// the sealed hub session on a hub-sign-in spoke, none without sign-in.
|
|
11
|
+
import { createSpokeCheckout } from "@working-theory/spoke-checkout";
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
checkoutNotServed,
|
|
15
|
+
checkoutServed,
|
|
16
|
+
spokeCheckoutConfig,
|
|
17
|
+
} from "../../../../lib/checkout";
|
|
18
|
+
import { readSession } from "../../../../lib/topology.generated";
|
|
19
|
+
|
|
20
|
+
export const runtime = "nodejs";
|
|
21
|
+
export const dynamic = "force-dynamic";
|
|
22
|
+
|
|
23
|
+
type BuyRoute = ReturnType<typeof createSpokeCheckout>["buy"];
|
|
24
|
+
type BuyContext = Parameters<BuyRoute["GET"]>[1];
|
|
25
|
+
|
|
26
|
+
let buy: BuyRoute | undefined;
|
|
27
|
+
|
|
28
|
+
/** `/account` for a request with a session, else `null` (the thank-you page). */
|
|
29
|
+
async function successPath(request: Request): Promise<string | null> {
|
|
30
|
+
return (await readSession(request.headers)) ? "/account" : null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Built on the first served request and kept, so the package's rate limiters persist. */
|
|
34
|
+
function buyRoute(): BuyRoute {
|
|
35
|
+
buy ??= createSpokeCheckout(spokeCheckoutConfig, { successPath }).buy;
|
|
36
|
+
return buy;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// platform-decisions-waiver: api-versioning — a browser buy link that redirects to Stripe Checkout, not a versioned API contract (spec 8748 §7)
|
|
40
|
+
export async function GET(request: Request, ctx: BuyContext): Promise<Response> {
|
|
41
|
+
if (!checkoutServed()) return checkoutNotServed();
|
|
42
|
+
return buyRoute().GET(request, ctx);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function POST(request: Request, ctx: BuyContext): Promise<Response> {
|
|
46
|
+
if (!checkoutServed()) return checkoutNotServed();
|
|
47
|
+
return buyRoute().POST(request, ctx);
|
|
48
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment node
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The standard buy route (#8748 D1, D11; spec
|
|
7
|
+
* docs/specs/8748-a-standard-spoke-checkout-route-spec.md AC-2, AC-4).
|
|
8
|
+
*
|
|
9
|
+
* The checkout package is mocked: every guarantee is its own, proven by its
|
|
10
|
+
* own suites. These cases prove the route is wiring only — built once from
|
|
11
|
+
* `spokeCheckoutConfig`, serving the package's `buy` handlers on a spoke and
|
|
12
|
+
* a 404 anywhere else — and that it declares no offering and no Stripe
|
|
13
|
+
* client of its own.
|
|
14
|
+
*/
|
|
15
|
+
import { readFileSync } from "node:fs";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
|
|
18
|
+
const mockBuyGet = jest.fn();
|
|
19
|
+
const mockBuyPost = jest.fn();
|
|
20
|
+
const mockCreateSpokeCheckout = jest.fn((_config: unknown) => ({
|
|
21
|
+
buy: { GET: mockBuyGet, POST: mockBuyPost },
|
|
22
|
+
}));
|
|
23
|
+
// `virtual`: CI runs this suite before the package's dist is built, so it
|
|
24
|
+
// may not resolve at all — the factory stands in either way.
|
|
25
|
+
jest.mock(
|
|
26
|
+
"@working-theory/spoke-checkout",
|
|
27
|
+
() => ({
|
|
28
|
+
__esModule: true,
|
|
29
|
+
createSpokeCheckout: (config: unknown) => mockCreateSpokeCheckout(config),
|
|
30
|
+
}),
|
|
31
|
+
{ virtual: true },
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
const mockTopology: { value: "hub" | "spoke" } = { value: "spoke" };
|
|
35
|
+
// eslint-disable-next-line no-restricted-syntax -- #507: the topology is a generated constant (#7426), not a runtime input; the seam IS the boundary between a hub and a spoke build, and no env or HTTP surface selects it.
|
|
36
|
+
jest.mock("../../../../lib/topology.generated", () => ({
|
|
37
|
+
__esModule: true,
|
|
38
|
+
get TOPOLOGY() {
|
|
39
|
+
return mockTopology.value;
|
|
40
|
+
},
|
|
41
|
+
}));
|
|
42
|
+
|
|
43
|
+
import { checkoutConfig } from "../../../../lib/checkout-config";
|
|
44
|
+
import { spokeCheckoutConfig } from "../../../../lib/checkout";
|
|
45
|
+
import { GET, POST } from "../[offering]/route";
|
|
46
|
+
|
|
47
|
+
const ROUTE_SOURCE = readFileSync(
|
|
48
|
+
join(__dirname, "..", "[offering]", "route.ts"),
|
|
49
|
+
"utf8",
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
function ctxFor(offering: string) {
|
|
53
|
+
return { params: Promise.resolve({ offering }) };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function request(method: "GET" | "POST"): Request {
|
|
57
|
+
return new Request("https://acme.example.com/api/checkout/example", {
|
|
58
|
+
method,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
describe("the standard buy route (#8748 AC-2)", () => {
|
|
63
|
+
beforeEach(() => {
|
|
64
|
+
mockBuyGet.mockReset();
|
|
65
|
+
mockBuyPost.mockReset();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("answers 404 with no-store on a hub, and never builds the package's handlers (D11)", async () => {
|
|
69
|
+
mockTopology.value = "hub";
|
|
70
|
+
for (const res of [
|
|
71
|
+
await GET(request("GET"), ctxFor("example")),
|
|
72
|
+
await POST(request("POST"), ctxFor("example")),
|
|
73
|
+
]) {
|
|
74
|
+
expect(res.status).toBe(404);
|
|
75
|
+
expect(res.headers.get("cache-control")).toBe("no-store");
|
|
76
|
+
}
|
|
77
|
+
expect(mockCreateSpokeCheckout).not.toHaveBeenCalled();
|
|
78
|
+
expect(mockBuyGet).not.toHaveBeenCalled();
|
|
79
|
+
expect(mockBuyPost).not.toHaveBeenCalled();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("serves the package's buy handlers on a spoke, built once from spokeCheckoutConfig", async () => {
|
|
83
|
+
mockTopology.value = "spoke";
|
|
84
|
+
const fromGet = new Response(null, { status: 303 });
|
|
85
|
+
const fromPost = new Response(null, { status: 303 });
|
|
86
|
+
mockBuyGet.mockResolvedValue(fromGet);
|
|
87
|
+
mockBuyPost.mockResolvedValue(fromPost);
|
|
88
|
+
|
|
89
|
+
const getRequest = request("GET");
|
|
90
|
+
const getCtx = ctxFor("example");
|
|
91
|
+
expect(await GET(getRequest, getCtx)).toBe(fromGet);
|
|
92
|
+
expect(mockBuyGet).toHaveBeenCalledWith(getRequest, getCtx);
|
|
93
|
+
|
|
94
|
+
const postRequest = request("POST");
|
|
95
|
+
const postCtx = ctxFor("example");
|
|
96
|
+
expect(await POST(postRequest, postCtx)).toBe(fromPost);
|
|
97
|
+
expect(mockBuyPost).toHaveBeenCalledWith(postRequest, postCtx);
|
|
98
|
+
|
|
99
|
+
expect(mockCreateSpokeCheckout).toHaveBeenCalledTimes(1);
|
|
100
|
+
const [config] = mockCreateSpokeCheckout.mock.calls[0] as [
|
|
101
|
+
typeof spokeCheckoutConfig,
|
|
102
|
+
];
|
|
103
|
+
expect(config).toBe(spokeCheckoutConfig);
|
|
104
|
+
expect(config.offerings).toBe(checkoutConfig.offerings);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("names no offering of the site's own", () => {
|
|
108
|
+
for (const slug of Object.keys(checkoutConfig.offerings)) {
|
|
109
|
+
const quoted = new RegExp(`["'\`]${slug}["'\`]`);
|
|
110
|
+
const property = new RegExp(`\\b${slug}\\s*:`);
|
|
111
|
+
expect(ROUTE_SOURCE).not.toMatch(quoted);
|
|
112
|
+
expect(ROUTE_SOURCE).not.toMatch(property);
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("reads no Stripe setting and imports no Stripe client (AC-4)", () => {
|
|
117
|
+
expect(ROUTE_SOURCE).not.toContain("STRIPE_");
|
|
118
|
+
expect(ROUTE_SOURCE).not.toContain("stripe-client");
|
|
119
|
+
});
|
|
120
|
+
});
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment node
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The buy route's success path (#8757 D12, AC-10): a buyer signed in when
|
|
7
|
+
* checkout starts returns to `/account`, everyone else to the thank-you page.
|
|
8
|
+
*
|
|
9
|
+
* The route hands the checkout package a `successPath` hook built on the
|
|
10
|
+
* site's generated session seam (`readSession` in lib/topology.generated —
|
|
11
|
+
* Better Auth on a site, the sealed hub session on a hub-sign-in spoke, none
|
|
12
|
+
* on a spoke without sign-in). The package is mocked: what it does with the
|
|
13
|
+
* answer is its own suite's (`buy-route.test.ts`). These cases prove the hook
|
|
14
|
+
* answers `"/account"` for a request with a session and `null` without one.
|
|
15
|
+
*/
|
|
16
|
+
const mockCreateSpokeCheckout = jest.fn((_config: unknown, _options?: unknown) => ({
|
|
17
|
+
buy: { GET: jest.fn(async () => new Response(null, { status: 303 })), POST: jest.fn() },
|
|
18
|
+
}));
|
|
19
|
+
// `virtual`: CI runs this suite before the package's dist is built.
|
|
20
|
+
jest.mock(
|
|
21
|
+
"@working-theory/spoke-checkout",
|
|
22
|
+
() => ({
|
|
23
|
+
__esModule: true,
|
|
24
|
+
createSpokeCheckout: (config: unknown, options?: unknown) =>
|
|
25
|
+
mockCreateSpokeCheckout(config, options),
|
|
26
|
+
}),
|
|
27
|
+
{ virtual: true },
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
const mockReadSession = jest.fn();
|
|
31
|
+
// eslint-disable-next-line no-restricted-syntax -- #507: the topology is a generated constant (#7426), not a runtime input; the seam IS the boundary between a hub and a spoke build, and no env or HTTP surface selects it.
|
|
32
|
+
jest.mock("../../../../lib/topology.generated", () => ({
|
|
33
|
+
__esModule: true,
|
|
34
|
+
TOPOLOGY: "spoke",
|
|
35
|
+
readSession: (headers: Headers) => mockReadSession(headers),
|
|
36
|
+
}));
|
|
37
|
+
|
|
38
|
+
import { GET } from "../[offering]/route";
|
|
39
|
+
|
|
40
|
+
type SuccessPath = (request: Request) => Promise<string | null>;
|
|
41
|
+
|
|
42
|
+
/** The `successPath` the route built its handlers with. */
|
|
43
|
+
async function successPathHook(): Promise<SuccessPath> {
|
|
44
|
+
await GET(new Request("https://acme.example.com/api/checkout/example"), {
|
|
45
|
+
params: Promise.resolve({ offering: "example" }),
|
|
46
|
+
});
|
|
47
|
+
expect(mockCreateSpokeCheckout).toHaveBeenCalledTimes(1);
|
|
48
|
+
const [, options] = mockCreateSpokeCheckout.mock.calls[0] as [
|
|
49
|
+
unknown,
|
|
50
|
+
{ successPath?: SuccessPath } | undefined,
|
|
51
|
+
];
|
|
52
|
+
expect(typeof options?.successPath).toBe("function");
|
|
53
|
+
return options!.successPath!;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
describe("the buy route's success path (#8757 AC-10)", () => {
|
|
57
|
+
let successPath: SuccessPath;
|
|
58
|
+
|
|
59
|
+
beforeAll(async () => {
|
|
60
|
+
successPath = await successPathHook();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
beforeEach(() => {
|
|
64
|
+
mockReadSession.mockReset();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("a request with a session answers /account", async () => {
|
|
68
|
+
mockReadSession.mockResolvedValue({
|
|
69
|
+
user: { email: "cleo@x.io", emailVerified: true },
|
|
70
|
+
});
|
|
71
|
+
const request = new Request("https://acme.example.com/api/checkout/example", {
|
|
72
|
+
headers: { cookie: "session=abc" },
|
|
73
|
+
});
|
|
74
|
+
await expect(successPath(request)).resolves.toBe("/account");
|
|
75
|
+
expect(mockReadSession).toHaveBeenCalledTimes(1);
|
|
76
|
+
const [headers] = mockReadSession.mock.calls[0] as [Headers];
|
|
77
|
+
expect(headers.get("cookie")).toBe("session=abc");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("a request without one answers null", async () => {
|
|
81
|
+
mockReadSession.mockResolvedValue(null);
|
|
82
|
+
await expect(
|
|
83
|
+
successPath(new Request("https://acme.example.com/api/checkout/example")),
|
|
84
|
+
).resolves.toBeNull();
|
|
85
|
+
});
|
|
86
|
+
});
|
|
@@ -13,6 +13,9 @@
|
|
|
13
13
|
* a callback that BURNS a code it cannot complete (AC-5), and a sign-out that
|
|
14
14
|
* refuses a cross-site request and revokes nothing (AC-5). Each route is a 404
|
|
15
15
|
* unless the web seam's `SIGN_IN_PROVIDER` is `"hub"` (AC-9).
|
|
16
|
+
*
|
|
17
|
+
* #8757 D9 (AC-6): a valid single `?email=` on the sign-in route becomes the
|
|
18
|
+
* authorize URL's `login_hint`; the sealed flow cookie never carries it.
|
|
16
19
|
*/
|
|
17
20
|
|
|
18
21
|
import { createHash } from "node:crypto";
|
|
@@ -36,7 +39,7 @@ jest.mock("../../../../lib/topology.generated", () => ({
|
|
|
36
39
|
},
|
|
37
40
|
}));
|
|
38
41
|
|
|
39
|
-
import { seal } from "../../../../lib/hub-session/seal";
|
|
42
|
+
import { seal, unseal } from "../../../../lib/hub-session/seal";
|
|
40
43
|
import { GET as callback } from "../callback/route";
|
|
41
44
|
import { GET as signIn } from "../sign-in/route";
|
|
42
45
|
import { POST as signOut } from "../sign-out/route";
|
|
@@ -161,6 +164,46 @@ describe("GET /api/hub-session/sign-in", () => {
|
|
|
161
164
|
});
|
|
162
165
|
});
|
|
163
166
|
|
|
167
|
+
describe("GET /api/hub-session/sign-in — the hinted email (#8757 D9)", () => {
|
|
168
|
+
function startWith(query: string): { authorize: URL; payload: Record<string, unknown> } {
|
|
169
|
+
const res = signIn(new Request(`${SITE}/api/hub-session/sign-in?${query}`));
|
|
170
|
+
expect(res.status).toBe(303);
|
|
171
|
+
const flow = cookieValue(res, "ailk-hub-flow");
|
|
172
|
+
if (flow === undefined) throw new Error("no flow cookie");
|
|
173
|
+
const payload = unseal(flow, SESSION_SECRET, "flow", "ailk-hub-flow");
|
|
174
|
+
if (payload === null || typeof payload !== "object") throw new Error("flow did not open");
|
|
175
|
+
return {
|
|
176
|
+
authorize: new URL(res.headers.get("location") ?? ""),
|
|
177
|
+
payload: payload as Record<string, unknown>,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
it("?next=/account&email=a%40b.example → the authorize URL carries login_hint; the flow cookie does not", () => {
|
|
182
|
+
const { authorize, payload } = startWith("next=/account&email=a%40b.example");
|
|
183
|
+
expect(authorize.searchParams.get("login_hint")).toBe("a@b.example");
|
|
184
|
+
expect(authorize.search).toContain("login_hint=a%40b.example");
|
|
185
|
+
// The six keys are untouched beside it.
|
|
186
|
+
expect(authorize.searchParams.get("code_challenge_method")).toBe("S256");
|
|
187
|
+
expect(authorize.searchParams.getAll("login_hint")).toHaveLength(1);
|
|
188
|
+
expect(payload.next).toBe("/account");
|
|
189
|
+
expect(payload).not.toHaveProperty("email");
|
|
190
|
+
expect(payload).not.toHaveProperty("login_hint");
|
|
191
|
+
expect(JSON.stringify(payload)).not.toContain("a@b.example");
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it.each([
|
|
195
|
+
["an invalid value", "next=/account&email=bad"],
|
|
196
|
+
["a repeated value", "next=/account&email=a%40b.example&email=c%40d.example"],
|
|
197
|
+
["a value carrying a separator", "next=/account&email=a%40b.example%26x%3D1"],
|
|
198
|
+
["no value", "next=/account"],
|
|
199
|
+
])("%s → no login_hint", (_label, query) => {
|
|
200
|
+
const { authorize, payload } = startWith(query);
|
|
201
|
+
expect(authorize.searchParams.has("login_hint")).toBe(false);
|
|
202
|
+
expect(payload).not.toHaveProperty("email");
|
|
203
|
+
expect(payload).not.toHaveProperty("login_hint");
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
164
207
|
describe("GET /api/hub-session/callback", () => {
|
|
165
208
|
function exchangeOk(): void {
|
|
166
209
|
mockExchange.mockResolvedValue({
|
|
@@ -10,10 +10,17 @@
|
|
|
10
10
|
* - not a hub-mode spoke (`SIGN_IN_PROVIDER` is not `"hub"`): 404;
|
|
11
11
|
* - hub mode but not configured: 303 to `/account`, whose gate says so.
|
|
12
12
|
*
|
|
13
|
+
* Hinted email (#8757 D9): a single `?email=` that `accountEmailHintSchema`
|
|
14
|
+
* accepts rides to the hub as `login_hint`, so its sign-in form opens with
|
|
15
|
+
* the address typed. A refused or repeated value is dropped. The hint goes on
|
|
16
|
+
* the authorize URL only — never into the sealed flow cookie.
|
|
17
|
+
*
|
|
13
18
|
* Nothing here logs the state, the verifier or the challenge.
|
|
14
19
|
*/
|
|
15
20
|
import { createHash, randomBytes } from "node:crypto";
|
|
16
21
|
|
|
22
|
+
import { accountEmailHintSchema } from "@working-theory/validation";
|
|
23
|
+
|
|
17
24
|
import { hubSessionConfig } from "../../../../lib/hub-session/config";
|
|
18
25
|
import { seal } from "../../../../lib/hub-session/seal";
|
|
19
26
|
import {
|
|
@@ -40,13 +47,22 @@ function see(location: string, setCookie?: string): Response {
|
|
|
40
47
|
return new Response(null, { status: 303, headers });
|
|
41
48
|
}
|
|
42
49
|
|
|
50
|
+
/** #8757 D9 — the one `email` the hint schema accepts; repeated or refused → none. */
|
|
51
|
+
function emailHint(params: URLSearchParams): string | undefined {
|
|
52
|
+
const values = params.getAll("email");
|
|
53
|
+
if (values.length !== 1) return undefined;
|
|
54
|
+
const parsed = accountEmailHintSchema.safeParse(values[0]);
|
|
55
|
+
return parsed.success ? parsed.data : undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
43
58
|
// platform-decisions-waiver: api-versioning — sign-in redirect endpoint, not a versioned API contract (spec 7894 §7)
|
|
44
59
|
export function GET(request: Request): Response {
|
|
45
60
|
if (SIGN_IN_PROVIDER !== "hub") return new Response(null, { status: 404 });
|
|
46
61
|
const config = hubSessionConfig();
|
|
47
62
|
if (config === null) return see(HUB_SESSION_FALLBACK_PATH);
|
|
48
63
|
|
|
49
|
-
const
|
|
64
|
+
const params = new URL(request.url).searchParams;
|
|
65
|
+
const next = hubNextPath(params.get("next"));
|
|
50
66
|
const state = randomBytes(32).toString("base64url");
|
|
51
67
|
const verifier = randomBytes(32).toString("base64url");
|
|
52
68
|
const challenge = createHash("sha256").update(verifier).digest("base64url");
|
|
@@ -60,14 +76,17 @@ export function GET(request: Request): Response {
|
|
|
60
76
|
);
|
|
61
77
|
|
|
62
78
|
const authorize = new URL(AUTHORIZE_PATH, config.hubOrigin);
|
|
63
|
-
|
|
79
|
+
const query = new URLSearchParams({
|
|
64
80
|
response_type: "code",
|
|
65
81
|
client_id: config.clientId,
|
|
66
82
|
redirect_uri: config.redirectUri,
|
|
67
83
|
state,
|
|
68
84
|
code_challenge: challenge,
|
|
69
85
|
code_challenge_method: "S256",
|
|
70
|
-
})
|
|
86
|
+
});
|
|
87
|
+
const loginHint = emailHint(params);
|
|
88
|
+
if (loginHint !== undefined) query.set("login_hint", loginHint);
|
|
89
|
+
authorize.search = query.toString();
|
|
71
90
|
|
|
72
91
|
return see(
|
|
73
92
|
authorize.toString(),
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment node
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The standard Stripe webhook route (#8748 D1, D11; spec
|
|
7
|
+
* docs/specs/8748-a-standard-spoke-checkout-route-spec.md AC-2, AC-4).
|
|
8
|
+
*
|
|
9
|
+
* The checkout package is mocked: its verification, filters, retries and
|
|
10
|
+
* duplicate guard are proven by its own suites. These cases prove the route
|
|
11
|
+
* is wiring only — built once from `spokeCheckoutConfig`, serving the
|
|
12
|
+
* package's `webhook` handler on a spoke and a 404 anywhere else.
|
|
13
|
+
*/
|
|
14
|
+
import { readFileSync } from "node:fs";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
|
|
17
|
+
const mockWebhookPost = jest.fn();
|
|
18
|
+
const mockCreateSpokeCheckout = jest.fn((_config: unknown) => ({
|
|
19
|
+
webhook: { POST: mockWebhookPost },
|
|
20
|
+
}));
|
|
21
|
+
// `virtual`: CI runs this suite before the package's dist is built, so it
|
|
22
|
+
// may not resolve at all — the factory stands in either way.
|
|
23
|
+
jest.mock(
|
|
24
|
+
"@working-theory/spoke-checkout",
|
|
25
|
+
() => ({
|
|
26
|
+
__esModule: true,
|
|
27
|
+
createSpokeCheckout: (config: unknown) => mockCreateSpokeCheckout(config),
|
|
28
|
+
}),
|
|
29
|
+
{ virtual: true },
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
const mockTopology: { value: "hub" | "spoke" } = { value: "spoke" };
|
|
33
|
+
// eslint-disable-next-line no-restricted-syntax -- #507: the topology is a generated constant (#7426), not a runtime input; the seam IS the boundary between a hub and a spoke build, and no env or HTTP surface selects it.
|
|
34
|
+
jest.mock("../../../../../lib/topology.generated", () => ({
|
|
35
|
+
__esModule: true,
|
|
36
|
+
get TOPOLOGY() {
|
|
37
|
+
return mockTopology.value;
|
|
38
|
+
},
|
|
39
|
+
}));
|
|
40
|
+
|
|
41
|
+
import { checkoutConfig } from "../../../../../lib/checkout-config";
|
|
42
|
+
import { spokeCheckoutConfig } from "../../../../../lib/checkout";
|
|
43
|
+
import { POST } from "../route";
|
|
44
|
+
|
|
45
|
+
const ROUTE_SOURCE = readFileSync(join(__dirname, "..", "route.ts"), "utf8");
|
|
46
|
+
|
|
47
|
+
function delivery(): Request {
|
|
48
|
+
return new Request("https://acme.example.com/api/webhooks/stripe", {
|
|
49
|
+
method: "POST",
|
|
50
|
+
body: "{}",
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
describe("the standard Stripe webhook route (#8748 AC-2)", () => {
|
|
55
|
+
beforeEach(() => {
|
|
56
|
+
mockWebhookPost.mockReset();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("answers 404 with no-store on a hub, and never builds the package's handlers (D11)", async () => {
|
|
60
|
+
mockTopology.value = "hub";
|
|
61
|
+
const res = await POST(delivery());
|
|
62
|
+
expect(res.status).toBe(404);
|
|
63
|
+
expect(res.headers.get("cache-control")).toBe("no-store");
|
|
64
|
+
expect(mockCreateSpokeCheckout).not.toHaveBeenCalled();
|
|
65
|
+
expect(mockWebhookPost).not.toHaveBeenCalled();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("serves the package's webhook handler on a spoke, built once from spokeCheckoutConfig", async () => {
|
|
69
|
+
mockTopology.value = "spoke";
|
|
70
|
+
const answered = new Response(null, { status: 200 });
|
|
71
|
+
mockWebhookPost.mockResolvedValue(answered);
|
|
72
|
+
|
|
73
|
+
const first = delivery();
|
|
74
|
+
expect(await POST(first)).toBe(answered);
|
|
75
|
+
expect(mockWebhookPost).toHaveBeenCalledWith(first);
|
|
76
|
+
const second = delivery();
|
|
77
|
+
expect(await POST(second)).toBe(answered);
|
|
78
|
+
expect(mockWebhookPost).toHaveBeenLastCalledWith(second);
|
|
79
|
+
|
|
80
|
+
expect(mockCreateSpokeCheckout).toHaveBeenCalledTimes(1);
|
|
81
|
+
const [config] = mockCreateSpokeCheckout.mock.calls[0] as [
|
|
82
|
+
typeof spokeCheckoutConfig,
|
|
83
|
+
];
|
|
84
|
+
expect(config).toBe(spokeCheckoutConfig);
|
|
85
|
+
expect(config.offerings).toBe(checkoutConfig.offerings);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("reads no Stripe setting and imports no Stripe client (AC-4)", () => {
|
|
89
|
+
expect(ROUTE_SOURCE).not.toContain("STRIPE_");
|
|
90
|
+
expect(ROUTE_SOURCE).not.toContain("stripe-client");
|
|
91
|
+
});
|
|
92
|
+
});
|