create-ailk 0.18.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (167) hide show
  1. package/LICENSE +6 -5
  2. package/NOTICE +2 -0
  3. package/dist/add-module.js +4 -1
  4. package/dist/category-profile.d.ts +10 -0
  5. package/dist/category-profile.js +21 -0
  6. package/dist/derive-architecture.d.ts +5 -1
  7. package/dist/derive-architecture.js +8 -3
  8. package/dist/lib/license-key-files.d.ts +6 -3
  9. package/dist/lib/license-key-files.js +8 -2
  10. package/dist/lib/scaffold-render-sweep.d.ts +9 -0
  11. package/dist/lib/scaffold-render-sweep.js +24 -3
  12. package/dist/license-set.d.ts +1 -1
  13. package/dist/license-set.js +13 -8
  14. package/dist/module-architecture.js +119 -0
  15. package/dist/programmatic.js +3 -0
  16. package/dist/spoke-checkout.d.ts +11 -3
  17. package/dist/spoke-checkout.js +62 -12
  18. package/dist/starter-grid.d.ts +1 -1
  19. package/dist/surfaces.js +140 -1
  20. package/dist/template-manifest.d.ts +4 -3
  21. package/dist/topology.d.ts +7 -2
  22. package/dist/topology.js +60 -3
  23. package/package.json +5 -4
  24. package/templates/.env.example +3 -3
  25. package/templates/.github/workflows/ci.yml +5 -0
  26. package/templates/apps/api/CLAUDE.md +25 -7
  27. package/templates/apps/api/LICENSE +6 -5
  28. package/templates/apps/api/NOTICE +2 -0
  29. package/templates/apps/api/jest.config.cjs +4 -29
  30. package/templates/apps/api/package.json +1 -0
  31. package/templates/apps/api/src/lib/__mocks__/prisma.ts +9 -0
  32. package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +38 -0
  33. package/templates/apps/api/src/lib/deliverable-asset-store.ts +5 -4
  34. package/templates/apps/api/src/lib/stripe.ts +81 -0
  35. package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +266 -39
  36. package/templates/apps/api/src/middleware/auth.ts +206 -46
  37. package/templates/apps/api/src/routes/leads/index.ts +4 -0
  38. package/templates/apps/api/src/server.ts +107 -0
  39. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +58 -0
  40. package/templates/apps/api/src/services/lead-routing.ts +30 -2
  41. package/templates/apps/api/vercel.json +2 -2
  42. package/templates/apps/mcp/.env.example +10 -0
  43. package/templates/apps/mcp/CLAUDE.md +9 -4
  44. package/templates/apps/mcp/LICENSE +6 -5
  45. package/templates/apps/mcp/NOTICE +2 -0
  46. package/templates/apps/mcp/__tests__/abuse/guard.test.ts +198 -0
  47. package/templates/apps/mcp/__tests__/audit-single-page.test.ts +15 -3
  48. package/templates/apps/mcp/__tests__/boot.test.ts +78 -1
  49. package/templates/apps/mcp/__tests__/http-transport.test.ts +291 -2
  50. package/templates/apps/mcp/__tests__/request_callback.test.ts +69 -7
  51. package/templates/apps/mcp/__tests__/submit_lead.test.ts +69 -8
  52. package/templates/apps/mcp/__tests__/subscribe_newsletter.test.ts +69 -7
  53. package/templates/apps/mcp/jest.config.cjs +3 -44
  54. package/templates/apps/mcp/src/abuse/index.ts +117 -21
  55. package/templates/apps/mcp/src/boot.ts +5 -23
  56. package/templates/apps/mcp/src/http-transport.ts +93 -73
  57. package/templates/apps/mcp/src/index.ts +2 -0
  58. package/templates/apps/mcp/src/request-context.ts +52 -0
  59. package/templates/apps/mcp/src/tools/capture_lead.ts +9 -1
  60. package/templates/apps/mcp/src/tools/request_callback.ts +9 -1
  61. package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +9 -1
  62. package/templates/apps/web/LICENSE +6 -5
  63. package/templates/apps/web/NOTICE +2 -0
  64. package/templates/apps/web/app/.well-known/ai-plugin.json/route.ts +3 -3
  65. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/billing.test.tsx +288 -0
  66. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +568 -0
  67. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +272 -42
  68. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +11 -13
  69. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +115 -16
  70. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-menu.test.tsx +305 -0
  71. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-popups.test.tsx +82 -0
  72. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/notifications.test.tsx +126 -0
  73. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +163 -72
  74. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/popups.test.tsx +451 -0
  75. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/security.test.tsx +139 -0
  76. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +20 -390
  77. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/support.test.tsx +16 -11
  78. package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +1 -1
  79. package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +114 -50
  80. package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +24 -45
  81. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/page.tsx +253 -0
  82. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/review-form.tsx +145 -0
  83. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/versions/[version]/file/route.ts +100 -0
  84. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/file-route.test.ts +179 -0
  85. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/page.test.tsx +514 -0
  86. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/review.test.tsx +288 -0
  87. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-actions.ts +123 -0
  88. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-data.ts +82 -0
  89. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/page.tsx +177 -0
  90. package/templates/apps/web/app/[locale]/(authed)/account/billing/page.tsx +172 -0
  91. package/templates/apps/web/app/[locale]/(authed)/account/billing-source.ts +147 -0
  92. package/templates/apps/web/app/[locale]/(authed)/account/components/request-quote.tsx +229 -0
  93. package/templates/apps/web/app/[locale]/(authed)/account/components/schedule-service.tsx +276 -0
  94. package/templates/apps/web/app/[locale]/(authed)/account/components/send-message.tsx +128 -0
  95. package/templates/apps/web/app/[locale]/(authed)/account/general/billing-anchor.tsx +27 -0
  96. package/templates/apps/web/app/[locale]/(authed)/account/general/deletion-request.tsx +132 -0
  97. package/templates/apps/web/app/[locale]/(authed)/account/general/page.tsx +131 -0
  98. package/templates/apps/web/app/[locale]/(authed)/account/general/profile-form.tsx +104 -0
  99. package/templates/apps/web/app/[locale]/(authed)/account/general/profile-source.ts +73 -0
  100. package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/__tests__/route.test.ts +18 -8
  101. package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/route.ts +4 -6
  102. package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +4 -1
  103. package/templates/apps/web/app/[locale]/(authed)/account/new-actions.ts +219 -0
  104. package/templates/apps/web/app/[locale]/(authed)/account/new-menu.tsx +340 -0
  105. package/templates/apps/web/app/[locale]/(authed)/account/new-popups.tsx +107 -0
  106. package/templates/apps/web/app/[locale]/(authed)/account/notifications/page.tsx +57 -0
  107. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +85 -26
  108. package/templates/apps/web/app/[locale]/(authed)/account/profile-actions.ts +116 -0
  109. package/templates/apps/web/app/[locale]/(authed)/account/security/page.tsx +80 -0
  110. package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +13 -354
  111. package/templates/apps/web/app/[locale]/(authed)/account/settings-frame.tsx +108 -0
  112. package/templates/apps/web/app/[locale]/(authed)/account/sign-in-method.ts +37 -0
  113. package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +9 -10
  114. package/templates/apps/web/app/[locale]/layout.tsx +23 -4
  115. package/templates/apps/web/app/api/auth/hub/__tests__/continuation.test.ts +143 -0
  116. package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +62 -10
  117. package/templates/apps/web/app/api/auth/hub/authorize/route.ts +22 -7
  118. package/templates/apps/web/app/api/auth/hub/continuation.ts +73 -0
  119. package/templates/apps/web/app/api/auth/hub/continue/[ref]/__tests__/route.test.ts +188 -0
  120. package/templates/apps/web/app/api/auth/hub/continue/[ref]/route.ts +53 -0
  121. package/templates/apps/web/app/robots.txt/route.ts +10 -4
  122. package/templates/apps/web/auth.ts +5 -1
  123. package/templates/apps/web/lib/__tests__/account-config.test.ts +258 -46
  124. package/templates/apps/web/lib/account-config.ts +188 -52
  125. package/templates/apps/web/lib/hub-session/__tests__/hub-session.test.ts +143 -0
  126. package/templates/apps/web/lib/hub-session/index.ts +1 -0
  127. package/templates/apps/web/lib/hub-session/reseal.ts +90 -0
  128. package/templates/apps/web/lib/hub-session/seal.ts +13 -9
  129. package/templates/apps/web/lib/topology.generated.ts +24 -0
  130. package/templates/apps/web/messages/en.json +156 -35
  131. package/templates/database/CHANGELOG.md +21 -0
  132. package/templates/database/LICENSE +6 -5
  133. package/templates/database/NOTICE +2 -0
  134. package/templates/database/README.md +1 -1
  135. package/templates/database/__tests__/member-area.db.test.ts +15 -3
  136. package/templates/database/__tests__/member-contract-migration.test.ts +85 -0
  137. package/templates/database/__tests__/member-contract.db.test.ts +413 -0
  138. package/templates/database/__tests__/seed.test.ts +54 -2
  139. package/templates/database/inbox/schema.prisma +90 -15
  140. package/templates/database/migrations/20261012120000_member_contract/migration.sql +192 -0
  141. package/templates/database/migrations/20261013120000_lead_spoke_checkout/migration.sql +20 -0
  142. package/templates/database/package.json +3 -2
  143. package/templates/database/seed.ts +34 -2
  144. package/versions.json +36 -36
  145. package/templates/apps/api/src/__tests__/content-write-production-boot.test.ts +0 -104
  146. package/templates/apps/api/src/__tests__/cors.test.ts +0 -276
  147. package/templates/apps/api/src/__tests__/module-exclusion.test.ts +0 -279
  148. package/templates/apps/api/src/__tests__/openapi-keyless-hub.test.ts +0 -122
  149. package/templates/apps/api/src/__tests__/review-surface-registration.test.ts +0 -167
  150. package/templates/apps/api/src/__tests__/root-modules.test.ts +0 -175
  151. package/templates/apps/api/src/__tests__/schedule-forwarded-rate-limit.test.ts +0 -117
  152. package/templates/apps/api/src/__tests__/server.test.ts +0 -591
  153. package/templates/apps/api/src/__tests__/workspace-module-registration.test.ts +0 -175
  154. package/templates/apps/api/src/openapi/__tests__/surface-drift.test.ts +0 -293
  155. package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +0 -1620
  156. package/templates/apps/api/src/routes/project-listings/__tests__/directory.test.ts +0 -528
  157. package/templates/apps/api/src/routes/workspaces/__tests__/inbox-wire-schemas.test.ts +0 -198
  158. package/templates/apps/api/src/services/__tests__/aeo-score.test.ts +0 -652
  159. package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +0 -408
  160. package/templates/apps/api/src/services/__tests__/leads-inbox.test.ts +0 -476
  161. package/templates/apps/mcp/__tests__/audit.test.ts +0 -346
  162. package/templates/apps/mcp/__tests__/parity.test.ts +0 -325
  163. package/templates/apps/mcp/__tests__/workspace-surface.test.ts +0 -364
  164. package/templates/apps/web/app/[locale]/(authed)/account/settings/color-mode.tsx +0 -68
  165. package/templates/apps/web/app/[locale]/(authed)/account/settings/deletion-request.tsx +0 -55
  166. package/templates/apps/web/app/[locale]/(authed)/account/settings/name-form.tsx +0 -118
  167. package/templates/database/__tests__/build.test.ts +0 -131
@@ -0,0 +1,37 @@
1
+ /**
2
+ * account/sign-in-method.ts (server code) — how a member signs in, for User settings ›
3
+ * General's Sign-in section (#8232, spec
4
+ * docs/specs/8232-member-settings-account-general-and-spec.md D7).
5
+ *
6
+ * One of the account tree's TWO topology seams (roadmap D1; the other is
7
+ * `billing-source.ts`): no other file under the account tree tests
8
+ * `SIGN_IN_PROVIDER`, `TOPOLOGY` or `SIGN_IN_ENABLED`.
9
+ *
10
+ * - A spoke that signs in through its hub (`SIGN_IN_PROVIDER === "hub"`,
11
+ * #7894) answers `hub`, with the hub's host when it is configured.
12
+ * - Any other site answers `site`, with the sign-in methods this deployment
13
+ * has configured. Better Auth's session does not say which one this
14
+ * member used, so the list is the deployment's (C7).
15
+ */
16
+
17
+ import { getActiveProviderIds } from "../../../../lib/auth-env";
18
+ import { hubSessionConfig } from "../../../../lib/hub-session/config";
19
+ import { SIGN_IN_PROVIDER } from "../../../../lib/topology.generated";
20
+
21
+ /** The sign-in methods General names, in the order it lists them. */
22
+ export const SIGN_IN_METHODS = ["google", "github", "magic-link", "email"] as const;
23
+
24
+ export type SignInMethodId = (typeof SIGN_IN_METHODS)[number];
25
+
26
+ export type SignInMethod =
27
+ | { kind: "hub"; hubHost: string | null }
28
+ | { kind: "site"; methods: SignInMethodId[] };
29
+
30
+ export function signInMethod(): SignInMethod {
31
+ if (SIGN_IN_PROVIDER === "hub") {
32
+ const config = hubSessionConfig();
33
+ return { kind: "hub", hubHost: config === null ? null : new URL(config.hubOrigin).host };
34
+ }
35
+ const active = new Set(getActiveProviderIds());
36
+ return { kind: "site", methods: SIGN_IN_METHODS.filter((id) => active.has(id)) };
37
+ }
@@ -4,9 +4,12 @@
4
4
  * account/support-actions.ts — the account area's Support request action.
5
5
  *
6
6
  * `submitSupportRequest` (#7430, spec 7423 D11) — files a Support request
7
- * (and, from User settings › Privacy, an account-deletion request) as a
8
- * contact lead: `POST /v1/leads/submit` with `source: "web"`, the session's
9
- * email and the payload `{ kind, subject, message }`. No `siteId` — the
7
+ * as a contact lead: `POST /v1/leads/submit` with `source: "web"`, the
8
+ * session's email and the payload `{ kind: "support", subject, message }`.
9
+ * Every request it files is `support`, whatever is posted (#8232 D12): an
10
+ * account-deletion request goes through `member.requestDeletion`
11
+ * (`profile-actions.ts`), which files it with the deployment that holds the
12
+ * account, so this action needs no topology test (SEC-7894-11). No `siteId` — the
10
13
  * route resolves the tenant server-side and ignores a posted one (#6203).
11
14
  * The request lands in the admin Inbox as a message. Not a `*Action`-suffixed
12
15
  * export in a `*.actions.ts` file: lead capture already has its MCP twin
@@ -21,7 +24,7 @@
21
24
  import { leads, visitorForwardHeaders } from "@working-theory/api-client";
22
25
  import { headers } from "next/headers";
23
26
 
24
- import { SIGN_IN_PROVIDER, readSession } from "../../../../lib/topology.generated";
27
+ import { readSession } from "../../../../lib/topology.generated";
25
28
 
26
29
  /** PII-safe error identity for logs (the `checkout.actions.ts` idiom: `err.name` only). */
27
30
  function errorName(err: unknown): string {
@@ -53,7 +56,7 @@ function isNotConfigured(res: { status: number; body: unknown }): boolean {
53
56
  }
54
57
 
55
58
  /**
56
- * Files the signed-in member's Support (or deletion) request. The email is
59
+ * Files the signed-in member's Support request. The email is
57
60
  * the session's own — never a form field — so a request always names the
58
61
  * person who sent it.
59
62
  */
@@ -61,10 +64,6 @@ export async function submitSupportRequest(
61
64
  _prev: SupportRequestState,
62
65
  formData: FormData,
63
66
  ): Promise<SupportRequestState> {
64
- // A hub sign-in spoke's account belongs to the hub, whose own settings take
65
- // a deletion request (#7894 D10, SEC-7894-11): here it can only be support.
66
- const kind =
67
- field(formData, "kind") === "deletion" && SIGN_IN_PROVIDER !== "hub" ? "deletion" : "support";
68
67
  const subject = field(formData, "subject");
69
68
  const message = field(formData, "message");
70
69
  if (!subject || !message || subject.length > MAX_SUBJECT || message.length > MAX_MESSAGE) {
@@ -79,7 +78,7 @@ export async function submitSupportRequest(
79
78
  const body = {
80
79
  source: "web",
81
80
  email: session.user.email,
82
- payload: { kind, subject, message },
81
+ payload: { kind: "support", subject, message },
83
82
  };
84
83
  // #7394 — a spoke counts the request against the visitor's own budget.
85
84
  const forwarded = visitorForwardHeaders(
@@ -27,6 +27,7 @@ import "../globals.css";
27
27
 
28
28
  import { Telemetry, VerificationMeta } from "@working-theory/analytics";
29
29
  import { MarketingShell } from "@working-theory/templates/shells";
30
+ import { themeToShellCss } from "@working-theory/theme";
30
31
  import { Link, isRtl, localeHref } from "@working-theory/ui";
31
32
  import { BRAND_PATHS, LocaleSwitcherNav } from "@working-theory/ui/blocks";
32
33
  import {
@@ -58,7 +59,11 @@ import { assertEnabledLocale } from "../../lib/locale-guard";
58
59
  import { footerColumns, navItems } from "../../lib/nav-config";
59
60
  import { getOgImageUrl } from "../../lib/og";
60
61
  import { backLinkHref } from "../../lib/profile-back-link";
61
- import { COPYRIGHT_HOLDER, SITE_NAME } from "../../lib/site-brand";
62
+ import {
63
+ COPYRIGHT_HOLDER,
64
+ SITE_DESCRIPTION,
65
+ SITE_NAME,
66
+ } from "../../lib/site-brand";
62
67
  import { siteChrome, type SiteChrome } from "../../lib/site-chrome";
63
68
  import { siteLogo } from "../../lib/site-logo";
64
69
  // Registers the `_site.mdx` identity with page-renderer at module load, so
@@ -126,6 +131,11 @@ const NAV_CLIENT_ACTION_FLOW: NavActionFlow["flow"] = {
126
131
  action: "#",
127
132
  };
128
133
 
134
+ // The member account shell's token reset (#8337 D3) — a package constant
135
+ // (no site, request or user input), computed once at module scope and
136
+ // emitted only on account routes, after `theme-vars`, below.
137
+ const SHELL_CSS = themeToShellCss();
138
+
129
139
  // ── Theme helpers ────────────────────────────────────────────────────────────
130
140
 
131
141
  /**
@@ -214,10 +224,10 @@ export async function generateMetadata(): Promise<Metadata> {
214
224
 
215
225
  return {
216
226
  title: SITE_NAME,
217
- description: "AX-first OSS template — API + MCP as canonical surfaces.",
227
+ description: SITE_DESCRIPTION,
218
228
  openGraph: {
219
229
  title: SITE_NAME,
220
- description: "AX-first OSS template — API + MCP as canonical surfaces.",
230
+ description: SITE_DESCRIPTION,
221
231
  type: "website",
222
232
  url: baseUrl,
223
233
  images: [ogImageUrl],
@@ -225,7 +235,7 @@ export async function generateMetadata(): Promise<Metadata> {
225
235
  twitter: {
226
236
  card: "summary_large_image",
227
237
  title: SITE_NAME,
228
- description: "AX-first OSS template — API + MCP as canonical surfaces.",
238
+ description: SITE_DESCRIPTION,
229
239
  images: [ogImageUrl],
230
240
  },
231
241
  ...getStealthRobots(),
@@ -617,6 +627,15 @@ export default async function LocaleLayout({
617
627
  data-ailk="theme-vars"
618
628
  dangerouslySetInnerHTML={{ __html: themeHead.css }}
619
629
  />
630
+ {/* The member account shell's token reset (#8337 D3) — account
631
+ routes only, after theme-vars, so :has([data-ailk-shell])'s
632
+ higher specificity wins whatever a browser's :has() support. */}
633
+ {isAccountRoute ? (
634
+ <style
635
+ data-ailk="shell-vars"
636
+ dangerouslySetInnerHTML={{ __html: SHELL_CSS }}
637
+ />
638
+ ) : null}
620
639
  {/* Favicons — single brand mark (theme-independent), PNG fallbacks, ICO legacy */}
621
640
  <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
622
641
  <link
@@ -0,0 +1,143 @@
1
+ /**
2
+ * @jest-environment node
3
+ */
4
+
5
+ /**
6
+ * The hub's sign-in continuation module (#8146 D3; spec AC-3(b), AC-4).
7
+ * Pure functions over `seal.ts`; no mocks.
8
+ */
9
+ import { seal } from "../../../../../lib/hub-session/seal";
10
+ import {
11
+ CONTINUATION_REF_MAX,
12
+ CONTINUATION_TTL_SECONDS,
13
+ CONTINUE_PATH,
14
+ mintContinuation,
15
+ openContinuation,
16
+ } from "../continuation";
17
+
18
+ const SECRET = "k".repeat(43);
19
+ const T = 1_790_000_000_000;
20
+
21
+ const Q = new URLSearchParams({
22
+ response_type: "code",
23
+ client_id: "spoke_abc",
24
+ redirect_uri: "https://spoke.example.com/api/hub-session/callback",
25
+ state: "s".repeat(43),
26
+ code_challenge: "c".repeat(43),
27
+ code_challenge_method: "S256",
28
+ }).toString();
29
+
30
+ describe("constants", () => {
31
+ it("pins the path, the ref cap and the 600 s life", () => {
32
+ expect(CONTINUE_PATH).toBe("/api/auth/hub/continue");
33
+ expect(CONTINUATION_REF_MAX).toBe(4096);
34
+ expect(CONTINUATION_TTL_SECONDS).toBe(600);
35
+ });
36
+ });
37
+
38
+ describe("AC-4 — the continuation lives 600 s and round-trips", () => {
39
+ it("opens to the query it was minted from, up to the last millisecond", () => {
40
+ const ref = mintContinuation(Q, SECRET, T);
41
+ expect(openContinuation(ref, SECRET, T + 599_000)).toBe(Q);
42
+ expect(openContinuation(ref, SECRET, T + 599_999)).toBe(Q);
43
+ });
44
+
45
+ it("is gone at exactly 600 s (exp <= now)", () => {
46
+ const ref = mintContinuation(Q, SECRET, T);
47
+ expect(openContinuation(ref, SECRET, T + 600_000)).toBeNull();
48
+ expect(openContinuation(ref, SECRET, T + 600_001)).toBeNull();
49
+ });
50
+
51
+ it("is base64url only, so no decode can change it", () => {
52
+ const ref = mintContinuation(Q, SECRET, T);
53
+ expect(ref).toMatch(/^[A-Za-z0-9_-]+$/);
54
+ expect(decodeURIComponent(decodeURIComponent(ref))).toBe(ref);
55
+ expect(ref.length).toBeLessThan(CONTINUATION_REF_MAX);
56
+ });
57
+ });
58
+
59
+ describe("AC-3(b) — a ref that is not ours opens to null", () => {
60
+ const good = mintContinuation(Q, SECRET, T);
61
+
62
+ it("flips the first character", () => {
63
+ const flipped = (good[0] === "A" ? "B" : "A") + good.slice(1);
64
+ expect(openContinuation(flipped, SECRET, T)).toBeNull();
65
+ });
66
+
67
+ it("sealed under another secret", () => {
68
+ const other = mintContinuation(Q, "x".repeat(43), T);
69
+ expect(openContinuation(other, SECRET, T)).toBeNull();
70
+ });
71
+
72
+ it('a valid payload sealed with purpose "flow" and the SAME AAD (only the key differs)', () => {
73
+ const forged = seal(
74
+ { v: 1, q: Q, exp: T + 600_000 },
75
+ SECRET,
76
+ "flow",
77
+ "hub-continue",
78
+ );
79
+ expect(openContinuation(forged, SECRET, T)).toBeNull();
80
+ });
81
+
82
+ it("a value sealed under another name never opens as a continuation", () => {
83
+ const forged = seal(
84
+ { v: 1, q: Q, exp: T + 600_000 },
85
+ SECRET,
86
+ "continue",
87
+ "ailk-hub-flow",
88
+ );
89
+ expect(openContinuation(forged, SECRET, T)).toBeNull();
90
+ });
91
+
92
+ it("exp in the past", () => {
93
+ const expired = seal(
94
+ { v: 1, q: Q, exp: T - 1 },
95
+ SECRET,
96
+ "continue",
97
+ "hub-continue",
98
+ );
99
+ expect(openContinuation(expired, SECRET, T)).toBeNull();
100
+ });
101
+
102
+ it("4097 characters", () => {
103
+ expect(openContinuation("A".repeat(4097), SECRET, T)).toBeNull();
104
+ });
105
+
106
+ it("a character outside base64url", () => {
107
+ expect(openContinuation(`${good}%`, SECRET, T)).toBeNull();
108
+ expect(openContinuation(`${good}?x=1`, SECRET, T)).toBeNull();
109
+ expect(openContinuation("", SECRET, T)).toBeNull();
110
+ });
111
+
112
+ it("the base64url of plain JSON, unsealed", () => {
113
+ const plain = Buffer.from(
114
+ JSON.stringify({ v: 1, q: Q, exp: T + 600_000 }),
115
+ ).toString("base64url");
116
+ expect(openContinuation(plain, SECRET, T)).toBeNull();
117
+ });
118
+
119
+ it.each([
120
+ ["a wrong version", { v: 2, q: Q, exp: T + 1000 }],
121
+ ["an empty q", { v: 1, q: "", exp: T + 1000 }],
122
+ ["a non-string q", { v: 1, q: 5, exp: T + 1000 }],
123
+ ["a non-number exp", { v: 1, q: Q, exp: "later" }],
124
+ ["a bare string", "hello"],
125
+ ["null", null],
126
+ ])("a sealed value with %s", (_name, value) => {
127
+ const ref = seal(value, SECRET, "continue", "hub-continue");
128
+ expect(openContinuation(ref, SECRET, T)).toBeNull();
129
+ });
130
+
131
+ it("re-serializes a forged q so a CR/LF is percent-encoded and never throws", () => {
132
+ const ref = seal(
133
+ { v: 1, q: "a=1\r\nX-Injected: 1", exp: T + 600_000 },
134
+ SECRET,
135
+ "continue",
136
+ "hub-continue",
137
+ );
138
+ const opened = openContinuation(ref, SECRET, T);
139
+ expect(opened).not.toBeNull();
140
+ expect(opened).not.toMatch(/[\r\n]/);
141
+ expect(() => new Headers({ Location: `/x?${opened}` })).not.toThrow();
142
+ });
143
+ });
@@ -15,12 +15,14 @@
15
15
  // --- Mocks set up BEFORE imports ---
16
16
 
17
17
  const mockGetSession = jest.fn();
18
+ const mockSecret = "k".repeat(43);
18
19
  // eslint-disable-next-line no-restricted-syntax -- auth.ts is the Better Auth singleton (its adapter needs a live database); this stand-in for the session read is the repo-standard double for that boundary (see the sibling suites under app/[locale]/(authed)/account/__tests__).
19
20
  jest.mock("../../../../../../auth", () => ({
20
21
  __esModule: true,
21
22
  auth: {
22
23
  api: { getSession: (...args: unknown[]) => mockGetSession(...args) },
23
24
  },
25
+ resolveSecret: () => mockSecret,
24
26
  }));
25
27
 
26
28
  const mockCreateCode = jest.fn();
@@ -30,9 +32,11 @@ jest.mock("@working-theory/api-client", () => ({
30
32
  }));
31
33
 
32
34
  import { resolveNextPath } from "../../../../../[locale]/(auth)/sign-in/next-path";
35
+ import { openContinuation } from "../../continuation";
33
36
  import { GET } from "../route";
34
37
 
35
38
  const HUB = "https://hub.example.com";
39
+ const CONTINUE = "/api/auth/hub/continue";
36
40
  const SPOKE_CALLBACK = "https://spoke.example.com/api/hub-session/callback";
37
41
  const CHALLENGE = "c".repeat(43);
38
42
  const STATE = "s".repeat(43);
@@ -102,34 +106,82 @@ describe("a malformed query is a 400 and never a redirect", () => {
102
106
  });
103
107
 
104
108
  describe("no Better Auth session", () => {
105
- it("AC-4: 303 to /sign-in?next= followed by the encoded authorize path and query", async () => {
109
+ /** The `next` value of the 303, and the ref inside it. */
110
+ function continuationOf(res: Response): { next: string; ref: string } {
111
+ const location = res.headers.get("location") ?? "";
112
+ const next = new URL(location, HUB).searchParams.get("next") ?? "";
113
+ return { next, ref: next.slice(`${CONTINUE}/`.length) };
114
+ }
115
+
116
+ it("#8146 AC-1(b): 303 to /sign-in?next= followed by the encoded continue path and a sealed ref", async () => {
106
117
  mockGetSession.mockResolvedValue(null);
107
118
  const search = query();
108
119
 
109
120
  const res = await GET(request(search));
110
121
 
111
122
  expect(res.status).toBe(303);
112
- const location = res.headers.get("location") ?? "";
113
- expect(location).toBe(
114
- `/sign-in?next=${encodeURIComponent(`/api/auth/hub/authorize?${search}`)}`,
123
+ const { next, ref } = continuationOf(res);
124
+ expect(res.headers.get("location")).toBe(
125
+ `/sign-in?next=${encodeURIComponent(`${CONTINUE}/${ref}`)}`,
115
126
  );
127
+ expect(next).toBe(`${CONTINUE}/${ref}`);
128
+ expect(openContinuation(ref, mockSecret)).toBe(search);
129
+ expect(res.headers.get("cache-control")).toBe("no-store");
130
+ expect(res.headers.get("referrer-policy")).toBe("no-referrer");
116
131
  expect(mockCreateCode).not.toHaveBeenCalled();
117
132
  });
118
133
 
119
- it("AC-6: the next value is a same-origin path the sign-in card keeps (resolveNextPath)", async () => {
134
+ it("#8146 AC-1(b): the next value is a same-origin path the sign-in card keeps (resolveNextPath) and a second decode cannot change", async () => {
120
135
  mockGetSession.mockResolvedValue(null);
121
136
 
122
137
  const res = await GET(request(query()));
123
138
 
124
- const next =
125
- new URL(res.headers.get("location") ?? "", HUB).searchParams.get(
126
- "next",
127
- ) ?? "";
128
- expect(next.startsWith("/api/auth/hub/authorize?")).toBe(true);
139
+ const { next } = continuationOf(res);
140
+ expect(next.startsWith(`${CONTINUE}/`)).toBe(true);
129
141
  // Unchanged — not the fallback — so Better Auth is handed it as the callback
130
142
  // for Google and for the emailed link alike.
131
143
  expect(resolveNextPath(next, "/account")).toBe(next);
132
144
  expect(next).not.toMatch(/^https?:/);
145
+ // Better Auth decodes the callback a second time: nothing may change.
146
+ expect(next).toMatch(/^[A-Za-z0-9_/-]+$/);
147
+ expect(decodeURIComponent(decodeURIComponent(next))).toBe(next);
148
+ });
149
+
150
+ it("#8146 AC-1(b): the ref holds the canonical six-key query — a reversed query with an extra key opens to the same string, without it", async () => {
151
+ mockGetSession.mockResolvedValue(null);
152
+ const canonical = query();
153
+ const reversed = [...new URLSearchParams(canonical).entries()]
154
+ .reverse()
155
+ .map(([key, value]) => `${key}=${encodeURIComponent(value)}`)
156
+ .join("&");
157
+
158
+ const res = await GET(request(`${reversed}&extra=1`));
159
+
160
+ expect(res.status).toBe(303);
161
+ const opened = openContinuation(continuationOf(res).ref, mockSecret);
162
+ expect(opened).toBe(canonical);
163
+ expect(opened).not.toContain("extra");
164
+ });
165
+
166
+ it("#8146 AC-3(b): a redirect_uri whose ref would pass 4096 characters is a 400 with NO Location", async () => {
167
+ mockGetSession.mockResolvedValue(null);
168
+ const redirectUri = "https://spoke.example.com/".padEnd(2048, "/");
169
+
170
+ const res = await GET(request(query({ redirect_uri: redirectUri })));
171
+
172
+ expect(res.status).toBe(400);
173
+ expect(res.headers.get("location")).toBeNull();
174
+ expect(await res.json()).toEqual({ error: "invalid_request" });
175
+ });
176
+
177
+ it("#8146 AC-3(b): a 2048-character redirect_uri whose ref stays under the cap is a normal 303", async () => {
178
+ mockGetSession.mockResolvedValue(null);
179
+ const redirectUri = "https://spoke.example.com/".padEnd(2048, "a");
180
+
181
+ const res = await GET(request(query({ redirect_uri: redirectUri })));
182
+
183
+ expect(res.status).toBe(303);
184
+ expect(res.headers.get("location")).toMatch(/^\/sign-in\?next=/);
133
185
  });
134
186
  });
135
187
 
@@ -13,11 +13,15 @@
13
13
  * &code_challenge=…&code_challenge_method=S256
14
14
  *
15
15
  * - a malformed query is a 400 and NEVER a redirect;
16
- * - no Better Auth session: 303 to `/sign-in?next=<this path and query>` —
17
- * a same-origin path, so `resolveNextPath` keeps it and the existing
18
- * sign-in card hands it to Better Auth as the callback for Google and for
19
- * the emailed link alike (the hub's own `magicLink` sends the link, and it
20
- * returns through this step);
16
+ * - no Better Auth session: the six validated keys are sealed into a short
17
+ * ref and the answer is a 303 to `/sign-in?next=<the same-origin path
18
+ * /api/auth/hub/continue/<ref>>` (#8146). `resolveNextPath` keeps that
19
+ * path and the existing sign-in card hands it to Better Auth as the
20
+ * callback for Google and for the emailed link alike; the continue route
21
+ * then returns the visitor here. This route's own URL is NOT the callback:
22
+ * Better Auth's magic-link verify decodes a callback twice, which turns the
23
+ * encoded `redirect_uri` inside it into a URL its origin check refuses. A
24
+ * ref longer than `CONTINUATION_REF_MAX` is a 400, never a redirect;
21
25
  * - a session: the API mints a single-use code, and this route redirects to
22
26
  * the REGISTERED redirect URI the API echoes back, with `code` and the
23
27
  * same `state` — never to the query's own `redirect_uri` until the API has
@@ -40,7 +44,12 @@
40
44
  import { hubSignIn } from "@working-theory/api-client";
41
45
  import { hubAuthorizeQuerySchema } from "@working-theory/validation";
42
46
 
43
- import { auth } from "../../../../../auth";
47
+ import { auth, resolveSecret } from "../../../../../auth";
48
+ import {
49
+ CONTINUATION_REF_MAX,
50
+ CONTINUE_PATH,
51
+ mintContinuation,
52
+ } from "../continuation";
44
53
 
45
54
  /** The six query keys, each read exactly once. */
46
55
  const QUERY_KEYS = [
@@ -106,7 +115,13 @@ export async function GET(request: Request): Promise<Response> {
106
115
 
107
116
  const session = await auth.api.getSession({ headers: request.headers });
108
117
  if (!session) {
109
- const next = `${url.pathname}${url.search}`;
118
+ // Only the six validated keys, in QUERY_KEYS order: a ref carries nothing
119
+ // that did not pass the schema, and extra or reordered keys are dropped.
120
+ const canonical = new URLSearchParams();
121
+ for (const key of QUERY_KEYS) canonical.set(key, query[key]);
122
+ const ref = mintContinuation(canonical.toString(), resolveSecret());
123
+ if (ref.length > CONTINUATION_REF_MAX) return json(400, "invalid_request");
124
+ const next = `${CONTINUE_PATH}/${ref}`;
110
125
  return see(`/sign-in?next=${encodeURIComponent(next)}`);
111
126
  }
112
127
 
@@ -0,0 +1,73 @@
1
+ /**
2
+ * The hub's sign-in continuation (#8146, spec
3
+ * docs/specs/8146-email-link-sign-in-from-spec.md D2, D3).
4
+ *
5
+ * Better Auth's magic-link verify decodes `callbackURL` a second time, so a
6
+ * callback that holds an encoded `redirect_uri` is refused as
7
+ * INVALID_CALLBACK_URL. The authorize step therefore does not hand the sign-in
8
+ * page its own URL. It seals the validated authorize query into a short,
9
+ * opaque ref and hands over the same-origin path `/api/auth/hub/continue/<ref>`
10
+ * instead. A base64url string has no `%`, `?`, `&`, `=` or `:`, so no number of
11
+ * decodes changes it.
12
+ *
13
+ * Sealed (AES-256-GCM, key derived from the caller's secret under its own
14
+ * purpose), with its expiry inside. The ref is opaque but is NOT a secret: it
15
+ * carries what the cleartext `next` carried before (spec § 9 SEC-8146-1).
16
+ * Nothing here logs a ref.
17
+ */
18
+ import { seal, unseal } from "../../../../lib/hub-session/seal";
19
+
20
+ /** The same-origin path a ref is appended to. */
21
+ export const CONTINUE_PATH = "/api/auth/hub/continue";
22
+
23
+ /** The longest ref the authorize step will mint, and the continue route open. */
24
+ export const CONTINUATION_REF_MAX = 4096;
25
+
26
+ /**
27
+ * 600 s, matching the spoke's flow cookie: a longer life is useless (the spoke
28
+ * refuses the callback once its flow expires) and it covers the 300 s emailed
29
+ * link plus time on the sign-in page.
30
+ */
31
+ export const CONTINUATION_TTL_SECONDS = 600;
32
+
33
+ /** A fixed name: a value sealed for a cookie never opens here, nor the reverse. */
34
+ const AAD = "hub-continue";
35
+
36
+ const BASE64URL = /^[A-Za-z0-9_-]+$/;
37
+
38
+ /** `search` (the canonical authorize query, no leading `?`) as a sealed ref. */
39
+ export function mintContinuation(
40
+ search: string,
41
+ secret: string,
42
+ now: number = Date.now(),
43
+ ): string {
44
+ return seal(
45
+ { v: 1, q: search, exp: now + CONTINUATION_TTL_SECONDS * 1000 },
46
+ secret,
47
+ "continue",
48
+ AAD,
49
+ );
50
+ }
51
+
52
+ /**
53
+ * The authorize query a ref holds, or `null` when the ref is too long, is not
54
+ * base64url, does not open, is not the expected shape, or has expired.
55
+ *
56
+ * The query is re-serialized through `URLSearchParams`: for what
57
+ * `mintContinuation` wrote that is the same string, and for a `q` forged under
58
+ * a known secret it percent-encodes CR/LF so a `Location` can never be split
59
+ * and never throws (spec § 9 SEC-8146-7).
60
+ */
61
+ export function openContinuation(
62
+ ref: string,
63
+ secret: string,
64
+ now: number = Date.now(),
65
+ ): string | null {
66
+ if (ref.length > CONTINUATION_REF_MAX || !BASE64URL.test(ref)) return null;
67
+ const value = unseal(ref, secret, "continue", AAD);
68
+ if (typeof value !== "object" || value === null) return null;
69
+ const { v, q, exp } = value as Record<string, unknown>;
70
+ if (v !== 1 || typeof q !== "string" || q === "") return null;
71
+ if (typeof exp !== "number" || exp <= now) return null;
72
+ return new URLSearchParams(q).toString();
73
+ }