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.
- package/README.md +14 -5
- package/dist/lib/fetch-template-snapshot.d.ts +2 -1
- package/dist/lib/fetch-template-snapshot.js +6 -5
- package/dist/lib/module-license-gate.js +2 -3
- package/dist/lib/module-tier.d.ts +2 -8
- package/dist/lib/module-tier.js +13 -12
- package/dist/module-architecture.js +14 -2
- package/dist/starter-grid.d.ts +6 -6
- package/dist/starter-grid.js +12 -5
- package/dist/surfaces.js +36 -2
- package/dist/template-record.d.ts +11 -3
- package/dist/template-record.js +20 -7
- package/dist/topology.js +23 -4
- package/dist/update.d.ts +1 -1
- package/dist/update.js +240 -20
- package/package.json +3 -3
- package/release-scope.json +1 -1
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +8 -2
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +2 -2
- package/site-templates/marketing-site/ailk-template.json +7 -1
- package/site-templates/marketing-site/provenance.json +7 -1
- package/site-templates/saas-product/ailk-template.json +2 -1
- package/site-templates/saas-product/provenance.json +1 -0
- package/templates/.env.example +22 -0
- package/templates/CLAUDE.md +20 -20
- package/templates/README.md +19 -19
- package/templates/apps/api/.env.example +28 -0
- package/templates/apps/api/CLAUDE.md +35 -9
- package/templates/apps/api/src/lib/__mocks__/prisma-client.js +16 -0
- package/templates/apps/api/vercel.json +7 -1
- package/templates/apps/mcp/CLAUDE.md +27 -10
- package/templates/apps/web/.env.example +13 -0
- package/templates/apps/web/CLAUDE.md +34 -6
- package/templates/apps/web/app/[locale]/(auth)/__tests__/auth-pages.test.tsx +43 -4
- package/templates/apps/web/app/[locale]/(auth)/legal-footnote.tsx +35 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/next-path.test.ts +24 -1
- package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/page.test.tsx +168 -8
- package/templates/apps/web/app/[locale]/(auth)/sign-in/next-path.ts +13 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-in/page.tsx +22 -39
- package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +6 -6
- package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-text.ts +20 -35
- package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/page.test.tsx +301 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/sign-up-card.test.tsx +111 -5
- package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +96 -18
- package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +132 -12
- package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +53 -1
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/billing.test.tsx +38 -0
- package/templates/apps/web/app/[locale]/(authed)/account/billing/page.tsx +4 -2
- package/templates/apps/web/app/[locale]/(authed)/sign-in-gate.tsx +47 -24
- package/templates/apps/web/app/[locale]/(authed)/signed-out-gate.generated.tsx +23 -4
- package/templates/apps/web/app/[locale]/layout.tsx +48 -13
- package/templates/apps/web/app/[locale]/meet/confirmed/BookingHandOff.tsx +128 -0
- package/templates/apps/web/app/[locale]/meet/confirmed/__tests__/BookingHandOff.test.tsx +126 -0
- package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +118 -0
- package/templates/apps/web/app/api/auth/hub/authorize/route.ts +14 -0
- package/templates/apps/web/app/api/hub-session/__tests__/routes.test.ts +51 -0
- package/templates/apps/web/app/api/hub-session/sign-in/route.ts +13 -0
- package/templates/apps/web/auth.ts +8 -4
- package/templates/apps/web/i18n/request.ts +49 -1
- package/templates/apps/web/lib/__tests__/account-config.test.ts +7 -0
- package/templates/apps/web/lib/__tests__/auth-email.test.ts +276 -0
- package/templates/apps/web/lib/__tests__/relay.test.ts +289 -1
- package/templates/apps/web/lib/auth-email.ts +102 -20
- package/templates/apps/web/lib/hub-session/__tests__/sign-in-gate.test.tsx +92 -23
- package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +97 -70
- package/templates/apps/web/lib/prisma-for-auth.ts +5 -2
- package/templates/apps/web/lib/relay.ts +116 -1
- package/templates/apps/web/lib/sign-up-agreements.ts +302 -28
- package/templates/apps/web/lib/site-contributions.ts +18 -0
- package/templates/apps/web/messages/en.json +271 -20
- package/templates/apps/web/messages/site/en.json +1 -0
- package/templates/apps/web/middleware.ts +8 -1
- package/templates/apps/web/next.config.mjs +9 -1
- package/templates/apps/web/package.json +2 -1
- 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 = {
|
|
@@ -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
|
|
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
|
-
- **
|
|
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`,
|
|
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)
|
|
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`),
|
|
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
|
|
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-
|
|
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
|
|
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:
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
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-
|
|
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")).
|
|
220
|
+
expect(screen.getByTestId("auth-invitation-body")).toHaveTextContent(
|
|
221
|
+
en.auth.invitationBody,
|
|
222
|
+
);
|
|
221
223
|
expect(
|
|
222
|
-
screen.getByRole("heading", { name: en.auth.
|
|
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
|
|
29
|
-
* the
|
|
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(
|
|
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("
|
|
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
|
-
//
|
|
256
|
-
expect(
|
|
257
|
-
|
|
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
|
+
}
|