create-ailk 0.25.1 → 0.27.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 (75) hide show
  1. package/README.md +14 -5
  2. package/dist/lib/fetch-template-snapshot.d.ts +2 -1
  3. package/dist/lib/fetch-template-snapshot.js +6 -5
  4. package/dist/lib/module-license-gate.js +2 -3
  5. package/dist/lib/module-tier.d.ts +2 -8
  6. package/dist/lib/module-tier.js +13 -12
  7. package/dist/module-architecture.js +14 -2
  8. package/dist/starter-grid.d.ts +6 -6
  9. package/dist/starter-grid.js +12 -5
  10. package/dist/surfaces.js +36 -2
  11. package/dist/template-record.d.ts +11 -3
  12. package/dist/template-record.js +20 -7
  13. package/dist/topology.js +23 -4
  14. package/dist/update.d.ts +1 -1
  15. package/dist/update.js +240 -20
  16. package/package.json +3 -3
  17. package/release-scope.json +1 -1
  18. package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +8 -2
  19. package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +2 -2
  20. package/site-templates/marketing-site/ailk-template.json +7 -1
  21. package/site-templates/marketing-site/provenance.json +7 -1
  22. package/site-templates/saas-product/ailk-template.json +2 -1
  23. package/site-templates/saas-product/provenance.json +1 -0
  24. package/templates/.env.example +22 -0
  25. package/templates/CLAUDE.md +20 -20
  26. package/templates/README.md +19 -19
  27. package/templates/apps/api/.env.example +28 -0
  28. package/templates/apps/api/CLAUDE.md +35 -9
  29. package/templates/apps/api/src/lib/__mocks__/prisma-client.js +16 -0
  30. package/templates/apps/api/vercel.json +7 -1
  31. package/templates/apps/mcp/CLAUDE.md +27 -10
  32. package/templates/apps/web/.env.example +13 -0
  33. package/templates/apps/web/CLAUDE.md +34 -6
  34. package/templates/apps/web/app/[locale]/(auth)/__tests__/auth-pages.test.tsx +43 -4
  35. package/templates/apps/web/app/[locale]/(auth)/legal-footnote.tsx +35 -0
  36. package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/next-path.test.ts +24 -1
  37. package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/page.test.tsx +168 -8
  38. package/templates/apps/web/app/[locale]/(auth)/sign-in/next-path.ts +13 -0
  39. package/templates/apps/web/app/[locale]/(auth)/sign-in/page.tsx +22 -39
  40. package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +6 -6
  41. package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-text.ts +20 -35
  42. package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/page.test.tsx +301 -0
  43. package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/sign-up-card.test.tsx +111 -5
  44. package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +96 -18
  45. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +132 -12
  46. package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +53 -1
  47. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/billing.test.tsx +38 -0
  48. package/templates/apps/web/app/[locale]/(authed)/account/billing/page.tsx +4 -2
  49. package/templates/apps/web/app/[locale]/(authed)/sign-in-gate.tsx +47 -24
  50. package/templates/apps/web/app/[locale]/(authed)/signed-out-gate.generated.tsx +23 -4
  51. package/templates/apps/web/app/[locale]/layout.tsx +48 -13
  52. package/templates/apps/web/app/[locale]/meet/confirmed/BookingHandOff.tsx +128 -0
  53. package/templates/apps/web/app/[locale]/meet/confirmed/__tests__/BookingHandOff.test.tsx +126 -0
  54. package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +118 -0
  55. package/templates/apps/web/app/api/auth/hub/authorize/route.ts +14 -0
  56. package/templates/apps/web/app/api/hub-session/__tests__/routes.test.ts +51 -0
  57. package/templates/apps/web/app/api/hub-session/sign-in/route.ts +13 -0
  58. package/templates/apps/web/auth.ts +8 -4
  59. package/templates/apps/web/i18n/request.ts +49 -1
  60. package/templates/apps/web/lib/__tests__/account-config.test.ts +7 -0
  61. package/templates/apps/web/lib/__tests__/auth-email.test.ts +276 -0
  62. package/templates/apps/web/lib/__tests__/relay.test.ts +289 -1
  63. package/templates/apps/web/lib/auth-email.ts +102 -20
  64. package/templates/apps/web/lib/hub-session/__tests__/sign-in-gate.test.tsx +92 -23
  65. package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +97 -70
  66. package/templates/apps/web/lib/prisma-for-auth.ts +5 -2
  67. package/templates/apps/web/lib/relay.ts +116 -1
  68. package/templates/apps/web/lib/sign-up-agreements.ts +302 -28
  69. package/templates/apps/web/lib/site-contributions.ts +18 -0
  70. package/templates/apps/web/messages/en.json +271 -20
  71. package/templates/apps/web/messages/site/en.json +1 -0
  72. package/templates/apps/web/middleware.ts +8 -1
  73. package/templates/apps/web/next.config.mjs +9 -1
  74. package/templates/apps/web/package.json +2 -1
  75. package/versions.json +48 -48
@@ -18,9 +18,25 @@ const MockPrismaClient = jest.fn().mockImplementation((options) => ({
18
18
  // nullable Json column reset. Real distinct sentinel objects, matching the
19
19
  // generated client's own runtime shape closely enough for identity checks
20
20
  // (`toBe(Prisma.JsonNull)`) in tests.
21
+ //
22
+ // #9686 — `Prisma.Sql`: a minimal stand-in for the query class
23
+ // `tenant-db.ts`'s raw reads build (`new Prisma.Sql(strings, values)`), so a
24
+ // suite that stubs `prisma.$queryRaw` can assert what was sent: `text` is the
25
+ // SQL with `?` where each bound value goes, `values` the bound values in order.
26
+ class MockSql {
27
+ constructor(strings, values) {
28
+ this.strings = strings;
29
+ this.values = values;
30
+ }
31
+ get text() {
32
+ return this.strings.join("?");
33
+ }
34
+ }
35
+
21
36
  const Prisma = {
22
37
  JsonNull: Symbol("Prisma.JsonNull"),
23
38
  DbNull: Symbol("Prisma.DbNull"),
39
+ Sql: MockSql,
24
40
  };
25
41
 
26
42
  module.exports = {
@@ -13,5 +13,11 @@
13
13
  ],
14
14
  "env": {
15
15
  "NODE_ENV": "production"
16
- }
16
+ },
17
+ "crons": [
18
+ {
19
+ "path": "/v1/cron/prospect-channel-archive",
20
+ "schedule": "0 7 * * *"
21
+ }
22
+ ]
17
23
  }
@@ -124,7 +124,7 @@ Every tool maps 1:1 to an `apps/api` endpoint. When you change a tool, change th
124
124
 
125
125
  - **21 in the `TOOLS` array / parity test**: the `TOOLS` array in
126
126
  `packages/metadata/src/mcp-tool-catalog.ts` defines 21 anonymous tools; the
127
- parity test counts that full array. It also holds the eighteen signed-in
127
+ parity test counts that full array. It also holds the thirty-one signed-in
128
128
  workspace tools (#3591), which no anonymous view returns — see § The
129
129
  signed-in workspace surface below.
130
130
  - **19 registered on a KEYLESS deployment, 20 on a commerce-claimed one,
@@ -170,29 +170,38 @@ here. Spec: `docs/specs/3591-tenant-scoped-agent-auth-secure-spec.md` D13–D16.
170
170
  - **apps/mcp checks only the key's shape.** The key is attached as the SDK's `req.auth` for that
171
171
  request alone and reaches a tool as `extra.authInfo.token`. apps/api is the one place it is
172
172
  checked, and its tenant guard and the caller's clamped role decide every answer.
173
- - **Eighteen tools, each a `twin`.** The catalog rows carry `surface: "workspace"`, `tier: "pro"`
173
+ - **Thirty-one tools, each a `twin`.** The catalog rows carry `surface: "workspace"`, `tier: "pro"`
174
174
  and `modulesAnyOf` (the modules any one of which registers the tool — its twin route's gate):
175
- `get_agent_context`, the four message tools, the three submission tools, the three
175
+ `get_agent_context`, (#9683) `get_workspace_knowledge` — twin of `GET
176
+ /v1/workspaces/:workspaceId/knowledge`: the business's shown offerings, live magnets, listed
177
+ meeting types, sites served and strategy documents, read with the `people-read` or
178
+ `people-memory-propose` grant — the five message tools (#9686 added `list_inbox_threads`, twin of
179
+ `GET …/messages/threads`: the inbox's threads, 25 to a page with a count per view; Done now
180
+ archives the message's thread and accept no longer marks it done, and an agent reaches any
181
+ inbox tool only with the `inbox-act` grant), the three submission tools, the three
176
182
  listing application tools (#9137: `list_listing_applications`, `get_listing_application`,
177
183
  `decide_listing_application` — a `ProjectListing` row, never a job application, which is a Job
178
184
  application magnet's submission; the pre-rename `list_inbox_applications`,
179
185
  `get_inbox_application` and `decide_inbox_application` stay registered for one release as
180
- deprecated aliases of the same handlers, each description opening `Deprecated: use …`) and (#8514) the two People notes tools, `get_person_notes` and
186
+ deprecated aliases of the same handlers, each description opening `Deprecated: use …`) and (#9714) `list_people` — twin of `GET /v1/workspaces/:workspaceId/people` in both its modes: newest-first pages by `cursor`, or with `changedSince` the clients whose thread gained an inbound item since then, oldest first with `more`, needing the `people-read` grant — and (#8514) the two People notes tools, `get_person_notes` and
181
187
  `set_person_notes` — the team's private note on one client, which an agent reads and writes
182
188
  only with the `member-pieces-manage` grant; `set_person_notes` requires the `ifUpdatedAt` it
183
189
  read and is refused `notes_changed` when someone saved since — and (#8516) the two proposals
184
190
  tools, `list_proposals` and `create_proposal`, twinning `GET`/`POST
185
191
  /v1/workspaces/:workspaceId/people/:personId/proposals` with the same grant: a proposal is
186
192
  private to one client, `payments` says whether approval leads to a Stripe page, and an agent's
187
- price is refused `proposal_needs_person` (a person prices). Each takes its route's params, query and body and returns the route's
193
+ price is refused `proposal_needs_person` (a person prices) — and (#9681) the four person-thread tools, `get_person_thread`, `set_person_thread_state`, `list_external_threads` and `add_external_thread`, twinning `GET`/`PATCH …/people/:personId/thread` and `GET`/`POST …/people/:personId/external-threads`: a client's whole thread page by page with its read/archived/resolved state, and pointers to email and Slack threads (never message text); reading needs the `people-read` grant, writing also `inbox-act`, and the client erasure route has no tool, by decision — and (#9682) the three person-memory tools, `list_person_memories`, `get_person_memory` and `propose_person_memory`, twinning `GET …/people/:personId/memories`, `GET …/memories/:path` and `POST …/memories`: an agent reads only live, unexpired memories (confirmed by a person, read within 180 days) and its proposal is never live — a new path is a draft, a confirmed one gets a pending version — needing the `people-read` or `people-memory-propose` grant to read and `people-memory-propose` to propose; the six person-only memory routes (edit, delete, confirm, redact, the versions list, restore) have no tool, by decision — and (#9749) `get_slack_connection_status`, twin of `GET /v1/workspaces/:workspaceId/slack-connection` (registered with `workspace`/`multi-tenant`, its route's `TENANCY` row: whether the Organization's Slack is connected, its team name and the date, never a credential; connect and disconnect are a person's act and have no tool, by decision), and `get_prospect_channel_settings` / `set_prospect_channel_settings`, twins of `GET`/`PUT …/prospect-channel` (registered with `scheduling`, their routes' module): the update replaces every field, needs the `meeting-types-manage` grant, is refused `slack_not_connected` while the Organization has no Slack connection, and an agent may change only `enabled`, `channelPrefix` and `archiveDays` — a change to `memberIds` or `welcomeTemplate` is refused `prospect_channel_needs_person`. Each takes its route's params, query and body and returns the route's
188
194
  `data`. They are in the parity guard like every other tool, and never in
189
195
  `/.well-known/mcp.json`.
190
196
  - **Handlers forward, nothing else.** `workspace-server.ts` resolves each tool from
191
- `@working-theory/mcp-workspace`'s `workspaceTools` (`agent_context.ts`, `inbox_messages.ts`,
192
- `inbox_submissions.ts`, `inbox_applications.ts`, `person_notes.ts`, `proposals.ts`, all
193
- `// oss: false`), loaded through the site's generated registry, as `server.ts` resolves the
197
+ `@working-theory/mcp-workspace`'s `workspaceTools` (`agent_context.ts`, `knowledge.ts`, `inbox_messages.ts`,
198
+ `inbox_submissions.ts`, `inbox_applications.ts`, `people_list.ts`, `person_notes.ts`, `person_thread.ts`, `person_memory.ts`, `proposals.ts`, `slack_connection.ts`, all
199
+ `// oss: false`) and, for the two prospect-channel settings tools, `@working-theory/mcp-scheduling`'s
200
+ `workspaceTools` (`prospect_channel_settings.ts`, #9749 — the pack the scheduling module carries, so a
201
+ scheduling-only build serves what it lists), loaded through the site's generated registry, as `server.ts` resolves the
194
202
  module-gated tools from their packs (#9460). Each calls `@working-theory/api-client`'s `inbox` surface
195
- (the notes and proposals tools, its `people` surface) with the key. A refusal from the
203
+ (the people-list, notes, person-thread, person-memory and proposals tools, its `people` surface; the
204
+ Slack status and prospect-channel tools, its `slack` surface) with the key. A refusal from the
196
205
  route is an MCP tool error naming the route's `error.code` and HTTP status only.
197
206
  - **Never log or return the key.** Don't log or serialise `extra`, `extra.authInfo` or
198
207
  `extra.requestInfo` — the SDK puts the raw `authorization` header there.
@@ -268,7 +277,15 @@ Example (lead routing):
268
277
  - Don't add a tool without the matching `apps/api` endpoint.
269
278
  - Don't put business logic in tool handlers — they're adapters.
270
279
 
271
- Last updated: 2026-10-22 — #9460: the server is `@working-theory/mcp-server` and the paid tools ride `packages-paid/mcp-*`; `src/` is the five-file boot set, `lib/paid-tools.generated.ts` is the one door to a paid MCP package, and the specifier tables are gone — verified against `packages/mcp-server/src/{server,workspace-server,boot,run,site}.ts`, `apps/mcp/src/{index,app,site}.ts` and `packages/create-ailk/src/paid-sections.ts`.
280
+ Last updated: 2026-10-24 — #9749: `get_slack_connection_status` (`mcp-workspace`'s `slack_connection.ts`) and `get_prospect_channel_settings` / `set_prospect_channel_settings` (`mcp-scheduling`'s `prospect_channel_settings.ts`, the first signed-in handlers outside `mcp-workspace`) twin the Slack status read and the prospect-channel settings pair — the signed-in workspace surface is thirty-one tools — verified against `packages/metadata/src/mcp-tool-catalog.ts`, `packages-paid/mcp-workspace/src/index.ts`, `packages-paid/mcp-scheduling/src/index.ts` and `tests/parity/api-parity/kinds/site.ts`.
281
+
282
+ Prior: 2026-10-24 — #9714: `list_people` (`people_list.ts`) twins `GET /v1/workspaces/:workspaceId/people` in both its modes — the signed-in workspace surface is twenty-eight tools — verified against `packages/metadata/src/mcp-tool-catalog.ts`, `packages-paid/mcp-workspace/src/index.ts` and `tests/parity/api-parity/kinds/site.ts`.
283
+
284
+ Prior: 2026-10-24 — #9686: `list_inbox_threads` (`inbox_messages.ts`) twins `GET /v1/workspaces/:workspaceId/messages/threads` — the signed-in workspace surface is twenty-seven tools — verified against `packages/metadata/src/mcp-tool-catalog.ts`, `packages-paid/mcp-workspace/src/index.ts` and `tests/parity/api-parity/kinds/site.ts`.
285
+
286
+ Prior: 2026-10-24 — #9683: `get_workspace_knowledge` (`knowledge.ts`) twins `GET /v1/workspaces/:workspaceId/knowledge` — the signed-in workspace surface is twenty-three tools; the inbox write twins forward no agent-session header — verified against `packages/metadata/src/mcp-tool-catalog.ts`, `packages-paid/mcp-workspace/src/index.ts` and `tests/parity/api-parity/kinds/site.ts`.
287
+
288
+ Prior: 2026-10-22 — #9460: the server is `@working-theory/mcp-server` and the paid tools ride `packages-paid/mcp-*`; `src/` is the five-file boot set, `lib/paid-tools.generated.ts` is the one door to a paid MCP package, and the specifier tables are gone — verified against `packages/mcp-server/src/{server,workspace-server,boot,run,site}.ts`, `apps/mcp/src/{index,app,site}.ts` and `packages/create-ailk/src/paid-sections.ts`.
272
289
 
273
290
  Prior: 2026-10-22 — #9534: `create_project` registers only while create-ailk's `release-scope.json` marks the Free level released (the release-scope filter in `shouldRegister`, re-applied at call time) — 19 registered keyless and 20 claimed while Free is held — and the content-root rule names the tool's `<cwd>/packages/create-ailk` root as its standing exception — verified against `apps/mcp/src/tools/create_project.ts`, `apps/mcp/src/server.ts` and `apps/web/app/.well-known/mcp.json/route.ts`.
274
291
 
@@ -47,6 +47,19 @@ CONTENT_ADAPTER=mdx
47
47
  # NEXT_PUBLIC_; see the repo-root .env.example, #7394). A cross-site post is
48
48
  # refused before the hub is called.
49
49
  #
50
+ # THE BOOKING BOT CHECK (#9889). On Vercel, POST /v1/schedule/book asks Vercel
51
+ # BotID for a verdict before the hub is called: a bot is refused 403
52
+ # bot_detected, so no booking and no Slack invite exist. A real visitor sees no
53
+ # extra step. It is ON by default; set this to `off` to turn it off (any other
54
+ # value leaves it on). Off Vercel it cannot run, so it is inert and the server
55
+ # logs that once. On Vercel it needs the project's OIDC token (on by default):
56
+ # without it every booking answers 503 bot_check_unavailable, as it does when
57
+ # BotID cannot answer. A self-hosted or local `next start` must not carry
58
+ # VERCEL=1 (as `vercel env pull` writes it), or every booking answers 503.
59
+ # Basic or Deep Analysis is the Vercel project's own setting; Deep Analysis is
60
+ # billed per check.
61
+ # AILK_BOOKING_BOT_CHECK=
62
+ #
50
63
  # EDGE PRECONDITION: the relay forwards x-forwarded-for exactly as this site
51
64
  # received it, so run the site behind an edge that OVERWRITES the client-address
52
65
  # headers (as Vercel does), not one that appends to them. Otherwise a caller
@@ -14,7 +14,7 @@ Inherits root `CLAUDE.md`. This file adds web-specific rules.
14
14
 
15
15
  ## Always do first
16
16
 
17
- - For any UI work: read § Theme & styling and § Iterate visually below before writing code. To add a whole page, run the `/ailk:add-page` skill.
17
+ - For any UI work: read § Theme & styling and § Iterate visually below before writing code. To add a whole page, run `/ailk:growth-ops page add`.
18
18
  - For any page-rendering or schema-emission work: read `packages/schema/README.md` (catalog) and `packages/schema/CLAUDE.md` (operational layer + component map) if you haven't this session.
19
19
  - For content authoring questions: read `content/CLAUDE.md` first.
20
20
  - For a full page build, follow the per-page loop (Content intent → Layout → Author content → Visual iterate). The visual-iterate step uses `screenshots/` as scratch (see § Iterate visually).
@@ -130,6 +130,25 @@ older hub's 404). The carried `__Host-ailk-signup-agreements` cookie is
130
130
  retired. Policy links on every agreement surface come from
131
131
  `lib/consent-policies.ts`'s `consentPoliciesFor(locale)`.
132
132
 
133
+ **A magic link can create the account while sign-up is open (#9667).** With
134
+ the email sign-in link on, an open `/sign-up` is the one-field "Send me a
135
+ magic link" body (the page's method is `magic-link` before `password`), and
136
+ the card POSTs `/api/auth/sign-in/magic-link` with the ticked agreements and
137
+ locale in `metadata`. A request carrying that key is a sign-up request: the
138
+ plugin refuses it `400 SIGN_UP_CLOSED` on a closed site and
139
+ `400 AGREEMENTS_REQUIRED` without the agreements, before any lookup; the
140
+ callback binds the accepted agreements to the token (a `verification` row
141
+ keyed on SHA-256 of the token) and mails the address whether or not it has
142
+ an account (`userId: "sign-up"` for a new one). The plugin's
143
+ `disableSignUp` follows `isSignUpOpen()`. On verify, the user hook refuses —
144
+ a redirect to `/sign-up?error=AGREEMENTS_REQUIRED` — a new user whose token
145
+ has no binding or whose binding no longer covers the evidence, and the
146
+ session hook writes the grants in their own transaction before the session
147
+ exists, deleting the user if that write fails. A request with no `metadata`
148
+ (every `/sign-in` one) is a sign-in, unchanged. Not one transaction: a crash
149
+ between the user and grant inserts leaves a user with no grant, whom the
150
+ account interstitial stops.
151
+
133
152
  ## Contact page
134
153
 
135
154
  The contact page (`apps/web/app/[locale]/contact/page.tsx`) renders the
@@ -470,10 +489,12 @@ one `[offerings-checkout] refused reason=<reason>` warning; no response or
470
489
  log line carries the key. It sends `OSS_API_INTERNAL_SECRET` as
471
490
  `x-internal-auth` only from a site that is not a spoke and only to an
472
491
  `https:` or loopback API, so the hub's own `apps/web` must hold the API's
473
- value when the API sets it. A spoke never holds that secret: it sells only
474
- when its hub lists the spoke's origin in `AILK_TRUSTED_ORIGINS` and does not
475
- set `OSS_API_INTERNAL_SECRET`, and otherwise every spoke buy returns to
476
- `/pricing`.
492
+ value when the API sets it. A spoke never holds that secret: on a hub that
493
+ sets `OSS_API_INTERNAL_SECRET` its spoke key authenticates the buy, which
494
+ sells when the spoke's hub sign-in client is enabled with its callback on the
495
+ spoke's own origin (#9910); on a hub that does not set it, the buy sells when
496
+ the hub lists the spoke's origin in `AILK_TRUSTED_ORIGINS`. Otherwise every
497
+ spoke buy returns to `/pricing`.
477
498
 
478
499
  An existing scaffold adopts the new page by bringing
479
500
  `lib/offerings-pricing.ts`, `app/api/offerings/checkout/[lookupKey]/route.ts`
@@ -533,6 +554,7 @@ A visitor's browser never calls the hub. Lead capture, newsletter signup, callba
533
554
  - **No cross-site POST.** A `Sec-Fetch-Site` other than `same-origin`, or an `Origin` not on this site's own host, is refused 403 before the hub is called.
534
555
  - **Headers are an allow-list.** The visitor's `x-forwarded-for` (else `x-real-ip`) and `user-agent` pass through so per-visitor rate limits still count each visitor; no `authorization`, client-sent `x-ailk-*` or `host` is forwarded, and a `cookie` goes only on `POST /v1/commerce/invoices`, the one session route.
535
556
  - **The relay has its own per-visitor budget (#7707).** Each visitor gets 8 calls per 10 minutes across the five visitor routes, and the session route (`POST /v1/commerce/invoices`) gets its own, wider budget of 30 per 10 minutes; the call that exceeds either gets `429 rate_limited` before any hub call. The budget is held in this instance's own process (no shared store, no env variable) and holds only where the site's edge overwrites `x-forwarded-for` and `x-real-ip` — Vercel does; a bare self-hosted Next server does not.
557
+ - **A bot check on the booking (#9889).** On Vercel, `POST /v1/schedule/book` asks Vercel BotID (`checkBotId`, from `BOT_CHECKED_ROUTES`) for a verdict after every refusal above and before the hub is called: a bot is `403 bot_detected`, and a check that throws, answers no boolean or takes over 5 seconds is `503 bot_check_unavailable`, so a scripted booking never reaches Cal.com or Slack. It is on unless `AILK_BOOKING_BOT_CHECK=off` and inert off Vercel; the locale layout mounts `BotIdClient` on the same list and predicate, `next.config.mjs` wraps `withBotId`, and `middleware.ts` keeps BotID's proxy prefix out of its matcher.
536
558
  - `/v1` is exempt from next-intl's locale rewrite in `middleware.ts`, as `/api` is.
537
559
 
538
560
  ## AEO & performance
@@ -551,7 +573,13 @@ A visitor's browser never calls the hub. Lead capture, newsletter signup, callba
551
573
  - Don't reach into `apps/api` or `apps/mcp` from `apps/web`. They're peer apps — talk via HTTP/MCP.
552
574
  - Don't commit images to `screenshots/` — only the `.gitkeep` is tracked.
553
575
 
554
- Last updated: 2026-10-21 — § Pricing page and § Magnet pages: `/pricing`, the magnet pages and the results permalink read the browser-facing API origin through `browserApiOrigin()` (#7874 D12), so a spoke that enables them passes its own `env:check` (#9480) — verified against `app/[locale]/pricing/page.tsx`, `app/[locale]/flows/[slug]/{magnet-page.tsx,results/[payload]/page.tsx}` and `lib/api-origin.generated.ts`.
576
+ Last updated: 2026-10-25 — the Offerings buy route's spoke sentence: on a hub that sets `OSS_API_INTERNAL_SECRET` a spoke's key authenticates its buy, held to its hub sign-in client's origin (#9910) — verified against `app/api/offerings/checkout/[lookupKey]/route.ts` and `packages/api/src/routes/checkout/sessions.ts`.
577
+
578
+ Prior: 2026-10-25 — § The same-origin relay: the booking bot check — Vercel BotID on `POST /v1/schedule/book`, `bot_detected` / `bot_check_unavailable`, the `AILK_BOOKING_BOT_CHECK` switch and the middleware's BotID exclusion (#9889) — verified against `lib/relay.ts`, `app/[locale]/layout.tsx`, `next.config.mjs` and `middleware.ts`.
579
+
580
+ Prior: 2026-10-24 — § The `(auth)` route group: a magic link can create the account while sign-up is open, carrying the ticked agreements bound to its token (#9667) — verified against `lib/auth-email.ts`, `lib/sign-up-agreements.ts` and `app/[locale]/(auth)/sign-up/{page,sign-up-card}.tsx`.
581
+
582
+ Prior: 2026-10-21 — § Pricing page and § Magnet pages: `/pricing`, the magnet pages and the results permalink read the browser-facing API origin through `browserApiOrigin()` (#7874 D12), so a spoke that enables them passes its own `env:check` (#9480) — verified against `app/[locale]/pricing/page.tsx`, `app/[locale]/flows/[slug]/{magnet-page.tsx,results/[payload]/page.tsx}` and `lib/api-origin.generated.ts`.
555
583
 
556
584
  Prior: 2026-10-20 — § Magnet pages: the trial magnet's pair, `/trial` (its button to `/trial/request` through `renderMagnetLanding`'s `startPath`) and the chrome-free `/trial/request` walk with its own `<h1>` fallback (#9287 C2, DD10) — verified against `app/[locale]/flows/[slug]/magnet-page.tsx`, `app/[locale]/trial/{page,request/page}.tsx` and `lib/magnet-routes.ts`.
557
585
 
@@ -17,7 +17,7 @@
17
17
  * `next-intl/server` is mocked globally (apps/web/__tests__/setup.ts) against
18
18
  * the real en.json, so the `auth` copy is asserted by its real strings.
19
19
  */
20
- import { render, screen } from "@testing-library/react";
20
+ import { render, screen, within } from "@testing-library/react";
21
21
 
22
22
  const mockGetActiveProviderIds = jest.fn();
23
23
  const mockIsSignUpOpen = jest.fn();
@@ -217,12 +217,51 @@ describe("/sign-up follows isSignUpOpen()", () => {
217
217
  it("renders the invitation intent when sign-up is closed", async () => {
218
218
  mockIsSignUpOpen.mockReturnValue(false);
219
219
  await renderPage(SignUpPage);
220
- expect(screen.getByTestId("auth-invitation-body")).toBeInTheDocument();
220
+ expect(screen.getByTestId("auth-invitation-body")).toHaveTextContent(
221
+ en.auth.invitationBody,
222
+ );
221
223
  expect(
222
- screen.getByRole("heading", { name: en.auth.invitationHeading }),
224
+ screen.getByRole("heading", { name: en.auth.signUpHeading }),
223
225
  ).toBeInTheDocument();
224
226
  expect(screen.queryByTestId("auth-submit")).not.toBeInTheDocument();
225
227
  });
228
+
229
+ // The one card (#9662) frames both branches: the mark, "Sign up", the toggle
230
+ // line to /sign-in (no footer cross-link) and the acknowledgement.
231
+ it.each([
232
+ ["closed", false],
233
+ ["open", true],
234
+ ] as const)("frames the %s card: mark, 'Sign up', toggle line, footnote (#9662)", async (_name, open) => {
235
+ mockIsSignUpOpen.mockReturnValue(open);
236
+ mockGetActiveProviderIds.mockReturnValue(["google", "email"]);
237
+ await renderPage(SignUpPage);
238
+ expect(screen.getByTestId("auth-card-logo")).toBeInTheDocument();
239
+ expect(screen.getByRole("heading", { name: "Sign up" })).toBeInTheDocument();
240
+ expect(screen.getByTestId("auth-card-prospect")).toHaveTextContent(
241
+ /^Already have an account\? Sign in$/,
242
+ );
243
+ expect(screen.getByTestId("auth-card-prospect-link")).toHaveAttribute(
244
+ "href",
245
+ "/sign-in",
246
+ );
247
+ expect(screen.queryByTestId("auth-footer-link")).not.toBeInTheDocument();
248
+ expect(screen.getByTestId("auth-card-footnote")).toHaveTextContent(
249
+ /^You acknowledge that you read, and agree to our Terms of Service and our Privacy Policy\.$/,
250
+ );
251
+ const footnote = within(screen.getByTestId("auth-card-footnote"));
252
+ expect(footnote.getByRole("link", { name: "Terms of Service" })).toHaveAttribute("href", "/terms");
253
+ expect(footnote.getByRole("link", { name: "Privacy Policy" })).toHaveAttribute("href", "/privacy");
254
+ });
255
+
256
+ it("offers 'or' + Google under the open card's form, with the consent checkboxes kept (#9662; #8438 D13)", async () => {
257
+ mockIsSignUpOpen.mockReturnValue(true);
258
+ mockGetActiveProviderIds.mockReturnValue(["google", "email"]);
259
+ await renderPage(SignUpPage);
260
+ expect(screen.getByLabelText("Email")).toBeInTheDocument();
261
+ expect(screen.getByTestId("auth-divider")).toHaveTextContent(/^or$/);
262
+ expect(screen.getByTestId("auth-social-google")).toBeInTheDocument();
263
+ expect(screen.getByTestId("sign-up-agreements")).toBeInTheDocument();
264
+ });
226
265
  });
227
266
 
228
267
  describe("/forgot-password and /reset-password follow the reset predicate", () => {
@@ -266,9 +305,9 @@ describe("/forgot-password and /reset-password follow the reset predicate", () =
266
305
 
267
306
  // ── Every non-form page routes back to sign-in ───────────────────────────────
268
307
 
308
+ // A closed /sign-up routes back through its toggle line instead (#9662, above).
269
309
  describe("the copy-only intents route back to sign-in", () => {
270
310
  it.each([
271
- ["sign-up (closed)", SignUpPage],
272
311
  ["forgot-password (unavailable)", ForgotPasswordPage],
273
312
  ["reset-password (unavailable)", ResetPasswordPage],
274
313
  ["verify-email", VerifyEmailPage],
@@ -0,0 +1,35 @@
1
+ /**
2
+ * The acknowledgement at the foot of every auth card (#8790 D6) — "You
3
+ * acknowledge that you read, and agree to our Terms of Service and our Privacy
4
+ * Policy." (#9662) — linking the site's own policy routes through the inline
5
+ * link variant, so the card never hardcodes a path.
6
+ *
7
+ * One builder for `/sign-in`, `/sign-up` and the `(authed)` gate.
8
+ * `caption` is the muted footnote role; a default `Text` would re-impose body
9
+ * size and the foreground colour over the card's footnote wrapper.
10
+ */
11
+
12
+ import { localeHref } from "@working-theory/ui";
13
+ import { Link, Text } from "@working-theory/ui/primitives";
14
+ import type { ReactElement } from "react";
15
+
16
+ import type { SignInCardText } from "./sign-in/sign-in-text";
17
+
18
+ export function legalFootnote(
19
+ locale: string,
20
+ text: SignInCardText["acknowledgement"],
21
+ ): ReactElement {
22
+ return (
23
+ <Text as="p" variant="caption">
24
+ {text.lead}{" "}
25
+ <Link variant="inline" href={localeHref(locale, "/terms")}>
26
+ {text.terms}
27
+ </Link>{" "}
28
+ {text.and}{" "}
29
+ <Link variant="inline" href={localeHref(locale, "/privacy")}>
30
+ {text.privacy}
31
+ </Link>
32
+ {text.end}
33
+ </Text>
34
+ );
35
+ }
@@ -2,8 +2,11 @@
2
2
  * Tests for `resolveNextPath` — the /sign-in `?next=` sanitizer (#4984).
3
3
  * Foreign, protocol-relative, backslash, control-char, and non-string inputs
4
4
  * all fall back; plain in-app paths pass through unchanged.
5
+ *
6
+ * `hrefWithNext` (#9912 D4) — a toggle carries `?next=` only when the page's
7
+ * resolved `next` is not its fallback.
5
8
  */
6
- import { resolveNextPath } from "../next-path";
9
+ import { hrefWithNext, resolveNextPath } from "../next-path";
7
10
 
8
11
  const FALLBACK = "/account";
9
12
 
@@ -29,3 +32,23 @@ describe("resolveNextPath()", () => {
29
32
  expect(resolveNextPath(raw, FALLBACK)).toBe(FALLBACK);
30
33
  });
31
34
  });
35
+
36
+ describe("hrefWithNext()", () => {
37
+ it("the fallback gives the bare href", () => {
38
+ expect(hrefWithNext("/sign-up", "/account", "/account")).toBe("/sign-up");
39
+ });
40
+
41
+ it("any other next rides as ?next=, URL-encoded", () => {
42
+ expect(
43
+ hrefWithNext("/sign-up", "/api/auth/hub/continue/abc_-1", "/account"),
44
+ ).toBe("/sign-up?next=%2Fapi%2Fauth%2Fhub%2Fcontinue%2Fabc_-1");
45
+ expect(
46
+ hrefWithNext("/fr/sign-in", "/fr/account?tab=x", "/fr/account"),
47
+ ).toBe("/fr/sign-in?next=%2Ffr%2Faccount%3Ftab%3Dx");
48
+ });
49
+
50
+ it("a refused next resolves to the fallback, so its toggle is bare", () => {
51
+ const next = resolveNextPath("//evil.example", FALLBACK);
52
+ expect(hrefWithNext("/sign-up", next, FALLBACK)).toBe("/sign-up");
53
+ });
54
+ });
@@ -24,11 +24,19 @@
24
24
  * (i) the hinted email (#8757 D9): a valid single `?email=` is the starting
25
25
  * value of every email input of the card; an invalid or repeated value,
26
26
  * or none, leaves them empty as before. It signs no one in.
27
+ * (j) the one card (#9662): the site's logo and "Sign in to <site>" from
28
+ * the deployment's own data — a crafted query parameter cannot change
29
+ * the name — the "Don't have an account? Sign up" line linking
30
+ * /sign-up, and the Terms + Privacy acknowledgement.
31
+ * (k) the Sign up toggle (#9912 D4): it carries the validated `?next=` to
32
+ * /sign-up; a `next` resolveNextPath refuses, or none, gives the bare
33
+ * /sign-up.
27
34
  *
28
- * `next-intl/server` is mocked globally (apps/web/__tests__/setup.ts) against
29
- * the real en.json, so `signIn` copy is asserted by its real strings.
35
+ * `next-intl/server` is mocked here against the real en.json, with `{name}`
36
+ * placeholders filled from the values passed, so `signIn` copy is asserted
37
+ * by its real strings and the heading by the name actually interpolated.
30
38
  */
31
- import { render, screen } from "@testing-library/react";
39
+ import { render, screen, within } from "@testing-library/react";
32
40
  import userEvent from "@testing-library/user-event";
33
41
 
34
42
  const mockGetActiveProviderIds = jest.fn();
@@ -75,7 +83,31 @@ jest.mock("@working-theory/api-client", () => ({
75
83
  __esModule: true,
76
84
  hubSignIn: { clientInfo: (...args: unknown[]) => mockClientInfo(...args) },
77
85
  }));
86
+ // The global shim (__tests__/__mocks__/next-intl-server.ts) returns messages
87
+ // uninterpolated; this suite fills `{name}` placeholders so the heading's
88
+ // organization name is the one the page actually passed (#9662).
89
+ jest.mock("next-intl/server", () => {
90
+ const messages = jest.requireActual("../../../../../messages/en.json") as Record<
91
+ string,
92
+ Record<string, string>
93
+ >;
94
+ return {
95
+ __esModule: true,
96
+ getTranslations: async ({ namespace }: { namespace: string }) => {
97
+ const catalog = messages[namespace];
98
+ const t = (key: string, values?: Record<string, string>): string =>
99
+ (catalog?.[key] ?? `${namespace}.${key}`).replace(
100
+ /\{(\w+)\}/g,
101
+ (match, name: string) => values?.[name] ?? match,
102
+ );
103
+ t.has = (key: string): boolean => catalog?.[key] !== undefined;
104
+ return t;
105
+ },
106
+ };
107
+ });
108
+
78
109
  import { mintContinuation } from "../../../../api/auth/hub/continuation";
110
+ import { SITE_NAME } from "../../../../../lib/site-brand";
79
111
  import en from "../../../../../messages/en.json";
80
112
  import Page, { generateMetadata } from "../page";
81
113
 
@@ -119,7 +151,9 @@ describe("/sign-in page", () => {
119
151
  expect(screen.queryByTestId("auth-submit")).toBeNull();
120
152
  expect(screen.queryByTestId("auth-footer-link")).toBeNull();
121
153
  expect(screen.queryByText(/create an account/i)).toBeNull();
122
- expect(screen.getByText(en.signIn.heading)).toBeInTheDocument();
154
+ expect(
155
+ screen.getByRole("heading", { name: `Sign in to ${SITE_NAME}` }),
156
+ ).toBeInTheDocument();
123
157
  });
124
158
 
125
159
  it("(b) email + OAuth: password body with both social buttons, no sign-up footer", async () => {
@@ -241,7 +275,7 @@ describe("/sign-in page — the email sign-in link (#7435)", () => {
241
275
  expect(screen.getByTestId("auth-submit")).toHaveTextContent(
242
276
  en.signIn.emailLinkSubmit,
243
277
  );
244
- expect(en.signIn.emailLinkSubmit).toBe("Email me a sign-in link");
278
+ expect(en.signIn.emailLinkSubmit).toBe("Send me a magic link");
245
279
  // One "or" divider between the primary and Google (#8790 D5).
246
280
  expect(screen.getAllByTestId("auth-divider")).toHaveLength(1);
247
281
  expect(screen.queryByTestId("auth-email-link-divider")).toBeNull();
@@ -252,9 +286,11 @@ describe("/sign-in page — the email sign-in link (#7435)", () => {
252
286
  await renderPage();
253
287
  expect(screen.getByTestId("auth-email")).toBeInTheDocument();
254
288
  expect(screen.queryByText(en.signIn.notConfiguredSubhead)).toBeNull();
255
- // "Welcome back" stands alone — no subhead (#8790 D3).
256
- expect(screen.getByText(en.signIn.heading)).toBeInTheDocument();
257
- expect(en.signIn.heading).toBe("Welcome back");
289
+ // The heading stands alone — no subhead (#8790 D3); #9662 names the site.
290
+ expect(
291
+ screen.getByRole("heading", { name: `Sign in to ${SITE_NAME}` }),
292
+ ).toBeInTheDocument();
293
+ expect(en.signIn.heading).toBe("Sign in to {site}");
258
294
  expect(screen.queryByTestId("auth-method-switch")).toBeNull();
259
295
  });
260
296
 
@@ -415,3 +451,127 @@ describe("/sign-in page — the hinted email (#8757 D9)", () => {
415
451
  },
416
452
  );
417
453
  });
454
+
455
+ describe("/sign-in page — the one card (#9662)", () => {
456
+ beforeEach(() => {
457
+ mockGetActiveProviderIds.mockReturnValue(["google", "email", "magic-link"]);
458
+ });
459
+
460
+ it("(j) renders the site's logo and 'Sign in to <site>' from the deployment's data", async () => {
461
+ await renderPage("/account");
462
+ expect(screen.getByTestId("auth-card-logo")).toBeInTheDocument();
463
+ expect(screen.getByRole("heading", { level: 2 })).toHaveTextContent(
464
+ `Sign in to ${SITE_NAME}`,
465
+ );
466
+ expect(screen.getByLabelText(en.signIn.email)).toBe(
467
+ screen.getByTestId("auth-email"),
468
+ );
469
+ expect(en.signIn.email).toBe("Email");
470
+ });
471
+
472
+ it("(j) a crafted query parameter cannot change the organization name", async () => {
473
+ const crafted = "Evil Corp";
474
+ expect(SITE_NAME).not.toBe(crafted);
475
+ const el = await Page({
476
+ params: Promise.resolve({ locale: "en" }),
477
+ searchParams: Promise.resolve({
478
+ next: `/account?site=${encodeURIComponent(crafted)}`,
479
+ site: crafted,
480
+ organization: crafted,
481
+ name: crafted,
482
+ siteName: crafted,
483
+ }),
484
+ });
485
+ render(el);
486
+ expect(screen.getByRole("heading", { level: 2 })).toHaveTextContent(
487
+ `Sign in to ${SITE_NAME}`,
488
+ );
489
+ expect(document.body.textContent).not.toContain(crafted);
490
+ });
491
+
492
+ it("(j) the prospect line toggles to /sign-up, with no trailing period", async () => {
493
+ await renderPage("/account");
494
+ expect(screen.getByTestId("auth-card-prospect")).toHaveTextContent(
495
+ /^Don't have an account\? Sign up$/,
496
+ );
497
+ expect(screen.getByTestId("auth-card-prospect-link")).toHaveAttribute(
498
+ "href",
499
+ "/sign-up",
500
+ );
501
+ });
502
+
503
+ it("(j) 'Send me a magic link' first, the in-place password switch, then 'or' and Google", async () => {
504
+ await renderPage("/account");
505
+ expect(screen.getByTestId("auth-submit")).toHaveTextContent(
506
+ "Send me a magic link",
507
+ );
508
+ expect(screen.getByTestId("auth-method-switch")).toHaveTextContent(
509
+ "Sign in using password",
510
+ );
511
+ expect(screen.getByTestId("auth-divider")).toHaveTextContent(/^or$/);
512
+ expect(screen.getByTestId("auth-social-google")).toBeInTheDocument();
513
+ });
514
+
515
+ it("(j) ends on the Terms of Service + Privacy Policy acknowledgement, linking the site's own routes", async () => {
516
+ await renderPage("/account");
517
+ const footnote = screen.getByTestId("auth-card-footnote");
518
+ expect(footnote).toHaveTextContent(
519
+ /^You acknowledge that you read, and agree to our Terms of Service and our Privacy Policy\.$/,
520
+ );
521
+ expect(
522
+ within(footnote).getByRole("link", { name: "Terms of Service" }),
523
+ ).toHaveAttribute("href", "/terms");
524
+ expect(
525
+ within(footnote).getByRole("link", { name: "Privacy Policy" }),
526
+ ).toHaveAttribute("href", "/privacy");
527
+ expect(within(footnote).getAllByRole("link")).toHaveLength(2);
528
+ });
529
+ });
530
+
531
+ describe("/sign-in page — the Sign up toggle keeps the next (#9912 D4)", () => {
532
+ beforeEach(() => {
533
+ mockClientInfo.mockReset().mockResolvedValue({ ok: false, status: 404 });
534
+ });
535
+
536
+ it("(k) a continuation next rides the toggle to /sign-up, URL-encoded", async () => {
537
+ const ref = mintContinuation("client_id=spoke_1", TEST_SECRET);
538
+ const next = `/api/auth/hub/continue/${ref}`;
539
+ await renderPage(next);
540
+ expect(screen.getByTestId("auth-card-prospect-link")).toHaveAttribute(
541
+ "href",
542
+ `/sign-up?next=${encodeURIComponent(next)}`,
543
+ );
544
+ });
545
+
546
+ it("(k) an in-app next rides the toggle too", async () => {
547
+ await renderPage("/account/billing");
548
+ expect(screen.getByTestId("auth-card-prospect-link")).toHaveAttribute(
549
+ "href",
550
+ "/sign-up?next=%2Faccount%2Fbilling",
551
+ );
552
+ });
553
+
554
+ it.each([
555
+ ["a protocol-relative next", "//evil.example"],
556
+ ["an absolute next", "https://evil.example/x"],
557
+ ["a backslash next", "/\\evil.example"],
558
+ ["a control character", "/x\r\ny"],
559
+ ])(
560
+ "(k) %s fails resolveNextPath, so the toggle is the bare /sign-up",
561
+ async (_label, next) => {
562
+ await renderPage(next);
563
+ expect(screen.getByTestId("auth-card-prospect-link")).toHaveAttribute(
564
+ "href",
565
+ "/sign-up",
566
+ );
567
+ },
568
+ );
569
+
570
+ it("(k) no next: the bare /sign-up", async () => {
571
+ await renderPage();
572
+ expect(screen.getByTestId("auth-card-prospect-link")).toHaveAttribute(
573
+ "href",
574
+ "/sign-up",
575
+ );
576
+ });
577
+ });
@@ -3,5 +3,18 @@
3
3
  * `lib/next-path.ts` (#7894 D7) so the hub-session callback, which a spoke
4
4
  * keeps when it drops this route group, shares it. Re-exported here so this
5
5
  * group's importers are unchanged.
6
+ *
7
+ * `hrefWithNext` (#9912 D4) builds the /sign-in ↔ /sign-up toggle: it carries
8
+ * the page's validated `next` only when that is not the page's fallback, so a
9
+ * refused or absent `next` gives today's bare link.
6
10
  */
7
11
  export { resolveNextPath } from "../../../../lib/next-path";
12
+
13
+ /** `href` with `?next=<next>` when `next` is not the page's fallback, else `href`. */
14
+ export function hrefWithNext(
15
+ href: string,
16
+ next: string,
17
+ fallback: string,
18
+ ): string {
19
+ return next === fallback ? href : `${href}?next=${encodeURIComponent(next)}`;
20
+ }