create-ailk 0.19.0 → 0.19.1

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 (91) hide show
  1. package/dist/add-module.js +7 -2
  2. package/dist/index.d.ts +1 -1
  3. package/dist/index.js +1 -1
  4. package/dist/lib/scaffold-render-sweep.d.ts +23 -0
  5. package/dist/lib/scaffold-render-sweep.js +52 -0
  6. package/dist/module-architecture.js +9 -3
  7. package/dist/programmatic.js +8 -1
  8. package/dist/site-roster.js +21 -4
  9. package/dist/surfaces.d.ts +14 -0
  10. package/dist/surfaces.js +31 -1
  11. package/dist/topology.js +29 -1
  12. package/package.json +3 -3
  13. package/templates/apps/api/.env.example +17 -1
  14. package/templates/apps/api/CLAUDE.md +10 -4
  15. package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +19 -0
  16. package/templates/apps/api/src/__tests__/stripe-boot.test.ts +132 -0
  17. package/templates/apps/api/src/lib/__mocks__/prisma.ts +8 -0
  18. package/templates/apps/api/src/lib/__tests__/consent-ledger-double.ts +237 -0
  19. package/templates/apps/api/src/lib/__tests__/consent-store.test.ts +334 -0
  20. package/templates/apps/api/src/lib/__tests__/stripe-admission.test.ts +217 -0
  21. package/templates/apps/api/src/lib/__tests__/stripe.test.ts +7 -0
  22. package/templates/apps/api/src/lib/consent-store.ts +335 -0
  23. package/templates/apps/api/src/lib/stripe.ts +97 -5
  24. package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +10 -4
  25. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +51 -0
  26. package/templates/apps/api/src/middleware/auth.ts +11 -3
  27. package/templates/apps/api/src/middleware/rate-limit.ts +5 -0
  28. package/templates/apps/api/src/routes/checkout/__tests__/live-key.test.ts +167 -0
  29. package/templates/apps/api/src/routes/checkout/session.ts +3 -3
  30. package/templates/apps/api/src/routes/checkout/sessions.ts +6 -4
  31. package/templates/apps/api/src/routes/consent/cookie.ts +90 -0
  32. package/templates/apps/api/src/routes/consent/index.ts +18 -0
  33. package/templates/apps/api/src/routes/consent/unsubscribe.ts +127 -0
  34. package/templates/apps/api/src/routes/leads/index.ts +15 -0
  35. package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +5 -0
  36. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +17 -4
  37. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +2 -0
  38. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode-live.test.ts +159 -0
  39. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode.test.ts +158 -0
  40. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +2 -0
  41. package/templates/apps/api/src/routes/webhooks/stripe.ts +17 -0
  42. package/templates/apps/api/src/server.ts +111 -16
  43. package/templates/apps/api/src/services/__tests__/consent-ledger.test.ts +503 -0
  44. package/templates/apps/api/src/services/__tests__/consent.test.ts +18 -3
  45. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +115 -4
  46. package/templates/apps/api/src/services/__tests__/person.test.ts +392 -0
  47. package/templates/apps/api/src/services/__tests__/request-confirm.test.ts +87 -0
  48. package/templates/apps/api/src/services/__tests__/revalidation.test.ts +57 -17
  49. package/templates/apps/api/src/services/consent.ts +488 -3
  50. package/templates/apps/api/src/services/lead-routing.ts +105 -12
  51. package/templates/apps/api/src/services/person.ts +501 -1
  52. package/templates/apps/api/src/services/project-listings.ts +7 -2
  53. package/templates/apps/api/src/services/request-confirm.ts +52 -0
  54. package/templates/apps/api/src/services/revalidation.ts +11 -1
  55. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/account-frame-sign-out.test.tsx +175 -0
  56. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/billing.test.tsx +17 -3
  57. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +156 -0
  58. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +89 -14
  59. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout-sign-out.test.tsx +146 -0
  60. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +122 -18
  61. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/notifications.test.tsx +15 -1
  62. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/security.test.tsx +290 -20
  63. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +55 -0
  64. package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +74 -4
  65. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/page.tsx +4 -7
  66. package/templates/apps/web/app/[locale]/(authed)/account/billing/page.tsx +3 -3
  67. package/templates/apps/web/app/[locale]/(authed)/account/general/contact-actions.ts +81 -0
  68. package/templates/apps/web/app/[locale]/(authed)/account/general/page.tsx +66 -4
  69. package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +26 -3
  70. package/templates/apps/web/app/[locale]/(authed)/account/notifications/page.tsx +2 -2
  71. package/templates/apps/web/app/[locale]/(authed)/account/security/page.tsx +89 -37
  72. package/templates/apps/web/app/[locale]/(authed)/account/security/security-panel.tsx +102 -0
  73. package/templates/apps/web/app/[locale]/(authed)/account/security/security-source.ts +62 -0
  74. package/templates/apps/web/app/[locale]/(authed)/account/settings-frame.tsx +41 -33
  75. package/templates/apps/web/app/[locale]/(authed)/account/sign-in-method.ts +1 -1
  76. package/templates/apps/web/auth.ts +20 -1
  77. package/templates/apps/web/lib/__tests__/auth-email.test.ts +84 -0
  78. package/templates/apps/web/lib/auth-email.ts +45 -10
  79. package/templates/apps/web/lib/topology.generated.ts +9 -0
  80. package/templates/apps/web/messages/en.json +54 -14
  81. package/templates/database/CHANGELOG.md +16 -0
  82. package/templates/database/__tests__/consent-ledger.db.test.ts +345 -0
  83. package/templates/database/__tests__/fence-fan-out.db.test.ts +6 -2
  84. package/templates/database/__tests__/fixtures/person-contact-backfill.json +182 -0
  85. package/templates/database/__tests__/person-contact.db.test.ts +563 -0
  86. package/templates/database/__tests__/person.db.test.ts +3 -1
  87. package/templates/database/inbox/schema.prisma +76 -10
  88. package/templates/database/migrations/20261014120000_consent_ledger/migration.sql +122 -0
  89. package/templates/database/migrations/20261014120000_person_contact/migration.sql +231 -0
  90. package/templates/database/package.json +1 -1
  91. package/versions.json +35 -35
@@ -40,7 +40,7 @@ import { ParseError, parseModules } from "./parse-args.js";
40
40
  import { writeSpokeFlowsReadme } from "./seed-flows.js";
41
41
  import { SITE_ROSTER_PATH, moduleRosterRows, planRosterAppend, readSiteRoster, siteRosterProblems, } from "./site-roster.js";
42
42
  import { spokeCheckoutSite, spokeEnvRowOptions, writeSpokeCheckoutSlice, } from "./spoke-checkout.js";
43
- import { MODULE_SURFACES, NAV_CONFIG_PATH, readNavConfigStamp, renderNavConfig, resolveSurfaces, } from "./surfaces.js";
43
+ import { MODULE_SURFACES, NAV_CONFIG_PATH, isUnservedSurfaceContent, readNavConfigStamp, renderNavConfig, resolveSurfaces, } from "./surfaces.js";
44
44
  import { syncRoutes } from "./sync-routes.js";
45
45
  import { TopologyError, isSpokeExcluded, readSiteTopology, readSpokeShape, spokeDestination, spokeModuleRefusal, } from "./topology.js";
46
46
  /**
@@ -155,9 +155,14 @@ export async function applyModuleToScaffold(opts) {
155
155
  markers: opts.markers,
156
156
  existingFilePolicy: "skip-existing",
157
157
  checkPatches: checkPlusConfigPatches,
158
+ // #8427 D4 — composed with the spoke filter: a module bundle never
159
+ // writes a seed under a gateable surface this site's own
160
+ // `architecture.yaml` header (resolved above, into `surfaces`) does
161
+ // not enable.
162
+ skip: (destination) => (spoke !== undefined && isSpokeExcluded(destination, spoke)) ||
163
+ isUnservedSurfaceContent(destination, surfaces),
158
164
  ...(spoke !== undefined
159
165
  ? {
160
- skip: (destination) => isSpokeExcluded(destination, spoke),
161
166
  // #7876 D10 — a module flow file lands in flows/ as declared data.
162
167
  remapDestination: spokeDestination,
163
168
  }
package/dist/index.d.ts CHANGED
@@ -14,6 +14,6 @@ export type { HomeSeedOutcome } from "./design-home.js";
14
14
  export { SITE_ROSTER_PATH, readSiteRoster, siteRosterProblems, } from "./site-roster.js";
15
15
  export { SYNC_ROUTES_HELP, formatSyncRoutesReport, listTemplateRouteDirs, parseSyncRoutesArgs, syncRoutes, type RouteReport, type SyncRoutesOptions, type SyncRoutesResult, } from "./sync-routes.js";
16
16
  export { deriveArchitecture } from "./derive-architecture.js";
17
- export { CORE_ROUTE_DIRS, CORE_SURFACE_PATHS, NAV_CONFIG_PATH, GATEABLE_SURFACES, MODULE_SURFACES, RECOGNIZED_SURFACES, ROUTE_SINCE, SurfaceError, excludedContentPaths, excludedRoutePaths, parseSurfaces, renderNavConfig, resolveSurfaces, surfaceForRouteDir, type Surface, } from "./surfaces.js";
17
+ export { CORE_ROUTE_DIRS, CORE_SURFACE_PATHS, NAV_CONFIG_PATH, GATEABLE_SURFACES, MODULE_SURFACES, RECOGNIZED_SURFACES, ROUTE_SINCE, SurfaceError, excludedContentPaths, excludedRoutePaths, isUnservedSurfaceContent, parseSurfaces, renderNavConfig, resolveSurfaces, surfaceForRouteDir, type Surface, } from "./surfaces.js";
18
18
  export { substituteMarkers, substituteWorkspaceVersions, type MarkerMap, type WorkspaceVersionMap, } from "./substitute.js";
19
19
  export { validateTarget, ValidationError } from "./validate.js";
package/dist/index.js CHANGED
@@ -30,6 +30,6 @@ export { deriveArchitecture } from "./derive-architecture.js";
30
30
  // The route-surface registry (#4154) — which public routes a scaffold carries.
31
31
  // Exported so tooling can answer "what surfaces does this site declare" without
32
32
  // re-parsing architecture.yaml.
33
- export { CORE_ROUTE_DIRS, CORE_SURFACE_PATHS, NAV_CONFIG_PATH, GATEABLE_SURFACES, MODULE_SURFACES, RECOGNIZED_SURFACES, ROUTE_SINCE, SurfaceError, excludedContentPaths, excludedRoutePaths, parseSurfaces, renderNavConfig, resolveSurfaces, surfaceForRouteDir, } from "./surfaces.js";
33
+ export { CORE_ROUTE_DIRS, CORE_SURFACE_PATHS, NAV_CONFIG_PATH, GATEABLE_SURFACES, MODULE_SURFACES, RECOGNIZED_SURFACES, ROUTE_SINCE, SurfaceError, excludedContentPaths, excludedRoutePaths, isUnservedSurfaceContent, parseSurfaces, renderNavConfig, resolveSurfaces, surfaceForRouteDir, } from "./surfaces.js";
34
34
  export { substituteMarkers, substituteWorkspaceVersions, } from "./substitute.js";
35
35
  export { validateTarget, ValidationError } from "./validate.js";
@@ -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 ?? {})
@@ -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
  */
@@ -186,6 +186,8 @@ const MEMBER_AREA_FILES = [
186
186
  "apps/api/src/routes/member/artifact-review.ts",
187
187
  "apps/api/src/routes/member/profile.ts",
188
188
  "apps/api/src/routes/member/deletion-request.ts",
189
+ // #8438 D6 — the member's own consents; the same gate as the writes above.
190
+ "apps/api/src/routes/member/consents.ts",
189
191
  "apps/api/src/services/member-area.ts",
190
192
  "apps/api/src/lib/member-store.ts",
191
193
  // #8226 D12 — the member-request match CLI; both import member-store.ts,
@@ -785,6 +787,10 @@ export const MODULE_SLICES = {
785
787
  "apps/api/src/routes/workspaces/index.ts",
786
788
  "apps/api/src/routes/workspaces/sessions.ts",
787
789
  "apps/api/src/routes/workspaces/config.ts",
790
+ // #8438 D8 — the Admin owner's consents to Working Theory: registered
791
+ // under `isModuleEnabled("multi-tenant")` alone, the module that
792
+ // contributes the Admin app.
793
+ "apps/api/src/routes/consent/platform.ts",
788
794
  // Co-owned with `workspace` (#5853): the content-write scope is gated on
789
795
  // content-write AND (workspace || multi-tenant) and this file imports
790
796
  // lib/tenant-db.ts, so each tenancy slice must deliver it.
@@ -1843,7 +1849,7 @@ export const MODULE_SLICES = {
1843
1849
  {
1844
1850
  pattern: STRIPE_LIVE_KEY_PATTERN,
1845
1851
  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",
1852
+ 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
1853
  },
1848
1854
  ],
1849
1855
  // ownedFiles (S1, #5088). SOURCE files only (#5182 D6) — no __tests__/,
@@ -45,7 +45,7 @@ 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
51
  import { SPOKE_ONLY_FILES, TopologyError, applySpokeTransforms, isSpokeExcluded, normalizeHubSignInUrl, normalizeHubUrl, readSiteTopology, spokeExclusions, spokeDestination, spokeModuleRefusal, spokeModules, writeHubOriginEnv, writeHubSignInEnv, } from "./topology.js";
@@ -573,7 +573,14 @@ export async function scaffold(opts) {
573
573
  versions,
574
574
  markers,
575
575
  ownership,
576
+ // #8427 D4 — a module bundle never writes a file inside the content
577
+ // directory of a gateable surface the result does not enable (the
578
+ // signal-collectors `projects`-surface seeds, on a scaffold whose
579
+ // roster carries no /projects route). The spoke filter still drops
580
+ // whatever it already drops; remapDestination is unchanged.
576
581
  ...spokeSkip,
582
+ skip: (destination) => spokeSkip.skip?.(destination) === true ||
583
+ isUnservedSurfaceContent(destination, result.surfaces),
577
584
  });
578
585
  }
579
586
  // The template lands LAST — after `scaffoldSync` and after every 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) {
@@ -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
package/dist/surfaces.js CHANGED
@@ -503,6 +503,31 @@ export function excludedContentPaths(enabled) {
503
503
  }
504
504
  return out.sort();
505
505
  }
506
+ /**
507
+ * True when `path` (scaffold-relative, `/`-separated) lies inside one of the
508
+ * content directories `excludedContentPaths(enabled)` names, with `<locale>`
509
+ * matching any one path segment (#8427 D4).
510
+ *
511
+ * This is `excludedContentPaths`' first non-export reader — its own comment
512
+ * already named exactly this class ("orphan content makes `/sitemap.xml`
513
+ * advertise URLs that 404"). It is wired into the `skip` predicate
514
+ * `extractModuleBundle` already takes (`programmatic.ts`, `add-module.ts`),
515
+ * and into `layerInstalledSeeds`'s installed-seed count (`site-roster.ts`),
516
+ * so a module bundle never writes — and a roster check never counts — a
517
+ * seed under a gateable surface the scaffold does not enable.
518
+ */
519
+ export function isUnservedSurfaceContent(path, enabled) {
520
+ const segments = path.split("/");
521
+ for (const contentPath of excludedContentPaths([...enabled])) {
522
+ const prefixSegments = contentPath.split("/");
523
+ if (prefixSegments.length > segments.length)
524
+ continue;
525
+ const matches = prefixSegments.every((segment, i) => segment === "<locale>" || segment === segments[i]);
526
+ if (matches)
527
+ return true;
528
+ }
529
+ return false;
530
+ }
506
531
  /**
507
532
  * Route manifest (#4986): every template route directory, keyed relative to
508
533
  * ROUTE_ROOT (`apps/web/app/[locale]`), → the `@working-theory/*` fixed-group
@@ -834,13 +859,18 @@ export const ROUTE_DEPENDENCIES = {
834
859
  `${ROUTE_ROOT}/(authed)/account/components/send-message.tsx`,
835
860
  ],
836
861
  // #8339 D11 — Security holds the former Sign-in section, moved verbatim
837
- // from General: `sign-in-method.ts` and what it imports.
862
+ // from General: `sign-in-method.ts` and what it imports. #8431 D8 — the
863
+ // tab's failed-read row (`error-row.tsx` and its `retry-button.tsx`); its
864
+ // own `security-source.ts` and `security-panel.tsx` live in the route dir
865
+ // and copy with it.
838
866
  "(authed)/account/security": [
839
867
  "apps/web/lib/account-config.ts",
840
868
  "apps/web/lib/auth-env.ts",
841
869
  "apps/web/lib/server-api.ts",
842
870
  "apps/web/lib/hub-session/config.ts",
843
871
  `${ROUTE_ROOT}/(authed)/account/account-data.ts`,
872
+ `${ROUTE_ROOT}/(authed)/account/error-row.tsx`,
873
+ `${ROUTE_ROOT}/(authed)/account/retry-button.tsx`,
844
874
  `${ROUTE_ROOT}/(authed)/account/settings-frame.tsx`,
845
875
  `${ROUTE_ROOT}/(authed)/account/sign-in-method.ts`,
846
876
  ],
package/dist/topology.js CHANGED
@@ -273,7 +273,9 @@ const WEB_SEAM_HEADER = `// topology.generated.ts — how this site reads a sess
273
273
  // UNSERVED_MCP_TOOLS names the MCP tools /.well-known/mcp.json leaves out
274
274
  // on this topology (#8070).
275
275
  // loadSpokeBilling reaches the spoke billing action only on a spoke that
276
- // signs in through its hub and carries commerce (#8217); elsewhere, null.`;
276
+ // signs in through its hub and carries commerce (#8217); elsewhere, null.
277
+ // readOwnAccounts lists the signed-in person's own sign-in accounts through
278
+ // Better Auth on a hub (#8431); a spoke has none here, so it answers null.`;
277
279
  const MCP_SEAM_HEADER = `// topology.generated.ts — how this MCP server resolves its workspace store.
278
280
  //
279
281
  // GENERATED by create-ailk for this site's topology (#7426). Do not edit:
@@ -303,6 +305,26 @@ const SITE_SESSION_TYPE = `export type SiteSession = {
303
305
  createdAt?: Date | string | null;
304
306
  };
305
307
  } | null;`;
308
+ /**
309
+ * `readOwnAccounts` (#8431 D2a): the session's own sign-in accounts, which
310
+ * the account Security tab reads to say whether the person has a password.
311
+ * The hub render loads `auth.ts` only when it is called; a spoke has no local
312
+ * accounts, so its renders answer `null` and import nothing.
313
+ */
314
+ function ownAccountsReader(topology) {
315
+ if (topology === "hub") {
316
+ return `export async function readOwnAccounts(
317
+ headers: Headers,
318
+ ): Promise<ReadonlyArray<{ providerId: string }> | null> {
319
+ const { auth } = await import("../auth");
320
+ return auth.api.listUserAccounts({ headers });
321
+ }`;
322
+ }
323
+ return `export function readOwnAccounts(headers: Headers): Promise<ReadonlyArray<{ providerId: string }> | null> {
324
+ void headers;
325
+ return Promise.resolve(null);
326
+ }`;
327
+ }
306
328
  /** The sign-in exports every web seam render carries (#7894 D8). */
307
329
  function signInExports(values) {
308
330
  const literal = (value) => value === null ? "null" : JSON.stringify(value);
@@ -382,6 +404,8 @@ export async function readSession(headers: Headers): Promise<SiteSession> {
382
404
  return auth.api.getSession({ headers });
383
405
  }
384
406
 
407
+ ${ownAccountsReader("hub")}
408
+
385
409
  export async function registerSiteStores(): Promise<void> {
386
410
  const { registerLicenseKeyStore } = await import("./license-key-store");
387
411
  await registerLicenseKeyStore();
@@ -404,6 +428,8 @@ export function readSession(headers: Headers): Promise<SiteSession> {
404
428
  return readHubSession(headers);
405
429
  }
406
430
 
431
+ ${ownAccountsReader("spoke")}
432
+
407
433
  export function registerSiteStores(): Promise<void> {
408
434
  return Promise.resolve();
409
435
  }
@@ -424,6 +450,8 @@ export function readSession(headers: Headers): Promise<SiteSession> {
424
450
  return Promise.resolve(null);
425
451
  }
426
452
 
453
+ ${ownAccountsReader("spoke")}
454
+
427
455
  export function registerSiteStores(): Promise<void> {
428
456
  return Promise.resolve();
429
457
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-ailk",
3
- "version": "0.19.0",
3
+ "version": "0.19.1",
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",
@@ -28,8 +28,8 @@
28
28
  "tar": "^7.5.21"
29
29
  },
30
30
  "devDependencies": {
31
- "@working-theory/schema": "0.39.0",
32
- "@working-theory/templates": "0.39.0"
31
+ "@working-theory/schema": "0.40.0",
32
+ "@working-theory/templates": "0.40.0"
33
33
  },
34
34
  "repository": {
35
35
  "type": "git",
@@ -32,8 +32,14 @@ DATABASE_URL="postgresql://user:password@localhost:5432/customer_template"
32
32
  # Stripe (Optional)
33
33
  # =============================================================================
34
34
  # Consumed by apps/api/src/lib/stripe.ts via @working-theory/stripe-client wrapper.
35
- # Use sandbox keys only — live-mode keys are rejected at runtime.
35
+ # Use sandbox keys only — live-mode keys are rejected at runtime, unless
36
+ # STRIPE_MODE=live below admits a restricted live key (#8450).
36
37
  STRIPE_SECRET_KEY=sk_test_xxx
38
+ # test (the default when unset) or live. `live` is admitted only beside a
39
+ # restricted live key, on a production build — VERCEL_ENV=production, or,
40
+ # off Vercel, NODE_ENV=production, so a staging host must not set the latter.
41
+ # Every other live key stays refused at boot and on every route.
42
+ STRIPE_MODE=test
37
43
  STRIPE_WEBHOOK_SECRET=whsec_xxx
38
44
 
39
45
  # =============================================================================
@@ -46,6 +52,16 @@ STRIPE_WEBHOOK_SECRET=whsec_xxx
46
52
  # Generate with: openssl rand -hex 32
47
53
  # OSS_API_INTERNAL_SECRET=your-shared-secret-here
48
54
 
55
+ # =============================================================================
56
+ # Consent — the signed unsubscribe link (#8438)
57
+ # =============================================================================
58
+ # Keys the HMAC on the unsubscribe token an email's link carries
59
+ # (POST /v1/consent/unsubscribe). Optional: unset, no link can be minted and the
60
+ # route answers 503 link_secret_not_configured. When set it must be at least
61
+ # 32 bytes — a shorter value logs an error at boot and is treated as unset.
62
+ # Generate with: openssl rand -hex 32
63
+ # AILK_CONSENT_LINK_SECRET=
64
+
49
65
  # =============================================================================
50
66
  # Scheduling — the `booker` section's provider (#4416)
51
67
  # =============================================================================