create-ailk 0.26.0 → 0.28.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 (224) hide show
  1. package/README.md +86 -9
  2. package/dist/add-module-core.js +2 -1
  3. package/dist/add-module.d.ts +2 -1
  4. package/dist/add-module.js +9 -4
  5. package/dist/add-route.js +46 -19
  6. package/dist/body-heading.d.ts +56 -0
  7. package/dist/body-heading.js +394 -0
  8. package/dist/cli.js +28 -1
  9. package/dist/content-payloads.js +2 -2
  10. package/dist/derive-architecture.js +8 -2
  11. package/dist/index.d.ts +2 -1
  12. package/dist/index.js +4 -1
  13. package/dist/lib/auth-secret-file.d.ts +6 -2
  14. package/dist/lib/auth-secret-file.js +46 -20
  15. package/dist/lib/env-catalog.js +20 -0
  16. package/dist/lib/site-modules.d.ts +8 -0
  17. package/dist/lib/site-modules.js +34 -0
  18. package/dist/module-architecture.js +25 -14
  19. package/dist/paid-sections.d.ts +17 -0
  20. package/dist/paid-sections.js +39 -0
  21. package/dist/programmatic.d.ts +16 -0
  22. package/dist/programmatic.js +73 -4
  23. package/dist/remove-module.d.ts +98 -0
  24. package/dist/remove-module.js +620 -0
  25. package/dist/seed-flows.d.ts +2 -2
  26. package/dist/seed-flows.js +16 -9
  27. package/dist/site-roster.js +4 -1
  28. package/dist/starter-grid.d.ts +26 -0
  29. package/dist/starter-grid.js +34 -0
  30. package/dist/surfaces.d.ts +26 -0
  31. package/dist/surfaces.js +112 -11
  32. package/dist/sync-routes.d.ts +7 -0
  33. package/dist/sync-routes.js +53 -6
  34. package/dist/template-pages.d.ts +36 -0
  35. package/dist/template-pages.js +129 -0
  36. package/dist/topology.d.ts +2 -1
  37. package/dist/topology.js +4 -2
  38. package/dist/update.d.ts +3 -1
  39. package/dist/update.js +90 -6
  40. package/package.json +3 -3
  41. package/release-scope.json +2 -1
  42. package/site-templates/aliases/hub.TEMPLATE-MANIFEST.json +115 -0
  43. package/templates/.env.example +12 -2
  44. package/templates/CLAUDE.md +20 -20
  45. package/templates/README.md +19 -19
  46. package/templates/apps/api/.env.example +16 -5
  47. package/templates/apps/api/CLAUDE.md +32 -11
  48. package/templates/apps/api/src/__tests__/vercel-handler.test.ts +37 -0
  49. package/templates/apps/api/src/vercel-handler.ts +8 -1
  50. package/templates/apps/api/vercel.json +7 -1
  51. package/templates/apps/mcp/CLAUDE.md +18 -10
  52. package/templates/apps/web/.env.example +27 -0
  53. package/templates/apps/web/CLAUDE.md +56 -8
  54. package/templates/apps/web/__tests__/setup.ts +7 -0
  55. package/templates/apps/web/app/.well-known/mcp.json/route.ts +7 -7
  56. package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/next-path.test.ts +24 -1
  57. package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/page.test.tsx +51 -0
  58. package/templates/apps/web/app/[locale]/(auth)/sign-in/next-path.ts +13 -0
  59. package/templates/apps/web/app/[locale]/(auth)/sign-in/page.tsx +10 -4
  60. package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/page.test.tsx +197 -4
  61. package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +23 -7
  62. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/versions/[version]/file/route.ts +61 -2
  63. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/file-route.test.ts +81 -0
  64. package/templates/apps/web/app/[locale]/(authed)/account/connect/__tests__/page.test.tsx +197 -0
  65. package/templates/apps/web/app/[locale]/(authed)/account/connect/page.tsx +152 -0
  66. package/templates/apps/web/app/[locale]/about/__tests__/about-routes.test.tsx +2 -2
  67. package/templates/apps/web/app/[locale]/careers/[slug]/[action]/page.tsx +76 -21
  68. package/templates/apps/web/app/[locale]/careers/[slug]/apply.actions.ts +46 -16
  69. package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +20 -3
  70. package/templates/apps/web/app/[locale]/careers/__tests__/apply.actions.test.ts +180 -2
  71. package/templates/apps/web/app/[locale]/careers/__tests__/careers-set.test.tsx +6 -2
  72. package/templates/apps/web/app/[locale]/careers/__tests__/job-apply-page.test.tsx +122 -0
  73. package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +1 -1
  74. package/templates/apps/web/app/[locale]/case-studies/[slug]/page.tsx +20 -3
  75. package/templates/apps/web/app/[locale]/case-studies/__tests__/hub-children.test.tsx +65 -0
  76. package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +6 -2
  77. package/templates/apps/web/app/[locale]/case-studies/page.tsx +23 -11
  78. package/templates/apps/web/app/[locale]/checkout/__tests__/thank-you-account.test.tsx +11 -1
  79. package/templates/apps/web/app/[locale]/clients/__tests__/page-renderer-migration.test.tsx +1 -1
  80. package/templates/apps/web/app/[locale]/consent.actions.ts +44 -6
  81. package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +4 -4
  82. package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +269 -42
  83. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +88 -30
  84. package/templates/apps/web/app/[locale]/courses/[slug]/page.tsx +20 -3
  85. package/templates/apps/web/app/[locale]/courses/__tests__/hub-children.test.tsx +32 -0
  86. package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +6 -2
  87. package/templates/apps/web/app/[locale]/courses/page.tsx +22 -6
  88. package/templates/apps/web/app/[locale]/events/[slug]/page.tsx +15 -2
  89. package/templates/apps/web/app/[locale]/events/__tests__/hub-children.test.tsx +32 -0
  90. package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +8 -2
  91. package/templates/apps/web/app/[locale]/events/page.tsx +23 -7
  92. package/templates/apps/web/app/[locale]/features/__tests__/hub-children.test.tsx +32 -0
  93. package/templates/apps/web/app/[locale]/features/page.tsx +23 -25
  94. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/flow.actions.test.ts +95 -0
  95. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/save.actions.test.ts +193 -0
  96. package/templates/apps/web/app/[locale]/flows/[slug]/email-moment.ts +92 -0
  97. package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +10 -0
  98. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsClient.tsx +513 -0
  99. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsEmailStep.tsx +122 -0
  100. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/ResultsClient.no-commerce.test.tsx +195 -0
  101. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/landing.test.tsx +293 -0
  102. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/page.test.tsx +397 -0
  103. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/checkout-seam.ts +74 -0
  104. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/page.tsx +264 -0
  105. package/templates/apps/web/app/[locale]/flows/[slug]/save.actions.ts +75 -0
  106. package/templates/apps/web/app/[locale]/for/__tests__/hub-children.test.tsx +32 -0
  107. package/templates/apps/web/app/[locale]/for/__tests__/page-renderer-migration.test.tsx +5 -1
  108. package/templates/apps/web/app/[locale]/for/page.tsx +23 -25
  109. package/templates/apps/web/app/[locale]/how-to/[slug]/page.tsx +20 -3
  110. package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +1 -1
  111. package/templates/apps/web/app/[locale]/integrations/[slug]/page.tsx +25 -4
  112. package/templates/apps/web/app/[locale]/integrations/__tests__/hub-children.test.tsx +38 -0
  113. package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +8 -2
  114. package/templates/apps/web/app/[locale]/integrations/page.tsx +23 -11
  115. package/templates/apps/web/app/[locale]/layout.tsx +161 -128
  116. package/templates/apps/web/app/[locale]/page.tsx +51 -3
  117. package/templates/apps/web/app/[locale]/partners/[slug]/page.tsx +16 -3
  118. package/templates/apps/web/app/[locale]/partners/__tests__/hub-children.test.tsx +32 -0
  119. package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +6 -2
  120. package/templates/apps/web/app/[locale]/partners/page.tsx +22 -6
  121. package/templates/apps/web/app/[locale]/portfolio/__tests__/hub-children.test.tsx +38 -0
  122. package/templates/apps/web/app/[locale]/portfolio/page.tsx +25 -24
  123. package/templates/apps/web/app/[locale]/pricing/[slug]/__tests__/page.test.tsx +321 -0
  124. package/templates/apps/web/app/[locale]/pricing/[slug]/page.tsx +87 -0
  125. package/templates/apps/web/app/[locale]/pricing/__tests__/offerings.test.tsx +3 -0
  126. package/templates/apps/web/app/[locale]/pricing/__tests__/pricing-pages.test.tsx +215 -0
  127. package/templates/apps/web/app/[locale]/pricing/page.tsx +56 -341
  128. package/templates/apps/web/app/[locale]/products/[slug]/page.tsx +21 -4
  129. package/templates/apps/web/app/[locale]/products/__tests__/hub-children.test.tsx +32 -0
  130. package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +8 -2
  131. package/templates/apps/web/app/[locale]/products/page.tsx +22 -6
  132. package/templates/apps/web/app/[locale]/projects/__tests__/hub-children.test.tsx +32 -0
  133. package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +4 -0
  134. package/templates/apps/web/app/[locale]/projects/page.tsx +18 -11
  135. package/templates/apps/web/app/[locale]/resources/[slug]/page.tsx +25 -4
  136. package/templates/apps/web/app/[locale]/resources/__tests__/hub-children.test.tsx +38 -0
  137. package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +6 -2
  138. package/templates/apps/web/app/[locale]/resources/page.tsx +20 -10
  139. package/templates/apps/web/app/[locale]/services/[slug]/[child]/page.tsx +68 -0
  140. package/templates/apps/web/app/[locale]/services/[slug]/page.tsx +64 -9
  141. package/templates/apps/web/app/[locale]/services/__tests__/hub-children.test.tsx +158 -0
  142. package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +12 -2
  143. package/templates/apps/web/app/[locale]/services/__tests__/service-detail-sections.test.tsx +223 -0
  144. package/templates/apps/web/app/[locale]/services/__tests__/service-families.test.tsx +368 -0
  145. package/templates/apps/web/app/[locale]/services/page.tsx +45 -19
  146. package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +1 -1
  147. package/templates/apps/web/app/[locale]/team/__tests__/hub-children.test.tsx +32 -0
  148. package/templates/apps/web/app/[locale]/team/page.tsx +25 -24
  149. package/templates/apps/web/app/[locale]/tech-articles/[slug]/page.tsx +24 -3
  150. package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +1 -1
  151. package/templates/apps/web/app/[locale]/templates/[slug]/__tests__/page.test.tsx +203 -0
  152. package/templates/apps/web/app/[locale]/templates/[slug]/page.tsx +81 -0
  153. package/templates/apps/web/app/[locale]/templates/__tests__/page.test.tsx +236 -0
  154. package/templates/apps/web/app/[locale]/templates/page.tsx +108 -0
  155. package/templates/apps/web/app/[locale]/testimonials/__tests__/page-renderer-migration.test.tsx +1 -1
  156. package/templates/apps/web/app/[locale]/whitepapers/[slug]/page.tsx +24 -3
  157. package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +1 -1
  158. package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +322 -2
  159. package/templates/apps/web/app/api/auth/hub/authorize/route.ts +161 -29
  160. package/templates/apps/web/app/api/hub-session/__tests__/routes.test.ts +51 -0
  161. package/templates/apps/web/app/api/hub-session/sign-in/route.ts +13 -0
  162. package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/__tests__/route.test.ts +233 -7
  163. package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/route.ts +92 -41
  164. package/templates/apps/web/app/api/offerings/checkout/__tests__/route.test.ts +77 -39
  165. package/templates/apps/web/app/llms.txt/route.ts +41 -3
  166. package/templates/apps/web/app/robots.txt/route.ts +5 -1
  167. package/templates/apps/web/app/sitemap.xml/route.ts +45 -3
  168. package/templates/apps/web/auth.ts +102 -50
  169. package/templates/apps/web/components/signed-in-nav.tsx +8 -1
  170. package/templates/apps/web/lib/__tests__/bot-check.test.ts +254 -0
  171. package/templates/apps/web/lib/__tests__/consent-visitor.test.ts +212 -13
  172. package/templates/apps/web/lib/__tests__/deck-page.test.ts +13 -1
  173. package/templates/apps/web/lib/__tests__/docs-index.test.ts +93 -0
  174. package/templates/apps/web/lib/__tests__/docs-path.test.ts +168 -0
  175. package/templates/apps/web/lib/__tests__/hub-children.fixtures.ts +278 -0
  176. package/templates/apps/web/lib/__tests__/nav-label-keys.test.ts +138 -0
  177. package/templates/apps/web/lib/__tests__/newsletter.actions.test.ts +178 -2
  178. package/templates/apps/web/lib/__tests__/offerings-pricing.test.ts +183 -0
  179. package/templates/apps/web/lib/__tests__/page-chrome.test.ts +412 -0
  180. package/templates/apps/web/lib/__tests__/pricing-page.test.ts +142 -0
  181. package/templates/apps/web/lib/__tests__/relay.test.ts +484 -4
  182. package/templates/apps/web/lib/__tests__/route-family.test.ts +57 -0
  183. package/templates/apps/web/lib/__tests__/service-listing.test.ts +111 -0
  184. package/templates/apps/web/lib/__tests__/services-path.test.ts +96 -0
  185. package/templates/apps/web/lib/__tests__/site-header.test.ts +151 -0
  186. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +148 -6
  187. package/templates/apps/web/lib/__tests__/telemetry-scrub.test.ts +84 -0
  188. package/templates/apps/web/lib/__tests__/template-listing.test.ts +158 -0
  189. package/templates/apps/web/lib/auth-secret.ts +57 -0
  190. package/templates/apps/web/lib/bot-check.ts +138 -0
  191. package/templates/apps/web/lib/consent-visitor.ts +81 -7
  192. package/templates/apps/web/lib/deck-page.ts +6 -3
  193. package/templates/apps/web/lib/docs-index.ts +100 -0
  194. package/templates/apps/web/lib/docs-path.ts +94 -0
  195. package/templates/apps/web/lib/hub-children.tsx +318 -0
  196. package/templates/apps/web/lib/hub-session/__tests__/sign-in-gate.test.tsx +22 -2
  197. package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +11 -8
  198. package/templates/apps/web/lib/modules.generated.ts +9 -0
  199. package/templates/apps/web/lib/nav-label-check.ts +61 -0
  200. package/templates/apps/web/lib/nav-label-keys.ts +78 -0
  201. package/templates/apps/web/lib/newsletter.actions.ts +31 -6
  202. package/templates/apps/web/lib/offerings-pricing.ts +135 -6
  203. package/templates/apps/web/lib/page-chrome.ts +367 -0
  204. package/templates/apps/web/lib/posting-title.ts +26 -4
  205. package/templates/apps/web/lib/pricing-page.ts +430 -0
  206. package/templates/apps/web/lib/relay.ts +44 -4
  207. package/templates/apps/web/lib/route-family.ts +69 -0
  208. package/templates/apps/web/lib/service-listing.ts +89 -0
  209. package/templates/apps/web/lib/services-path.ts +97 -0
  210. package/templates/apps/web/lib/sign-up-agreements.ts +17 -6
  211. package/templates/apps/web/lib/site-header.ts +94 -0
  212. package/templates/apps/web/lib/telemetry-scrub.ts +68 -3
  213. package/templates/apps/web/lib/template-listing.ts +169 -0
  214. package/templates/apps/web/messages/en.json +144 -3
  215. package/templates/apps/web/middleware.ts +179 -12
  216. package/templates/apps/web/next.config.mjs +220 -3
  217. package/templates/apps/web/package.json +3 -2
  218. package/templates/apps/web/scripts/assert-content-bundled.ts +28 -6
  219. package/templates/apps/web/scripts/check-nav-labels.ts +39 -0
  220. package/templates/apps/web/sentry.server.config.ts +30 -2
  221. package/templates/apps/web/src/lib/__tests__/rate-limit.test.ts +67 -9
  222. package/templates/apps/web/src/lib/rate-limit.ts +68 -27
  223. package/templates/apps/web/src/lib/routes.ts +15 -1
  224. package/versions.json +48 -48
@@ -6,27 +6,41 @@
6
6
  * provider body. A failed link lands back here with `?error=<code>`, mapped
7
7
  * to fixed copy and never echoed.
8
8
  *
9
+ * #9912 D4, D5: the page reads `?next=` through `resolveNextPath`, as
10
+ * `/sign-in` does. The "Sign in" toggle (open and closed card) carries it, and
11
+ * the password, Google and magic-link paths each hand it to Better Auth as
12
+ * `callbackURL`, the magic link's failed-link return keeping it too. A `next`
13
+ * resolveNextPath refuses, or none, gives `/account` and the bare links.
14
+ *
9
15
  * `next-intl/server` is mocked globally (apps/web/__tests__/setup.ts) against
10
16
  * the real en.json.
11
17
  */
12
18
  import { render, screen } from "@testing-library/react";
19
+ import userEvent from "@testing-library/user-event";
13
20
 
14
21
  const mockGetActiveProviderIds = jest.fn();
22
+ const mockIsSignUpOpen = jest.fn();
15
23
  // eslint-disable-next-line no-restricted-syntax -- #507: auth-env.ts is a pure env-introspection module with no external boundary to mock instead.
16
24
  jest.mock("../../../../../lib/auth-env", () => ({
17
25
  __esModule: true,
18
26
  getActiveProviderIds: () => mockGetActiveProviderIds(),
19
- isSignUpOpen: () => true,
27
+ isSignUpOpen: () => mockIsSignUpOpen(),
20
28
  isResetAvailable: () => false,
21
29
  resetSendConfigured: () => false,
22
30
  }));
23
31
 
32
+ const mockSocial = jest.fn();
33
+ const mockSignUpEmail = jest.fn();
34
+ const mockFetch = jest.fn();
24
35
  jest.mock("better-auth/react", () => ({
25
36
  __esModule: true,
26
37
  createAuthClient: () => ({
27
- signIn: { social: jest.fn(), email: jest.fn() },
28
- signUp: { email: jest.fn() },
29
- $fetch: jest.fn(),
38
+ signIn: {
39
+ social: (...args: unknown[]) => mockSocial(...args),
40
+ email: jest.fn(),
41
+ },
42
+ signUp: { email: (...args: unknown[]) => mockSignUpEmail(...args) },
43
+ $fetch: (...args: unknown[]) => mockFetch(...args),
30
44
  }),
31
45
  }));
32
46
 
@@ -46,6 +60,10 @@ async function renderSignUp(
46
60
 
47
61
  beforeEach(() => {
48
62
  mockGetActiveProviderIds.mockReset();
63
+ mockIsSignUpOpen.mockReset().mockReturnValue(true);
64
+ mockSocial.mockReset().mockResolvedValue({ data: {}, error: null });
65
+ mockSignUpEmail.mockReset().mockResolvedValue({ data: {}, error: null });
66
+ mockFetch.mockReset().mockResolvedValue({ data: {}, error: null });
49
67
  });
50
68
 
51
69
  describe("the method (D6)", () => {
@@ -106,3 +124,178 @@ describe("the failed-link notice (D6)", () => {
106
124
  expect(screen.queryByTestId("sign-up-link-notice")).toBeNull();
107
125
  });
108
126
  });
127
+
128
+ /** A continue path as the hub's authorize step hands it (#8146). */
129
+ const CONTINUATION = "/api/auth/hub/continue/AbC-123_xyz";
130
+
131
+ /** The three values `resolveNextPath` refuses that a toggle must never echo. */
132
+ const REFUSED: ReadonlyArray<[string, string]> = [
133
+ ["a protocol-relative next", "//evil.example"],
134
+ ["an absolute next", "https://evil.example/x"],
135
+ ["a backslash next", "/\\evil.example"],
136
+ ];
137
+
138
+ /** Each query that carries no `next` a toggle may echo: the refused three, and none. */
139
+ const UNCARRIED: Array<[string, Record<string, string>]> = [
140
+ ...REFUSED.map(([label, next]): [string, Record<string, string>] => [
141
+ label,
142
+ { next },
143
+ ]),
144
+ ["no next", {}],
145
+ ];
146
+
147
+ /** The completion cases with no `next` to honour: one refused, and none. */
148
+ const TO_ACCOUNT: Array<[string, Record<string, string>]> = [
149
+ ["a refused next", { next: "//evil.example" }],
150
+ ["no next", {}],
151
+ ];
152
+
153
+ describe("the Sign in toggle keeps the next (#9912 D4)", () => {
154
+ beforeEach(() => {
155
+ mockGetActiveProviderIds.mockReturnValue(["google", "email"]);
156
+ });
157
+
158
+ it("carries a validated next on the open card", async () => {
159
+ await renderSignUp({ next: "/account/billing" });
160
+ expect(screen.getByTestId("auth-card-prospect-link")).toHaveAttribute(
161
+ "href",
162
+ "/sign-in?next=%2Faccount%2Fbilling",
163
+ );
164
+ });
165
+
166
+ it("carries a validated next on the closed (invitation) card", async () => {
167
+ mockIsSignUpOpen.mockReturnValue(false);
168
+ await renderSignUp({ next: "/account/billing" });
169
+ expect(screen.getByTestId("auth-card-prospect-link")).toHaveAttribute(
170
+ "href",
171
+ "/sign-in?next=%2Faccount%2Fbilling",
172
+ );
173
+ });
174
+
175
+ it.each(UNCARRIED)(
176
+ "%s gives the bare /sign-in on the open card",
177
+ async (_label, query) => {
178
+ await renderSignUp(query);
179
+ expect(screen.getByTestId("auth-card-prospect-link")).toHaveAttribute(
180
+ "href",
181
+ "/sign-in",
182
+ );
183
+ },
184
+ );
185
+
186
+ it.each(UNCARRIED)(
187
+ "%s gives the bare /sign-in on the closed card",
188
+ async (_label, query) => {
189
+ mockIsSignUpOpen.mockReturnValue(false);
190
+ await renderSignUp(query);
191
+ expect(screen.getByTestId("auth-card-prospect-link")).toHaveAttribute(
192
+ "href",
193
+ "/sign-in",
194
+ );
195
+ },
196
+ );
197
+ });
198
+
199
+ describe("sign-up completion follows the validated next (#9912 D5)", () => {
200
+ async function tickTerms(): Promise<void> {
201
+ await userEvent.setup().click(screen.getByTestId("consent-account_terms"));
202
+ }
203
+
204
+ async function submitPassword(): Promise<void> {
205
+ const user = userEvent.setup();
206
+ await tickTerms();
207
+ await user.type(screen.getByTestId("auth-email"), "ada@example.com");
208
+ await user.type(
209
+ screen.getByTestId("auth-password"),
210
+ "correct horse battery staple",
211
+ );
212
+ await user.click(screen.getByTestId("auth-submit"));
213
+ }
214
+
215
+ async function clickGoogle(): Promise<void> {
216
+ await tickTerms();
217
+ await userEvent.setup().click(screen.getByTestId("auth-social-google"));
218
+ }
219
+
220
+ async function submitLink(): Promise<void> {
221
+ const user = userEvent.setup();
222
+ await tickTerms();
223
+ await user.type(screen.getByTestId("auth-email"), "ada@example.com");
224
+ await user.click(screen.getByTestId("auth-submit"));
225
+ }
226
+
227
+ describe("with a continuation next", () => {
228
+ it("password: signUp.email's callbackURL is the next", async () => {
229
+ mockGetActiveProviderIds.mockReturnValue(["google", "email"]);
230
+ await renderSignUp({ next: CONTINUATION });
231
+ await submitPassword();
232
+ expect(mockSignUpEmail).toHaveBeenCalledTimes(1);
233
+ expect(mockSignUpEmail.mock.calls[0][0]).toMatchObject({
234
+ callbackURL: CONTINUATION,
235
+ });
236
+ });
237
+
238
+ it("Google: signIn.social's callbackURL is the next", async () => {
239
+ mockGetActiveProviderIds.mockReturnValue(["google", "email"]);
240
+ await renderSignUp({ next: CONTINUATION });
241
+ await clickGoogle();
242
+ expect(mockSocial).toHaveBeenCalledTimes(1);
243
+ expect(mockSocial.mock.calls[0][0]).toMatchObject({
244
+ provider: "google",
245
+ callbackURL: CONTINUATION,
246
+ });
247
+ });
248
+
249
+ it("magic link: the request's callbackURL is the next and its failed-link return keeps it", async () => {
250
+ mockGetActiveProviderIds.mockReturnValue(["google", "magic-link"]);
251
+ await renderSignUp({ next: CONTINUATION });
252
+ await submitLink();
253
+ expect(mockFetch).toHaveBeenCalledTimes(1);
254
+ const [path, init] = mockFetch.mock.calls[0] as [
255
+ string,
256
+ { body: Record<string, unknown> },
257
+ ];
258
+ expect(path).toBe("/sign-in/magic-link");
259
+ expect(init.body.callbackURL).toBe(CONTINUATION);
260
+ expect(init.body.errorCallbackURL).toBe(
261
+ `/sign-up?next=${encodeURIComponent(CONTINUATION)}`,
262
+ );
263
+ });
264
+ });
265
+
266
+ describe.each(TO_ACCOUNT)("with %s", (_label, query) => {
267
+ it("password: callbackURL /account", async () => {
268
+ mockGetActiveProviderIds.mockReturnValue(["google", "email"]);
269
+ await renderSignUp(query);
270
+ await submitPassword();
271
+ expect(mockSignUpEmail).toHaveBeenCalledTimes(1);
272
+ expect(mockSignUpEmail.mock.calls[0][0]).toMatchObject({
273
+ callbackURL: "/account",
274
+ });
275
+ });
276
+
277
+ it("Google: callbackURL /account", async () => {
278
+ mockGetActiveProviderIds.mockReturnValue(["google", "email"]);
279
+ await renderSignUp(query);
280
+ await clickGoogle();
281
+ expect(mockSocial).toHaveBeenCalledTimes(1);
282
+ expect(mockSocial.mock.calls[0][0]).toMatchObject({
283
+ provider: "google",
284
+ callbackURL: "/account",
285
+ });
286
+ });
287
+
288
+ it("magic link: callbackURL /account and the bare /sign-up return", async () => {
289
+ mockGetActiveProviderIds.mockReturnValue(["google", "magic-link"]);
290
+ await renderSignUp(query);
291
+ await submitLink();
292
+ expect(mockFetch).toHaveBeenCalledTimes(1);
293
+ const [, init] = mockFetch.mock.calls[0] as [
294
+ string,
295
+ { body: Record<string, unknown> },
296
+ ];
297
+ expect(init.body.callbackURL).toBe("/account");
298
+ expect(init.body.errorCallbackURL).toBe("/sign-up");
299
+ });
300
+ });
301
+ });
@@ -19,6 +19,13 @@
19
19
  * keeps its invitation copy inside that frame — never a form the server
20
20
  * would refuse (operator, 2026-10-24).
21
21
  *
22
+ * `?next=` (#9912 D4, D5) is read through `resolveNextPath`, the call
23
+ * `/sign-in` makes. The validated `next` is where every method completes —
24
+ * the password verification link, the magic link and Google — and both the
25
+ * "Sign in" toggle and the failed-link return carry it, so a buyer sent here
26
+ * by a spoke's round trip ends back on the spoke. A refused or absent `next`
27
+ * gives `/account` and the bare links, as before.
28
+ *
22
29
  * `AuthShell` (the (auth) group's layout) owns the 400px column; the card
23
30
  * carries the site's mark (#8790 D3).
24
31
  */
@@ -38,13 +45,17 @@ import { getActiveProviderIds, isSignUpOpen } from "../../../../lib/auth-env";
38
45
  import { consentPoliciesFor } from "../../../../lib/consent-policies";
39
46
  import { siteLogo } from "../../../../lib/site-logo";
40
47
  import { legalFootnote } from "../legal-footnote";
48
+ import { hrefWithNext, resolveNextPath } from "../sign-in/next-path";
41
49
  import { resolveSignInCardText } from "../sign-in/sign-in-text";
42
50
 
43
51
  import { SignUpCard, type SignUpProvider } from "./sign-up-card";
44
52
 
45
53
  interface Props {
46
54
  params: Promise<{ locale: string }>;
47
- searchParams?: Promise<{ error?: string | string[] }>;
55
+ searchParams?: Promise<{
56
+ next?: string | string[];
57
+ error?: string | string[];
58
+ }>;
48
59
  }
49
60
 
50
61
  /**
@@ -79,13 +90,15 @@ export default async function SignUpPage({
79
90
  searchParams,
80
91
  }: Props): Promise<ReactElement> {
81
92
  const { locale } = await params;
93
+ const { next: rawNext, error } = (await searchParams) ?? {};
82
94
  const t = await getTranslations({ locale, namespace: "auth" });
83
- const signInHref = localeHref(locale, "/sign-in");
95
+ const fallback = localeHref(locale, "/account");
96
+ const next = resolveNextPath(rawNext, fallback);
84
97
  // The toggle line to /sign-in, and the acknowledgement /sign-in shares.
85
98
  const prospect = {
86
99
  prompt: t("signUpFooterPrompt"),
87
100
  linkLabel: t("signUpFooterLink"),
88
- href: signInHref,
101
+ href: hrefWithNext(localeHref(locale, "/sign-in"), next, fallback),
89
102
  };
90
103
  const footnote = legalFootnote(
91
104
  locale,
@@ -122,8 +135,7 @@ export default async function SignUpPage({
122
135
  : active.includes("email")
123
136
  ? "password"
124
137
  : "sso";
125
- const noticeKey =
126
- method === "magic-link" ? linkNoticeKey((await searchParams)?.error) : null;
138
+ const noticeKey = method === "magic-link" ? linkNoticeKey(error) : null;
127
139
 
128
140
  const labels: AuthCardLabels = {
129
141
  signUpHeading: t("signUpHeading"),
@@ -156,7 +168,7 @@ export default async function SignUpPage({
156
168
  logo={siteLogo()}
157
169
  providers={providers}
158
170
  method={method}
159
- next={localeHref(locale, "/account")}
171
+ next={next}
160
172
  prospect={prospect}
161
173
  footnote={footnote}
162
174
  labels={labels}
@@ -170,7 +182,11 @@ export default async function SignUpPage({
170
182
  agreementPolicies={consentPoliciesFor(locale)}
171
183
  agreementsLegend={t("signUpAgreementsLegend")}
172
184
  locale={locale}
173
- errorCallbackURL={localeHref(locale, "/sign-up")}
185
+ errorCallbackURL={hrefWithNext(
186
+ localeHref(locale, "/sign-up"),
187
+ next,
188
+ fallback,
189
+ )}
174
190
  {...(noticeKey ? { linkNotice: t(noticeKey) } : {})}
175
191
  linkLabels={{
176
192
  sentHeading: t("sentHeading"),
@@ -17,7 +17,8 @@
17
17
  * sandbox `Content-Security-Policy` and `Cache-Control: private, no-store` —
18
18
  * so the file is never a document on this origin and never cached on the way.
19
19
  * A hub answer missing any of the five is refused 502 rather than served
20
- * without it.
20
+ * without it — and so is one whose safety headers carry the wrong value
21
+ * (#8320, #9965 D8): presence alone is not the hub's promise.
21
22
  *
22
23
  * The params are shape-checked before any call (a malformed piece id or a
23
24
  * version that is not a whole number from 1 is a 404 with no call). A
@@ -43,6 +44,59 @@ const FILE_HEADER_NAMES: Record<keyof MemberFileHeaders, string> = {
43
44
  cacheControl: "Cache-Control",
44
45
  };
45
46
 
47
+ /**
48
+ * HTTP's own whitespace — SP and HTAB, never JS `\s`. A browser reads any
49
+ * other byte (a non-breaking space among them) as part of the token, so a
50
+ * check that trimmed or split on it would pass a value it does not describe.
51
+ */
52
+ const HTTP_VALUE = /^[\t\x20-\x7e]*$/;
53
+ const httpTrim = (value: string) => value.replace(/^[ \t]+|[ \t]+$/g, "");
54
+
55
+ /**
56
+ * The CSP's directives by name, or `null` for two policies joined or a
57
+ * directive named twice — a browser enforces the first copy, so a check that
58
+ * read a later one could pass a policy it does not describe.
59
+ */
60
+ function cspDirectives(policy: string): Map<string, string> | null {
61
+ // `Headers.get` joins two CSP headers with ", " — a second policy the hub
62
+ // never sent (#9965 SEC-9965-16).
63
+ if (policy.includes(",")) return null;
64
+ const directives = new Map<string, string>();
65
+ for (const part of policy.split(";")) {
66
+ const [name = "", ...rest] = httpTrim(part).split(/[ \t]+/);
67
+ if (!name) continue;
68
+ if (directives.has(name)) return null;
69
+ directives.set(name, rest.join(" "));
70
+ }
71
+ return directives;
72
+ }
73
+
74
+ /**
75
+ * Whether each safety header carries the value the hub sends (D8). Every
76
+ * comparison is case-insensitive and trimmed of SP and HTAB, and a value
77
+ * carrying any byte outside visible ASCII, SP and HTAB is refused before it
78
+ * is parsed. `Content-Type` is not checked: the other four contain it.
79
+ */
80
+ const SAFE_VALUE: Partial<
81
+ Record<keyof MemberFileHeaders, (value: string) => boolean>
82
+ > = {
83
+ contentTypeOptions: (value) => value === "nosniff",
84
+ contentDisposition: (value) => /^attachment[ \t]*(;|$)/.test(value),
85
+ contentSecurityPolicy: (value) => {
86
+ const directives = cspDirectives(value);
87
+ const sandbox = directives?.get("sandbox");
88
+ return (
89
+ sandbox !== undefined &&
90
+ !sandbox.split(" ").includes("allow-scripts") &&
91
+ directives?.get("default-src") === "'none'"
92
+ );
93
+ },
94
+ cacheControl: (value) => {
95
+ const directives = value.split(",").map((part) => part.trim());
96
+ return directives.includes("private") && directives.includes("no-store");
97
+ },
98
+ };
99
+
46
100
  function refusal(status: number): Response {
47
101
  return new Response(null, {
48
102
  status,
@@ -90,7 +144,12 @@ export async function GET(
90
144
  string,
91
145
  ][]) {
92
146
  const value = headers[key];
93
- if (value === null) {
147
+ const check = SAFE_VALUE[key];
148
+ const safe =
149
+ value !== null &&
150
+ (check === undefined ||
151
+ (HTTP_VALUE.test(value) && check(httpTrim(value).toLowerCase())));
152
+ if (!safe) {
94
153
  await stream.cancel();
95
154
  return refusal(502);
96
155
  }
@@ -14,6 +14,8 @@
14
14
  * unchanged;
15
15
  * - a hub answer missing any of the five is refused 502, its stream
16
16
  * cancelled — never bytes on this origin without them;
17
+ * - so is one carrying a safety header with the wrong value (#8320, #9965
18
+ * D8): presence is not enough;
17
19
  * - malformed params are a 404 with no call — and `locale`, which Next
18
20
  * always passes, never makes a real request fail;
19
21
  * - refusals map to a status with no body and `private, no-store`;
@@ -140,6 +142,85 @@ describe("the version file read", () => {
140
142
  },
141
143
  );
142
144
 
145
+ it.each([
146
+ [
147
+ "an inline Content-Disposition",
148
+ { contentDisposition: "inline; filename=a.pdf" },
149
+ ],
150
+ [
151
+ "X-Content-Type-Options other than nosniff",
152
+ { contentTypeOptions: "sniff" },
153
+ ],
154
+ [
155
+ "a Content-Security-Policy with no sandbox",
156
+ { contentSecurityPolicy: "default-src *" },
157
+ ],
158
+ [
159
+ "a sandbox that allows scripts",
160
+ { contentSecurityPolicy: "default-src 'none'; sandbox allow-scripts" },
161
+ ],
162
+ [
163
+ "a sandbox with default-src other than 'none'",
164
+ { contentSecurityPolicy: "default-src *; sandbox" },
165
+ ],
166
+ [
167
+ "two CSP headers, joined (the hub's plus default-src *)",
168
+ { contentSecurityPolicy: "default-src 'none'; sandbox, default-src *" },
169
+ ],
170
+ [
171
+ "a repeated sandbox directive, whose first copy allows scripts",
172
+ {
173
+ contentSecurityPolicy:
174
+ "sandbox allow-scripts; sandbox; default-src 'none'",
175
+ },
176
+ ],
177
+ // HTTP whitespace is SP and HTAB only: a browser reads a non-breaking
178
+ // space as part of the token, so a value JS would trim to the safe one
179
+ // is not the safe one.
180
+ [
181
+ "an attachment Content-Disposition with a non-breaking space",
182
+ { contentDisposition: "attachment\u00a0; filename=x.html" },
183
+ ],
184
+ [
185
+ "nosniff followed by a non-breaking space",
186
+ { contentTypeOptions: "nosniff\u00a0" },
187
+ ],
188
+ [
189
+ "a CSP whose directives are split by non-breaking spaces",
190
+ { contentSecurityPolicy: "default-src\u00a0'none'; sandbox\u00a0" },
191
+ ],
192
+ [
193
+ "a public, cacheable Cache-Control",
194
+ { cacheControl: "public, max-age=3600" },
195
+ ],
196
+ [
197
+ "a Cache-Control without no-store",
198
+ { cacheControl: "private, max-age=60" },
199
+ ],
200
+ ])("refuses 502 with no bytes on %s", async (_case, headers) => {
201
+ const result = fileResult(headers);
202
+ const cancel = jest.spyOn(result.body.stream as ReadableStream, "cancel");
203
+ mockArtifactFile.mockResolvedValue(result);
204
+ const res = await get();
205
+ expect(cancel).toHaveBeenCalled();
206
+ await expectBodylessRefusal(res, 502);
207
+ });
208
+
209
+ it("serves the hub's own attachment policy, compared case-insensitively", async () => {
210
+ mockArtifactFile.mockResolvedValue(
211
+ fileResult({
212
+ contentDisposition: 'Attachment; filename="brief-v2.pdf"',
213
+ contentTypeOptions: "NoSniff",
214
+ contentSecurityPolicy:
215
+ "default-src 'none'; sandbox; frame-ancestors 'none'; form-action 'none'; base-uri 'none'",
216
+ cacheControl: "no-store, Private",
217
+ }),
218
+ );
219
+ const res = await get();
220
+ expect(res.status).toBe(200);
221
+ await expect(res.text()).resolves.toBe("%PDF-1.7 bytes");
222
+ });
223
+
143
224
  it.each([
144
225
  ["a version of 0", PIECE, "0"],
145
226
  ["a version that is not a number", PIECE, "abc"],
@@ -0,0 +1,197 @@
1
+ /**
2
+ * Tests for /[locale]/account/connect — the hub's consent page (#9966 D2;
3
+ * AC-4).
4
+ *
5
+ * Covers:
6
+ * 1. A ref that opens and a client the registry answers for renders the
7
+ * spoke's registry name, its origin and the signed-in email, and one
8
+ * form posting `ref` alone to `/api/auth/hub/authorize`.
9
+ * 2. A forged ref, a missing ref, a client the registry does not answer for
10
+ * and a ref whose `redirect_uri` is on another origin each render
11
+ * `hubConnect.unavailable` and no form.
12
+ * 3. `generateMetadata` answers `referrer: "same-origin"` and `noindex`.
13
+ * 4. A spoke build is a 404.
14
+ */
15
+ import { render, screen } from "@testing-library/react";
16
+
17
+ jest.mock("next-intl/server", () => {
18
+ const msgs: unknown = require("../../../../../../messages/en.json");
19
+ const dig = (obj: unknown, path: string): unknown =>
20
+ path.split(".").reduce<unknown>(
21
+ (acc, k) => (acc !== null && typeof acc === "object" ? (acc as Record<string, unknown>)[k] : undefined),
22
+ obj,
23
+ );
24
+ return {
25
+ __esModule: true,
26
+ getTranslations: async (arg: string | { namespace?: string; locale?: string }) => {
27
+ const ns = typeof arg === "string" ? arg : (arg.namespace ?? "");
28
+ const messages = dig(msgs, ns);
29
+ return (key: string, values?: Record<string, unknown>): string => {
30
+ const value = dig(messages, key);
31
+ if (typeof value !== "string") return `${ns}.${key}`;
32
+ return value.replace(/\{(\w+)\}/g, (m, name: string) =>
33
+ values && name in values ? String(values[name]) : m,
34
+ );
35
+ };
36
+ },
37
+ };
38
+ });
39
+
40
+ jest.mock("next/headers", () => ({
41
+ __esModule: true,
42
+ headers: jest.fn().mockResolvedValue(new Headers()),
43
+ cookies: jest.fn().mockResolvedValue({ toString: () => "session=abc" }),
44
+ }));
45
+
46
+ const mockSecret = "k".repeat(43);
47
+ const mockGetSession = jest.fn();
48
+ // eslint-disable-next-line no-restricted-syntax -- #507: mock-internal rewrite pending; auth.ts's lazy Better-Auth singleton has no HTTP/DB boundary a unit test of the session fields can mock without standing up Prisma.
49
+ jest.mock("../../../../../../auth", () => ({
50
+ __esModule: true,
51
+ auth: { api: { getSession: (...args: unknown[]) => mockGetSession(...args) } },
52
+ resolveSecret: () => mockSecret,
53
+ }));
54
+
55
+ const mockClientInfo = jest.fn();
56
+ jest.mock("@working-theory/api-client", () => ({
57
+ __esModule: true,
58
+ hubSignIn: { clientInfo: (...args: unknown[]) => mockClientInfo(...args) },
59
+ subscription: { me: jest.fn(), portalSession: jest.fn() },
60
+ visitorForwardHeaders: () => undefined,
61
+ }));
62
+
63
+ class NotFound extends Error {}
64
+ jest.mock("next/navigation", () => ({
65
+ __esModule: true,
66
+ notFound: () => {
67
+ throw new NotFound();
68
+ },
69
+ redirect: jest.fn(),
70
+ }));
71
+
72
+ const mockTopology: { value: "hub" | "spoke" } = { value: "hub" };
73
+ // 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.
74
+ jest.mock("../../../../../../lib/topology.generated", () => {
75
+ const real = jest.requireActual("../../../../../../lib/topology.generated") as Record<string, unknown>;
76
+ return {
77
+ ...real,
78
+ __esModule: true,
79
+ get TOPOLOGY() {
80
+ return mockTopology.value;
81
+ },
82
+ };
83
+ });
84
+
85
+ import { mintContinuation } from "../../../../../api/auth/hub/continuation";
86
+ import Page, { generateMetadata } from "../page";
87
+
88
+ const SPOKE_ORIGIN = "https://spoke.example.com";
89
+ const SPOKE_CALLBACK = `${SPOKE_ORIGIN}/api/hub-session/callback`;
90
+
91
+ function query(overrides: Record<string, string> = {}): string {
92
+ return new URLSearchParams({
93
+ response_type: "code",
94
+ client_id: "spoke_abc",
95
+ redirect_uri: SPOKE_CALLBACK,
96
+ state: "s".repeat(43),
97
+ code_challenge: "c".repeat(43),
98
+ code_challenge_method: "S256",
99
+ ...overrides,
100
+ }).toString();
101
+ }
102
+
103
+ const SESSION = {
104
+ user: { id: "user_1", name: "Ada", email: "ada@example.com", emailVerified: true },
105
+ };
106
+
107
+ beforeEach(() => {
108
+ mockTopology.value = "hub";
109
+ mockGetSession.mockReset().mockResolvedValue(SESSION);
110
+ mockClientInfo.mockReset().mockResolvedValue({
111
+ ok: true,
112
+ body: { clientId: "spoke_abc", name: "Acme Studio", origin: SPOKE_ORIGIN },
113
+ });
114
+ });
115
+
116
+ async function renderPage(ref?: string | string[]): Promise<void> {
117
+ const element = await Page({
118
+ params: Promise.resolve({ locale: "en" }),
119
+ searchParams: Promise.resolve(ref === undefined ? {} : { ref }),
120
+ });
121
+ render(element);
122
+ }
123
+
124
+ function expectUnavailable(): void {
125
+ expect(screen.getByTestId("hub-connect-unavailable")).toBeTruthy();
126
+ expect(screen.getByText("This sign-in can't continue")).toBeTruthy();
127
+ expect(screen.queryByTestId("hub-connect-form")).toBeNull();
128
+ expect(document.querySelector("form")).toBeNull();
129
+ }
130
+
131
+ describe("the consent page (#9966 D2, AC-4)", () => {
132
+ it("names the spoke from the registry and posts the ref alone to the authorize route", async () => {
133
+ const ref = mintContinuation(query(), mockSecret);
134
+
135
+ await renderPage(ref);
136
+
137
+ expect(screen.getByRole("heading", { level: 1 }).textContent).toBe("Sign in to Acme Studio?");
138
+ expect(screen.getByText(/https:\/\/spoke\.example\.com will see your name/).textContent).toContain(
139
+ "ada@example.com",
140
+ );
141
+ expect(mockClientInfo).toHaveBeenCalledWith("spoke_abc");
142
+ const form = screen.getByTestId("hub-connect-form") as HTMLFormElement;
143
+ expect(form.getAttribute("method")).toBe("post");
144
+ expect(form.getAttribute("action")).toBe("/api/auth/hub/authorize");
145
+ const fields = Array.from(form.querySelectorAll("input"));
146
+ expect(fields.map((f) => [f.type, f.name, f.value])).toEqual([["hidden", "ref", ref]]);
147
+ expect(screen.getByTestId("hub-connect-continue").textContent).toBe("Continue");
148
+ expect(screen.getByTestId("hub-connect-cancel").getAttribute("href")).toBe("/account");
149
+ });
150
+
151
+ it.each([
152
+ ["a forged ref", () => "x".repeat(80)],
153
+ ["no ref", () => undefined],
154
+ ["two refs", () => [mintContinuation(query(), mockSecret), mintContinuation(query(), mockSecret)]],
155
+ ["a ref sealing a query that fails the schema", () => mintContinuation(query({ code_challenge_method: "plain" }), mockSecret)],
156
+ ])("%s renders unavailable and no form", async (_name, ref) => {
157
+ await renderPage(ref());
158
+ expectUnavailable();
159
+ });
160
+
161
+ it("a client the registry does not answer for renders unavailable and no form", async () => {
162
+ mockClientInfo.mockResolvedValue({ ok: false, status: 404, body: null });
163
+ await renderPage(mintContinuation(query(), mockSecret));
164
+ expectUnavailable();
165
+ });
166
+
167
+ it("a registry read that throws renders unavailable and no form", async () => {
168
+ mockClientInfo.mockRejectedValue(new Error("network"));
169
+ await renderPage(mintContinuation(query(), mockSecret));
170
+ expectUnavailable();
171
+ });
172
+
173
+ it("a ref whose redirect_uri is on another origin than the registry names renders unavailable (SEC-9966-10)", async () => {
174
+ await renderPage(
175
+ mintContinuation(query({ redirect_uri: "https://other.example.com/api/hub-session/callback" }), mockSecret),
176
+ );
177
+ expectUnavailable();
178
+ });
179
+
180
+ it("generateMetadata keeps the ref out of a cross-site Referer and the page out of the index", async () => {
181
+ const metadata = await generateMetadata({
182
+ params: Promise.resolve({ locale: "en" }),
183
+ searchParams: Promise.resolve({}),
184
+ });
185
+ expect(metadata).toEqual({
186
+ title: "Sign in to a site",
187
+ robots: { index: false, follow: false },
188
+ referrer: "same-origin",
189
+ });
190
+ });
191
+
192
+ it("a spoke build is a 404", async () => {
193
+ mockTopology.value = "spoke";
194
+ await expect(renderPage(mintContinuation(query(), mockSecret))).rejects.toBeInstanceOf(NotFound);
195
+ expect(mockClientInfo).not.toHaveBeenCalled();
196
+ });
197
+ });