create-ailk 0.19.0 → 0.20.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 (197) hide show
  1. package/README.md +64 -7
  2. package/dist/add-module.js +7 -2
  3. package/dist/cli.js +35 -0
  4. package/dist/copy.d.ts +41 -0
  5. package/dist/copy.js +87 -21
  6. package/dist/index.d.ts +1 -1
  7. package/dist/index.js +1 -1
  8. package/dist/lib/fetch-template-snapshot.d.ts +21 -0
  9. package/dist/lib/fetch-template-snapshot.js +205 -0
  10. package/dist/lib/merge-three-way.d.ts +31 -0
  11. package/dist/lib/merge-three-way.js +65 -0
  12. package/dist/lib/scaffold-render-sweep.d.ts +23 -0
  13. package/dist/lib/scaffold-render-sweep.js +52 -0
  14. package/dist/module-architecture.js +64 -5
  15. package/dist/programmatic.d.ts +7 -1
  16. package/dist/programmatic.js +40 -3
  17. package/dist/site-roster.js +21 -4
  18. package/dist/spoke-checkout.d.ts +7 -0
  19. package/dist/spoke-checkout.js +8 -1
  20. package/dist/surfaces.d.ts +14 -0
  21. package/dist/surfaces.js +98 -3
  22. package/dist/template-record.d.ts +59 -0
  23. package/dist/template-record.js +183 -0
  24. package/dist/topology.js +29 -1
  25. package/dist/update.d.ts +48 -0
  26. package/dist/update.js +504 -0
  27. package/package.json +3 -3
  28. package/templates/.claude/cloudparity-repo-hooks.json +9 -0
  29. package/templates/.claude/hooks/architecture-check.sh +8 -0
  30. package/templates/.claude/rules/loop-contract.md +129 -0
  31. package/templates/.claude/settings.json +3 -2
  32. package/templates/.env.example +13 -0
  33. package/templates/CLAUDE.md +1 -1
  34. package/templates/CONVENTIONS.md +3 -2
  35. package/templates/_gitignore +162 -0
  36. package/templates/apps/api/.env.example +17 -1
  37. package/templates/apps/api/CLAUDE.md +18 -8
  38. package/templates/apps/api/package.json +2 -1
  39. package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +41 -2
  40. package/templates/apps/api/src/__tests__/setup.ts +7 -0
  41. package/templates/apps/api/src/__tests__/stripe-boot.test.ts +132 -0
  42. package/templates/apps/api/src/bin/__tests__/rate-limit-probe.test.ts +184 -0
  43. package/templates/apps/api/src/bin/rate-limit-probe.ts +53 -0
  44. package/templates/apps/api/src/lib/__mocks__/prisma.ts +8 -0
  45. package/templates/apps/api/src/lib/__tests__/consent-ledger-double.ts +237 -0
  46. package/templates/apps/api/src/lib/__tests__/consent-store.test.ts +334 -0
  47. package/templates/apps/api/src/lib/__tests__/stripe-admission.test.ts +217 -0
  48. package/templates/apps/api/src/lib/__tests__/stripe.test.ts +7 -0
  49. package/templates/apps/api/src/lib/consent-store.ts +335 -0
  50. package/templates/apps/api/src/lib/proposal-paid-store.ts +49 -0
  51. package/templates/apps/api/src/lib/rate-limit-probe.ts +165 -0
  52. package/templates/apps/api/src/lib/rate-limit-store.ts +142 -0
  53. package/templates/apps/api/src/lib/stripe.ts +97 -5
  54. package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +13 -4
  55. package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +2 -0
  56. package/templates/apps/api/src/middleware/__tests__/rate-limit-cross-isolate.db.test.ts +220 -0
  57. package/templates/apps/api/src/middleware/__tests__/rate-limit-store.test.ts +430 -0
  58. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +72 -14
  59. package/templates/apps/api/src/middleware/auth.ts +16 -4
  60. package/templates/apps/api/src/middleware/default-tenant.ts +9 -0
  61. package/templates/apps/api/src/middleware/rate-limit.ts +295 -82
  62. package/templates/apps/api/src/routes/checkout/__tests__/live-key.test.ts +167 -0
  63. package/templates/apps/api/src/routes/checkout/session.ts +3 -3
  64. package/templates/apps/api/src/routes/checkout/sessions.ts +6 -4
  65. package/templates/apps/api/src/routes/consent/cookie.ts +90 -0
  66. package/templates/apps/api/src/routes/consent/index.ts +18 -0
  67. package/templates/apps/api/src/routes/consent/unsubscribe.ts +127 -0
  68. package/templates/apps/api/src/routes/leads/index.ts +15 -0
  69. package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +5 -0
  70. package/templates/apps/api/src/routes/webhooks/README.md +33 -5
  71. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +17 -4
  72. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +2 -0
  73. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode-live.test.ts +159 -0
  74. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode.test.ts +158 -0
  75. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-proposal-paid.test.ts +369 -0
  76. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +2 -0
  77. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +442 -0
  78. package/templates/apps/api/src/routes/webhooks/stripe.ts +201 -6
  79. package/templates/apps/api/src/server.ts +125 -18
  80. package/templates/apps/api/src/services/__tests__/audit-log.test.ts +91 -0
  81. package/templates/apps/api/src/services/__tests__/consent-ledger.test.ts +503 -0
  82. package/templates/apps/api/src/services/__tests__/consent.test.ts +18 -3
  83. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +115 -4
  84. package/templates/apps/api/src/services/__tests__/person.test.ts +392 -0
  85. package/templates/apps/api/src/services/__tests__/request-confirm.test.ts +87 -0
  86. package/templates/apps/api/src/services/__tests__/revalidation.test.ts +57 -17
  87. package/templates/apps/api/src/services/audit-log.ts +72 -10
  88. package/templates/apps/api/src/services/consent.ts +488 -3
  89. package/templates/apps/api/src/services/default-tenant.ts +12 -3
  90. package/templates/apps/api/src/services/hub-sign-in.ts +2 -1
  91. package/templates/apps/api/src/services/lead-routing.ts +105 -12
  92. package/templates/apps/api/src/services/person.ts +501 -1
  93. package/templates/apps/api/src/services/project-listings.ts +7 -2
  94. package/templates/apps/api/src/services/public-site-resolver.ts +21 -2
  95. package/templates/apps/api/src/services/request-confirm.ts +52 -0
  96. package/templates/apps/api/src/services/revalidation.ts +11 -1
  97. package/templates/apps/api/vercel.json +1 -1
  98. package/templates/apps/mcp/CLAUDE.md +19 -7
  99. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +95 -8
  100. package/templates/apps/mcp/src/tools/person_notes.ts +25 -0
  101. package/templates/apps/mcp/src/tools/proposals.ts +27 -0
  102. package/templates/apps/mcp/src/workspace-server.ts +16 -0
  103. package/templates/apps/web/_gitignore +6 -0
  104. package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/sign-up-card.test.tsx +165 -0
  105. package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +19 -0
  106. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +106 -23
  107. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up.actions.ts +125 -0
  108. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/account-frame-sign-out.test.tsx +175 -0
  109. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/agreements-prompt.test.tsx +281 -0
  110. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/billing.test.tsx +17 -3
  111. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +154 -53
  112. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +116 -27
  113. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +57 -2
  114. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout-sign-out.test.tsx +146 -0
  115. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +124 -18
  116. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/notifications.test.tsx +116 -18
  117. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/onboarding.test.tsx +323 -0
  118. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +365 -0
  119. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/security.test.tsx +290 -20
  120. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +56 -0
  121. package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +74 -4
  122. package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +8 -0
  123. package/templates/apps/web/app/[locale]/(authed)/account/agreements-actions.ts +170 -0
  124. package/templates/apps/web/app/[locale]/(authed)/account/agreements-prompt.tsx +181 -0
  125. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/page.tsx +88 -3
  126. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/pay-form.tsx +65 -0
  127. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/review-form.tsx +27 -0
  128. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/page.test.tsx +176 -3
  129. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/pay.test.tsx +193 -0
  130. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/review.test.tsx +188 -0
  131. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-actions.ts +65 -4
  132. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-data.ts +17 -0
  133. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/page.tsx +15 -9
  134. package/templates/apps/web/app/[locale]/(authed)/account/billing/page.tsx +3 -3
  135. package/templates/apps/web/app/[locale]/(authed)/account/general/contact-actions.ts +81 -0
  136. package/templates/apps/web/app/[locale]/(authed)/account/general/page.tsx +70 -27
  137. package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +26 -3
  138. package/templates/apps/web/app/[locale]/(authed)/account/notifications/page.tsx +65 -19
  139. package/templates/apps/web/app/[locale]/(authed)/account/onboarding/actions.ts +120 -0
  140. package/templates/apps/web/app/[locale]/(authed)/account/onboarding/onboarding-form.tsx +322 -0
  141. package/templates/apps/web/app/[locale]/(authed)/account/onboarding/page.tsx +126 -0
  142. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +43 -1
  143. package/templates/apps/web/app/[locale]/(authed)/account/privacy/actions.ts +65 -0
  144. package/templates/apps/web/app/[locale]/(authed)/account/privacy/page.tsx +226 -0
  145. package/templates/apps/web/app/[locale]/(authed)/account/security/page.tsx +89 -37
  146. package/templates/apps/web/app/[locale]/(authed)/account/security/security-panel.tsx +102 -0
  147. package/templates/apps/web/app/[locale]/(authed)/account/security/security-source.ts +62 -0
  148. package/templates/apps/web/app/[locale]/(authed)/account/settings-frame.tsx +44 -34
  149. package/templates/apps/web/app/[locale]/(authed)/account/sign-in-method.ts +1 -1
  150. package/templates/apps/web/app/[locale]/consent-banner.tsx +32 -0
  151. package/templates/apps/web/app/[locale]/consent.actions.ts +71 -0
  152. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +13 -1
  153. package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +25 -3
  154. package/templates/apps/web/app/[locale]/layout.tsx +23 -2
  155. package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +12 -1
  156. package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe-rsc-boundary.test.tsx +209 -0
  157. package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe.test.tsx +158 -0
  158. package/templates/apps/web/app/[locale]/unsubscribe/page.tsx +54 -0
  159. package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe-button.tsx +73 -0
  160. package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe.actions.ts +55 -0
  161. package/templates/apps/web/app/robots.txt/route.ts +13 -10
  162. package/templates/apps/web/auth.ts +20 -1
  163. package/templates/apps/web/jest.config.cjs +3 -0
  164. package/templates/apps/web/lib/__tests__/auth-email.test.ts +84 -0
  165. package/templates/apps/web/lib/__tests__/consent-visitor.test.ts +163 -0
  166. package/templates/apps/web/lib/__tests__/relay.test.ts +49 -0
  167. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +96 -7
  168. package/templates/apps/web/lib/account-config.ts +31 -16
  169. package/templates/apps/web/lib/auth-email.ts +45 -10
  170. package/templates/apps/web/lib/consent-visitor.ts +103 -0
  171. package/templates/apps/web/lib/relay.ts +20 -0
  172. package/templates/apps/web/lib/topology.generated.ts +9 -0
  173. package/templates/apps/web/messages/en.json +121 -18
  174. package/templates/apps/web/next.config.mjs +8 -2
  175. package/templates/database/CHANGELOG.md +55 -0
  176. package/templates/database/__tests__/consent-ledger.db.test.ts +345 -0
  177. package/templates/database/__tests__/fence-fan-out.db.test.ts +6 -2
  178. package/templates/database/__tests__/fixtures/person-contact-backfill.json +182 -0
  179. package/templates/database/__tests__/outbound-message-action.db.test.ts +80 -0
  180. package/templates/database/__tests__/person-contact.db.test.ts +563 -0
  181. package/templates/database/__tests__/person-notes.db.test.ts +249 -0
  182. package/templates/database/__tests__/person.db.test.ts +3 -1
  183. package/templates/database/__tests__/piece-kind-migration.test.ts +67 -0
  184. package/templates/database/__tests__/piece-kind.db.test.ts +208 -0
  185. package/templates/database/_gitignore +8 -0
  186. package/templates/database/inbox/schema.prisma +114 -22
  187. package/templates/database/migrations/20261014120000_consent_ledger/migration.sql +122 -0
  188. package/templates/database/migrations/20261014120000_person_contact/migration.sql +231 -0
  189. package/templates/database/migrations/20261015120000_person_notes/migration.sql +77 -0
  190. package/templates/database/migrations/20261015120000_rate_limit_counters/migration.sql +43 -0
  191. package/templates/database/migrations/20261016120000_piece_kind/migration.sql +95 -0
  192. package/templates/database/migrations/20261017120000_outbound_message_action/migration.sql +30 -0
  193. package/templates/database/ops/schema.prisma +16 -0
  194. package/templates/database/package.json +1 -1
  195. package/templates/project.yaml +6 -3
  196. package/versions.json +35 -35
  197. package/templates/.claude/rules/preserve-existing-style.md +0 -51
@@ -0,0 +1,65 @@
1
+ /**
2
+ * `git merge-file` wrapper — the three-way text merge `create-ailk update`
3
+ * runs for a copied app file the site owner edited and the template also
4
+ * changed (#8522 D5).
5
+ *
6
+ * `git merge-file` is the reference diff3 merge: every AILK owner already
7
+ * has `git` on `PATH` (it is how they cloned or will push this site), and
8
+ * using it adds no new npm dependency. The three versions are written to a
9
+ * fresh temp directory (never the site's own tree) and read back as text;
10
+ * nothing is left behind.
11
+ */
12
+ import { spawnSync } from "node:child_process";
13
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
14
+ import { tmpdir } from "node:os";
15
+ import { join } from "node:path";
16
+ /**
17
+ * Run `git merge-file -p` over the three texts in a `mkdtemp` directory, no
18
+ * shell, argv holding only temp-file paths and the three labels (#8522 D5).
19
+ *
20
+ * Exit 0 is a clean merge (`merged`). Exit 1-127 is a conflicting hunk
21
+ * (`conflict`) — the file is never written with conflict markers by this
22
+ * function; the caller decides what to do with a conflict, and nothing here
23
+ * ever applies one. Any other exit, or a spawn failure (no `git` on `PATH`),
24
+ * is `error`.
25
+ */
26
+ export function mergeThreeWay(opts) {
27
+ const dir = mkdtempSync(join(tmpdir(), "ailk-merge-"));
28
+ try {
29
+ const oursPath = join(dir, "ours");
30
+ const basePath = join(dir, "base");
31
+ const theirsPath = join(dir, "theirs");
32
+ writeFileSync(oursPath, opts.ours);
33
+ writeFileSync(basePath, opts.base);
34
+ writeFileSync(theirsPath, opts.theirs);
35
+ const result = spawnSync("git", [
36
+ "merge-file",
37
+ "-p",
38
+ "-L",
39
+ opts.oursLabel,
40
+ "-L",
41
+ opts.baseLabel,
42
+ "-L",
43
+ opts.theirsLabel,
44
+ oursPath,
45
+ basePath,
46
+ theirsPath,
47
+ ], { shell: false });
48
+ if (result.error !== undefined) {
49
+ return { status: "error", reason: result.error.message };
50
+ }
51
+ if (result.status === 0) {
52
+ return { status: "merged", text: result.stdout.toString("utf8") };
53
+ }
54
+ if (result.status !== null && result.status >= 1 && result.status <= 127) {
55
+ return { status: "conflict" };
56
+ }
57
+ return {
58
+ status: "error",
59
+ reason: `git merge-file exited ${result.status ?? `signal ${result.signal}`}`,
60
+ };
61
+ }
62
+ finally {
63
+ rmSync(dir, { recursive: true, force: true });
64
+ }
65
+ }
@@ -105,6 +105,20 @@ export declare function assessConfigGatedRoute(input: {
105
105
  html: string;
106
106
  locale: string;
107
107
  }): SweepVerdict;
108
+ /**
109
+ * The environment the sitemap sweep's server runs under (#8501): `env` with
110
+ * every `CONFIG_GATED_ROUTES` variable removed.
111
+ *
112
+ * `/sitemap.xml` is prerendered at `next build`, from the content seeds, so it
113
+ * describes the scaffold AS GENERATED — and a config-gated route's seeds are
114
+ * served only while its variable is unset (the Free placeholder hand-off,
115
+ * #6942). Set it and the route serves its live module page instead: `/meet`
116
+ * becomes the booker for the operator's enabled events, `/meet/<seed>` a 404.
117
+ * A leg that configures the module for its booking steps would therefore score
118
+ * the sitemap against pages it was never written for, so the sweep runs
119
+ * against a server that leaves the gated variables unset. Pure.
120
+ */
121
+ export declare function sitemapSweepEnv(env: Readonly<Record<string, string | undefined>>): Record<string, string | undefined>;
108
122
  /**
109
123
  * Every FILE under `dir`, recursing into directories, as sorted slash-joined
110
124
  * paths relative to it — so `public/.well-known/security.txt` is listed and
@@ -124,6 +138,15 @@ export declare function assessUnknownSlug(input: {
124
138
  hops: RedirectHop[];
125
139
  status: number | null;
126
140
  }): SweepVerdict;
141
+ /**
142
+ * Every `<loc>` path in a sitemap XML document, in order, deduped (#8427
143
+ * D6). Pure — no network or filesystem access, like its neighbours. Each
144
+ * `<loc>` is un-escaped against the five entities `escapeXml` writes, then
145
+ * read as `new URL(loc).pathname` — so a query string contributes nothing
146
+ * and an `<xhtml:link rel="alternate">` sibling, which carries no `<loc>` of
147
+ * its own, contributes nothing either.
148
+ */
149
+ export declare function sitemapLocPaths(xml: string): string[];
127
150
  /** Fails, naming each route, when `.next/prerender-manifest.json`'s `dynamicRoutes` has a key under `/[locale]/`. */
128
151
  export declare function assessPrerenderManifest(manifest: {
129
152
  dynamicRoutes?: Record<string, unknown>;
@@ -239,6 +239,23 @@ export function assessConfigGatedRoute(input) {
239
239
  function headingTexts(html) {
240
240
  return [...html.matchAll(/<h1\b[^>]*>([\s\S]*?)<\/h1>/gi)].map((m) => (m[1] ?? "").replace(/<[^>]*>/g, ""));
241
241
  }
242
+ /**
243
+ * The environment the sitemap sweep's server runs under (#8501): `env` with
244
+ * every `CONFIG_GATED_ROUTES` variable removed.
245
+ *
246
+ * `/sitemap.xml` is prerendered at `next build`, from the content seeds, so it
247
+ * describes the scaffold AS GENERATED — and a config-gated route's seeds are
248
+ * served only while its variable is unset (the Free placeholder hand-off,
249
+ * #6942). Set it and the route serves its live module page instead: `/meet`
250
+ * becomes the booker for the operator's enabled events, `/meet/<seed>` a 404.
251
+ * A leg that configures the module for its booking steps would therefore score
252
+ * the sitemap against pages it was never written for, so the sweep runs
253
+ * against a server that leaves the gated variables unset. Pure.
254
+ */
255
+ export function sitemapSweepEnv(env) {
256
+ const gated = new Set(Object.values(CONFIG_GATED_ROUTES));
257
+ return Object.fromEntries(Object.entries(env).filter(([name]) => !gated.has(name)));
258
+ }
242
259
  /**
243
260
  * Every FILE under `dir`, recursing into directories, as sorted slash-joined
244
261
  * paths relative to it — so `public/.well-known/security.txt` is listed and
@@ -291,6 +308,41 @@ export function assessUnknownSlug(input) {
291
308
  return { ok: false, reason: `${route} -> status ${status} (expected 404 for an unknown slug)` };
292
309
  return { ok: true, reason: "404" };
293
310
  }
311
+ /**
312
+ * The five entities `escapeXml` (`apps/web/app/sitemap.xml/route.ts`) writes,
313
+ * reversed.
314
+ */
315
+ const XML_ENTITIES = {
316
+ "&amp;": "&",
317
+ "&lt;": "<",
318
+ "&gt;": ">",
319
+ "&quot;": '"',
320
+ "&apos;": "'",
321
+ };
322
+ function unescapeXml(value) {
323
+ return value.replace(/&amp;|&lt;|&gt;|&quot;|&apos;/g, (entity) => XML_ENTITIES[entity] ?? entity);
324
+ }
325
+ /**
326
+ * Every `<loc>` path in a sitemap XML document, in order, deduped (#8427
327
+ * D6). Pure — no network or filesystem access, like its neighbours. Each
328
+ * `<loc>` is un-escaped against the five entities `escapeXml` writes, then
329
+ * read as `new URL(loc).pathname` — so a query string contributes nothing
330
+ * and an `<xhtml:link rel="alternate">` sibling, which carries no `<loc>` of
331
+ * its own, contributes nothing either.
332
+ */
333
+ export function sitemapLocPaths(xml) {
334
+ const seen = new Set();
335
+ const paths = [];
336
+ for (const match of xml.matchAll(/<loc>([^<]*)<\/loc>/g)) {
337
+ const loc = unescapeXml(match[1] ?? "");
338
+ const path = new URL(loc).pathname;
339
+ if (seen.has(path))
340
+ continue;
341
+ seen.add(path);
342
+ paths.push(path);
343
+ }
344
+ return paths;
345
+ }
294
346
  /** Fails, naming each route, when `.next/prerender-manifest.json`'s `dynamicRoutes` has a key under `/[locale]/`. */
295
347
  export function assessPrerenderManifest(manifest) {
296
348
  const locale = Object.keys(manifest.dynamicRoutes ?? {})
@@ -21,7 +21,7 @@
21
21
  * `architecture.yaml` template files — a fixed template IS the drift this
22
22
  * removes.
23
23
  */
24
- import { existsSync, readFileSync } from "node:fs";
24
+ import { existsSync, lstatSync, readFileSync, realpathSync } from "node:fs";
25
25
  import { join, sep } from "node:path";
26
26
  import { CONTENT_PAYLOADS, } from "./content-payloads.js";
27
27
  import { importClosure } from "./lib/import-graph.js";
@@ -77,8 +77,8 @@ const PUBLIC_SCHEDULING_VAR_PATTERN = [
77
77
  * live-key-shaped token in this file's source would itself trip the repo's
78
78
  * own live-key hard block (`rules/environment-boundaries.md`). `apps/api` is
79
79
  * excluded because `STRIPE_SECRET_KEY` legitimately lives there, gated by
80
- * `assertSandboxKey`'s sandbox-only allow-list at request time and by
81
- * `assertCommerceSandboxKey` at boot (server.ts) — this pattern catches a
80
+ * lib/stripe.ts's admitted-key guard at request time and by
81
+ * `admitCommerceStripeKey` at boot (server.ts) — this pattern catches a
82
82
  * live-shaped key leaking anywhere ELSE (a fixture, a client component, a
83
83
  * `NEXT_PUBLIC_` var).
84
84
  */
@@ -183,10 +183,18 @@ const MEMBER_AREA_FILES = [
183
183
  "apps/api/src/routes/member/request-form.ts",
184
184
  "apps/api/src/routes/member/billing.ts",
185
185
  "apps/api/src/routes/member/requests.ts",
186
+ // #8515 D13 — the client's onboarding read and write.
187
+ "apps/api/src/routes/member/onboarding.ts",
186
188
  "apps/api/src/routes/member/artifact-review.ts",
187
189
  "apps/api/src/routes/member/profile.ts",
188
190
  "apps/api/src/routes/member/deletion-request.ts",
191
+ // #8438 D6 — the member's own consents; the same gate as the writes above.
192
+ "apps/api/src/routes/member/consents.ts",
189
193
  "apps/api/src/services/member-area.ts",
194
+ // #8516 D8 — whether proposal payment runs, and the Checkout Session;
195
+ // imported only by member-area files (pieces.ts and the two member
196
+ // artifact routes), and itself imports only base files.
197
+ "apps/api/src/services/proposal-payments.ts",
190
198
  "apps/api/src/lib/member-store.ts",
191
199
  // #8226 D12 — the member-request match CLI; both import member-store.ts,
192
200
  // which a build without the member slice does not carry.
@@ -785,6 +793,10 @@ export const MODULE_SLICES = {
785
793
  "apps/api/src/routes/workspaces/index.ts",
786
794
  "apps/api/src/routes/workspaces/sessions.ts",
787
795
  "apps/api/src/routes/workspaces/config.ts",
796
+ // #8438 D8 — the Admin owner's consents to Working Theory: registered
797
+ // under `isModuleEnabled("multi-tenant")` alone, the module that
798
+ // contributes the Admin app.
799
+ "apps/api/src/routes/consent/platform.ts",
788
800
  // Co-owned with `workspace` (#5853): the content-write scope is gated on
789
801
  // content-write AND (workspace || multi-tenant) and this file imports
790
802
  // lib/tenant-db.ts, so each tenancy slice must deliver it.
@@ -1482,6 +1494,13 @@ export const MODULE_SLICES = {
1482
1494
  "apps/api/src/routes/slack/index.ts",
1483
1495
  "apps/api/src/routes/slack/actions.ts",
1484
1496
  "apps/api/src/routes/waitlist-signups/index.ts",
1497
+ // #7479 — the listing-draft forwarded-visitor suite exercises the three
1498
+ // project-listings draft routes through the real build(), and those
1499
+ // routes register only under isModuleEnabled("signal-collectors"), so
1500
+ // the suite strips with this slice (the #7389 shape for
1501
+ // schedule-forwarded-rate-limit.test.ts under scheduling) instead of
1502
+ // failing every keyless scaffold's apps/api jest gate.
1503
+ "apps/api/src/__tests__/listings-forwarded-rate-limit.test.ts",
1485
1504
  // apps/api/src/routes/project-listings/{start,patch-draft,submit,public,
1486
1505
  // me,respond,resume-token}.ts and services/{project-listings,
1487
1506
  // listing-config}.ts deliberately NOT listed — see the comment above
@@ -1843,7 +1862,7 @@ export const MODULE_SLICES = {
1843
1862
  {
1844
1863
  pattern: STRIPE_LIVE_KEY_PATTERN,
1845
1864
  paths_excluded: ["apps/api/**"],
1846
- reason: "The commerce credential belongs to apps/api, behind assertSandboxKey's sandbox-only allow-list. A live-mode-shaped key anywhere else is a leak, not a config choice",
1865
+ reason: "The commerce credential belongs to apps/api, behind lib/stripe.ts's admitted-key guard. A live-mode-shaped key anywhere else is a leak, not a config choice",
1847
1866
  },
1848
1867
  ],
1849
1868
  // ownedFiles (S1, #5088). SOURCE files only (#5182 D6) — no __tests__/,
@@ -2195,11 +2214,51 @@ export function missingArchitectureEnforcement(siteDir) {
2195
2214
  missing.push("package.json scripts.preflight (runs pnpm architecture)");
2196
2215
  }
2197
2216
  const postToolUse = readJson(".claude/settings.json")?.hooks?.PostToolUse ?? [];
2217
+ const ARCH_PNPM_RE = /\bpnpm\b.*\barchitecture\b/;
2218
+ // CloudParity's repo-owned-hook command grammar (#8509 D2/D6): no
2219
+ // $CLAUDE_PROJECT_DIR prefix, no arguments, no shell operators.
2220
+ const ARCH_HOOK_SCRIPT_RE = /^bash \.claude\/hooks\/([A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*)$/;
2221
+ /**
2222
+ * A command "runs architecture" either in today's inline form (matched on
2223
+ * the command string itself, so a pre-#8509 scaffold still verifies) or in
2224
+ * the script form CloudParity's declaration grammar requires — read the
2225
+ * named script's own text, so a deleted or emptied script still reports
2226
+ * missing even though the command string is unchanged.
2227
+ */
2228
+ const commandRunsArchitecture = (command) => {
2229
+ if (ARCH_PNPM_RE.test(command))
2230
+ return true;
2231
+ const match = ARCH_HOOK_SCRIPT_RE.exec(command);
2232
+ if (!match)
2233
+ return false;
2234
+ const scriptRel = match[1];
2235
+ // The charset `ARCH_HOOK_SCRIPT_RE` allows per segment admits "." and
2236
+ // "..", which CloudParity's own grammar forbids (#8509 spec §1) — reject
2237
+ // them here too, so a crafted command string can't walk this read
2238
+ // outside siteDir/.claude/hooks/.
2239
+ if (scriptRel.split("/").some((seg) => seg === "." || seg === ".."))
2240
+ return false;
2241
+ const hooksDir = join(siteDir, ".claude", "hooks");
2242
+ const scriptPath = join(hooksDir, scriptRel);
2243
+ if (!existsSync(scriptPath))
2244
+ return false;
2245
+ try {
2246
+ const realHooksDir = realpathSync(hooksDir);
2247
+ const realScript = realpathSync(scriptPath);
2248
+ if (!realScript.startsWith(realHooksDir + sep) || !lstatSync(realScript).isFile()) {
2249
+ return false;
2250
+ }
2251
+ return ARCH_PNPM_RE.test(readFileSync(realScript, "utf8"));
2252
+ }
2253
+ catch {
2254
+ return false;
2255
+ }
2256
+ };
2198
2257
  const hooked = postToolUse.some((entry) => {
2199
2258
  const tools = (entry.matcher ?? "").split("|");
2200
2259
  return (tools.includes("Edit") &&
2201
2260
  tools.includes("Write") &&
2202
- (entry.hooks ?? []).some((h) => /\bpnpm\b.*\barchitecture\b/.test(h.command ?? "")));
2261
+ (entry.hooks ?? []).some((h) => commandRunsArchitecture(h.command ?? "")));
2203
2262
  });
2204
2263
  if (!hooked) {
2205
2264
  missing.push(".claude/settings.json PostToolUse hook on Edit|Write (runs pnpm architecture)");
@@ -195,8 +195,14 @@ export interface ScaffoldResult {
195
195
  *
196
196
  * Throws ValidationError if targetDir already exists and is non-empty.
197
197
  * Throws Error if the template copy fails for any other reason.
198
+ *
199
+ * @param copied — INTERNAL (#8522 D2): when passed, every recordable path
200
+ * `copyTemplate` writes is hashed into this map (destination path -> the
201
+ * sha256 of the bytes written), for `scaffold()` to build
202
+ * `.ailk-template-files.json` from once every later layer has landed. No
203
+ * existing caller passes it, so their behaviour is unchanged.
198
204
  */
199
- export declare function scaffoldSync(opts: ScaffoldOptions): ScaffoldResult;
205
+ export declare function scaffoldSync(opts: ScaffoldOptions, copied?: Map<string, string>): ScaffoldResult;
200
206
  /**
201
207
  * Scaffold an AI Launch Kit starter project into targetDir (async wrapper).
202
208
  *
@@ -45,9 +45,10 @@ import { SITE_FONTS_PATH, renderSiteFonts } from "./site-fonts.js";
45
45
  import { planSiteManifest } from "./site-manifest.js";
46
46
  import { SITE_ROSTER_PATH, planHomeRecord, planSiteManifestDesign, readSiteRoster, withMeetPlaceholderSeeds, } from "./site-roster.js";
47
47
  import { spokeCheckoutSite, spokeEnvRowOptions, writeSpokeCheckoutSlice, } from "./spoke-checkout.js";
48
- import { GATEABLE_SURFACES, MODULE_SURFACES, NAV_CONFIG_PATH, excludedRoutePaths, renderNavConfig, resolveSurfaces, } from "./surfaces.js";
48
+ import { GATEABLE_SURFACES, MODULE_SURFACES, NAV_CONFIG_PATH, excludedRoutePaths, isUnservedSurfaceContent, renderNavConfig, resolveSurfaces, } from "./surfaces.js";
49
49
  import { syncRoutes } from "./sync-routes.js";
50
50
  import { BASE_LAYER_DIR, SHAPE_STARTERS, TEMPLATE_MANIFESTS, TemplateError, mergeRoster, resolveTemplate, resolveTemplateLineage, rosterDeclares, templateDelivery, templateTreeDir, } from "./template-manifest.js";
51
+ import { TEMPLATE_RECORD_PATH, isRecordable, planTemplateRecord, serializeTemplateRecord, sha256Hex, } from "./template-record.js";
51
52
  import { SPOKE_ONLY_FILES, TopologyError, applySpokeTransforms, isSpokeExcluded, normalizeHubSignInUrl, normalizeHubUrl, readSiteTopology, spokeExclusions, spokeDestination, spokeModuleRefusal, spokeModules, writeHubOriginEnv, writeHubSignInEnv, } from "./topology.js";
52
53
  import { validateTarget } from "./validate.js";
53
54
  const PKG_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
@@ -148,8 +149,14 @@ function resolveShapeTemplate(shape) {
148
149
  *
149
150
  * Throws ValidationError if targetDir already exists and is non-empty.
150
151
  * Throws Error if the template copy fails for any other reason.
152
+ *
153
+ * @param copied — INTERNAL (#8522 D2): when passed, every recordable path
154
+ * `copyTemplate` writes is hashed into this map (destination path -> the
155
+ * sha256 of the bytes written), for `scaffold()` to build
156
+ * `.ailk-template-files.json` from once every later layer has landed. No
157
+ * existing caller passes it, so their behaviour is unchanged.
151
158
  */
152
- export function scaffoldSync(opts) {
159
+ export function scaffoldSync(opts, copied) {
153
160
  // Validate target — throws ValidationError on rejection.
154
161
  const targetAbs = validateTarget(opts.targetDir);
155
162
  const projectName = opts.projectName ?? basename(targetAbs);
@@ -177,6 +184,14 @@ export function scaffoldSync(opts) {
177
184
  : SPOKE_ONLY_FILES),
178
185
  ],
179
186
  markers: buildScaffoldMarkers(projectName),
187
+ ...(copied !== undefined
188
+ ? {
189
+ onWrite: (relPath, bytes) => {
190
+ if (isRecordable(relPath))
191
+ copied.set(relPath, sha256Hex(bytes));
192
+ },
193
+ }
194
+ : {}),
180
195
  });
181
196
  // #7426 D5/D6 — the root files a spoke rewrites, and the spoke seams.
182
197
  if (spoke !== undefined)
@@ -495,6 +510,10 @@ export async function scaffold(opts) {
495
510
  const surfaces = template !== undefined
496
511
  ? [...new Set([...(opts.surfaces ?? []), ...rosterSurfaces(template)])].sort()
497
512
  : opts.surfaces;
513
+ // #8522 D2 — every recordable path `copyTemplate` writes, hashed as it is
514
+ // written: the input to the per-file record `scaffold()` writes below,
515
+ // once every later layer has landed.
516
+ const copied = new Map();
498
517
  const result = scaffoldSync({
499
518
  ...opts,
500
519
  modules,
@@ -509,7 +528,7 @@ export async function scaffold(opts) {
509
528
  // #6813 D8 step 6 — the resolved design's OWN layout (never its name):
510
529
  // `resolveSiteChrome` reads a layout object now, not a `StarterDesign`.
511
530
  ...(resolvedDesign !== undefined ? { designLayout: resolvedDesign.pkg.layout } : {}),
512
- });
531
+ }, copied);
513
532
  // Threaded to extractModuleBundle for `package.json` patch targets'
514
533
  // `workspace:*` substitution (spec 5418 § 3 D2) — the same version map
515
534
  // `scaffoldSync` already reads for `copyTemplate`, re-read here since
@@ -573,7 +592,14 @@ export async function scaffold(opts) {
573
592
  versions,
574
593
  markers,
575
594
  ownership,
595
+ // #8427 D4 — a module bundle never writes a file inside the content
596
+ // directory of a gateable surface the result does not enable (the
597
+ // signal-collectors `projects`-surface seeds, on a scaffold whose
598
+ // roster carries no /projects route). The spoke filter still drops
599
+ // whatever it already drops; remapDestination is unchanged.
576
600
  ...spokeSkip,
601
+ skip: (destination) => spokeSkip.skip?.(destination) === true ||
602
+ isUnservedSurfaceContent(destination, result.surfaces),
577
603
  });
578
604
  }
579
605
  // The template lands LAST — after `scaffoldSync` and after every module
@@ -778,6 +804,17 @@ export async function scaffold(opts) {
778
804
  templatesDir: TEMPLATES_DIR,
779
805
  markers,
780
806
  }));
807
+ // #8522 D2 — the per-file record, as this scaffold's LAST write (same
808
+ // guard and roster as the site manifest just above): a kept copied
809
+ // path only when its final bytes still hash to the copy — a later
810
+ // layer (a module bundle's patch, a generator) that rewrote the file
811
+ // is absent here too.
812
+ writeFileSync(join(result.targetDir, TEMPLATE_RECORD_PATH), serializeTemplateRecord(planTemplateRecord({
813
+ targetDir: result.targetDir,
814
+ copied,
815
+ version: markers["CREATE_AILK_VERSION"] ?? "",
816
+ projectName,
817
+ })));
781
818
  }
782
819
  }
783
820
  // #7536 D3 — the registry follows `apps/web/package.json`, which a module
@@ -33,6 +33,7 @@ import { locatePageSections } from "./design-presets.js";
33
33
  import { diffTwoStrings } from "./lib/extract-module-bundle.js";
34
34
  import { MODULE_SLICES, normalizeModules, } from "./module-architecture.js";
35
35
  import { substituteMarkers } from "./substitute.js";
36
+ import { isUnservedSurfaceContent, resolveSurfaces } from "./surfaces.js";
36
37
  import { BASE_LAYER_DIR, SHAPE_STARTERS, TEMPLATE_MANIFESTS, resolveTemplate, rosterDeclares, shippedManifestPath, templateTreeDir, } from "./template-manifest.js";
37
38
  /** The site-root file that is the roster of record (#6658 D7). */
38
39
  export const SITE_ROSTER_PATH = ".ailk-site-manifest.json";
@@ -613,7 +614,7 @@ function spliceRows(file, rows) {
613
614
  return text.slice(0, open + 1) + body + text.slice(close);
614
615
  }
615
616
  /** Every `content/en/**.mdx` some layer installs into a site of this template and module set. */
616
- function layerInstalledSeeds(template, modules, opts) {
617
+ function layerInstalledSeeds(template, modules, opts, surfaces) {
617
618
  const out = new Set();
618
619
  for (const root of [
619
620
  opts.templatesDir,
@@ -640,7 +641,15 @@ function layerInstalledSeeds(template, modules, opts) {
640
641
  for (const path of row.content ?? [])
641
642
  out.add(path);
642
643
  }
643
- destinations.push(...Object.values(slice.installMap ?? {}));
644
+ // #8427 D4 — a module installMap destination the scaffold's own
645
+ // surfaces do not serve is not "installed": the writer
646
+ // (programmatic.ts's module-bundle loop, add-module.ts) declines the
647
+ // write, so the reader must agree or report a seed that is not there.
648
+ for (const dest of Object.values(slice.installMap ?? {})) {
649
+ if (isUnservedSurfaceContent(dest, surfaces))
650
+ continue;
651
+ destinations.push(dest);
652
+ }
644
653
  }
645
654
  for (const to of destinations) {
646
655
  if (to.startsWith("content/en/") && to.endsWith(".mdx"))
@@ -666,7 +675,15 @@ export function siteRosterProblems(opts) {
666
675
  const roster = readSiteRoster(opts.targetDir);
667
676
  if (roster === undefined)
668
677
  return [];
669
- const { modules } = readArchitectureHeader(readFileSync(join(opts.targetDir, "architecture.yaml"), "utf8"));
678
+ const { modules, surfaces: headerSurfaces } = readArchitectureHeader(readFileSync(join(opts.targetDir, "architecture.yaml"), "utf8"));
679
+ // #8427 D4 — the FULL effective surface set (module-contributed surfaces
680
+ // unioned with the header's own explicit list), the same set the writer
681
+ // resolves. Throws SurfaceError on a header surface that is unknown or
682
+ // owned by a module the site lacks — intended: `add-module` already
683
+ // refuses such a header before its first write, so a direct caller
684
+ // reaches the same refusal instead of a report built on a header the
685
+ // scaffold would never actually write.
686
+ const surfaces = resolveSurfaces(modules, headerSurfaces);
670
687
  const problems = [];
671
688
  const routes = roster.rows.map((r) => r.value["route"]);
672
689
  const seen = new Set();
@@ -715,7 +732,7 @@ export function siteRosterProblems(opts) {
715
732
  });
716
733
  // #6942 — the Free /meet placeholder seeds the scheduling module's rows keep.
717
734
  const declared = withMeetPlaceholderSeeds(effective);
718
- const installed = [...layerInstalledSeeds(roster.template, modules, opts)]
735
+ const installed = [...layerInstalledSeeds(roster.template, modules, opts, surfaces)]
719
736
  .filter((path) => existsSync(join(opts.targetDir, path)))
720
737
  .sort();
721
738
  for (const path of installed) {
@@ -8,6 +8,13 @@ export declare const SPOKE_CHECKOUT_PACKAGE = "@working-theory/spoke-checkout";
8
8
  export declare const SPOKE_CHECKOUT_CONFIG_PATH = "apps/web/lib/checkout-config.ts";
9
9
  /** The two return pages: Stripe's success URL and every refused buy's target. */
10
10
  export declare const SPOKE_CHECKOUT_PAGE_FILES: readonly string[];
11
+ /**
12
+ * The marker `renderPage()` stamps into both generated return pages' header
13
+ * comment (D3). `apps/web`'s structured-data test cannot import create-ailk
14
+ * (the import boundary), so it carries this text as a literal, kept in sync
15
+ * by `packages/create-ailk/__tests__/spoke-checkout.test.ts` (#8513 AC-5).
16
+ */
17
+ export declare const SPOKE_CHECKOUT_PAGE_MARKER = "GENERATED by create-ailk (#7838)";
11
18
  /** The variables a commerce spoke depends on (D9). The package reads three of them. */
12
19
  export declare const SPOKE_CHECKOUT_ENV_CATALOG: Readonly<Record<string, EnvCatalogEntry>>;
13
20
  /** The names a commerce spoke declares although only the package reads them (D9). */
@@ -38,6 +38,13 @@ export const SPOKE_CHECKOUT_PAGE_FILES = Object.freeze([
38
38
  "apps/web/app/[locale]/checkout/thank-you/page.tsx",
39
39
  "apps/web/app/[locale]/checkout/unavailable/page.tsx",
40
40
  ]);
41
+ /**
42
+ * The marker `renderPage()` stamps into both generated return pages' header
43
+ * comment (D3). `apps/web`'s structured-data test cannot import create-ailk
44
+ * (the import boundary), so it carries this text as a literal, kept in sync
45
+ * by `packages/create-ailk/__tests__/spoke-checkout.test.ts` (#8513 AC-5).
46
+ */
47
+ export const SPOKE_CHECKOUT_PAGE_MARKER = "GENERATED by create-ailk (#7838)";
41
48
  /** The variables a commerce spoke depends on (D9). The package reads three of them. */
42
49
  export const SPOKE_CHECKOUT_ENV_CATALOG = Object.freeze({
43
50
  STRIPE_SECRET_KEY: {
@@ -245,7 +252,7 @@ function renderConfig(site) {
245
252
  /** One return page (D7): a server component that reads nothing but its copy. */
246
253
  function renderPage(opts) {
247
254
  return [
248
- `// ${opts.title} — GENERATED by create-ailk (#7838).`,
255
+ `// ${opts.title} — ${SPOKE_CHECKOUT_PAGE_MARKER}.`,
249
256
  ...opts.about.map((line) => `// ${line}`),
250
257
  `import { SectionRenderer } from "${UI_SECTIONS_PACKAGE}";`,
251
258
  `import type { PageSection } from "${VALIDATION_PACKAGE}";`,
@@ -126,6 +126,20 @@ export declare function excludedRoutePaths(enabled: string[]): string[];
126
126
  * route has been removed would advertise URLs that 404.
127
127
  */
128
128
  export declare function excludedContentPaths(enabled: string[]): string[];
129
+ /**
130
+ * True when `path` (scaffold-relative, `/`-separated) lies inside one of the
131
+ * content directories `excludedContentPaths(enabled)` names, with `<locale>`
132
+ * matching any one path segment (#8427 D4).
133
+ *
134
+ * This is `excludedContentPaths`' first non-export reader — its own comment
135
+ * already named exactly this class ("orphan content makes `/sitemap.xml`
136
+ * advertise URLs that 404"). It is wired into the `skip` predicate
137
+ * `extractModuleBundle` already takes (`programmatic.ts`, `add-module.ts`),
138
+ * and into `layerInstalledSeeds`'s installed-seed count (`site-roster.ts`),
139
+ * so a module bundle never writes — and a roster check never counts — a
140
+ * seed under a gateable surface the scaffold does not enable.
141
+ */
142
+ export declare function isUnservedSurfaceContent(path: string, enabled: readonly string[]): boolean;
129
143
  /**
130
144
  * Route manifest (#4986): every template route directory, keyed relative to
131
145
  * ROUTE_ROOT (`apps/web/app/[locale]`), → the `@working-theory/*` fixed-group