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
@@ -12,6 +12,7 @@
12
12
  * ?response_type=code&client_id=…&redirect_uri=…&state=…
13
13
  * &code_challenge=…&code_challenge_method=S256
14
14
  * [&login_hint=<email>] (optional, #8757 D9 — see below)
15
+ * [&prompt=create] (optional, #9912 D3 — see below)
15
16
  *
16
17
  * - a malformed query is a 400 and NEVER a redirect;
17
18
  * - no Better Auth session: the six validated keys are sealed into a short
@@ -27,6 +28,11 @@
27
28
  * the REGISTERED redirect URI the API echoes back, with `code` and the
28
29
  * same `state` — never to the query's own `redirect_uri` until the API has
29
30
  * confirmed it is the client's;
31
+ * - a person who has not said yes to this spoke (403 `consent_required`,
32
+ * #9966 D2) goes to the hub's own consent page, `/account/connect?ref=…`,
33
+ * with the six keys sealed as the no-session branch seals them. The GET
34
+ * never sends `consent`: a spoke can send a visitor here on page load, so
35
+ * a GET must never be the yes;
30
36
  * - a person whose email is not verified (403 `email_unverified`) goes to the
31
37
  * hub's own `/account/settings`, whose For-you item says how to verify —
32
38
  * never to the spoke;
@@ -41,6 +47,24 @@
41
47
  * route rebuilds the query from the ref, so the hint is gone after sign-in —
42
48
  * and with a session it is ignored.
43
49
  *
50
+ * `prompt=create` (#9912 D3) is the spoke's "Sign up": read beside
51
+ * `login_hint` by the same once-only rule (repeated → 400, before the session
52
+ * read). On the no-session branch exactly `create` sends the visitor to
53
+ * `/sign-up?next=<the same continue path>` instead of `/sign-in`, with no
54
+ * `email`; any other value does nothing. It never enters the ref — so a
55
+ * password sign-up, which ends with no session, comes back through the
56
+ * continuation to `/sign-in` — nor a code or a cookie, and with a session it
57
+ * is ignored: the person has an account, and the spoke signs them in.
58
+ *
59
+ * POST /api/auth/hub/authorize (form field `ref`, #9966 D2)
60
+ *
61
+ * - the consent page's Continue. Accepted only with `Sec-Fetch-Site:
62
+ * same-origin` and an `Origin` equal to the pinned origin (a missing or
63
+ * `null` Origin is refused), before any session read; the ref is opened
64
+ * and its query re-validated, and the session branch runs as GET's does,
65
+ * with `consent: true` — and `x-internal-auth` where the API requires it —
66
+ * on the code request. This is the one call site that sends consent.
67
+ *
44
68
  * `BETTER_AUTH_URL` pins the `Origin` sent with the cookie-authenticated write
45
69
  * (Node's `fetch` sets none). Unset, the step answers 503: it needs a pinned
46
70
  * origin, exactly as the emailed link does.
@@ -54,13 +78,17 @@ import { hubSignIn } from "@working-theory/api-client";
54
78
  import {
55
79
  accountEmailHintSchema,
56
80
  hubAuthorizeQuerySchema,
81
+ type HubAuthorizeQuery,
57
82
  } from "@working-theory/validation";
58
83
 
59
84
  import { auth, resolveSecret } from "../../../../../auth";
85
+ import { browserApiOrigin } from "../../../../../lib/api-origin.generated";
86
+ import { TOPOLOGY } from "../../../../../lib/topology.generated";
60
87
  import {
61
88
  CONTINUATION_REF_MAX,
62
89
  CONTINUE_PATH,
63
90
  mintContinuation,
91
+ openContinuation,
64
92
  } from "../continuation";
65
93
 
66
94
  /** The six query keys, each read exactly once. */
@@ -76,6 +104,11 @@ const QUERY_KEYS = [
76
104
  /** Where an unverified person is sent to verify: the hub's own account page. */
77
105
  const VERIFY_EMAIL_PATH = "/account/settings";
78
106
 
107
+ /** The hub's consent page, where a person says yes to one spoke (#9966 D2). */
108
+ const CONSENT_PATH = "/account/connect";
109
+
110
+ const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1"]);
111
+
79
112
  function json(status: number, error: string): Response {
80
113
  return new Response(JSON.stringify({ error }), {
81
114
  status,
@@ -110,38 +143,50 @@ function pinnedOrigin(): string | null {
110
143
  }
111
144
  }
112
145
 
113
- // platform-decisions-waiver: api-versioning — sign-in redirect endpoint, not a versioned API contract (spec 7894 §7)
114
- export async function GET(request: Request): Promise<Response> {
115
- const url = new URL(request.url);
116
-
117
- // Each declared key exactly once: a repeated key is ambiguous, so 400.
118
- const raw: Record<string, string | undefined> = {};
119
- for (const key of QUERY_KEYS) {
120
- const values = url.searchParams.getAll(key);
121
- if (values.length > 1) return json(400, "invalid_request");
122
- raw[key] = values[0];
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");
127
- const parsed = hubAuthorizeQuerySchema.safeParse(raw);
128
- if (!parsed.success) return json(400, "invalid_request");
129
- const query = parsed.data;
130
- const hint = accountEmailHintSchema.safeParse(hints[0]);
146
+ /** The six validated keys as a sealed continuation ref, or `null` past the cap. */
147
+ function sealQuery(query: HubAuthorizeQuery): string | null {
148
+ // Only the six validated keys, in QUERY_KEYS order: a ref carries nothing
149
+ // that did not pass the schema, and extra or reordered keys are dropped.
150
+ const canonical = new URLSearchParams();
151
+ for (const key of QUERY_KEYS) canonical.set(key, query[key]);
152
+ const ref = mintContinuation(canonical.toString(), resolveSecret());
153
+ return ref.length > CONTINUATION_REF_MAX ? null : ref;
154
+ }
131
155
 
132
- const session = await auth.api.getSession({ headers: request.headers });
133
- if (!session) {
134
- // Only the six validated keys, in QUERY_KEYS order: a ref carries nothing
135
- // that did not pass the schema, and extra or reordered keys are dropped.
136
- const canonical = new URLSearchParams();
137
- for (const key of QUERY_KEYS) canonical.set(key, query[key]);
138
- const ref = mintContinuation(canonical.toString(), resolveSecret());
139
- if (ref.length > CONTINUATION_REF_MAX) return json(400, "invalid_request");
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()}`);
156
+ /**
157
+ * `x-internal-auth` for the consent-bearing code request, under the Offerings
158
+ * buy route's `startHeaders` rule: only from a site that is not a spoke, and
159
+ * only to an `https:` or loopback API, so the secret never crosses a plain
160
+ * network hop. The API honours `consent: true` only beside it where it is set.
161
+ */
162
+ function internalAuthHeader(): Record<string, string> {
163
+ const secret = process.env.OSS_API_INTERNAL_SECRET;
164
+ const spoke = TOPOLOGY === "spoke" || !!process.env.AILK_SPOKE_KEY?.trim();
165
+ if (!secret || spoke) return {};
166
+ try {
167
+ const api = new URL(
168
+ process.env.AILK_API_URL ||
169
+ browserApiOrigin() ||
170
+ process.env.NEXT_PUBLIC_API_URL ||
171
+ "http://localhost:3001",
172
+ );
173
+ const safe = api.protocol === "https:" || LOOPBACK_HOSTS.has(api.hostname);
174
+ return safe ? { "x-internal-auth": secret } : {};
175
+ } catch {
176
+ return {};
143
177
  }
178
+ }
144
179
 
180
+ /**
181
+ * The session branch, shared by GET and the consent POST: ask the API for a
182
+ * code and answer with where the visitor goes next. Only the POST passes
183
+ * `consent` (#9966 D2).
184
+ */
185
+ async function codeRedirect(
186
+ request: Request,
187
+ query: HubAuthorizeQuery,
188
+ consent: boolean,
189
+ ): Promise<Response> {
145
190
  const origin = pinnedOrigin();
146
191
  if (origin === null) return json(503, "temporarily_unavailable");
147
192
 
@@ -152,11 +197,13 @@ export async function GET(request: Request): Promise<Response> {
152
197
  clientId: query.client_id,
153
198
  redirectUri: query.redirect_uri,
154
199
  codeChallenge: query.code_challenge,
200
+ ...(consent ? { consent: true as const } : {}),
155
201
  },
156
202
  {
157
203
  headers: {
158
204
  cookie: request.headers.get("cookie") ?? "",
159
205
  origin,
206
+ ...(consent ? internalAuthHeader() : {}),
160
207
  },
161
208
  },
162
209
  );
@@ -184,5 +231,90 @@ export async function GET(request: Request): Promise<Response> {
184
231
  if (result.status === 403 && code === "email_unverified") {
185
232
  return see(VERIFY_EMAIL_PATH);
186
233
  }
234
+ // Not yet said yes to this spoke: ask, on the hub's own page. The POST that
235
+ // page sends already carries consent, so it never comes back here.
236
+ if (!consent && result.status === 403 && code === "consent_required") {
237
+ const ref = sealQuery(query);
238
+ if (ref === null) return json(400, "invalid_request");
239
+ return see(`${CONSENT_PATH}?${new URLSearchParams({ ref }).toString()}`);
240
+ }
187
241
  return json(400, "invalid_request");
188
242
  }
243
+
244
+ // platform-decisions-waiver: api-versioning — sign-in redirect endpoint, not a versioned API contract (spec 7894 §7)
245
+ export async function GET(request: Request): Promise<Response> {
246
+ const url = new URL(request.url);
247
+
248
+ // Each declared key exactly once: a repeated key is ambiguous, so 400.
249
+ const raw: Record<string, string | undefined> = {};
250
+ for (const key of QUERY_KEYS) {
251
+ const values = url.searchParams.getAll(key);
252
+ if (values.length > 1) return json(400, "invalid_request");
253
+ raw[key] = values[0];
254
+ }
255
+ // #8757 D9 — the optional hint: the same once-only rule; refused → dropped.
256
+ const hints = url.searchParams.getAll("login_hint");
257
+ if (hints.length > 1) return json(400, "invalid_request");
258
+ // #9912 D3 — the sign-up hint, by the same rule; only `create` acts.
259
+ const prompts = url.searchParams.getAll("prompt");
260
+ if (prompts.length > 1) return json(400, "invalid_request");
261
+ const parsed = hubAuthorizeQuerySchema.safeParse(raw);
262
+ if (!parsed.success) return json(400, "invalid_request");
263
+ const query = parsed.data;
264
+ const hint = accountEmailHintSchema.safeParse(hints[0]);
265
+
266
+ const session = await auth.api.getSession({ headers: request.headers });
267
+ if (!session) {
268
+ const ref = sealQuery(query);
269
+ if (ref === null) return json(400, "invalid_request");
270
+ const signIn = new URLSearchParams({ next: `${CONTINUE_PATH}/${ref}` });
271
+ if (prompts[0] === "create") return see(`/sign-up?${signIn.toString()}`);
272
+ if (hint.success) signIn.set("email", hint.data);
273
+ return see(`/sign-in?${signIn.toString()}`);
274
+ }
275
+
276
+ return codeRedirect(request, query, false);
277
+ }
278
+
279
+ /**
280
+ * The consent page's Continue (#9966 D2): the one request that says yes to a
281
+ * spoke. Same-origin only, decided before any session read, so neither a
282
+ * cross-site form nor a sandboxed frame (`Origin: null`) can post it.
283
+ */
284
+ export async function POST(request: Request): Promise<Response> {
285
+ const origin = pinnedOrigin();
286
+ if (origin === null) return json(503, "temporarily_unavailable");
287
+ const sent = request.headers.get("origin");
288
+ if (
289
+ request.headers.get("sec-fetch-site") !== "same-origin" ||
290
+ sent === null ||
291
+ sent !== origin
292
+ ) {
293
+ return json(403, "invalid_request");
294
+ }
295
+
296
+ let refs: FormDataEntryValue[];
297
+ try {
298
+ refs = (await request.formData()).getAll("ref");
299
+ } catch {
300
+ return json(400, "invalid_request");
301
+ }
302
+ const [ref] = refs;
303
+ if (refs.length !== 1 || typeof ref !== "string") {
304
+ return json(400, "invalid_request");
305
+ }
306
+ const search = openContinuation(ref, resolveSecret());
307
+ if (search === null) return json(400, "invalid_request");
308
+ const params = new URLSearchParams(search);
309
+ const raw: Record<string, string | undefined> = {};
310
+ for (const key of QUERY_KEYS) raw[key] = params.get(key) ?? undefined;
311
+ const parsed = hubAuthorizeQuerySchema.safeParse(raw);
312
+ if (!parsed.success) return json(400, "invalid_request");
313
+
314
+ const session = await auth.api.getSession({ headers: request.headers });
315
+ if (!session) {
316
+ const signIn = new URLSearchParams({ next: `${CONTINUE_PATH}/${ref}` });
317
+ return see(`/sign-in?${signIn.toString()}`);
318
+ }
319
+ return codeRedirect(request, parsed.data, true);
320
+ }
@@ -16,6 +16,10 @@
16
16
  *
17
17
  * #8757 D9 (AC-6): a valid single `?email=` on the sign-in route becomes the
18
18
  * authorize URL's `login_hint`; the sealed flow cookie never carries it.
19
+ *
20
+ * #9912 D2 (AC-1): exactly one `?prompt=create` rides to the hub as
21
+ * `prompt=create`, so the hub sends a visitor with no session to /sign-up;
22
+ * any other value adds nothing, and the flow cookie's keys are unchanged.
19
23
  */
20
24
 
21
25
  import { createHash } from "node:crypto";
@@ -204,6 +208,53 @@ describe("GET /api/hub-session/sign-in — the hinted email (#8757 D9)", () => {
204
208
  });
205
209
  });
206
210
 
211
+ describe("GET /api/hub-session/sign-in — the sign-up hint (#9912 D2)", () => {
212
+ function startWith(query: string): { authorize: URL; payload: Record<string, unknown> } {
213
+ const res = signIn(new Request(`${SITE}/api/hub-session/sign-in?${query}`));
214
+ expect(res.status).toBe(303);
215
+ const flow = cookieValue(res, "ailk-hub-flow");
216
+ if (flow === undefined) throw new Error("no flow cookie");
217
+ const payload = unseal(flow, SESSION_SECRET, "flow", "ailk-hub-flow");
218
+ if (payload === null || typeof payload !== "object") throw new Error("flow did not open");
219
+ return {
220
+ authorize: new URL(res.headers.get("location") ?? ""),
221
+ payload: payload as Record<string, unknown>,
222
+ };
223
+ }
224
+
225
+ it("?next=/account&prompt=create → the authorize URL carries prompt=create; the flow cookie keeps exactly its five keys", () => {
226
+ const { authorize, payload } = startWith("next=/account&prompt=create");
227
+ expect(authorize.pathname).toBe("/api/auth/hub/authorize");
228
+ expect(authorize.searchParams.getAll("prompt")).toEqual(["create"]);
229
+ // The six keys are untouched beside it.
230
+ expect(authorize.searchParams.get("response_type")).toBe("code");
231
+ expect(authorize.searchParams.get("code_challenge_method")).toBe("S256");
232
+ expect(Object.keys(payload).sort()).toEqual(
233
+ ["expiresAt", "next", "state", "v", "verifier"],
234
+ );
235
+ expect(payload.next).toBe("/account");
236
+ expect(JSON.stringify(payload)).not.toContain("create");
237
+ });
238
+
239
+ it("forwards the hint beside a valid login_hint", () => {
240
+ const { authorize } = startWith("next=/account&prompt=create&email=a%40b.example");
241
+ expect(authorize.searchParams.get("prompt")).toBe("create");
242
+ expect(authorize.searchParams.get("login_hint")).toBe("a@b.example");
243
+ });
244
+
245
+ it.each([
246
+ ["another OIDC value", "next=/account&prompt=login"],
247
+ ["an empty value", "next=/account&prompt="],
248
+ ["a repeated value", "next=/account&prompt=create&prompt=create"],
249
+ ["a cased value", "next=/account&prompt=Create"],
250
+ ["no value", "next=/account"],
251
+ ])("%s → no prompt", (_label, query) => {
252
+ const { authorize, payload } = startWith(query);
253
+ expect(authorize.searchParams.has("prompt")).toBe(false);
254
+ expect(payload).not.toHaveProperty("prompt");
255
+ });
256
+ });
257
+
207
258
  describe("GET /api/hub-session/callback", () => {
208
259
  function exchangeOk(): void {
209
260
  mockExchange.mockResolvedValue({
@@ -15,6 +15,12 @@
15
15
  * the address typed. A refused or repeated value is dropped. The hint goes on
16
16
  * the authorize URL only — never into the sealed flow cookie.
17
17
  *
18
+ * Sign-up hint (#9912 D2): exactly one `?prompt=create` (the gate's "Sign up")
19
+ * rides to the hub as `prompt=create`, so a visitor with no hub session lands
20
+ * on its sign-up screen instead of sign-in; the round trip is otherwise the
21
+ * same. Any other value, an empty or a repeated one adds nothing, and the hint
22
+ * never enters the flow cookie.
23
+ *
18
24
  * Nothing here logs the state, the verifier or the challenge.
19
25
  */
20
26
  import { createHash, randomBytes } from "node:crypto";
@@ -55,6 +61,12 @@ function emailHint(params: URLSearchParams): string | undefined {
55
61
  return parsed.success ? parsed.data : undefined;
56
62
  }
57
63
 
64
+ /** #9912 D2 — `"create"` when the one `prompt` is exactly that, else none. */
65
+ function signUpHint(params: URLSearchParams): "create" | undefined {
66
+ const values = params.getAll("prompt");
67
+ return values.length === 1 && values[0] === "create" ? "create" : undefined;
68
+ }
69
+
58
70
  // platform-decisions-waiver: api-versioning — sign-in redirect endpoint, not a versioned API contract (spec 7894 §7)
59
71
  export function GET(request: Request): Response {
60
72
  if (SIGN_IN_PROVIDER !== "hub") return new Response(null, { status: 404 });
@@ -84,6 +96,7 @@ export function GET(request: Request): Response {
84
96
  code_challenge: challenge,
85
97
  code_challenge_method: "S256",
86
98
  });
99
+ if (signUpHint(params) !== undefined) query.set("prompt", "create");
87
100
  const loginHint = emailHint(params);
88
101
  if (loginHint !== undefined) query.set("login_hint", loginHint);
89
102
  authorize.search = query.toString();
@@ -11,8 +11,13 @@
11
11
  * #7874 D5). The rest of the ladder is pinned in `../../__tests__/route.test.ts`.
12
12
  *
13
13
  * Each case loads the route in its own module registry, so no case reads
14
- * another's shared shown-set answer; a spoke case swaps in the spoke render
15
- * of both generated seams, as `lib/__tests__/relay.test.ts` does.
14
+ * another's shared shown-set answer or spends another's start budget; a spoke
15
+ * case swaps in the spoke render of both generated seams, as
16
+ * `lib/__tests__/relay.test.ts` does.
17
+ *
18
+ * The per-visitor start budget (#9965: #9059, #9063) is pinned at the end:
19
+ * keyed only on the address Vercel's edge writes, never skipped for a visitor
20
+ * with none, and spent only by a start the API made.
16
21
  */
17
22
  import { workspaceShownSetSchema } from "@working-theory/validation";
18
23
 
@@ -71,6 +76,7 @@ const ENV_NAMES = [
71
76
  "NODE_ENV",
72
77
  "OSS_API_INTERNAL_SECRET",
73
78
  "AILK_SPOKE_KEY",
79
+ "VERCEL",
74
80
  ] as const;
75
81
  const savedEnv = Object.fromEntries(ENV_NAMES.map((name) => [name, env[name]]));
76
82
 
@@ -83,16 +89,21 @@ function json(status: number, body: unknown): Promise<Response> {
83
89
  );
84
90
  }
85
91
 
86
- /** The shown-set read answers `shownStatus`; the session POST starts one. */
92
+ function started(): Promise<Response> {
93
+ return json(201, {
94
+ sessionId: "cs_test_a1",
95
+ url: "https://checkout.stripe.com/c/pay/cs_test_a1",
96
+ });
97
+ }
98
+
99
+ /** The shown-set read answers `shownStatus`; the session POST `sessionAnswer`. */
87
100
  let shownStatus: "unconnected" | "shown";
101
+ let sessionAnswer: () => Promise<Response>;
88
102
  const realFetch = global.fetch;
89
103
  const fetchMock = jest.fn(
90
104
  (input: string | URL | Request, _init?: RequestInit) => {
91
105
  if (new URL(String(input)).pathname === "/v1/checkout/sessions") {
92
- return json(201, {
93
- sessionId: "cs_test_a1",
94
- url: "https://checkout.stripe.com/c/pay/cs_test_a1",
95
- });
106
+ return sessionAnswer();
96
107
  }
97
108
  return json(200, {
98
109
  ok: true,
@@ -119,6 +130,7 @@ beforeEach(() => {
119
130
  env.NODE_ENV = "production";
120
131
  env.NEXT_PUBLIC_BASE_URL = "https://shop.example.com";
121
132
  shownStatus = "unconnected";
133
+ sessionAnswer = started;
122
134
  fetchMock.mockClear();
123
135
  warnSpy = jest.spyOn(console, "warn").mockImplementation(() => {});
124
136
  });
@@ -163,6 +175,53 @@ async function buy(topology: "hub" | "spoke"): Promise<Response> {
163
175
  return res;
164
176
  }
165
177
 
178
+ type Get = (typeof import("../route"))["GET"];
179
+
180
+ /** The hub route in one module registry, so its calls share one budget. */
181
+ async function loadHubRoute(): Promise<Get> {
182
+ let get: Get | undefined;
183
+ await jest.isolateModulesAsync(async () => {
184
+ jest.dontMock(ORIGIN_SEAM);
185
+ jest.dontMock(TOPOLOGY_SEAM);
186
+ ({ GET: get } = await import("../route"));
187
+ });
188
+ if (get === undefined) throw new Error("the route did not load");
189
+ return get;
190
+ }
191
+
192
+ /** A buyer's click on `growth_monthly` carrying `headers`. */
193
+ function click(
194
+ get: Get,
195
+ headers: Record<string, string> = {},
196
+ ): Promise<Response> {
197
+ const request = new Request(
198
+ "https://shop.example.com/api/offerings/checkout/growth_monthly",
199
+ {
200
+ headers: {
201
+ "sec-fetch-mode": "navigate",
202
+ "sec-fetch-dest": "document",
203
+ "sec-fetch-site": "same-origin",
204
+ ...headers,
205
+ },
206
+ },
207
+ );
208
+ return get(request, {
209
+ params: Promise.resolve({ lookupKey: "growth_monthly" }),
210
+ });
211
+ }
212
+
213
+ function postCount(): number {
214
+ return fetchMock.mock.calls.filter(
215
+ ([input]) => new URL(String(input)).pathname === "/v1/checkout/sessions",
216
+ ).length;
217
+ }
218
+
219
+ function refusedFor(reason: string): number {
220
+ return warnSpy.mock.calls.filter(
221
+ ([line]) => line === `[offerings-checkout] refused reason=${reason}`,
222
+ ).length;
223
+ }
224
+
166
225
  function postHeaders(): Record<string, string> {
167
226
  const post = fetchMock.mock.calls.find(
168
227
  ([input]) => new URL(String(input)).pathname === "/v1/checkout/sessions",
@@ -220,3 +279,170 @@ describe("the route's API origin comes from the topology seam", () => {
220
279
  expect(postHeaders()["x-internal-auth"]).toBeUndefined();
221
280
  });
222
281
  });
282
+
283
+ describe("the per-visitor start budget (#9059, #9063)", () => {
284
+ beforeEach(() => {
285
+ env.AILK_API_URL = HUB_API;
286
+ shownStatus = "shown";
287
+ });
288
+
289
+ it("on Vercel, keys on x-vercel-forwarded-for, never a client-chosen x-forwarded-for", async () => {
290
+ env.VERCEL = "1";
291
+ const get = await loadHubRoute();
292
+
293
+ for (let i = 0; i < 6; i += 1) {
294
+ await click(get, {
295
+ "x-vercel-forwarded-for": "198.51.100.1",
296
+ "x-forwarded-for": `203.0.113.${i}`,
297
+ "x-real-ip": `192.0.2.${i}`,
298
+ });
299
+ }
300
+
301
+ expect(postCount()).toBe(5);
302
+ expect(refusedFor("rate_limited")).toBe(1);
303
+
304
+ await click(get, { "x-vercel-forwarded-for": "198.51.100.2" });
305
+ expect(postCount()).toBe(6);
306
+ });
307
+
308
+ it("off Vercel, reads no address header, so every visitor shares the one wide bucket", async () => {
309
+ const get = await loadHubRoute();
310
+
311
+ for (let i = 0; i < 51; i += 1) {
312
+ await click(get, {
313
+ "x-vercel-forwarded-for": `198.51.100.${i}`,
314
+ "x-forwarded-for": `203.0.113.${i}`,
315
+ });
316
+ }
317
+
318
+ expect(postCount()).toBe(50);
319
+ expect(refusedFor("rate_limited")).toBe(1);
320
+ });
321
+
322
+ it("charges a visitor with no readable address to the shared bucket, never skips one", async () => {
323
+ env.VERCEL = "1";
324
+ const get = await loadHubRoute();
325
+
326
+ for (let i = 0; i < 51; i += 1) await click(get);
327
+
328
+ expect(postCount()).toBe(50);
329
+ expect(refusedFor("rate_limited")).toBe(1);
330
+
331
+ // An addressed visitor keeps a budget of its own.
332
+ await click(get, { "x-vercel-forwarded-for": "198.51.100.1" });
333
+ expect(postCount()).toBe(51);
334
+ });
335
+
336
+ it("spends a start only when the API starts a Checkout", async () => {
337
+ env.VERCEL = "1";
338
+ const get = await loadHubRoute();
339
+ // The same address in both headers, so the unfixed keying counts it too.
340
+ const visitor = {
341
+ "x-vercel-forwarded-for": "198.51.100.1",
342
+ "x-forwarded-for": "198.51.100.1",
343
+ };
344
+
345
+ const failures = [
346
+ () => json(502, { error: "upstream" }),
347
+ () => json(503, { error: "unavailable" }),
348
+ () => json(500, { error: "internal" }),
349
+ () => Promise.reject(new TypeError("fetch failed")),
350
+ () => Promise.reject(new TypeError("fetch failed")),
351
+ ];
352
+ for (const failure of failures) {
353
+ sessionAnswer = failure;
354
+ await click(get, visitor);
355
+ }
356
+ expect(postCount()).toBe(5);
357
+ expect(refusedFor("rate_limited")).toBe(0);
358
+
359
+ sessionAnswer = started;
360
+ for (let i = 0; i < 5; i += 1) {
361
+ const res = await click(get, visitor);
362
+ expect(res.headers.get("location")).toBe(
363
+ "https://checkout.stripe.com/c/pay/cs_test_a1",
364
+ );
365
+ }
366
+ expect(postCount()).toBe(10);
367
+
368
+ const sixth = await click(get, visitor);
369
+ expect(sixth.headers.get("location")).toBe("/pricing");
370
+ expect(postCount()).toBe(10);
371
+ expect(refusedFor("rate_limited")).toBe(1);
372
+ });
373
+
374
+ it("holds a start in flight against the budget, so concurrent clicks cannot overrun it", async () => {
375
+ env.VERCEL = "1";
376
+ const get = await loadHubRoute();
377
+ // The same address in both headers, so the unfixed keying counts it too.
378
+ const visitor = {
379
+ "x-vercel-forwarded-for": "198.51.100.1",
380
+ "x-forwarded-for": "198.51.100.1",
381
+ };
382
+ const answers: Array<(res: Response) => void> = [];
383
+ sessionAnswer = () =>
384
+ new Promise<Response>((resolve) => {
385
+ answers.push(resolve);
386
+ });
387
+
388
+ const clicks = Array.from({ length: 6 }, () => click(get, visitor));
389
+ // The sixth is refused while the other five still wait on the API.
390
+ await new Promise((resolve) => setTimeout(resolve, 0));
391
+ expect(answers).toHaveLength(5);
392
+ expect(refusedFor("rate_limited")).toBe(1);
393
+
394
+ for (const answer of answers) {
395
+ answer(
396
+ new Response(JSON.stringify({ error: "upstream" }), {
397
+ status: 502,
398
+ headers: { "content-type": "application/json" },
399
+ }),
400
+ );
401
+ }
402
+ await Promise.all(clicks);
403
+
404
+ // Five failed starts spent nothing, so the visitor can start again.
405
+ sessionAnswer = started;
406
+ await click(get, visitor);
407
+ expect(postCount()).toBe(6);
408
+ });
409
+
410
+ it("a start in flight across the window's end settles on the window it holds", async () => {
411
+ env.VERCEL = "1";
412
+ const get = await loadHubRoute();
413
+ const visitor = { "x-vercel-forwarded-for": "198.51.100.1" };
414
+ const answers: Array<(res: Response) => void> = [];
415
+ sessionAnswer = () =>
416
+ new Promise<Response>((resolve) => {
417
+ answers.push(resolve);
418
+ });
419
+ const now = jest.spyOn(Date, "now");
420
+ try {
421
+ now.mockReturnValue(1_000_000);
422
+ const first = click(get, visitor);
423
+ await new Promise((resolve) => setTimeout(resolve, 0));
424
+ now.mockReturnValue(1_000_000 + 10 * 60_000);
425
+ const second = click(get, visitor);
426
+ await new Promise((resolve) => setTimeout(resolve, 0));
427
+ expect(answers).toHaveLength(2);
428
+
429
+ for (const answer of answers) {
430
+ answer(
431
+ new Response(JSON.stringify({ error: "upstream" }), {
432
+ status: 502,
433
+ headers: { "content-type": "application/json" },
434
+ }),
435
+ );
436
+ }
437
+ await Promise.all([first, second]);
438
+
439
+ // Both failed, so nothing is held: the window admits its full five.
440
+ sessionAnswer = started;
441
+ for (let i = 0; i < 6; i += 1) await click(get, visitor);
442
+ expect(postCount()).toBe(7);
443
+ expect(refusedFor("rate_limited")).toBe(1);
444
+ } finally {
445
+ now.mockRestore();
446
+ }
447
+ });
448
+ });