create-ailk 0.20.0 → 0.21.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 (83) hide show
  1. package/README.md +21 -1
  2. package/component-catalog.json +184 -6
  3. package/component-catalog.md +20 -9
  4. package/dist/apply-design.d.ts +9 -0
  5. package/dist/apply-design.js +78 -31
  6. package/dist/design-home.d.ts +54 -11
  7. package/dist/design-home.js +160 -22
  8. package/dist/lib/design-checks.d.ts +6 -1
  9. package/dist/lib/design-checks.js +23 -2
  10. package/dist/programmatic.js +44 -20
  11. package/dist/site-roster.d.ts +4 -2
  12. package/dist/site-roster.js +7 -5
  13. package/dist/starter-grid.js +16 -0
  14. package/dist/surfaces.js +11 -0
  15. package/dist/sync-routes.d.ts +28 -0
  16. package/dist/sync-routes.js +123 -1
  17. package/dist/update.d.ts +6 -1
  18. package/dist/update.js +120 -18
  19. package/package.json +3 -3
  20. package/site-templates/_base/ailk-template.json +2 -2
  21. package/site-templates/_base/content/en/WorkingAgreementPage/working-agreement.mdx +78 -0
  22. package/site-templates/designs/minimalism/DESIGN.md +1 -1
  23. package/site-templates/designs/minimalism/contact/local-business-service.mdx +23 -0
  24. package/site-templates/designs/minimalism/contact/organization-product.mdx +23 -0
  25. package/site-templates/designs/minimalism/contact/organization-service.mdx +23 -0
  26. package/site-templates/designs/minimalism/contact/person-service.mdx +23 -0
  27. package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +18 -1
  28. package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +11 -0
  29. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +18 -1
  30. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +34 -23
  31. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.json +18 -1
  32. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.md +33 -23
  33. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.json +18 -1
  34. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.md +33 -23
  35. package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.json +18 -1
  36. package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.md +31 -21
  37. package/templates/README.md +26 -25
  38. package/templates/apps/api/CLAUDE.md +7 -3
  39. package/templates/apps/api/jest.config.cjs +1 -0
  40. package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +26 -0
  41. package/templates/apps/api/src/services/project-listings.ts +6 -1
  42. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +69 -61
  43. package/templates/apps/mcp/jest.config.cjs +1 -0
  44. package/templates/apps/web/CLAUDE.md +26 -1
  45. package/templates/apps/web/app/[locale]/about/[slug]/page.tsx +46 -0
  46. package/templates/apps/web/app/[locale]/about/__tests__/about-routes.test.tsx +350 -0
  47. package/templates/apps/web/app/[locale]/about/page.tsx +83 -1
  48. package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +2 -2
  49. package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +2 -2
  50. package/templates/apps/web/app/[locale]/contact/ConversationalIntakeWrapper.tsx +7 -0
  51. package/templates/apps/web/app/[locale]/contact/__tests__/contact-form-magnet.test.ts +138 -0
  52. package/templates/apps/web/app/[locale]/contact/__tests__/contact-form-text.test.ts +17 -0
  53. package/templates/apps/web/app/[locale]/contact/__tests__/contact-magnet-page.test.tsx +347 -0
  54. package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +73 -0
  55. package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +5 -3
  56. package/templates/apps/web/app/[locale]/contact/contact-form-magnet.ts +77 -0
  57. package/templates/apps/web/app/[locale]/contact/contact-form-text.ts +2 -0
  58. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +35 -3
  59. package/templates/apps/web/app/[locale]/contact/page.tsx +81 -9
  60. package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +2 -2
  61. package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +1 -1
  62. package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +2 -2
  63. package/templates/apps/web/app/[locale]/layout.tsx +23 -2
  64. package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +2 -2
  65. package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +2 -2
  66. package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +2 -2
  67. package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +1 -1
  68. package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +1 -1
  69. package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +1 -1
  70. package/templates/apps/web/app/[locale]/working-agreement/__tests__/page.test.tsx +63 -0
  71. package/templates/apps/web/app/[locale]/working-agreement/page.tsx +41 -0
  72. package/templates/apps/web/jest.config.cjs +1 -0
  73. package/templates/apps/web/lib/__tests__/deck-page.test.ts +244 -0
  74. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +4 -2
  75. package/templates/apps/web/lib/deck-page.ts +186 -0
  76. package/templates/apps/web/messages/en.json +16 -5
  77. package/templates/database/CHANGELOG.md +9 -0
  78. package/templates/database/__tests__/seed.test.ts +39 -2
  79. package/templates/database/jest.config.cjs +1 -0
  80. package/templates/database/package.json +1 -1
  81. package/templates/database/seed.ts +29 -0
  82. package/templates/tools/jest/pin-timezone.cjs +34 -0
  83. package/versions.json +34 -34
@@ -4,7 +4,7 @@
4
4
  "categories": ["consulting"],
5
5
  "shape": "person-service",
6
6
  "design": "standard",
7
- "generatedHash": "a763acef14c0c36649ad99dd4a4fea566ebdf64257bddb2f553ab8823349c309",
7
+ "generatedHash": "5017d55ac2de4c2cdb7a10fc2488c9ad675f30614f51a064b7e91ab983976e51",
8
8
  "layout": {
9
9
  "nav": "full",
10
10
  "footer": "columns",
@@ -338,6 +338,23 @@
338
338
  }
339
339
  ]
340
340
  },
341
+ {
342
+ "route": "/working-agreement",
343
+ "kind": "page",
344
+ "pageType": "WorkingAgreementPage",
345
+ "schema": "WorkingAgreementPage",
346
+ "shell": "MarketingShell",
347
+ "chrome": "on",
348
+ "sections": [
349
+ {
350
+ "type": "document-chassis",
351
+ "component": "DocumentChassis",
352
+ "options": {
353
+ "layout": "article"
354
+ }
355
+ }
356
+ ]
357
+ },
341
358
  {
342
359
  "route": "/blog",
343
360
  "kind": "page",
@@ -232,6 +232,14 @@ _Code route — no content, no sections._
232
232
 
233
233
  <!-- generated:end -->
234
234
 
235
+ <!-- generated:begin page=/working-agreement -->
236
+
237
+ | # | Section | Component | Copy (first line) |
238
+ | --- | ----------------------------------- | ----------------- | ----------------- |
239
+ | 1 | `document-chassis` (layout=article) | `DocumentChassis` | Working Agreement |
240
+
241
+ <!-- generated:end -->
242
+
235
243
  `/blog` (the Free teaser row, § 1.9 — served by the generic `[slug]` route over the always-copied overlay's `BlogList/blog.mdx`):
236
244
 
237
245
  <!-- generated:begin page=/blog -->
@@ -287,8 +295,9 @@ The standard page set for this shape (`derive-roster.py --shape person-service -
287
295
  | `PortfolioList` · `PortfolioItem` | the portfolio pair belongs to every shape's standard roster (operator, 2026-09-25; roadmap § 4) |
288
296
  | `BlogList` | every Free scaffold's nav links `/blog` unconditionally (`renderNavConfig`, operator ruling 2026-09-23); the Free teaser serves it, as on organization-product (#6753 D3) |
289
297
  | `LandingPage` | the Free `/meet` meeting-types hub is a LandingPage seed, the same generic `[slug]` route serves it through (#6897 D3); the four `/meet/[slug]` BookCall detail seeds ride the `BookCall` type already in the standard roster |
298
+ | `WorkingAgreementPage` | the Contact form's terms checkbox links `/working-agreement` beside `/terms` and `/privacy`, so every starter ships the page from `_base` (#8661 D10) |
290
299
 
291
- `|S − D| = 13` and `|R| = 18`.
300
+ `|S − D| = 13` and `|R| = 19`.
292
301
 
293
302
  `/sign-in` and `/sign-up` are also rostered (#6797) — code routes (`kind: route`) with no page type, so they sit outside this set equation: the locale layout's header links both, and the roster of record lists every page the scaffold serves.
294
303
 
@@ -305,26 +314,27 @@ person-service:
305
314
  shape: person-service
306
315
  design: standard
307
316
  roster:
308
- - { route: "/", kind: page, content: ["content/en/HomePage/home.mdx"] }
309
- - { route: "/services", kind: page, content: ["content/en/ServicesList/services.mdx"] }
310
- - { route: "/services/[slug]", kind: page, contentDir: "content/en/Service" }
311
- - { route: "/for", kind: page, content: ["content/en/UseCasesList/for.mdx"] }
312
- - { route: "/for/[slug]", kind: page, contentDir: "content/en/UseCase" }
313
- - { route: "/about", kind: page, content: ["content/en/AboutPage/about.mdx"] }
314
- - { route: "/contact", kind: page, content: ["content/en/ContactPage/contact.mdx"] }
315
- - { route: "/faq", kind: page, content: ["content/en/FAQPage/faq.mdx"] }
316
- - { route: "/glossary", kind: page, content: ["content/en/GlossaryList/glossary.mdx"] }
317
- - { route: "/newsletter", kind: page, content: ["content/en/NewsletterSignup/newsletter.mdx"] }
318
- - { route: "/sign-in", kind: route }
319
- - { route: "/sign-up", kind: route }
320
- - { route: "/purchase-policy", kind: page, content: ["content/en/PurchasePolicyPage/purchase-policy.mdx"] }
321
- - { route: "/privacy", kind: page, content: ["content/en/PrivacyPage/privacy.mdx"] }
322
- - { route: "/terms", kind: page, content: ["content/en/TermsPage/terms.mdx"] }
323
- - { route: "/blog", kind: page, content: ["content/en/BlogList/blog.mdx"] }
324
- - { route: "/meet", kind: page, content: ["content/en/LandingPage/meet.mdx"] }
325
- - { route: "/meet/[slug]", kind: page, content: ["content/en/BookCall/book-a-call.mdx", "content/en/BookCall/onsite-estimate.mdx", "content/en/BookCall/start-project.mdx", "content/en/BookCall/onsite-service.mdx"] }
326
- - { route: "/portfolio", kind: page, content: ["content/en/PortfolioList/portfolio.mdx"] }
327
- - { route: "/portfolio/[slug]", kind: page, contentDir: "content/en/PortfolioItem" }
317
+ - { route: "/", kind: page, content: ["content/en/HomePage/home.mdx"] }
318
+ - { route: "/services", kind: page, content: ["content/en/ServicesList/services.mdx"] }
319
+ - { route: "/services/[slug]", kind: page, contentDir: "content/en/Service" }
320
+ - { route: "/for", kind: page, content: ["content/en/UseCasesList/for.mdx"] }
321
+ - { route: "/for/[slug]", kind: page, contentDir: "content/en/UseCase" }
322
+ - { route: "/about", kind: page, content: ["content/en/AboutPage/about.mdx"] }
323
+ - { route: "/contact", kind: page, content: ["content/en/ContactPage/contact.mdx"] }
324
+ - { route: "/faq", kind: page, content: ["content/en/FAQPage/faq.mdx"] }
325
+ - { route: "/glossary", kind: page, content: ["content/en/GlossaryList/glossary.mdx"] }
326
+ - { route: "/newsletter", kind: page, content: ["content/en/NewsletterSignup/newsletter.mdx"] }
327
+ - { route: "/sign-in", kind: route }
328
+ - { route: "/sign-up", kind: route }
329
+ - { route: "/purchase-policy", kind: page, content: ["content/en/PurchasePolicyPage/purchase-policy.mdx"] }
330
+ - { route: "/privacy", kind: page, content: ["content/en/PrivacyPage/privacy.mdx"] }
331
+ - { route: "/terms", kind: page, content: ["content/en/TermsPage/terms.mdx"] }
332
+ - { route: "/working-agreement", kind: page, content: ["content/en/WorkingAgreementPage/working-agreement.mdx"] }
333
+ - { route: "/blog", kind: page, content: ["content/en/BlogList/blog.mdx"] }
334
+ - { route: "/meet", kind: page, content: ["content/en/LandingPage/meet.mdx"] }
335
+ - { route: "/meet/[slug]", kind: page, content: ["content/en/BookCall/book-a-call.mdx", "content/en/BookCall/onsite-estimate.mdx", "content/en/BookCall/start-project.mdx", "content/en/BookCall/onsite-service.mdx"] }
336
+ - { route: "/portfolio", kind: page, content: ["content/en/PortfolioList/portfolio.mdx"] }
337
+ - { route: "/portfolio/[slug]", kind: page, contentDir: "content/en/PortfolioItem" }
328
338
  ```
329
339
 
330
340
  <!-- generated:end -->
@@ -43,31 +43,32 @@ The pages of the Free `organization-product` shape, and where each one's copy li
43
43
 
44
44
  <!-- generated:begin content -->
45
45
 
46
- | Page | Content file | Ships in |
47
- | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------ |
48
- | `/` | `content/en/HomePage/home.mdx` | every scaffold |
49
- | `/features` | `content/en/FeaturesList/features.mdx` | `--shape organization-product` |
50
- | `/features/[slug]` | — (a code route) | `--shape organization-product` |
51
- | `/for` | `content/en/UseCasesList/for.mdx` | `--shape organization-product` |
52
- | `/for/[slug]` | — (a code route) | `--shape organization-product` |
53
- | `/pricing` | `content/en/Pricing/pricing.mdx` | `--shape organization-product` |
54
- | `/about` | `content/en/AboutPage/about.mdx` | every scaffold |
55
- | `/faq` | `content/en/FAQPage/faq.mdx` | every scaffold |
56
- | `/contact` | `content/en/ContactPage/contact.mdx` | every scaffold |
57
- | `/glossary` | `content/en/GlossaryList/glossary.mdx` | `--shape organization-product` |
58
- | `/newsletter` | `content/en/NewsletterSignup/newsletter.mdx` | `--shape organization-product` |
59
- | `/sign-in` | — (a code route) | every scaffold |
60
- | `/sign-up` | — (a code route) | every scaffold |
61
- | `/privacy` | `content/en/PrivacyPage/privacy.mdx` | `--shape organization-product` |
62
- | `/terms` | `content/en/TermsPage/terms.mdx` | `--shape organization-product` |
63
- | `/purchase-policy` | `content/en/PurchasePolicyPage/purchase-policy.mdx` | every scaffold |
64
- | `/blog` | `content/en/BlogList/blog.mdx` | every scaffold |
65
- | `/meet` | `content/en/LandingPage/meet.mdx` | every scaffold |
66
- | `/meet/[slug]` | `content/en/BookCall/book-a-call.mdx`, `content/en/BookCall/onsite-estimate.mdx`, `content/en/BookCall/start-project.mdx`, `content/en/BookCall/onsite-service.mdx` | every scaffold |
67
- | `/portfolio` | `content/en/PortfolioList/portfolio.mdx` | `--shape organization-product` |
68
- | `/portfolio/[slug]` | — (a code route) | `--shape organization-product` |
69
- | `/team` | `content/en/TeamList/team.mdx` | `--shape organization-product` |
70
- | `/team/[slug]` | — (a code route) | `--shape organization-product` |
46
+ | Page | Content file | Ships in |
47
+ | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------ |
48
+ | `/` | `content/en/HomePage/home.mdx` | every scaffold |
49
+ | `/features` | `content/en/FeaturesList/features.mdx` | `--shape organization-product` |
50
+ | `/features/[slug]` | — (a code route) | `--shape organization-product` |
51
+ | `/for` | `content/en/UseCasesList/for.mdx` | `--shape organization-product` |
52
+ | `/for/[slug]` | — (a code route) | `--shape organization-product` |
53
+ | `/pricing` | `content/en/Pricing/pricing.mdx` | `--shape organization-product` |
54
+ | `/about` | `content/en/AboutPage/about.mdx` | every scaffold |
55
+ | `/faq` | `content/en/FAQPage/faq.mdx` | every scaffold |
56
+ | `/contact` | `content/en/ContactPage/contact.mdx` | every scaffold |
57
+ | `/glossary` | `content/en/GlossaryList/glossary.mdx` | `--shape organization-product` |
58
+ | `/newsletter` | `content/en/NewsletterSignup/newsletter.mdx` | `--shape organization-product` |
59
+ | `/sign-in` | — (a code route) | every scaffold |
60
+ | `/sign-up` | — (a code route) | every scaffold |
61
+ | `/privacy` | `content/en/PrivacyPage/privacy.mdx` | `--shape organization-product` |
62
+ | `/terms` | `content/en/TermsPage/terms.mdx` | `--shape organization-product` |
63
+ | `/working-agreement` | `content/en/WorkingAgreementPage/working-agreement.mdx` | `--shape organization-product` |
64
+ | `/purchase-policy` | `content/en/PurchasePolicyPage/purchase-policy.mdx` | every scaffold |
65
+ | `/blog` | `content/en/BlogList/blog.mdx` | every scaffold |
66
+ | `/meet` | `content/en/LandingPage/meet.mdx` | every scaffold |
67
+ | `/meet/[slug]` | `content/en/BookCall/book-a-call.mdx`, `content/en/BookCall/onsite-estimate.mdx`, `content/en/BookCall/start-project.mdx`, `content/en/BookCall/onsite-service.mdx` | every scaffold |
68
+ | `/portfolio` | `content/en/PortfolioList/portfolio.mdx` | `--shape organization-product` |
69
+ | `/portfolio/[slug]` | — (a code route) | `--shape organization-product` |
70
+ | `/team` | `content/en/TeamList/team.mdx` | `--shape organization-product` |
71
+ | `/team/[slug]` | — (a code route) | `--shape organization-product` |
71
72
 
72
73
  <!-- generated:end -->
73
74
 
@@ -44,11 +44,11 @@ Routes are versioned under `/v1/*`:
44
44
  - `/v1/leads/capture` (GET) — `{ ok: true, data: { contact, newsletter } }`, two booleans saying whether a submit / a subscribe would be accepted (#6730). The web contact page reads it to show the form or the site's contact email. Never names the delivery mode, an address or an env var. Registered at the top level, outside `leadsScope`'s strict per-IP limit. No MCP twin — a recorded exception on the same footing as the anonymous form-lifecycle routes: an agent needs no preflight, because the three lead-write tools already return the 503 when capture is not accepted.
45
45
  - `/v1/leads` (GET, `?cursor=&limit=`) — the leads INBOX read (#5678): the caller's OWN sites' leads, newest first, cursor-paginated (`limit` defaults to 50, max 200; `(createdAt desc, id desc)`; `cursor` is the previous page's last row id, and one that does not resolve within the caller's scope is a 400, never a silent page 1 — the API keeps that contract, and the web inbox deliberately softens it on its own side by re-reading page one when a URL-supplied cursor 400s, which is a rendering choice and not a change to this response). Five fields per row and nothing more — `id`, `name` (lifted from `payload.name` when the site's form captured one), `email`, `source`, `createdAt`; `payload`'s other keys, `siteId`, `promotedUserId` and `promotedAt` are never selected (`src/services/leads-inbox.ts` is the only read path). Registered in `authScope` only (auth, no tenant guard) and — alone in that scope — **unconditionally**, with no `isModuleEnabled()` gate: lead capture is a never-gate surface at every level, so reading a lead back cannot be gated either. Identity is `request.userId`; there is no site, org or workspace parameter, so the scope cannot be widened from the wire. It is resolved from the caller's own memberships by `src/services/owner-workspaces.ts`'s `resolveOwnerWorkspaces` — the ONE site→workspace resolution in this app, shared with `lead-promotion.ts` rather than written a second time — and every read filters `siteId ∈ workspaceIds`. Role floor: the `inbox-read` permission in `@working-theory/validation`'s permission table (#7636 D2) — every role, a member included (the operator's 2026-10-04 decision; contact PII is the inbox's job); the scope is the UNION of every workspace in the caller's organizations whose role holds `inbox-read`, not one active tenant, because this route registers outside the tenant scope. **Two deployment preconditions decide whether it returns anything, and both fail EMPTY rather than wide:** on a build with no `multi-tenant` module `resolveOwnerWorkspaces` resolves none (a deliberate scope limit since #5677), so there the scope is the default workspace, granted only through `services/default-tenant.ts`'s `resolveDefaultTenantForOperator` — `AILK_OPERATOR_EMAIL` set, the caller's verified email equal to it, a one-workspace deployment, a role holding `inbox-read` — and every other caller gets the empty page (#6730); and the `siteId` the leads were WRITTEN under must equal a workspace id. That `siteId` has TWO sources, and `currentSiteId()` is not the one on the primary path: the three POST capture routes read it from the REQUEST BODY (`routes/leads/index.ts`), which the capture sections declare in content and default to `"default"` (`ContactForm`/`Newsletter`/`StepFlow`, `ext.siteId ?? "default"`), while the recommender's capture path (`routes/flows/recommender.ts`) is the one that uses `currentSiteId()` (`lib/site.ts`, `AILK_SITE_ID ?? "default"`). A workspace id is `wksp_<uuidv7>`, so `siteId ∈ workspaceIds` matches only when the content-declared value — and, for recommender leads, `AILK_SITE_ID` — is set to it (the same convention `waitlist-dashboard.ts` already relies on). The rendered `name` is lifted from `payload.name` and truncated at 200 characters: the write side bounds `payload` at 64 keys and no per-value length, and `POST /v1/leads/submit` is anonymous with no ownership check on `siteId`. No MCP twin — identity-bound (#3591 exception, the same shape as `/v1/workspaces/*` and `/v1/billing/*`).
46
46
  - `/v1/flows/:slug` (GET), `/v1/flows/:slug/steps` (POST) — serve a flow config + submit a step's answers; server-side per-step partial-state persistence (#3956). The GET serves the stored config **minus `completionEmail`** (#7519 D5, SEC-7519-I1): `serveFlow` projects the owner's completion-email copy out, while completion reads the stored snapshot and sends it (`sendCompletionEmail`, for a flow with no `deliverables`). Module: `signal-collectors` (D-48, #5366) — registers only when the module is enabled; MCP twins `get_flow`/`submit_flow_step` carry the same `module` tag (D7).
47
- - `/v1/flows/:slug/definition` (PUT) — a registered spoke seeds one of its declared `flows/<slug>.json` into its OWN workspace (#7876, `src/routes/flows/index.ts` over `services/flow-engine.ts`'s `seedDeclaredFlow`). No MCP twin — recorded exception: a seed is a deploy-time write by the site's owner, and a spoke's `apps/mcp` acts through the same key for every anonymous agent, so a twin would give each of them write access to the site's flows. Off until the hub's operator sets `AILK_SPOKE_FLOW_SEED` (`403 flow_seed_disabled`, answered before the key or the database is read). The workspace comes from the `x-ailk-spoke-key` alone, through `resolveCallerSite`: a keyless call is `403 spoke_key_required` on any deployment, and a refused key `503 tenant_unresolved`. The body is a flow config of `slug`, `defaultLocale`, `steps`, `thankYou` and `flags` only — every block the hub acts on outside the spoke's own pages (emails, deliverables, pricing, registers, listings) is `400 field_not_seedable`; a slug a listing owns, a stored config the Admin extended with one of those blocks, or the standard `onboarding` slug (#8515 D15, before any read) is `409 slug_owned`, so a seed never deletes them or replaces the owner's onboarding questions. A config equal to the stored one (keys sorted) answers `unchanged` and writes nothing; otherwise `created` or `updated` through `upsertFlowDefinition`, so `kind`, `title` and `status` are written on create only. Route-level `bodyLimit` 256 KiB; the config is never echoed or logged. Module: `signal-collectors`.
47
+ - `/v1/flows/:slug/definition` (PUT) — a registered spoke seeds one of its declared `flows/<slug>.json` into its OWN workspace (#7876, `src/routes/flows/index.ts` over `services/flow-engine.ts`'s `seedDeclaredFlow`). No MCP twin — recorded exception: a seed is a deploy-time write by the site's owner, and a spoke's `apps/mcp` acts through the same key for every anonymous agent, so a twin would give each of them write access to the site's flows. Off until the hub's operator sets `AILK_SPOKE_FLOW_SEED` (`403 flow_seed_disabled`, answered before the key or the database is read). The workspace comes from the `x-ailk-spoke-key` alone, through `resolveCallerSite`: a keyless call is `403 spoke_key_required` on any deployment, and a refused key `503 tenant_unresolved`. The body is a flow config of `slug`, `defaultLocale`, `steps`, `thankYou` and `flags` only — every block the hub acts on outside the spoke's own pages (emails, deliverables, pricing, registers, listings) is `400 field_not_seedable`; a slug a listing owns, a stored config the Admin extended with one of those blocks, or a standard magnet's slug (`onboarding`, #8515 D15, and `contact`, #8661 D4 — `RESERVED_FLOW_SLUGS`, before any read) is `409 slug_owned`, so a seed never deletes them or replaces the owner's onboarding questions or Contact form. `upsertFlowDefinition` itself refuses the reserved slugs the same way, so no preset, approved listing or waitlist register writes one either, and a new listing's derived slug skips them (`contact-2`). A config equal to the stored one (keys sorted) answers `unchanged` and writes nothing; otherwise `created` or `updated` through `upsertFlowDefinition`, so `kind`, `title` and `status` are written on create only. Route-level `bodyLimit` 256 KiB; the config is never echoed or logged. Module: `signal-collectors`.
48
48
  - `/v1/flows/:slug/aggregate` (GET) — anonymous, site-scoped per-question response counts across all completed respondents for the results reveal (#4771): respondents are completed `FlowSession` rows (not `Lead` rows — a recommending-mode respondent has no Lead until the email moment), question metadata + the chart flag come from the `WaitlistExperiment` register, and only `resultsReveal`-flagged `single`/`multi` questions are returned (`src/services/flow-aggregate.ts`). No individual answer, email, name, session id, or score in the response; no auth. Module: `signal-collectors`. No MCP twin — recorded exception (an agent walking a flow already gets everything it needs from `submit_flow_step`'s own response; "how everyone else answered" changes none of it).
49
49
  - `/v1/deliverables/:grantId` (GET) — the capability-token download route for gated PDFs and reports, including client deliverables (#3961 D5); the token itself is the sole gate, no auth preHandler applies (`src/routes/deliverables/index.ts`). Module: `signal-collectors` (D-48, #5366). No MCP twin — identity-bound capability-link download (#3591 exception, the same shape as `/v1/waitlist-signups/me`). **Three grant classes since #6310**, keyed on `grant.assetId`'s ADR-0031 prefix: `playbook:` (unchanged, the FEAT-074 U4b virtual asset); `asset_` — a workspace `Asset`, resolved through the named boundary `src/lib/deliverable-asset-store.ts`, scoped by `grant.workspaceId` (a serve-time tenancy re-check restored for this class — D15) — served `attachment` with an extension pinned to the stored content type, `nosniff`, a non-raster `Content-Security-Policy` (for EVERY content type, including raster — never `inline`), and `Cache-Control: private, no-store` so a deleted asset stops downloading immediately; `dasset_` — the LEGACY `DeliverableAsset` store (unchanged, tried only when a magnet's `assetKey` does not resolve against `Asset`). A client's file — tagged `private`, attached to a member-area message, or (#8171 D3) named by a delivered piece version — is never minted or served as a deliverable. Every miss on the `asset_` class — a NULL `workspaceId`, no row, a foreign-workspace row — folds into the SAME uniform 404 as every other class; there is deliberately no redirect to the public `GET /v1/assets/:assetId` (that route's year-long `immutable` cache would defeat deletion as a revocation lever, and would collapse every lead's capability onto one workspace-canonical id — spec #6310 §9 SEC-001/SEC-002).
50
50
  - `/v1/flows/:slug/save` (POST), `/v1/flows/:slug/checkout` (POST) — the recommender's email moment (#4841): `save` creates the FlowSession + Lead (+ the T+0 DeliverableGrant) for the permalink's answers; `checkout` does the same, records the `FlowCheckout` buyer row at a SERVER-recomputed `price()` (the body carries no amount — `flowCheckoutInputSchema` is strict), and opens a Stripe TEST-mode Checkout Session whose metadata carries the permalink + offer key (`src/routes/flows/recommender.ts`, `src/services/recommender-capture.ts`, `packages/stripe-client`'s `createQuotedCheckoutSession`). Anonymous, rate-limited; a non-`sk_test_` key is refused before any side effect. No MCP twin — recorded exception: an agent walking a flow has no email to give and no card to pay with; the anonymous MCP surface is identity-blind.
51
- - `/v1/workspaces/:workspaceId/magnets` (GET, POST), `/v1/workspaces/:workspaceId/magnets/:id` (GET, PATCH), `/v1/workspaces/:workspaceId/magnets/:id/signups` (GET), `/v1/workspaces/:workspaceId/magnets/:id/subscribers` (GET), `/v1/workspaces/:workspaceId/magnets/:id/messages` (GET) — the tenant-scoped magnet Admin authoring surface over the flow engine's `FlowDefinition` rows (#6157 S5c, `src/routes/magnets/index.ts`): list (cursor-paginated, fixed 50-row page), create, get, update, and the three per-magnet captured-lead reads (#6246 S6h). Reads and writes only through `tenantDb(tenant).flows` — never `prisma.flowDefinition`, `currentSiteId()`, or `upsertFlowDefinition` (the PUBLIC/seed path below, which the admin path never calls). No DELETE — the reversible alternative is `status: "draft"` (#6156), which stops a NEW walk from starting while an in-flight walk finishes on its pinned snapshot. `slug` lives only inside `config.slug` and is immutable through this surface after create (`400 slug_immutable` on a mismatched PATCH). The two operator-writable config URLs (`abandonEmail.resumeUrl`, `followUps[].cta.href`) pass an exact-origin gate against `FRONTEND_URL` before any write, the same discipline the recommender's checkout redirect already applies (#4912). Role floors from `@working-theory/validation`'s permission table (#7636 D2) (D5): list and get serve the full row to `magnets-manage` (owner, admin) and, to a caller holding only `magnets-read` (a member), a LIVE-only summary of exactly `{ id, slug, kind, title, landingPage }` — a member's list is read to completion (at most 20 pages of 50, a `warn` at the cap) and answers `nextCursor: null`, a member's `?cursor=` is a 400, and a draft's get is the same 404 as an absent id — because a magnet's `config` carries `scoring` thresholds, `offers`/`rateTable` and `deliverables` asset keys and no draft is a member's; POST and PATCH need `magnets-manage`; the per-magnet captured-people lists need `inbox-read` (every role), and for a caller without `magnets-manage` a magnet that is not live is the same 404. Both writes declare a route-level `bodyLimit` (256 KiB) and each records one `recordAgentAction` audit row. **`customersEnabled` (#8171 D6)** — the member-area customer switch — is served on every full row and written by the PATCH (a PATCH of it alone skips the plan check); the member summary row never carries it. **The standard onboarding magnet (#8515 D2–D4):** every workspace holds one `diagnostic` magnet at slug `onboarding` (`ONBOARDING_FLOW_SLUG`, `onboardingFlowConfig` in `@working-theory/validation`), `draft` and `customersEnabled: true`, written only by `lib/tenant-db.ts`'s insert-only seed (`createMany` with `skipDuplicates`, so a re-seed creates nothing and never overwrites the owner's edits or an owner's own magnet at that slug): `POST /v1/workspaces` seeds the workspace it just created, after its transaction commits (a failed seed is one `error` line and still a `201`), and the seed-presets CLI (`node dist/bin/seed-presets.js`, run once after deploy) seeds every existing workspace and prints counts only. Its two bare-id exports have one production caller each, pinned by `tests/architecture/onboarding-seed-callers.test.ts`. Module: `multi-tenant || signal-collectors`, the same gate the review-surface dashboard (`/v1/waitlist-experiments/*`, `/v1/project-listings/*`) already registers under. No MCP twin on any of the seven — identity-bound tenant routes (#3591 exception, same shape as the sibling `/v1/workspaces/*` routes); the public `/v1/flows/*` routes and their two MCP tools are unchanged, pinned by `src/routes/flows/__tests__/parity.test.ts`. **The three new list routes (#6246 S6h)** join every magnet through `Lead.source = 'flow:' + <the resolved magnet's slug>` — the literal string `flow-engine.ts`'s `completeSubmission` stamps on every flow-engine `Lead` row regardless of kind, including `newsletter`/`waitlist` (`Subscriber` is written only by the separate, pre-flow-engine `subscribe_newsletter` path, unrelated to a magnet) — via ONE new `tenantDb(tenant).leads.listBySource(source, { limit })` accessor, an exact-source-equality sibling of #6204's allowlist-scoped `listUnhandledMessages`, served by a new `(workspaceId, source)` composite index on `Lead`. `/signups` expects `kind: "waitlist"`, `/subscribers` expects `"newsletter"`, `/messages` expects `"contact"`; a magnet id absent from the caller's workspace OR resolved to the WRONG kind for the called route is `404 not_found`, indistinguishably (mirrors the existing GET's absent-or-foreign fold). `/signups` and `/subscribers` serve four keys (`id`, `name`, `email`, `createdAt`); `/messages` serves those four plus `handledAt`. Bounded `?limit=` (default 50, max 200), newest-first — mirrors `workspaces/messages.ts`'s own query schema. Applications (Listicle) is deliberately OUT of this surface — `ProjectListing` carries no reverse link to the listicle-kind `FlowDefinition` that captured it, so "scoped to that magnet" cannot be honored without a schema change (split to #6314).
51
+ - `/v1/workspaces/:workspaceId/magnets` (GET, POST), `/v1/workspaces/:workspaceId/magnets/:id` (GET, PATCH), `/v1/workspaces/:workspaceId/magnets/:id/signups` (GET), `/v1/workspaces/:workspaceId/magnets/:id/subscribers` (GET), `/v1/workspaces/:workspaceId/magnets/:id/messages` (GET) — the tenant-scoped magnet Admin authoring surface over the flow engine's `FlowDefinition` rows (#6157 S5c, `src/routes/magnets/index.ts`): list (cursor-paginated, fixed 50-row page), create, get, update, and the three per-magnet captured-lead reads (#6246 S6h). Reads and writes only through `tenantDb(tenant).flows` — never `prisma.flowDefinition`, `currentSiteId()`, or `upsertFlowDefinition` (the PUBLIC/seed path below, which the admin path never calls). No DELETE — the reversible alternative is `status: "draft"` (#6156), which stops a NEW walk from starting while an in-flight walk finishes on its pinned snapshot. `slug` lives only inside `config.slug`. **A PATCH whose `config.slug` differs renames the magnet (#8641 D8):** `tenantDb(tenant).flows.rename` moves the row, this workspace's `Lead.source = 'flow:<slug>'` rows and its `FlowSession.flowSlug` rows to the new slug in one transaction, so its submissions follow it; `FlowCheckout` rows and lead payload copies are never rewritten. Refused before any write: `409 slug_fixed` (the stored slug is `onboarding`, `request-quote` or `contact` (#8661 D5), which the server reads by name, or the row belongs to a listing) and `409 slug_reserved` (the new slug is one of those three); inside the transaction, `409 slug_bound` (a checkout or a waitlist register of this workspace names either slug, or a lead or session of this workspace already sits at the new one) and `409 conflict` (another magnet holds it). A rename records a `magnet.rename` audit row whose summary is `{ from, to, leads, sessions }`. The two operator-writable config URLs (`abandonEmail.resumeUrl`, `followUps[].cta.href`) pass an exact-origin gate against `FRONTEND_URL` before any write, the same discipline the recommender's checkout redirect already applies (#4912). Role floors from `@working-theory/validation`'s permission table (#7636 D2) (D5): list and get serve the full row to `magnets-manage` (owner, admin) and, to a caller holding only `magnets-read` (a member), a LIVE-only summary of exactly `{ id, slug, kind, title, landingPage }` — a member's list is read to completion (at most 20 pages of 50, a `warn` at the cap) and answers `nextCursor: null`, a member's `?cursor=` is a 400, and a draft's get is the same 404 as an absent id — because a magnet's `config` carries `scoring` thresholds, `offers`/`rateTable` and `deliverables` asset keys and no draft is a member's; POST and PATCH need `magnets-manage`; the per-magnet captured-people lists need `inbox-read` (every role), and for a caller without `magnets-manage` a magnet that is not live is the same 404. Both writes declare a route-level `bodyLimit` (256 KiB) and each records one `recordAgentAction` audit row. **`customersEnabled` (#8171 D6)** — the member-area customer switch — is served on every full row and written by the PATCH (a PATCH of it alone skips the plan check); the member summary row never carries it. **The standard onboarding magnet (#8515 D2–D4):** every workspace holds one `diagnostic` magnet at slug `onboarding` (`ONBOARDING_FLOW_SLUG`, `onboardingFlowConfig` in `@working-theory/validation`), `draft` and `customersEnabled: true`, written only by `lib/tenant-db.ts`'s insert-only seed (`createMany` with `skipDuplicates`, so a re-seed creates nothing and never overwrites the owner's edits or an owner's own magnet at that slug): `POST /v1/workspaces` seeds the workspace it just created, after its transaction commits (a failed seed is one `error` line and still a `201`), and the seed-presets CLI (`node dist/bin/seed-presets.js`, run once after deploy) seeds every existing workspace and prints counts only. Its two bare-id exports have one production caller each, pinned by `tests/architecture/onboarding-seed-callers.test.ts`. **The standard Contact form magnet (#8661 D1–D3):** every workspace also holds one `contact` magnet at slug `contact` (`CONTACT_FORM_FLOW_SLUG`, `contactFormFlowConfig`), titled "Contact form", `live`, `customersEnabled: false`, landing page `/contact` — Name, Email, a five-option Subject, Message and a required terms checkbox. It is written by the same insert-only seed, from the same two callers (a failed seed is one `contact-form-seed` `error` line and still a `201`), and by `database/seed.ts` for the default workspace; a re-seed never duplicates it or overwrites an edit. The web /contact page reads it on every request through `GET /v1/flows/contact`, and a submission that used it is stamped `flow:contact`, an Inbox submission under "Contact form". Module: `multi-tenant || signal-collectors`, the same gate the review-surface dashboard (`/v1/waitlist-experiments/*`, `/v1/project-listings/*`) already registers under. No MCP twin on any of the seven — identity-bound tenant routes (#3591 exception, same shape as the sibling `/v1/workspaces/*` routes); the public `/v1/flows/*` routes and their two MCP tools are unchanged, pinned by `src/routes/flows/__tests__/parity.test.ts`. **The three new list routes (#6246 S6h)** join every magnet through `Lead.source = 'flow:' + <the resolved magnet's slug>` — the literal string `flow-engine.ts`'s `completeSubmission` stamps on every flow-engine `Lead` row regardless of kind, including `newsletter`/`waitlist` (`Subscriber` is written only by the separate, pre-flow-engine `subscribe_newsletter` path, unrelated to a magnet) — via ONE new `tenantDb(tenant).leads.listBySource(source, { limit })` accessor, an exact-source-equality sibling of #6204's allowlist-scoped `listUnhandledMessages`, served by a new `(workspaceId, source)` composite index on `Lead`. `/signups` expects `kind: "waitlist"`, `/subscribers` expects `"newsletter"`, `/messages` expects `"contact"`; a magnet id absent from the caller's workspace OR resolved to the WRONG kind for the called route is `404 not_found`, indistinguishably (mirrors the existing GET's absent-or-foreign fold). `/signups` and `/subscribers` serve four keys (`id`, `name`, `email`, `createdAt`); `/messages` serves those four plus `handledAt`. Bounded `?limit=` (default 50, max 200), newest-first — mirrors `workspaces/messages.ts`'s own query schema. Applications (Listicle) is deliberately OUT of this surface — `ProjectListing` carries no reverse link to the listicle-kind `FlowDefinition` that captured it, so "scoped to that magnet" cannot be honored without a schema change (split to #6314).
52
52
  - `/v1/content/{types,:type,:type/:slug}` — public content reads
53
53
  - `/v1/auth/tokens`, `/v1/user/{profile,data/:key}` — local auth + user data
54
54
  - `/v1/hub-sign-in/codes` (POST), `/v1/hub-sign-in/token` (POST), `/v1/hub-sign-in/revoke` (POST), `/v1/hub-sign-in/me` (GET) — the hub signs in its spokes (#7894, `src/routes/auth/hub-sign-in.ts` over `src/services/hub-sign-in.ts`; spec `docs/specs/7894-the-hub-signs-in-its-spec.md`): an authorization code with PKCE between a spoke (a confidential client whose `OAuthClient.clientId` IS its `Spoke.id`) and this hub. `codes` is in `authScope` and answers a COOKIE session only (a personal `ApiToken` is 403, an agent key 403 at the preHandler, a hub credential 404): it mints a single-use 60 s code only for an email-verified person and a callback in the client's registered `redirectUris` (exact match), 30 a user per 10 minutes. `token` and `revoke` are in their OWN scope outside `authScope` (per-IP `rateLimitMiddleware`, scope `hubsignin`, 60/min; plus a per-client bucket of 120 exchanges a minute keyed on the VERIFIED client): the caller authenticates with `Authorization: Basic clientId:secret` AND `x-ailk-spoke-key`, which must resolve to the unrevoked spoke whose id is that client — either alone is `401 invalid_client`. `token` consumes the code first and exactly once, then checks the client, the callback and the PKCE verifier (`400 invalid_grant` for any failure, the code gone either way) and issues `ailk_hst_…`, a 24 h credential stored only as a SHA-256 digest in `OAuthAuthorization` (one row per person and spoke, so a new sign-in rotates it); it writes no `Session` row and sets no cookie. `revoke` ends a credential or burns an unexpired code of that client (`{ revoked }`, never an error). `me` answers the credential's person, read live, and is reachable only by that credential. **The credential is admitted by one branch of `middleware/auth.ts`** — only beside the same spoke's key, only while its grant is unrevoked and unexpired, its client enabled and its person's email verified, and only on `HUB_SESSION_ROUTES`, an exact list of 24 method + registered-URL strings, never a prefix (#8220): `GET /v1/hub-sign-in/me`, #7877's three member reads, #8169's eight member reads, #8170's six member writes, #8435's two contact routes, #8438's two consent routes and #8515's onboarding read and write; every other route answers `404` with no handler run (the status the account area's `read()` loads as "nothing for this caller"), and the list is extended only by a reviewed edit. **The per-person budget (#8220 D2, SEC-8169-4):** each person at each spoke gets 60 requests and 10 writes a minute and 30 writes an hour (a fixed hour window from the first admitted write, so up to twice that across a window edge), keyed on the VERIFIED spoke and person and charged only after admission (a refused credential or an off-list route never gets a bucket); over budget is `429 rate_limited` with `Retry-After`. The write limits refuse only writes, and a refused request is not counted. Every line the request logger writes for an admitted request after admission carries `spokeId` (never the key or the credential); a helper logging through the module-level `logger` carries no request binding. The server-wide per-IP limiter still counts all of one spoke's members together — a named residual whose follow-up (the visitor forward key on member calls) must land before Phase 3 (#8163) ships to a live spoke. A bearer write skips the origin check, so the cross-site-request protection for the member writes is the spoke's: its server actions that send a sealed credential carry it (Phase 3, #8163; SEC-8220-11). A completed password reset (`apps/web/auth.ts` `onPasswordReset`) revokes every credential of that person. The operator registers a spoke's client with `spokes sign-in-client --id <spoke_id> --redirect-uri <url>` (`bin/spokes.ts`; `--disable` switches it off) — `lib/hub-sign-in-store.ts` is the only file that names `prisma.oAuthClient` and `prisma.oAuthAuthorization`. No MCP twin for any of the four — a credential exchange between a site and its hub is not an agent-facing call (#3591 exception, the `/v1/auth/tokens` footing). The browser step lives on the hub's web app: `apps/web/app/api/auth/hub/authorize/route.ts`.
@@ -177,7 +177,11 @@ sweep's own DB constraint (never a caller-side lock).
177
177
  - Don't put session state in `apps/api`. It's stateless.
178
178
  - Don't bundle `apps/api` (esbuild, rollup, webpack). Slice specifiers cannot survive it — see `## Deploy path`.
179
179
 
180
- Last updated: 2026-10-14 — the standard client onboarding diagnostic (#8515): the magnets bullet names the standard onboarding seed and its two callers; the flow-definition seed refuses the `onboarding` slug; the member-area bullet names Start onboarding and the outbound action; the member reads and writes bullets name the onboarding pair and the message `action`; `HUB_SESSION_ROUTES` is 24 entries — verified against `apps/api/src/lib/{tenant-db,member-store}.ts`, `apps/api/src/routes/{member/onboarding,workspaces/messages-out,workspaces/index}.ts`, `apps/api/src/services/flow-engine.ts`, `apps/api/src/bin/seed-presets.ts` and `apps/api/src/middleware/auth.ts`.
180
+ Last updated: 2026-10-14 — the Contact form magnet (#8661): the magnets bullet names the Contact form seed, its callers and the `contact` slug's place in `slug_fixed`/`slug_reserved`; the definition bullet names the `contact` refusal and the `upsertFlowDefinition` guard — verified against `apps/api/src/lib/tenant-db.ts`, `apps/api/src/routes/{workspaces,magnets}/index.ts`, `apps/api/src/services/{flow-engine,project-listings}.ts` and `apps/api/src/bin/seed-presets.ts`.
181
+
182
+ Prior: 2026-10-14 — magnets editable after creation (#8641): the magnets bullet names the slug rename, its four `409` refusals and its audit row in place of the old immutable-slug refusal — verified against `apps/api/src/routes/magnets/index.ts` and `apps/api/src/lib/tenant-db.ts`.
183
+
184
+ Prior: 2026-10-14 — the standard client onboarding diagnostic (#8515): the magnets bullet names the standard onboarding seed and its two callers; the flow-definition seed refuses the `onboarding` slug; the member-area bullet names Start onboarding and the outbound action; the member reads and writes bullets name the onboarding pair and the message `action`; `HUB_SESSION_ROUTES` is 24 entries — verified against `apps/api/src/lib/{tenant-db,member-store}.ts`, `apps/api/src/routes/{member/onboarding,workspaces/messages-out,workspaces/index}.ts`, `apps/api/src/services/flow-engine.ts`, `apps/api/src/bin/seed-presets.ts` and `apps/api/src/middleware/auth.ts`.
181
185
 
182
186
  Prior: 2026-10-14 — proposals as an artifact kind (#8516): the member-area bullet names the piece `kind`, the proposals pair, its two MCP twins and the money gate (C4); the member-writes bullet names a proposal's artifact keys, the client review audit row and the pay route with its same-site and sandbox rule; the BYO Stripe bullet names the webhook's proposal stamp — verified against `apps/api/src/routes/workspaces/pieces.ts`, `apps/api/src/routes/member/{artifacts,artifact-review}.ts`, `apps/api/src/services/proposal-payments.ts`, `apps/api/src/lib/{tenant-db,member-store,proposal-paid-store}.ts` and `apps/api/src/routes/webhooks/stripe.ts`.
183
187
 
@@ -1,5 +1,6 @@
1
1
  module.exports = {
2
2
  preset: 'ts-jest',
3
+ globalSetup: require.resolve('../../tools/jest/pin-timezone.cjs'),
3
4
  testEnvironment: 'node',
4
5
  roots: ['<rootDir>/src'],
5
6
  testMatch: ['**/__tests__/**/*.test.ts', '**/?(*.)+(test).ts'],
@@ -974,6 +974,32 @@ describe("the draft lifecycle (#5036 D1)", () => {
974
974
  expect(row["resumeTokenHash"]).toBeNull();
975
975
  });
976
976
 
977
+ // #8661 D4 — a listing's slug becomes its diagnostic flow's slug, so the
978
+ // standard magnets' slugs are taken; any other name is unchanged.
979
+ it.each([
980
+ ["Contact", "contact-2"],
981
+ ["Onboarding", "onboarding-2"],
982
+ ["Atlas", "atlas"],
983
+ ])("submit: a draft named %s is stored at %s (#8661 D4, AC-2)", async (name, slug) => {
984
+ const { id, resumeToken } = await startDraft(app);
985
+ await app.inject({
986
+ method: "PATCH",
987
+ url: `/v1/project-listings/drafts/${id}`,
988
+ headers: bearer(resumeToken),
989
+ payload: { ...COMPLETE_APPLICATION, name },
990
+ });
991
+
992
+ const res = await app.inject({
993
+ method: "POST",
994
+ url: `/v1/project-listings/drafts/${id}/submit`,
995
+ headers: bearer(resumeToken),
996
+ });
997
+
998
+ expect(res.statusCode).toBe(200);
999
+ expect(res.json()).toEqual({ ok: true, data: { id, slug } });
1000
+ expect(rows.find((r) => r["id"] === id)!["slug"]).toBe(slug);
1001
+ });
1002
+
977
1003
  it("submit: a spent token cannot patch or re-submit the listing", async () => {
978
1004
  const { id, resumeToken } = await startDraft(app);
979
1005
  await app.inject({
@@ -65,6 +65,7 @@ import {
65
65
  LISTING_ENTRY_TYPE_DEFAULT,
66
66
  PROJECT_LISTING_CONCURRENT_LIMIT,
67
67
  PROJECT_LISTING_NON_TERMINAL_STATUSES,
68
+ RESERVED_FLOW_SLUGS,
68
69
  } from "@working-theory/validation";
69
70
  import type {
70
71
  FlowAddressValue,
@@ -184,6 +185,10 @@ const SLUG_COLLISION_SCAN_LIMIT = 200;
184
185
  * free slug and race to the insert; the loser hits the unique constraint and
185
186
  * is reported as `slug_taken`, exactly as it was when the client supplied the
186
187
  * slug itself.
188
+ *
189
+ * #8661 D4 — a listing's slug becomes its diagnostic flow's slug on approval,
190
+ * so the standard magnets' reserved slugs are treated as taken: a project
191
+ * named "Contact" is stored at `contact-2`.
187
192
  */
188
193
  async function firstFreeSlug(siteId: string, base: string): Promise<string> {
189
194
  const rows = await prisma.projectListing.findMany({
@@ -191,7 +196,7 @@ async function firstFreeSlug(siteId: string, base: string): Promise<string> {
191
196
  select: { slug: true },
192
197
  take: SLUG_COLLISION_SCAN_LIMIT,
193
198
  });
194
- const taken = new Set(rows.map((r) => r.slug));
199
+ const taken = new Set<string | null>([...RESERVED_FLOW_SLUGS, ...rows.map((r) => r.slug)]);
195
200
  if (!taken.has(base)) return base;
196
201
 
197
202
  // Bounded by `taken.size + 1` iterations — one of the first `n` candidates
@@ -276,71 +276,79 @@ describe("MCP catalog ↔ server drift — UNSERVED_MCP_TOOLS subtracts from the
276
276
  });
277
277
  });
278
278
 
279
- describe("MCP catalog ↔ server agreement after a bounded re-read (#7156 AC-1)", () => {
280
- const priorEnabledModules = process.env.AILK_ENABLED_MODULES;
281
-
282
- beforeEach(() => {
283
- process.env.AILK_ENABLED_MODULES = PAID_MODULES;
284
- jest.useFakeTimers();
285
- });
279
+ describe.each([
280
+ ["hub", []],
281
+ ["spoke", ["create_project"]],
282
+ ] as const)(
283
+ "MCP catalog ↔ server agreement after a bounded re-read (#7156 AC-1) — %s seam",
284
+ (_topology, unserved) => {
285
+ const priorEnabledModules = process.env.AILK_ENABLED_MODULES;
286
286
 
287
- afterEach(() => {
288
- resetMcpCommerceClaim();
289
- jest.useRealTimers();
290
- if (priorEnabledModules === undefined) {
291
- delete process.env.AILK_ENABLED_MODULES;
292
- } else {
293
- process.env.AILK_ENABLED_MODULES = priorEnabledModules;
294
- }
295
- });
287
+ beforeEach(() => {
288
+ process.env.AILK_ENABLED_MODULES = PAID_MODULES;
289
+ mockSeam.unserved = unserved;
290
+ jest.useFakeTimers();
291
+ });
296
292
 
297
- it("a new server and listRegisteredTools() match the catalog once the re-read resolves", async () => {
298
- __setMcpFeatureGateForTest("refuse");
299
- await initMcpCommerceClaim({
300
- checkLicense: resolving({
301
- tier: "pro",
302
- features: ["scheduling", "flows-deliverables", "commerce"],
303
- }),
293
+ afterEach(() => {
294
+ resetMcpCommerceClaim();
295
+ mockSeam.unserved = undefined;
296
+ jest.useRealTimers();
297
+ if (priorEnabledModules === undefined) {
298
+ delete process.env.AILK_ENABLED_MODULES;
299
+ } else {
300
+ process.env.AILK_ENABLED_MODULES = priorEnabledModules;
301
+ }
304
302
  });
305
303
 
306
- const bootOptions: McpCatalogOptions = {
307
- addons: { commerce: true },
308
- features: ["scheduling", "flows-deliverables"],
309
- featureGate: "refuse",
310
- };
311
- const bootNames = Object.keys(registeredToolsOf(createMcpServer())).sort();
312
- expect(bootNames).toEqual(
313
- servedCatalog(bootOptions)
304
+ it("a new server and listRegisteredTools() match the catalog once the re-read resolves", async () => {
305
+ __setMcpFeatureGateForTest("refuse");
306
+ await initMcpCommerceClaim({
307
+ checkLicense: resolving({
308
+ tier: "pro",
309
+ features: ["scheduling", "flows-deliverables", "commerce"],
310
+ }),
311
+ });
312
+
313
+ const bootOptions: McpCatalogOptions = {
314
+ addons: { commerce: true },
315
+ features: ["scheduling", "flows-deliverables"],
316
+ featureGate: "refuse",
317
+ };
318
+ const bootNames = Object.keys(
319
+ registeredToolsOf(createMcpServer()),
320
+ ).sort();
321
+ // Both counts below are of the served catalog — the full catalog (20 at
322
+ // boot, 14 after the re-read) minus this seam's UNSERVED_MCP_TOOLS (#8665).
323
+ expect(bootNames).toEqual(
324
+ servedCatalog(bootOptions)
325
+ .map((t) => t.name)
326
+ .sort(),
327
+ );
328
+ expect(bootNames).toHaveLength(20 - UNSERVED_MCP_TOOLS.length);
329
+
330
+ startMcpClaimRefresh({ checkLicense: resolving({ features: [] }) });
331
+ await jest.advanceTimersByTimeAsync(MCP_CLAIM_REFRESH_MS);
332
+
333
+ const refreshedOptions: McpCatalogOptions = {
334
+ addons: { commerce: false },
335
+ features: [],
336
+ featureGate: "refuse",
337
+ };
338
+ const servedNames = servedCatalog(refreshedOptions)
314
339
  .map((t) => t.name)
315
- .sort(),
316
- );
317
- expect(bootNames).toHaveLength(20 - UNSERVED_MCP_TOOLS.length);
318
-
319
- startMcpClaimRefresh({ checkLicense: resolving({ features: [] }) });
320
- await jest.advanceTimersByTimeAsync(MCP_CLAIM_REFRESH_MS);
321
-
322
- const refreshedOptions: McpCatalogOptions = {
323
- addons: { commerce: false },
324
- features: [],
325
- featureGate: "refuse",
326
- };
327
- const catalogNames = getMcpToolCatalog(refreshedOptions)
328
- .map((t) => t.name)
329
- .sort();
330
- expect(catalogNames).toHaveLength(14);
331
-
332
- const servedNames = servedCatalog(refreshedOptions)
333
- .map((t) => t.name)
334
- .sort();
340
+ .sort();
341
+ expect(servedNames).toHaveLength(14 - UNSERVED_MCP_TOOLS.length);
335
342
 
336
- const serverNames = Object.keys(
337
- registeredToolsOf(createMcpServer()),
338
- ).sort();
339
- expect(serverNames).toEqual(servedNames);
343
+ const serverNames = Object.keys(
344
+ registeredToolsOf(createMcpServer()),
345
+ ).sort();
346
+ expect(serverNames).toEqual(servedNames);
340
347
 
341
- const listedNames = listRegisteredTools()
342
- .map((t) => t.name)
343
- .sort();
344
- expect(listedNames).toEqual(servedNames);
345
- });
346
- });
348
+ const listedNames = listRegisteredTools()
349
+ .map((t) => t.name)
350
+ .sort();
351
+ expect(listedNames).toEqual(servedNames);
352
+ });
353
+ },
354
+ );
@@ -1,5 +1,6 @@
1
1
  module.exports = {
2
2
  preset: 'ts-jest',
3
+ globalSetup: require.resolve('../../tools/jest/pin-timezone.cjs'),
3
4
  testEnvironment: 'node',
4
5
  // Workaround: @hono/node-server@1.19.14 (transitive via
5
6
  // @modelcontextprotocol/sdk's StreamableHTTPServerTransport) calls
@@ -123,6 +123,31 @@ the site's contact email, never a form that would report a message as sent
123
123
  when it reached nobody. A failed status read counts as accepting, so the
124
124
  form renders and its submit reports honestly.
125
125
 
126
+ The form's fields come from the workspace's **Contact form magnet** (#8661):
127
+ every workspace is seeded one, live at slug `contact`, and the page reads it
128
+ on every request (`force-dynamic`) through the public flow read,
129
+ `flows.get("contact")` — the API resolves the workspace (the site alias
130
+ keyless, a spoke by its key), so the page names none. `contact-form-magnet.ts`
131
+ maps the config's first step onto the block's fields, terms statement and
132
+ submit label. The order is: a usable magnet (its first step has `name` and
133
+ `email` fields), else the page's `contact-form` section fields when it
134
+ declares any, else the built-in form, which is the seeded config itself. Any
135
+ failed read (absent, draft, a Free build with no flow route, a throw) is the
136
+ fallback, never an error. A magnet-driven post carries a hidden
137
+ `source: "flow:contact"`, so it is an Inbox submission of the Contact form
138
+ with every answer shown; a fallback post stays `web`. The terms statement
139
+ links Terms, Privacy Policy and Working Agreement from a page-built
140
+ `policies` map, and `captureLeadAction` refuses a post without
141
+ `consent=true` (the intake excepted) and keeps only the sources `web`,
142
+ `conversational-intake` and `flow:contact`.
143
+
144
+ A `contact-form` section in the `column` variant (the minimalism design's
145
+ Contact page) renders one centred heading, one line and the form, and the
146
+ page passes the block `look="column"` (the stacked look, CSS in
147
+ `packages/theme/src/tokens.css`) and `consentGate="on-submit"`: Send stays
148
+ enabled and an unticked submit shows the terms error. Every other look keeps
149
+ Send disabled until the box is ticked.
150
+
126
151
  The chat-style intake is not mounted by default.
127
152
  `ConversationalIntakeWrapper.tsx` remains for a site that prefers it: it hosts
128
153
  `@working-theory/ui`'s `ConversationalIntake` block (contract in
@@ -158,4 +183,4 @@ A visitor's browser never calls the hub. Lead capture, newsletter signup, callba
158
183
  - Don't reach into `apps/api` or `apps/mcp` from `apps/web`. They're peer apps — talk via HTTP/MCP.
159
184
  - Don't commit images to `screenshots/` — only the `.gitkeep` is tracked.
160
185
 
161
- Last updated: 2026-10-05 — doc-sync: added § The same-origin relay (#7644) — verified against `apps/web/lib/relay.ts` and `apps/web/app/v1/[...path]/route.ts`.
186
+ Last updated: 2026-10-14 — § Contact page: the Contact form magnet read, the fallback order and the `column` look (#8661).
@@ -0,0 +1,46 @@
1
+ // A person's own page (#8650 D3). Reads the Person MDX for the given slug and
2
+ // emits a JSON-LD <script>. Unknown slugs 404 through renderPage. Renders
3
+ // `sections[]` when the frontmatter carries them (the /for composed
4
+ // pattern), MDX prose otherwise.
5
+ import {
6
+ getMdxSource as defaultGetMdxSource,
7
+ renderPage,
8
+ renderPageMetadata,
9
+ } from "@working-theory/page-renderer";
10
+ import type { Metadata } from "next";
11
+ import type { ReactElement } from "react";
12
+
13
+ interface Props {
14
+ params: Promise<{ locale: string; slug: string }>;
15
+ getMdxSource?: typeof defaultGetMdxSource;
16
+ }
17
+
18
+ export async function generateMetadata({
19
+ params,
20
+ }: Pick<Props, "params">): Promise<Metadata> {
21
+ const { locale, slug } = await params;
22
+ return renderPageMetadata({ type: "Person", slug, locale });
23
+ }
24
+
25
+ export default async function Page({
26
+ params,
27
+ getMdxSource = defaultGetMdxSource,
28
+ }: Props): Promise<ReactElement> {
29
+ const { locale, slug } = await params;
30
+ // Read once to pick the body mode. A null source still goes to renderPage,
31
+ // which owns the unknown-slug branch (#7537).
32
+ const source = await getMdxSource("Person", slug, locale);
33
+
34
+ const composed =
35
+ source !== null &&
36
+ Array.isArray((source.frontmatter as { sections?: unknown[] }).sections) &&
37
+ ((source.frontmatter as { sections?: unknown[] }).sections?.length ?? 0) > 0;
38
+
39
+ return renderPage({
40
+ type: "Person",
41
+ slug,
42
+ locale,
43
+ ...(composed ? {} : { bodyMode: "mdx-body" as const }),
44
+ getMdxSource: async () => source,
45
+ });
46
+ }