create-ailk 0.11.1 → 0.11.3

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 (105) hide show
  1. package/component-catalog.json +12 -1
  2. package/component-catalog.md +6 -5
  3. package/dist/content-payloads.js +2 -2
  4. package/dist/module-architecture.js +11 -0
  5. package/dist/surfaces.d.ts +4 -5
  6. package/dist/surfaces.js +50 -8
  7. package/package.json +3 -3
  8. package/templates/.env.example +42 -3
  9. package/templates/apps/api/.env.example +5 -0
  10. package/templates/apps/api/src/__tests__/cors.test.ts +91 -10
  11. package/templates/apps/api/src/__tests__/leads-forwarded-rate-limit.test.ts +135 -0
  12. package/templates/apps/api/src/__tests__/server.test.ts +5 -1
  13. package/templates/apps/api/src/__tests__/trusted-origins.server.test.ts +135 -0
  14. package/templates/apps/api/src/__tests__/vercel-handler.test.ts +233 -12
  15. package/templates/apps/api/src/config/__tests__/trusted-origins.test.ts +238 -0
  16. package/templates/apps/api/src/config/index.ts +136 -0
  17. package/templates/apps/api/src/lib/auth-email-budget.ts +3 -2
  18. package/templates/apps/api/src/middleware/__tests__/adversarial/bearer-token-format.test.ts +1 -1
  19. package/templates/apps/api/src/middleware/__tests__/adversarial/cookie-or-bearer-precedence.test.ts +1 -1
  20. package/templates/apps/api/src/middleware/__tests__/adversarial/csrf-bearer-skip.test.ts +1 -1
  21. package/templates/apps/api/src/middleware/__tests__/adversarial/csrf-cookie-path.test.ts +2 -2
  22. package/templates/apps/api/src/middleware/__tests__/adversarial/csrf-trusted-origins.test.ts +142 -0
  23. package/templates/apps/api/src/middleware/__tests__/adversarial/session-fixation.test.ts +1 -1
  24. package/templates/apps/api/src/middleware/__tests__/adversarial/session-timeout.test.ts +1 -1
  25. package/templates/apps/api/src/middleware/__tests__/auth.bearer-path.test.ts +1 -1
  26. package/templates/apps/api/src/middleware/__tests__/auth.both-present-precedence.test.ts +1 -1
  27. package/templates/apps/api/src/middleware/__tests__/auth.cookie-path.test.ts +1 -1
  28. package/templates/apps/api/src/middleware/__tests__/auth.database-not-configured.test.ts +1 -1
  29. package/templates/apps/api/src/middleware/__tests__/auth.neither-present.test.ts +1 -1
  30. package/templates/apps/api/src/middleware/__tests__/auth.optional-cookie.test.ts +1 -1
  31. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +529 -1
  32. package/templates/apps/api/src/middleware/auth.ts +19 -21
  33. package/templates/apps/api/src/middleware/rate-limit.ts +341 -28
  34. package/templates/apps/api/src/routes/auth/__tests__/auth-tokens.functional.test.ts +1 -1
  35. package/templates/apps/api/src/routes/auth/__tests__/auth-user.functional.test.ts +1 -1
  36. package/templates/apps/api/src/routes/auth/__tests__/transactional-email.test.ts +105 -1
  37. package/templates/apps/api/src/routes/auth/transactional-email.ts +37 -9
  38. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +168 -39
  39. package/templates/apps/api/src/routes/checkout/sessions.ts +20 -34
  40. package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +1 -1
  41. package/templates/apps/api/src/server.ts +49 -16
  42. package/templates/apps/api/src/services/__tests__/lead-accept-user.test.ts +251 -0
  43. package/templates/apps/api/src/services/lead-accept-user.ts +126 -0
  44. package/templates/apps/api/src/vercel-handler.ts +58 -12
  45. package/templates/apps/mcp/__tests__/submit_lead.test.ts +81 -0
  46. package/templates/apps/mcp/src/tools/capture_lead.ts +4 -0
  47. package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +3 -1
  48. package/templates/apps/web/.env.example +4 -0
  49. package/templates/apps/web/app/.well-known/ai-plugin.json/route.ts +2 -1
  50. package/templates/apps/web/app/.well-known/mcp.json/build-manifest.ts +14 -9
  51. package/templates/apps/web/app/.well-known/mcp.json/route.ts +5 -4
  52. package/templates/apps/web/app/[locale]/(auth)/__tests__/password-reset.test.tsx +2 -0
  53. package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/page.test.tsx +69 -2
  54. package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/sign-in-card.test.tsx +156 -0
  55. package/templates/apps/web/app/[locale]/(auth)/sign-in/page.tsx +44 -26
  56. package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +104 -4
  57. package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-text.ts +57 -0
  58. package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +306 -28
  59. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +143 -0
  60. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +297 -0
  61. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +145 -234
  62. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +397 -0
  63. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/support.test.tsx +203 -0
  64. package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +183 -0
  65. package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +143 -0
  66. package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +3 -1
  67. package/templates/apps/web/app/[locale]/(authed)/account/error-row.tsx +35 -0
  68. package/templates/apps/web/app/[locale]/(authed)/account/for-you-labels.ts +36 -0
  69. package/templates/apps/web/app/[locale]/(authed)/account/inbox/[kind]/[id]/page.tsx +96 -0
  70. package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +88 -0
  71. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +120 -297
  72. package/templates/apps/web/app/[locale]/(authed)/account/retry-button.tsx +27 -0
  73. package/templates/apps/web/app/[locale]/(authed)/account/settings/color-mode.tsx +68 -0
  74. package/templates/apps/web/app/[locale]/(authed)/account/settings/deletion-request.tsx +55 -0
  75. package/templates/apps/web/app/[locale]/(authed)/account/settings/name-form.tsx +118 -0
  76. package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +298 -0
  77. package/templates/apps/web/app/[locale]/(authed)/account/settings/theme-modes.ts +32 -0
  78. package/templates/apps/web/app/[locale]/(authed)/account/support/page.tsx +60 -0
  79. package/templates/apps/web/app/[locale]/(authed)/account/support/support-form.tsx +98 -0
  80. package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +98 -0
  81. package/templates/apps/web/app/[locale]/(authed)/layout.tsx +77 -3
  82. package/templates/apps/web/app/[locale]/(authed)/sign-in-gate.tsx +52 -9
  83. package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +63 -2
  84. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +22 -2
  85. package/templates/apps/web/app/[locale]/layout.tsx +98 -84
  86. package/templates/apps/web/app/[locale]/newsletter/__tests__/newsletter.actions.test.ts +66 -2
  87. package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +22 -6
  88. package/templates/apps/web/app/openapi.json/__tests__/route.test.ts +176 -0
  89. package/templates/apps/web/app/openapi.json/route.ts +127 -0
  90. package/templates/apps/web/auth.ts +102 -18
  91. package/templates/apps/web/components/sign-out-trigger.tsx +3 -2
  92. package/templates/apps/web/components/signed-in-nav.tsx +58 -0
  93. package/templates/apps/web/jest.config.cjs +5 -0
  94. package/templates/apps/web/lib/__tests__/account-config.test.ts +285 -0
  95. package/templates/apps/web/lib/__tests__/auth-email.test.ts +156 -1
  96. package/templates/apps/web/lib/__tests__/auth-routes.test.ts +41 -2
  97. package/templates/apps/web/lib/account-config.ts +219 -11
  98. package/templates/apps/web/lib/auth-email.ts +104 -2
  99. package/templates/apps/web/lib/auth-env.ts +37 -5
  100. package/templates/apps/web/lib/auth-routes.ts +33 -4
  101. package/templates/apps/web/messages/en.json +147 -21
  102. package/templates/database/CHANGELOG.md +7 -0
  103. package/templates/database/package.json +1 -1
  104. package/versions.json +33 -33
  105. package/templates/apps/web/app/[locale]/(authed)/account/sign-out-button.tsx +0 -39
@@ -49,7 +49,7 @@
49
49
  "importPath": "@working-theory/templates/shells",
50
50
  "rootBarrel": "@working-theory/templates",
51
51
  "sourceRoot": "packages/templates/src/shells",
52
- "count": 5
52
+ "count": 6
53
53
  },
54
54
  {
55
55
  "name": "docs-blog-blocks",
@@ -3015,6 +3015,17 @@
3015
3015
  "descriptionGrade": "weak",
3016
3016
  "sectionType": null
3017
3017
  },
3018
+ {
3019
+ "name": "SignedInShell",
3020
+ "tier": "shells",
3021
+ "sourcePath": "packages/templates/src/shells/SignedInShell.tsx",
3022
+ "importPath": "@working-theory/templates/shells",
3023
+ "exported": true,
3024
+ "description": "Every AILK signed-in area runs on this frame: the admin app's workspace (AppFrame supplies its switcher, Library and gating as content) and a site's own /account area.",
3025
+ "descriptionSource": "doc-comment",
3026
+ "descriptionGrade": "strong",
3027
+ "sectionType": null
3028
+ },
3018
3029
  {
3019
3030
  "name": "DocsActionBar",
3020
3031
  "tier": "docs-blog-blocks",
@@ -15,13 +15,13 @@ The component inventory of `@working-theory/ui` and the shells of `@working-theo
15
15
  3. Check the Primitives, Panels and Shells tables too — a low-level control, a collapsible layout container, or a route-level frame lives there, not under Sections or Blocks.
16
16
  4. Only then build. If you do build, you are declaring the capability is genuinely absent.
17
17
 
18
- Inventory: **303 component files** across 16 tiers — 55 sections, 99 blocks, 18 chrome, 79 primitives, 6 panels, 5 shells, 5 docs-blog-blocks, 1 docs-blog-chrome, 2 docs-blog-shells, 2 scheduling-sections, 5 scheduling-blocks, 3 signal-collectors-sections, 8 signal-collectors-blocks, 2 commerce-sections, 12 commerce-blocks, 1 commerce-panels. 300 distinct names: `SchedulerEmbed`, `Separator`, `Tabs` exist in more than one tier as genuinely different components, listed once per tier with its own source path.
18
+ Inventory: **304 component files** across 16 tiers — 55 sections, 99 blocks, 18 chrome, 79 primitives, 6 panels, 6 shells, 5 docs-blog-blocks, 1 docs-blog-chrome, 2 docs-blog-shells, 2 scheduling-sections, 5 scheduling-blocks, 3 signal-collectors-sections, 8 signal-collectors-blocks, 2 commerce-sections, 12 commerce-blocks, 1 commerce-panels. 301 distinct names: `SchedulerEmbed`, `Separator`, `Tabs` exist in more than one tier as genuinely different components, listed once per tier with its own source path.
19
19
 
20
- Description coverage: **299/303** components carry a capability description derived from source — 44 from the section registry's hand-authored when-to-use text, 224 from the component's own doc-comment header, 31 from its tier barrel's roster line. **4** have none; they are listed under [Undescribed components](#undescribed-components) rather than given a sentence invented from their name.
20
+ Description coverage: **300/304** components carry a capability description derived from source — 44 from the section registry's hand-authored when-to-use text, 225 from the component's own doc-comment header, 31 from its tier barrel's roster line. **4** have none; they are listed under [Undescribed components](#undescribed-components) rather than given a sentence invented from their name.
21
21
 
22
- Of those, **268** are *strong* — a capability sentence you could match a requirement against — and **31** are *weak*: either one short clause, or a long one that spends itself on wiring (client boundaries, class names, which primitives it composes) instead of on what the thing is for. Weak rows are marked `(weak)` and listed in full under [Weak descriptions](#weak-descriptions). Coverage is 99%, but **findable-without-luck coverage is 88%**.
22
+ Of those, **269** are *strong* — a capability sentence you could match a requirement against — and **31** are *weak*: either one short clause, or a long one that spends itself on wiring (client boundaries, class names, which primitives it composes) instead of on what the thing is for. Weak rows are marked `(weak)` and listed in full under [Weak descriptions](#weak-descriptions). Coverage is 99%, but **findable-without-luck coverage is 88%**.
23
23
 
24
- Per tier, strong out of total: **Sections** 54/55 · **Blocks** 93/99 · **Chrome** 10/18 · **Primitives** 64/79 (4 undescribed) · **Panels** 5/6 · **Shells** 4/5 · **Docs + Blog Blocks** 5/5 · **Docs + Blog Chrome** 1/1 · **Docs + Blog Shells** 0/2 · **Scheduling Sections** 2/2 · **Scheduling Blocks** 5/5 · **Signal-Collectors Sections** 3/3 · **Signal-Collectors Blocks** 7/8 · **Commerce Sections** 2/2 · **Commerce Blocks** 12/12 · **Commerce Panels** 1/1. A tier low on this line is a tier where a capability search is most likely to come up empty on something that exists.
24
+ Per tier, strong out of total: **Sections** 54/55 · **Blocks** 93/99 · **Chrome** 10/18 · **Primitives** 64/79 (4 undescribed) · **Panels** 5/6 · **Shells** 5/6 · **Docs + Blog Blocks** 5/5 · **Docs + Blog Chrome** 1/1 · **Docs + Blog Shells** 0/2 · **Scheduling Sections** 2/2 · **Scheduling Blocks** 5/5 · **Signal-Collectors Sections** 3/3 · **Signal-Collectors Blocks** 7/8 · **Commerce Sections** 2/2 · **Commerce Blocks** 12/12 · **Commerce Panels** 1/1. A tier low on this line is a tier where a capability search is most likely to come up empty on something that exists.
25
25
 
26
26
  That grade is **mechanical**. It counts what it can measure — how much a description adds beyond the component's own name, how much of it is spent on wiring, whether the extraction came out damaged — and it cannot tell a fluent paragraph about the wrong subject from a useful one. A person reading these tables will find more rows unusable than the weak count admits. Treat it as a floor on the problem, not a measurement of it, and read the source before concluding a capability is absent.
27
27
 
@@ -365,7 +365,7 @@ Import: `import { X } from "@working-theory/ui/panels";` (or from the `@working-
365
365
 
366
366
  ---
367
367
 
368
- ## Shells (5)
368
+ ## Shells (6)
369
369
 
370
370
  Route-level slot-frames — the outermost layout a page is placed into. They supply geometry and slots (nav / sidebar / main / footer), not content: you inject the real Nav, Footer and sidebar. A different package from every tier above: `@working-theory/templates`.
371
371
 
@@ -378,6 +378,7 @@ Import: `import { X } from "@working-theory/templates/shells";` (or from the `@w
378
378
  | `AuthShell` | The frame the five auth pages (/sign-in, /sign-up, /forgot-password, /reset-password, /verify-email) share: a brand mark top-start, one centered content column, and a slim footer. No nav — an auth page has nothing to navigate to, and the marketing Nav is exactly what this shell exists to replace on those routes. | `import { AuthShell } from "@working-theory/templates/shells"` |
379
379
  | `DeckHeroObserver` | Internal client island for MarketingShell's `deckHero` option. | internal — compose from `packages/templates/src/shells/DeckHeroObserver.tsx` |
380
380
  | `MarketingShell` | Public-facing marketing site **slot-frame**. Server Component (RSC default). A shell is a slot-defining frame: it carves out *where chrome goes* (top + bottom) and *where sections go* (the body), owning **geometry only**. Chrome (Nav/Footer) and sections **fill** the slots — nothing is baked into the component. See (first instance). *(weak)* | `import { MarketingShell } from "@working-theory/templates/shells"` |
381
+ | `SignedInShell` | Every AILK signed-in area runs on this frame: the admin app's workspace (AppFrame supplies its switcher, Library and gating as content) and a site's own /account area. | `import { SignedInShell } from "@working-theory/templates/shells"` |
381
382
 
382
383
  ---
383
384
 
@@ -29,8 +29,8 @@ export const CONTENT_PAYLOADS = Object.freeze([
29
29
  kind: "template-set",
30
30
  name: "scaffold-commerce",
31
31
  feature: "commerce",
32
- version: "1.0.0",
33
- digest: "baa4ac6e86ec7e3b367eb67e77c3407da90c7ff9ef51d5f4e1185615194543ee",
32
+ version: "1.0.1",
33
+ digest: "112704a7910dba3275defc0cb86b1b4f33fc8f262f4c09320e8f6b3a8d873916",
34
34
  sourceRoot: "packages/create-ailk/module-templates/commerce/.claude/skills/scaffold-commerce",
35
35
  files: Object.freeze([
36
36
  "references/lookup-keys-template.md",
@@ -790,6 +790,15 @@ export const MODULE_SLICES = {
790
790
  // deliverable by BOTH bundles.
791
791
  "apps/api/src/services/inbox-messages.ts",
792
792
  "apps/api/src/routes/workspaces/messages.ts",
793
+ // #7399 — the two siblings #6216 left `workspace`-only. They register
794
+ // under that same `workspace || multi-tenant` gate, so a scaffold with
795
+ // `multi-tenant` and without `workspace` loaded them with no file behind
796
+ // the specifier and 500'd every request at server construction. Co-owned
797
+ // with `workspace` (see that slice's entry below); both import only
798
+ // `lib/tenant-db.ts` (owned here) and base files.
799
+ // `__tests__/tenancy-slice-resolution.test.ts` holds the whole gate set.
800
+ "apps/api/src/routes/workspaces/general.ts",
801
+ "apps/api/src/routes/workspaces/members.ts",
793
802
  "apps/api/src/lib/tenant-db.ts",
794
803
  "apps/api/src/lib/ws-token.ts",
795
804
  // #6157 D16 — the magnet Admin authoring surface, co-owned with
@@ -890,6 +899,8 @@ export const MODULE_SLICES = {
890
899
  "apps/web/app/[locale]/(authed)/admin/page.tsx",
891
900
  "apps/web/app/[locale]/(authed)/admin/site/page.tsx",
892
901
  "apps/web/app/[locale]/(authed)/admin/team/page.tsx",
902
+ // Co-owned with `multi-tenant` (#7399) — see that slice's entry: both
903
+ // register under the workspace||multi-tenant gate.
893
904
  "apps/api/src/routes/workspaces/general.ts",
894
905
  "apps/api/src/routes/workspaces/members.ts",
895
906
  // Co-owned with `multi-tenant` (#6216) — see that slice's entry: the
@@ -162,11 +162,10 @@ export declare function surfaceForRouteDir(routeDir: string): string | undefined
162
162
  * file only when the target does not already have it — it never overwrites,
163
163
  * the same rule routes themselves follow.
164
164
  *
165
- * `(authed)/account` depending on two files INSIDE `(authed)/waitlist`
166
- * (`ForbiddenState.tsx`, `format.ts`) is real, found by reading the account
167
- * page's own imports, not guessed from the issue text: the account page
168
- * renders a shared "forbidden" empty state and shared count/date formatters
169
- * that the waitlist dashboard also uses, rather than duplicating them.
165
+ * Every list is read from the route's own imports, not guessed from issue
166
+ * text. `(authed)/account` no longer reaches into `(authed)/waitlist` (#7430):
167
+ * its layout renders the site's mark through `siteLogo()` on SignedInShell,
168
+ * so `site-logo.tsx` and the `site-brand.tsx` it reads travel with it.
170
169
  */
171
170
  export declare const ROUTE_DEPENDENCIES: Readonly<Record<string, readonly string[]>>;
172
171
  /**
package/dist/surfaces.js CHANGED
@@ -609,6 +609,13 @@ export const ROUTE_SINCE = {
609
609
  // choice #5048's "projects/apply" entry already made for the same reason.
610
610
  "(authed)": "0.18.0",
611
611
  "(authed)/account": "0.18.0",
612
+ // #7430 — the base account area's three new route directories (spec 7423
613
+ // U4): item detail, User settings and Support. Genuinely new, so they
614
+ // attach at today's fixed-group version, the same rule #5977, #5678, #5479,
615
+ // #5371 and #5048 applied.
616
+ "(authed)/account/inbox/[kind]/[id]": "0.30.0",
617
+ "(authed)/account/settings": "0.30.0",
618
+ "(authed)/account/support": "0.30.0",
612
619
  // The leads inbox (#5678). The ROUTE ships in every scaffold, Free
613
620
  // included — lead capture is a never-gate surface, so reading a lead back
614
621
  // is one too. Whether it shows a ROW is a separate question with two
@@ -691,11 +698,10 @@ export function surfaceForRouteDir(routeDir) {
691
698
  * file only when the target does not already have it — it never overwrites,
692
699
  * the same rule routes themselves follow.
693
700
  *
694
- * `(authed)/account` depending on two files INSIDE `(authed)/waitlist`
695
- * (`ForbiddenState.tsx`, `format.ts`) is real, found by reading the account
696
- * page's own imports, not guessed from the issue text: the account page
697
- * renders a shared "forbidden" empty state and shared count/date formatters
698
- * that the waitlist dashboard also uses, rather than duplicating them.
701
+ * Every list is read from the route's own imports, not guessed from issue
702
+ * text. `(authed)/account` no longer reaches into `(authed)/waitlist` (#7430):
703
+ * its layout renders the site's mark through `siteLogo()` on SignedInShell,
704
+ * so `site-logo.tsx` and the `site-brand.tsx` it reads travel with it.
699
705
  */
700
706
  export const ROUTE_DEPENDENCIES = {
701
707
  // #5963 — the (auth) group's layout renders the brand mark through
@@ -715,12 +721,45 @@ export const ROUTE_DEPENDENCIES = {
715
721
  "(auth)/sign-up": ["apps/web/lib/auth-env.ts"],
716
722
  "(auth)/forgot-password": ["apps/web/lib/auth-env.ts"],
717
723
  "(auth)/reset-password": ["apps/web/lib/auth-env.ts"],
718
- "(authed)": ["apps/web/auth.ts", "apps/web/lib/auth-env.ts"],
724
+ // #7430 — the group's layout reads `isAccountPath()` to give the account
725
+ // area its member-facing signed-out gate.
726
+ "(authed)": [
727
+ "apps/web/auth.ts",
728
+ "apps/web/lib/auth-env.ts",
729
+ "apps/web/lib/auth-routes.ts",
730
+ ],
719
731
  "(authed)/account": [
720
732
  "apps/web/lib/account-config.ts",
721
733
  "apps/web/lib/server-api.ts",
722
- `${ROUTE_ROOT}/(authed)/waitlist/components/ForbiddenState.tsx`,
723
- `${ROUTE_ROOT}/(authed)/waitlist/format.ts`,
734
+ "apps/web/lib/site-brand.tsx",
735
+ "apps/web/lib/site-logo.tsx",
736
+ ],
737
+ // #7430 — the three child routes also import files in their PARENT's
738
+ // directory. A scaffold whose `(authed)/account` predates #7430 keeps its
739
+ // own copy of that route (sync-routes never overwrites), so the files the
740
+ // children need travel with them: each is new in #7430, never a changed
741
+ // file sync-routes could not update.
742
+ "(authed)/account/inbox/[kind]/[id]": [
743
+ "apps/web/lib/account-config.ts",
744
+ "apps/web/lib/server-api.ts",
745
+ `${ROUTE_ROOT}/(authed)/account/account-data.ts`,
746
+ `${ROUTE_ROOT}/(authed)/account/error-row.tsx`,
747
+ `${ROUTE_ROOT}/(authed)/account/retry-button.tsx`,
748
+ ],
749
+ "(authed)/account/settings": [
750
+ "apps/web/lib/account-config.ts",
751
+ "apps/web/lib/auth-env.ts",
752
+ "apps/web/lib/server-api.ts",
753
+ "apps/web/lib/site-brand.tsx",
754
+ "apps/web/lib/site-theme.ts",
755
+ `${ROUTE_ROOT}/(authed)/account/account-data.ts`,
756
+ `${ROUTE_ROOT}/(authed)/account/support-actions.ts`,
757
+ ],
758
+ "(authed)/account/support": [
759
+ "apps/web/lib/account-config.ts",
760
+ "apps/web/lib/server-api.ts",
761
+ `${ROUTE_ROOT}/(authed)/account/account-data.ts`,
762
+ `${ROUTE_ROOT}/(authed)/account/support-actions.ts`,
724
763
  ],
725
764
  "(authed)/admin": [
726
765
  "apps/web/lib/server-api.ts",
@@ -773,6 +812,9 @@ export const ROUTE_MESSAGE_NAMESPACES = {
773
812
  "(auth)/reset-password": ["auth"],
774
813
  "(auth)/verify-email": ["auth"],
775
814
  "(authed)/account": ["account"],
815
+ "(authed)/account/inbox/[kind]/[id]": ["account"],
816
+ "(authed)/account/settings": ["account"],
817
+ "(authed)/account/support": ["account"],
776
818
  "(authed)/admin": ["admin"],
777
819
  "(authed)/leads": ["leadsInbox"],
778
820
  // #5977 — every string on both editor routes comes from the `edit`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-ailk",
3
- "version": "0.11.1",
3
+ "version": "0.11.3",
4
4
  "description": "Scaffolder for AI Launch Kit — `pnpm create ailk@latest <dir>` produces a starter repo consuming @working-theory/* from npm (per ADR 0007).",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -27,8 +27,8 @@
27
27
  "tar": "^7.5.21"
28
28
  },
29
29
  "devDependencies": {
30
- "@working-theory/schema": "0.30.0",
31
- "@working-theory/templates": "0.30.0"
30
+ "@working-theory/schema": "0.31.0",
31
+ "@working-theory/templates": "0.31.0"
32
32
  },
33
33
  "repository": {
34
34
  "type": "git",
@@ -59,10 +59,20 @@ CONTENT_ADAPTER=mdx
59
59
  # Unset in production, either one makes the API log a startup warning; the
60
60
  # API keeps running.
61
61
  # FRONTEND_URL is the site the browser loads (no trailing slash). Set it on the
62
- # API project: unset in production, CORS, cookie origin checks and the
63
- # billing-portal return URLs fall back to http://localhost:3000, and checkout
64
- # answers 500 to a caller without X-Internal-Auth.
62
+ # API project: it is the first trusted origin for CORS, cookie-authenticated
63
+ # writes and checkout redirects. Unset in production, those three trust only
64
+ # AILK_TRUSTED_ORIGINS below (with neither set, they refuse every browser
65
+ # origin and checkout answers 500 to a caller without X-Internal-Auth), and the
66
+ # billing-portal return URLs fall back to http://localhost:3000.
65
67
  # FRONTEND_URL=https://your-domain.com
68
+ # AILK_TRUSTED_ORIGINS lists MORE browser origins the API trusts, beside
69
+ # FRONTEND_URL — the Admin app and spoke sites a hub API serves. Comma-separated
70
+ # exact origins, written as a browser sends them: scheme + host (+ port), no
71
+ # path, no trailing slash, lower-case. `*`, wildcard hosts and any other form
72
+ # are refused and the API does not start; production requires https. Each
73
+ # listed origin gets CORS reads, cookie writes and checkout-redirect trust, so
74
+ # list ONLY origins the deployment owner operates — never another party's site.
75
+ # AILK_TRUSTED_ORIGINS=https://admin.your-domain.com,https://spoke.example
66
76
  # OSS_API_INTERNAL_SECRET gates POST /v1/checkout/sessions. Unset, the route
67
77
  # admits every caller. Set, it answers 401 to any caller that does not send it
68
78
  # as X-Internal-Auth — including the MCP start_purchase tool and a browser
@@ -439,6 +449,35 @@ CONTENT_ADAPTER=mdx
439
449
  # API is not on localhost.
440
450
  # AILK_API_URL=http://localhost:3001
441
451
  #
452
+ # Hub and spokes — per-visitor lead limits (#7394). When one apps/api (the
453
+ # hub) is the lead sink for several apps/web sites (spokes) that call it
454
+ # server-to-server through AILK_API_URL, every visitor of a spoke arrives at
455
+ # the hub from the spoke's one egress IP. A spoke holding a hub-issued key
456
+ # forwards the visitor's IP on its three lead writes (contact, newsletter,
457
+ # callback), and the hub counts each visitor separately. Runbook:
458
+ # docs/runbooks/site-configuration.md § Hub and spokes: per-visitor lead
459
+ # limits. All four unset = the behaviour before #7394.
460
+ #
461
+ # HUB (apps/api). One key per spoke, comma-separated; each at least 32
462
+ # characters (mint with `openssl rand -hex 32`). A shorter entry is ignored
463
+ # with a warning naming its position. A key's position identifies its spoke
464
+ # in the rate-limit log: to revoke one, BLANK its entry (`k1,,k3`) rather
465
+ # than deleting it, so no other key changes position.
466
+ # AILK_VISITOR_FORWARD_KEYS=
467
+ #
468
+ # HUB (apps/api). The leads scope's per-visitor limit per minute, and each
469
+ # key's own ceiling per minute across all of its visitors. Whole numbers from
470
+ # 1 to 999999; an invalid value is ignored with a warning. Defaults: 10 and
471
+ # 300.
472
+ # AILK_RATE_LIMIT_LEADS_PER_MINUTE=10
473
+ # AILK_RATE_LIMIT_LEADS_PER_KEY_PER_MINUTE=300
474
+ #
475
+ # SPOKE (apps/web, server-only — never NEXT_PUBLIC_). This spoke's key: one
476
+ # entry of the hub's AILK_VISITOR_FORWARD_KEYS. Sent only when AILK_API_URL
477
+ # is https (or loopback). Issue a key only to a spoke whose platform
478
+ # overwrites x-forwarded-for / x-real-ip with the real client address.
479
+ # AILK_VISITOR_FORWARD_KEY=
480
+ #
442
481
  # NEXT_PUBLIC_ENABLED_LOCALES — do NOT set by hand. Baked at build time by
443
482
  # apps/web/next.config.mjs from project.yaml `product.locales` (falls back to
444
483
  # "en" when project.yaml is absent) and read by middleware.ts, the sitemap
@@ -15,6 +15,11 @@ PORT=3002
15
15
  # Frontend URL (for CORS)
16
16
  # =============================================================================
17
17
  FRONTEND_URL=http://localhost:3000
18
+ # More trusted browser origins (Admin app, spoke sites): comma-separated exact
19
+ # origins, no path or trailing slash; `*` is refused at startup; https in production.
20
+ # Each listed origin gets credentialed CORS, cookie writes and checkout-redirect
21
+ # trust — list only origins the deployment owner operates.
22
+ # AILK_TRUSTED_ORIGINS=https://admin.your-domain.com,https://spoke.example
18
23
 
19
24
  # =============================================================================
20
25
  # Local Database (required in production)
@@ -44,7 +44,7 @@ const DRAFT_PATCH_URL = "/v1/project-listings/drafts/listing_abc123";
44
44
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- server is typed via Fastify generics; explicit any avoids pulling in the full FastifyInstance type here
45
45
  type App = any;
46
46
 
47
- const ENV_KEYS = ["FRONTEND_URL", "NODE_ENV"] as const;
47
+ const ENV_KEYS = ["FRONTEND_URL", "AILK_TRUSTED_ORIGINS", "NODE_ENV"] as const;
48
48
 
49
49
  /**
50
50
  * Build a server whose `config` was resolved from `env`. Restores the prior
@@ -52,6 +52,7 @@ const ENV_KEYS = ["FRONTEND_URL", "NODE_ENV"] as const;
52
52
  */
53
53
  async function buildWith(env: {
54
54
  FRONTEND_URL?: string;
55
+ AILK_TRUSTED_ORIGINS?: string;
55
56
  NODE_ENV: string;
56
57
  }): Promise<App> {
57
58
  for (const key of ENV_KEYS) {
@@ -147,18 +148,19 @@ describe("CORS preflight (#5397)", () => {
147
148
  // ACT
148
149
  const response = await preflight(app, FOREIGN_ORIGIN, "PATCH");
149
150
 
150
- // ASSERT — with a STRING `origin` option, @fastify/cors echoes the
151
- // configured origin rather than the requester's
152
- // (`getAccessControlAllowOriginHeader`), and the refusal is the browser's:
153
- // it blocks any response whose allow-origin is not its own origin. So the
154
- // contract to pin is that the foreign origin is never named back, and the
155
- // wildcard never appears — `credentials: true` with `*` would let any site
156
- // read credentialed responses (the security-review M1 posture server.ts
157
- // already documents). Widening `methods` must not widen WHO may use them.
151
+ // ASSERT — the refusal is the browser's: it blocks any response whose
152
+ // allow-origin is not its own origin. So the contract to pin is that the
153
+ // foreign origin is never named back, and the wildcard never appears —
154
+ // `credentials: true` with `*` would let any site read credentialed
155
+ // responses (the security-review M1 posture server.ts already documents).
156
+ // Widening `methods` must not widen WHO may use them. With the ARRAY
157
+ // `origin` option (#7395 D4), @fastify/cors writes no allow-origin header
158
+ // at all for an unlisted origin (D6.4); before #7395 it echoed the one
159
+ // configured string.
158
160
  const allowOrigin = response.headers["access-control-allow-origin"];
159
161
  expect(allowOrigin).not.toBe(FOREIGN_ORIGIN);
160
162
  expect(allowOrigin).not.toBe("*");
161
- expect(allowOrigin).toBe(FRONTEND_URL);
163
+ expect(allowOrigin).toBeUndefined();
162
164
  });
163
165
 
164
166
  it("advertises every method the registered route table actually serves", async () => {
@@ -193,3 +195,82 @@ describe("CORS preflight (#5397)", () => {
193
195
  }
194
196
  });
195
197
  });
198
+
199
+ // ── #7395 — the trusted-origin allowlist (AC-4) ──────────────────────────────
200
+ //
201
+ // One deployment serves the hub site, its Admin app and every spoke, so CORS
202
+ // answers for each origin the operator listed in FRONTEND_URL or
203
+ // AILK_TRUSTED_ORIGINS, and for no other — in development as in production,
204
+ // since the reflect-any dev arm is gone (D4). A credentialed GET, not only a
205
+ // preflight, carries the header, so a hand-written OPTIONS route cannot pass.
206
+
207
+ describe("CORS trusted-origin allowlist (#7395)", () => {
208
+ const HUB = "https://hub.example";
209
+ const SPOKE_A = "https://spoke-a.example";
210
+ const priorEnv = new Map<string, string | undefined>(
211
+ ENV_KEYS.map((key) => [key, process.env[key]]),
212
+ );
213
+ let app: App;
214
+
215
+ afterEach(async () => {
216
+ if (app) {
217
+ await app.close();
218
+ app = undefined;
219
+ }
220
+ for (const [key, value] of priorEnv) {
221
+ if (value === undefined) {
222
+ delete process.env[key];
223
+ } else {
224
+ process.env[key] = value;
225
+ }
226
+ }
227
+ jest.resetModules();
228
+ });
229
+
230
+ describe.each(["production", "development"])("NODE_ENV=%s", (nodeEnv) => {
231
+ beforeEach(async () => {
232
+ app = await buildWith({
233
+ FRONTEND_URL: HUB,
234
+ AILK_TRUSTED_ORIGINS: SPOKE_A,
235
+ NODE_ENV: nodeEnv,
236
+ });
237
+ });
238
+
239
+ it("allows a PATCH preflight from a listed spoke, with credentials and Vary: Origin", async () => {
240
+ const response = await preflight(app, SPOKE_A, "PATCH");
241
+
242
+ expect(response.headers["access-control-allow-origin"]).toBe(SPOKE_A);
243
+ expect(response.headers["access-control-allow-credentials"]).toBe(
244
+ "true",
245
+ );
246
+ expect(String(response.headers["vary"] ?? "")).toMatch(/\bOrigin\b/i);
247
+ expect(allowedMethods(response)).toContain("PATCH");
248
+ });
249
+
250
+ it("names a listed spoke on a credentialed GET, not only on a preflight", async () => {
251
+ const response = await app.inject({
252
+ method: "GET",
253
+ url: "/health",
254
+ headers: { origin: SPOKE_A, cookie: "better-auth.session_token=x" },
255
+ });
256
+
257
+ expect(response.statusCode).toBe(200);
258
+ expect(response.headers["access-control-allow-origin"]).toBe(SPOKE_A);
259
+ expect(response.headers["access-control-allow-credentials"]).toBe(
260
+ "true",
261
+ );
262
+ });
263
+
264
+ it("still allows the primary FRONTEND_URL origin", async () => {
265
+ const response = await preflight(app, HUB, "PATCH");
266
+
267
+ expect(response.headers["access-control-allow-origin"]).toBe(HUB);
268
+ });
269
+
270
+ it("writes no allow-origin header for an unlisted origin", async () => {
271
+ const response = await preflight(app, FOREIGN_ORIGIN, "PATCH");
272
+
273
+ expect(response.headers["access-control-allow-origin"]).toBeUndefined();
274
+ });
275
+ });
276
+ });
@@ -0,0 +1,135 @@
1
+ /**
2
+ * @file leads-forwarded-rate-limit.test.ts
3
+ * @description #7394 AC-7 — a keyed spoke's lead writes are limited per
4
+ * visitor end to end, through the real `build()` and the REAL
5
+ * `@fastify/rate-limit` global ceiling.
6
+ *
7
+ * `server.test.ts` (and ten other `apps/api` files) replace
8
+ * `@fastify/rate-limit` with a no-op for the whole file, so none of them can
9
+ * observe the global ceiling. This file does not mock it. It mocks only the
10
+ * two boundaries `server.test.ts` mocks for infrastructure reasons:
11
+ * `@fastify/cors` and `@working-theory/email` (an ESM-only transitive dep).
12
+ *
13
+ * `DATABASE_URL` is unset for the file's duration, so a lead write takes the
14
+ * zero-config path, where the route refuses the empty body `{}` with a 400
15
+ * before any write. With the setup file's placeholder `DATABASE_URL`, the
16
+ * route would first try to resolve the site's workspace against a database
17
+ * this test does not have. The limiter runs on `onRequest`, before either.
18
+ *
19
+ * Every request comes from one `x-forwarded-for` — the spoke host. Each
20
+ * sub-case gets a fresh `build()` after `__resetBuckets()`, so neither the
21
+ * in-house buckets nor the plugin's own in-memory store carry over.
22
+ *
23
+ * Spec: docs/specs/7394-the-leads-rate-limit-scope-spec.md §5.1 AC-7.
24
+ */
25
+
26
+ import { __resetBuckets } from "../middleware/rate-limit";
27
+ import { build } from "../server";
28
+
29
+ jest.mock("@fastify/cors", () => {
30
+ return jest.fn().mockResolvedValue(undefined);
31
+ });
32
+
33
+ jest.mock("@working-theory/email", () => ({
34
+ sendLeadNotification: jest.fn().mockResolvedValue({ ok: true }),
35
+ }));
36
+
37
+ const KEY = "0123456789abcdef".repeat(4); // gitleaks:allow — test fixture, not a credential (#7394)
38
+ const SPOKE_HOST = "198.51.100.7";
39
+
40
+ /** A distinct, valid visitor IP per index. */
41
+ function visitor(i: number): string {
42
+ return `203.0.${Math.floor(i / 250)}.${(i % 250) + 1}`;
43
+ }
44
+
45
+ function headersFor(
46
+ visitorIp: string,
47
+ withKey: boolean,
48
+ ): Record<string, string> {
49
+ return {
50
+ "x-forwarded-for": SPOKE_HOST,
51
+ "x-ailk-visitor-ip": visitorIp,
52
+ ...(withKey && { "x-ailk-visitor-forward-key": KEY }),
53
+ };
54
+ }
55
+
56
+ describe("leads rate limit for a keyed spoke, against the real global ceiling (#7394 AC-7)", () => {
57
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- server is typed via Fastify generics; explicit any matches server.test.ts
58
+ let app: any;
59
+ const prior = process.env.AILK_VISITOR_FORWARD_KEYS;
60
+ const priorDatabaseUrl = process.env.DATABASE_URL;
61
+
62
+ async function statuses(
63
+ count: number,
64
+ request: (i: number) => {
65
+ method: string;
66
+ url: string;
67
+ headers: Record<string, string>;
68
+ payload?: unknown;
69
+ },
70
+ ): Promise<number[]> {
71
+ const codes: number[] = [];
72
+ for (let i = 0; i < count; i++) {
73
+ const res = await app.inject(request(i));
74
+ codes.push(res.statusCode);
75
+ }
76
+ return codes;
77
+ }
78
+
79
+ function leadWrite(visitorIp: string, withKey: boolean) {
80
+ return {
81
+ method: "POST",
82
+ url: "/v1/leads/submit",
83
+ headers: headersFor(visitorIp, withKey),
84
+ payload: {},
85
+ };
86
+ }
87
+
88
+ beforeEach(async () => {
89
+ __resetBuckets();
90
+ process.env.AILK_VISITOR_FORWARD_KEYS = KEY;
91
+ delete process.env.DATABASE_URL;
92
+ app = await build();
93
+ await app.ready();
94
+ });
95
+
96
+ afterEach(async () => {
97
+ if (app) await app.close();
98
+ app = undefined;
99
+ });
100
+
101
+ afterAll(() => {
102
+ if (prior === undefined) delete process.env.AILK_VISITOR_FORWARD_KEYS;
103
+ else process.env.AILK_VISITOR_FORWARD_KEYS = prior;
104
+ if (priorDatabaseUrl !== undefined)
105
+ process.env.DATABASE_URL = priorDatabaseUrl;
106
+ });
107
+
108
+ it("(a) 61 keyed lead writes from distinct visitors behind one spoke host: none is 429", async () => {
109
+ const codes = await statuses(61, (i) => leadWrite(visitor(i), true));
110
+ // The route refuses `{}` before any write, so every answer is that 400.
111
+ expect(codes.every((code) => code === 400)).toBe(true);
112
+ });
113
+
114
+ it("(b) 11 keyed lead writes from one visitor: the eleventh is 429", async () => {
115
+ const codes = await statuses(11, () => leadWrite("203.0.113.1", true));
116
+ expect(codes.slice(0, 10).every((code) => code === 400)).toBe(true);
117
+ expect(codes[10]).toBe(429);
118
+ });
119
+
120
+ it("(c) 11 unkeyed lead writes from distinct visitors: the eleventh is 429", async () => {
121
+ const codes = await statuses(11, (i) => leadWrite(visitor(i), false));
122
+ expect(codes.slice(0, 10).every((code) => code === 400)).toBe(true);
123
+ expect(codes[10]).toBe(429);
124
+ });
125
+
126
+ it("(d) 61 keyed GET /health from distinct visitors: the global ceiling still keys on the spoke host", async () => {
127
+ const codes = await statuses(61, (i) => ({
128
+ method: "GET",
129
+ url: "/health",
130
+ headers: headersFor(visitor(i), true),
131
+ }));
132
+ expect(codes[59]).toBe(200);
133
+ expect(codes[60]).toBe(429);
134
+ });
135
+ });
@@ -474,7 +474,8 @@ describe("#6216 D11 — a lead_… path segment is truncated to 8 characters, cs
474
474
  //
475
475
  // POST /v1/checkout/sessions admits a caller without X-Internal-Auth when
476
476
  // OSS_API_INTERNAL_SECRET is unset, and with FRONTEND_URL unset its redirect
477
- // lock answers 500 while the CORS/cookie origin checks pin to localhost. Both
477
+ // lock answers 500 while CORS and cookie writes trust only AILK_TRUSTED_ORIGINS
478
+ // (#7395 D5; before #7395 they pinned to localhost). Both
478
479
  // are optional by design (OSS standalone compat), so startup must never fail
479
480
  // on them — but a production boot says so, once per variable. Each case rebuilds the module graph because `config/index.ts`
480
481
  // snapshots NODE_ENV at import time.
@@ -545,6 +546,9 @@ describe("#6889 — production startup warns on an unset FRONTEND_URL or OSS_API
545
546
  });
546
547
  expect(naming(messages, "FRONTEND_URL")).toHaveLength(1);
547
548
  expect(naming(messages, "OSS_API_INTERNAL_SECRET")).toHaveLength(0);
549
+ // #7395 D5 — nothing is pinned to localhost any more; an unset
550
+ // FRONTEND_URL in production trusts only AILK_TRUSTED_ORIGINS.
551
+ expect(naming(messages, "FRONTEND_URL")[0]).not.toContain("localhost:3000");
548
552
  });
549
553
 
550
554
  it("warns exactly once naming OSS_API_INTERNAL_SECRET when it is unset in production", async () => {