create-ailk 0.15.0 → 0.16.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 (127) hide show
  1. package/README.md +124 -0
  2. package/dist/add-module.js +52 -7
  3. package/dist/add-route.js +8 -0
  4. package/dist/adopt.d.ts +74 -0
  5. package/dist/adopt.js +314 -0
  6. package/dist/cli.js +82 -2
  7. package/dist/derive-architecture.d.ts +22 -0
  8. package/dist/derive-architecture.js +58 -1
  9. package/dist/lib/declared-env.d.ts +142 -0
  10. package/dist/lib/declared-env.js +985 -0
  11. package/dist/lib/env-catalog.d.ts +17 -0
  12. package/dist/lib/env-catalog.js +234 -0
  13. package/dist/lib/extract-module-bundle.d.ts +9 -0
  14. package/dist/lib/extract-module-bundle.js +18 -1
  15. package/dist/lib/module-tier.d.ts +2 -0
  16. package/dist/lib/module-tier.js +4 -0
  17. package/dist/module-architecture.js +76 -0
  18. package/dist/parse-args.d.ts +5 -0
  19. package/dist/parse-args.js +17 -1
  20. package/dist/programmatic.d.ts +8 -0
  21. package/dist/programmatic.js +78 -9
  22. package/dist/seed-flows.d.ts +65 -0
  23. package/dist/seed-flows.js +350 -0
  24. package/dist/site-manifest.d.ts +43 -0
  25. package/dist/site-manifest.js +98 -3
  26. package/dist/site-roster.d.ts +13 -0
  27. package/dist/site-roster.js +22 -8
  28. package/dist/spoke-checkout.d.ts +42 -0
  29. package/dist/spoke-checkout.js +359 -0
  30. package/dist/surfaces.js +7 -0
  31. package/dist/sync-routes.js +23 -8
  32. package/dist/topology.d.ts +124 -9
  33. package/dist/topology.js +449 -40
  34. package/package.json +3 -3
  35. package/templates/.env.example +10 -0
  36. package/templates/apps/api/CLAUDE.md +19 -4
  37. package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +95 -16
  38. package/templates/apps/api/src/__tests__/openapi-keyless-hub.test.ts +122 -0
  39. package/templates/apps/api/src/__tests__/support/hub-sign-in-fake-db.ts +362 -0
  40. package/templates/apps/api/src/bin/spokes.ts +62 -1
  41. package/templates/apps/api/src/config/__tests__/hub-require-spoke-key.test.ts +75 -0
  42. package/templates/apps/api/src/config/index.ts +35 -0
  43. package/templates/apps/api/src/lib/__mocks__/prisma.ts +89 -0
  44. package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +66 -5
  45. package/templates/apps/api/src/lib/__tests__/hub-sign-in-store.test.ts +209 -0
  46. package/templates/apps/api/src/lib/__tests__/spoke-store.test.ts +20 -0
  47. package/templates/apps/api/src/lib/deliverable-asset-store.ts +24 -5
  48. package/templates/apps/api/src/lib/hub-sign-in-store.ts +419 -0
  49. package/templates/apps/api/src/lib/spoke-store.ts +25 -3
  50. package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +310 -0
  51. package/templates/apps/api/src/middleware/auth.ts +126 -0
  52. package/templates/apps/api/src/routes/auth/__tests__/hub-sign-in.test.ts +483 -0
  53. package/templates/apps/api/src/routes/auth/hub-sign-in.ts +201 -0
  54. package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +7 -0
  55. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +105 -0
  56. package/templates/apps/api/src/routes/webhooks/stripe.ts +72 -1
  57. package/templates/apps/api/src/server.ts +105 -0
  58. package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +4 -4
  59. package/templates/apps/api/src/services/__tests__/hub-sign-in.test.ts +854 -0
  60. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +293 -0
  61. package/templates/apps/api/src/services/__tests__/person.test.ts +553 -0
  62. package/templates/apps/api/src/services/hub-sign-in.ts +471 -0
  63. package/templates/apps/api/src/services/lead-routing.ts +131 -2
  64. package/templates/apps/api/src/services/person.ts +541 -0
  65. package/templates/apps/web/.env.example +58 -5
  66. package/templates/apps/web/app/[locale]/(auth)/sign-in/next-path.ts +0 -0
  67. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +206 -0
  68. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +256 -3
  69. package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +5 -8
  70. package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/__tests__/route.test.ts +205 -0
  71. package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/route.ts +82 -0
  72. package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +98 -29
  73. package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +6 -3
  74. package/templates/apps/web/app/[locale]/(authed)/layout.tsx +19 -101
  75. package/templates/apps/web/app/[locale]/(authed)/signed-out-gate.generated.tsx +90 -0
  76. package/templates/apps/web/app/[locale]/layout.tsx +28 -15
  77. package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +295 -0
  78. package/templates/apps/web/app/api/auth/hub/authorize/route.ts +156 -0
  79. package/templates/apps/web/app/api/hub-session/__tests__/routes.test.ts +341 -0
  80. package/templates/apps/web/app/api/hub-session/callback/route.ts +168 -0
  81. package/templates/apps/web/app/api/hub-session/sign-in/route.ts +76 -0
  82. package/templates/apps/web/app/api/hub-session/sign-out/route.ts +63 -0
  83. package/templates/apps/web/app/openapi.json/__tests__/route.test.ts +15 -0
  84. package/templates/apps/web/app/openapi.json/route.ts +3 -6
  85. package/templates/apps/web/auth.ts +29 -2
  86. package/templates/apps/web/components/sign-out-trigger.tsx +20 -1
  87. package/templates/apps/web/components/signed-in-nav.tsx +17 -3
  88. package/templates/apps/web/lib/__tests__/account-config.test.ts +5 -2
  89. package/templates/apps/web/lib/__tests__/listings-config.test.ts +83 -0
  90. package/templates/apps/web/lib/__tests__/listings.test.tsx +49 -0
  91. package/templates/apps/web/lib/__tests__/meeting-slugs.test.ts +49 -1
  92. package/templates/apps/web/lib/__tests__/relay.test.ts +27 -0
  93. package/templates/apps/web/lib/account-config.ts +130 -1
  94. package/templates/apps/web/lib/api-origin.generated.ts +11 -0
  95. package/templates/apps/web/lib/hub-session/__tests__/hub-session.test.ts +262 -0
  96. package/templates/apps/web/lib/hub-session/config.ts +104 -0
  97. package/templates/apps/web/lib/hub-session/index.ts +23 -0
  98. package/templates/apps/web/lib/hub-session/seal.ts +82 -0
  99. package/templates/apps/web/lib/hub-session/session.ts +180 -0
  100. package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +79 -0
  101. package/templates/apps/web/lib/listings-config.ts +31 -0
  102. package/templates/apps/web/lib/listings.tsx +7 -3
  103. package/templates/apps/web/lib/meeting-slugs.ts +11 -1
  104. package/templates/apps/web/lib/next-path.ts +35 -0
  105. package/templates/apps/web/lib/relay.ts +3 -1
  106. package/templates/apps/web/lib/server-api.ts +11 -1
  107. package/templates/apps/web/lib/topology.generated.ts +15 -5
  108. package/templates/apps/web/messages/en.json +32 -3
  109. package/templates/database/CHANGELOG.md +26 -0
  110. package/templates/database/__tests__/agents.db.test.ts +25 -5
  111. package/templates/database/__tests__/fixtures/person-backfill.json +264 -0
  112. package/templates/database/__tests__/member-area.db.test.ts +245 -0
  113. package/templates/database/__tests__/person.db.test.ts +584 -0
  114. package/templates/database/auth/schema.prisma +3 -2
  115. package/templates/database/config/schema.prisma +13 -0
  116. package/templates/database/inbox/schema.prisma +98 -0
  117. package/templates/database/migrations/20261010120000_person/migration.sql +106 -0
  118. package/templates/database/migrations/20261010121000_person_backfill/migration.sql +132 -0
  119. package/templates/database/migrations/20261010130000_agent_grants_people_read/migration.sql +20 -0
  120. package/templates/database/migrations/20261011120000_member_area/migration.sql +158 -0
  121. package/templates/database/migrations/20261011130000_agent_grants_member_pieces_manage/migration.sql +21 -0
  122. package/templates/database/package.json +1 -1
  123. package/templates/database/scripts/person-backfill.sql +132 -0
  124. package/templates/database/tenancy.ts +53 -0
  125. package/templates/tools/check-declared-env.ts +37 -0
  126. package/templates/tools/declared-env.ts +1163 -0
  127. package/versions.json +35 -35
@@ -0,0 +1,359 @@
1
+ /**
2
+ * The spoke checkout slice (#7838) — what a spoke carrying `commerce` gets in
3
+ * `apps/web`: the buy route and the Stripe webhook route (the only two files
4
+ * allowed to import the checkout package, #7873), the site's own
5
+ * `lib/checkout-config.ts`, and the thank-you and unavailable return pages.
6
+ *
7
+ * Every guarantee lives in `@working-theory/spoke-checkout` and arrives by a
8
+ * package bump; these files are thin and, apart from the config's one stamp
9
+ * line, byte-identical across spokes at one create-ailk version (roadmap D3,
10
+ * AC-SPOKE-02). A hub never gets them: its checkout is `apps/api`'s.
11
+ *
12
+ * Spec: docs/specs/7838-spoke-web-checkout-slice-spec.md (D3–D7, D9).
13
+ *
14
+ * Pure module: no `import.meta.url`, so jest imports it directly. Every
15
+ * package specifier in a rendered file is interpolated from a constant, so the
16
+ * monorepo's own architecture lint never reads a rendered line as an import
17
+ * create-ailk makes (the `CONTENT_ADAPTERS_PACKAGE` idiom in topology.ts).
18
+ */
19
+ import { createHash } from "node:crypto";
20
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
21
+ import { dirname, join } from "node:path";
22
+ import { SPOKE_CHECKOUT_ROUTE_FILES } from "./derive-architecture.js";
23
+ import { ENV_CATALOG } from "./lib/env-catalog.js";
24
+ import { diffTwoStrings, } from "./lib/extract-module-bundle.js";
25
+ import { substituteWorkspaceVersions, } from "./substitute.js";
26
+ import { TopologyError } from "./topology.js";
27
+ /** The checkout package a spoke's two route files import. */
28
+ export const SPOKE_CHECKOUT_PACKAGE = "@working-theory/spoke-checkout";
29
+ const API_CLIENT_PACKAGE = "@working-theory/api-client";
30
+ const UI_SECTIONS_PACKAGE = "@working-theory/ui/sections";
31
+ const VALIDATION_PACKAGE = "@working-theory/validation";
32
+ /** The site's checkout data — written once, the site's from then on. */
33
+ export const SPOKE_CHECKOUT_CONFIG_PATH = "apps/web/lib/checkout-config.ts";
34
+ /** The two return pages: Stripe's success URL and every refused buy's target. */
35
+ export const SPOKE_CHECKOUT_PAGE_FILES = Object.freeze([
36
+ "apps/web/app/[locale]/checkout/thank-you/page.tsx",
37
+ "apps/web/app/[locale]/checkout/unavailable/page.tsx",
38
+ ]);
39
+ /** The variables a commerce spoke depends on (D9). The package reads three of them. */
40
+ export const SPOKE_CHECKOUT_ENV_CATALOG = Object.freeze({
41
+ STRIPE_SECRET_KEY: {
42
+ description: `Read by ${SPOKE_CHECKOUT_PACKAGE}: a test key (sk_test_ or rk_test_), or a restricted live key the operator sets with STRIPE_MODE=live.`,
43
+ secret: true,
44
+ },
45
+ STRIPE_WEBHOOK_SECRET: {
46
+ description: `Read by ${SPOKE_CHECKOUT_PACKAGE}: the signing secret of this site's Stripe webhook endpoint.`,
47
+ secret: true,
48
+ },
49
+ STRIPE_MODE: {
50
+ description: `Read by ${SPOKE_CHECKOUT_PACKAGE}: test (the default) or live. Live is the operator's switch, per site.`,
51
+ secret: false,
52
+ },
53
+ AILK_SPOKE_KEY: {
54
+ description: "The hub-issued key this site sends as x-ailk-spoke-key; the checkout fulfilment refuses to run without it. Never committed.",
55
+ secret: true,
56
+ },
57
+ });
58
+ /** The names a commerce spoke declares although only the package reads them (D9). */
59
+ export const SPOKE_CHECKOUT_DECLARED_ENV = Object.freeze([
60
+ "STRIPE_SECRET_KEY",
61
+ "STRIPE_WEBHOOK_SECRET",
62
+ "STRIPE_MODE",
63
+ ]);
64
+ /**
65
+ * The catalog (and declared names) a spoke's `appendEnvRows` call passes: a
66
+ * commerce spoke adds the checkout's four rows (D9). `ENV_CATALOG` itself is
67
+ * unchanged — its test fails on an entry the monorepo's apps never read.
68
+ */
69
+ export function spokeEnvRowOptions(commerce) {
70
+ return commerce
71
+ ? {
72
+ catalog: { ...ENV_CATALOG, ...SPOKE_CHECKOUT_ENV_CATALOG },
73
+ declare: SPOKE_CHECKOUT_DECLARED_ENV,
74
+ }
75
+ : { catalog: ENV_CATALOG };
76
+ }
77
+ /** `metadata.site` / an offering slug — the package's own pattern. */
78
+ const SITE_PATTERN = /^[a-z0-9][a-z0-9-]{0,39}$/;
79
+ /** A stamp a renderer may interpolate: the package's pattern, never `default` (D6). */
80
+ function isValidSite(site) {
81
+ return SITE_PATTERN.test(site) && site !== "default";
82
+ }
83
+ function assertSite(site) {
84
+ if (!isValidSite(site)) {
85
+ throw new TopologyError(`spoke checkout: site stamp ${JSON.stringify(site)} must match ^[a-z0-9][a-z0-9-]{0,39}$ and must not be "default"`);
86
+ }
87
+ }
88
+ /**
89
+ * The site stamp for a project name (D6): lower-cased, every run outside
90
+ * `a-z0-9` collapsed to `-`, trimmed, cut to 40. A name that leaves nothing
91
+ * usable — or `default`, which is also an unset hub's own stamp — gets
92
+ * `site-` and the first 8 hex characters of its SHA-256. Never `AILK_SITE_ID`.
93
+ */
94
+ export function spokeCheckoutSite(projectName) {
95
+ const slug = projectName
96
+ .toLowerCase()
97
+ .replace(/[^a-z0-9]+/g, "-")
98
+ .replace(/^-+|-+$/g, "")
99
+ .slice(0, 40)
100
+ .replace(/-+$/, "");
101
+ if (isValidSite(slug))
102
+ return slug;
103
+ const digest = createHash("sha256").update(projectName).digest("hex");
104
+ return `site-${digest.slice(0, 8)}`;
105
+ }
106
+ /** One route file (D4): three lines of wiring, every guarantee in the package. */
107
+ function renderRoute(opts) {
108
+ return [
109
+ `// ${opts.title} — GENERATED by create-ailk (#7838). Do not edit: every`,
110
+ "// guarantee lives in the spoke-checkout package and arrives by a package",
111
+ "// bump. This site's data is lib/checkout-config.ts.",
112
+ `import { createSpokeCheckout } from "${SPOKE_CHECKOUT_PACKAGE}";`,
113
+ "",
114
+ `import { checkoutConfig } from "../../../../lib/checkout-config";`,
115
+ "",
116
+ `export const runtime = "nodejs";`,
117
+ `export const dynamic = "force-dynamic";`,
118
+ "",
119
+ `// platform-decisions-waiver: api-versioning — ${opts.waiver} (spec 7838 §7)`,
120
+ opts.exportLine,
121
+ "",
122
+ ].join("\n");
123
+ }
124
+ const BUY_ROUTE = renderRoute({
125
+ title: "The buy route",
126
+ waiver: "a browser buy link that redirects to Stripe Checkout, not a versioned API contract",
127
+ exportLine: "export const { GET, POST } = createSpokeCheckout(checkoutConfig).buy;",
128
+ });
129
+ const WEBHOOK_ROUTE = renderRoute({
130
+ title: "The Stripe webhook route",
131
+ waiver: "the endpoint URL registered in Stripe, not a versioned API contract",
132
+ exportLine: "export const { POST } = createSpokeCheckout(checkoutConfig).webhook;",
133
+ });
134
+ /** The site's checkout data (D5). Only the `const SITE` line varies by site. */
135
+ function renderConfig(site) {
136
+ return [
137
+ "// checkout-config.ts — this spoke's checkout, as data (#7838).",
138
+ "//",
139
+ "// Written once by create-ailk and yours from then on: create-ailk never",
140
+ "// rewrites it. The buy route and the Stripe webhook route build their",
141
+ "// handlers from it. Those two are the only files allowed to import the",
142
+ "// checkout package (architecture.yaml), so this file never imports it,",
143
+ "// stripe-client or the stripe SDK. Keep it to data and the fulfil callback.",
144
+ "//",
145
+ "// site Stamped on every Checkout Session as metadata.site. This site's",
146
+ "// webhook acts only on its own stamp, and the hub ignores any stamp",
147
+ "// that is not its own, so the stamp must be unique on the Stripe",
148
+ '// account this site shares. Never "default". Change it only before',
149
+ "// any price exists: every lookup key starts with it.",
150
+ "// offerings slug -> Stripe lookup_key. The buy link is /api/checkout/<slug>.",
151
+ "// scaffold-commerce replaces the placeholder below.",
152
+ "// fulfil Runs for each paid session, at least once. It posts one lead to",
153
+ "// the hub with the session id as its Idempotency-Key, so a repeat",
154
+ "// writes nothing new. The hub dedupes only an identical body: change",
155
+ "// the payload's shape only when no webhook is failing.",
156
+ `import { leads } from "${API_CLIENT_PACKAGE}";`,
157
+ "",
158
+ `import { resolveBaseUrl } from "../src/lib/routes";`,
159
+ "",
160
+ `const SITE = "${site}";`,
161
+ "",
162
+ "/** What this site's fulfil callback reads from a completed checkout. */",
163
+ "interface CompletedCheckout {",
164
+ " idempotencyKey: string;",
165
+ " sessionId: string;",
166
+ " offering: string;",
167
+ ' kind: "fixed" | "quoted";',
168
+ ' mode: "test" | "live";',
169
+ " amountTotal: number | null;",
170
+ " currency: string | null;",
171
+ " email: string | null;",
172
+ " customerId: string | null;",
173
+ "}",
174
+ "",
175
+ "export const checkoutConfig = {",
176
+ " site: SITE,",
177
+ " siteBaseUrl: resolveBaseUrl(),",
178
+ " offerings: {",
179
+ " // A placeholder until scaffold-commerce creates this site's prices.",
180
+ ' example: { kind: "fixed", lookupKey: `${SITE}_example`, cancelPath: "/" },',
181
+ " },",
182
+ " async fulfil(checkout: CompletedCheckout): Promise<void> {",
183
+ " if (checkout.email === null) return;",
184
+ " // No spoke key: the hub cannot place the lead in this site's workspace.",
185
+ " // Fail, so Stripe retries and the dashboard shows a failed webhook.",
186
+ " if (!process.env.AILK_SPOKE_KEY)",
187
+ ' throw new Error("AILK_SPOKE_KEY is not set");',
188
+ " const result = await leads.submit(",
189
+ " {",
190
+ " siteId: SITE,",
191
+ ' source: "web",',
192
+ " email: checkout.email,",
193
+ " payload: {",
194
+ " site: SITE,",
195
+ ' page: "checkout",',
196
+ " offering: checkout.offering,",
197
+ " checkoutKind: checkout.kind,",
198
+ " mode: checkout.mode,",
199
+ " amountTotal: checkout.amountTotal,",
200
+ " currency: checkout.currency,",
201
+ " stripeCustomerId: checkout.customerId,",
202
+ " checkoutSession: checkout.sessionId,",
203
+ " },",
204
+ " },",
205
+ ' { headers: { "Idempotency-Key": checkout.idempotencyKey } },',
206
+ " );",
207
+ " // A replay under the same key answers 201 with the first lead; any",
208
+ " // failure (422 idempotency_key_reused included) makes Stripe retry.",
209
+ " if (!result.ok) throw new Error(`hub lead write answered ${result.status}`);",
210
+ " },",
211
+ "};",
212
+ "",
213
+ ].join("\n");
214
+ }
215
+ /** One return page (D7): a server component that reads nothing but its copy. */
216
+ function renderPage(opts) {
217
+ return [
218
+ `// ${opts.title} — GENERATED by create-ailk (#7838).`,
219
+ ...opts.about.map((line) => `// ${line}`),
220
+ `import { SectionRenderer } from "${UI_SECTIONS_PACKAGE}";`,
221
+ `import type { PageSection } from "${VALIDATION_PACKAGE}";`,
222
+ `import type { Metadata } from "next";`,
223
+ `import { getTranslations } from "next-intl/server";`,
224
+ `import type { ReactElement } from "react";`,
225
+ "",
226
+ "interface Props {",
227
+ " params: Promise<{ locale: string }>;",
228
+ "}",
229
+ "",
230
+ "export async function generateMetadata({ params }: Props): Promise<Metadata> {",
231
+ " const { locale } = await params;",
232
+ ` const t = await getTranslations({ locale, namespace: "${opts.namespace}" });`,
233
+ ` return { title: t("title"), robots: { index: false, follow: false } };`,
234
+ "}",
235
+ "",
236
+ "export default async function Page({ params }: Props): Promise<ReactElement> {",
237
+ " const { locale } = await params;",
238
+ ` const t = await getTranslations({ locale, namespace: "${opts.namespace}" });`,
239
+ " const sections: PageSection[] = [",
240
+ ...opts.sections.map((line) => ` ${line}`),
241
+ " ];",
242
+ " return (",
243
+ ' <main id="main">',
244
+ " <SectionRenderer sections={sections} locale={locale} leadingSectionOwnsTitle />",
245
+ " </main>",
246
+ " );",
247
+ "}",
248
+ "",
249
+ ].join("\n");
250
+ }
251
+ const THANK_YOU_PAGE = renderPage({
252
+ title: "The checkout thank-you page",
253
+ about: [
254
+ "Stripe returns a buyer here after paying. It reads no search parameter and",
255
+ "calls neither Stripe nor the hub: the webhook is what fulfils the order.",
256
+ ],
257
+ namespace: "checkoutThankYou",
258
+ sections: [
259
+ "{",
260
+ ' id: "checkout-thank-you",',
261
+ ' type: "content-block",',
262
+ ' data: { headline: t("title"), body: t("body") },',
263
+ "},",
264
+ ],
265
+ });
266
+ const UNAVAILABLE_PAGE = renderPage({
267
+ title: "The checkout unavailable page",
268
+ about: [
269
+ "Every refused buy lands here. It never says why: a reason is a probe's",
270
+ "answer, and the buyer needs only that nothing was charged.",
271
+ ],
272
+ namespace: "checkoutUnavailable",
273
+ sections: [
274
+ "{",
275
+ ' id: "checkout-unavailable",',
276
+ ' type: "content-block",',
277
+ ' data: { headline: t("title"), body: t("body") },',
278
+ "},",
279
+ "{",
280
+ ' id: "checkout-unavailable-cta",',
281
+ ' type: "cta",',
282
+ " data: {",
283
+ ' headline: t("ctaHeadline"),',
284
+ ' primaryCta: { label: t("ctaLabel"), href: "/contact" },',
285
+ " },",
286
+ "},",
287
+ ],
288
+ });
289
+ /**
290
+ * The slice for `site`, in order: the two route files, the config, the two
291
+ * pages (D3). Throws `TopologyError` for an invalid stamp (SEC-7838-10).
292
+ */
293
+ export function renderSpokeCheckoutFiles(site) {
294
+ assertSite(site);
295
+ const [buyPath, webhookPath] = SPOKE_CHECKOUT_ROUTE_FILES;
296
+ const [thankYouPath, unavailablePath] = SPOKE_CHECKOUT_PAGE_FILES;
297
+ return [
298
+ [buyPath, BUY_ROUTE],
299
+ [webhookPath, WEBHOOK_ROUTE],
300
+ [SPOKE_CHECKOUT_CONFIG_PATH, renderConfig(site)],
301
+ [thankYouPath, THANK_YOU_PAGE],
302
+ [unavailablePath, UNAVAILABLE_PAGE],
303
+ ];
304
+ }
305
+ const WEB_PACKAGE_JSON = "apps/web/package.json";
306
+ /** `deps` with `name: version` added in sorted position, every other key kept in place. */
307
+ function withDependency(deps, name, version) {
308
+ const out = {};
309
+ let placed = false;
310
+ for (const [key, value] of Object.entries(deps)) {
311
+ // Code-unit order, as package managers sort — never the host's locale.
312
+ if (!placed && key > name) {
313
+ out[name] = version;
314
+ placed = true;
315
+ }
316
+ out[key] = value;
317
+ }
318
+ if (!placed)
319
+ out[name] = version;
320
+ return out;
321
+ }
322
+ /**
323
+ * Write the slice into `targetDir` (D3). A file that already exists is left
324
+ * byte-for-byte and reported `skipped` — the config is the site's once
325
+ * written. Adds the package to `apps/web/package.json` only when absent.
326
+ * Every refusal (an invalid stamp, a version map without the package, a
327
+ * missing or malformed `apps/web/package.json`) throws before the first write.
328
+ */
329
+ export function writeSpokeCheckoutSlice(targetDir, opts) {
330
+ const files = renderSpokeCheckoutFiles(opts.site);
331
+ const range = JSON.parse(substituteWorkspaceVersions(`{"${SPOKE_CHECKOUT_PACKAGE}": "workspace:*"}`, opts.versions));
332
+ const version = range[SPOKE_CHECKOUT_PACKAGE];
333
+ const pkgPath = join(targetDir, WEB_PACKAGE_JSON);
334
+ const before = readFileSync(pkgPath, "utf8");
335
+ const pkg = JSON.parse(before);
336
+ const rows = [];
337
+ for (const [path, text] of files) {
338
+ const abs = join(targetDir, path);
339
+ if (existsSync(abs)) {
340
+ rows.push({ path, action: "skipped", wanted: text });
341
+ continue;
342
+ }
343
+ mkdirSync(dirname(abs), { recursive: true });
344
+ writeFileSync(abs, text);
345
+ rows.push({ path, action: "written" });
346
+ }
347
+ const deps = pkg.dependencies ?? {};
348
+ if (!Object.prototype.hasOwnProperty.call(deps, SPOKE_CHECKOUT_PACKAGE)) {
349
+ pkg.dependencies = withDependency(deps, SPOKE_CHECKOUT_PACKAGE, version);
350
+ const after = `${JSON.stringify(pkg, null, 2)}${before.endsWith("\n") ? "\n" : ""}`;
351
+ writeFileSync(pkgPath, after);
352
+ rows.push({
353
+ path: WEB_PACKAGE_JSON,
354
+ action: "patched",
355
+ change: diffTwoStrings(WEB_PACKAGE_JSON, before, after, true),
356
+ });
357
+ }
358
+ return rows;
359
+ }
package/dist/surfaces.js CHANGED
@@ -626,6 +626,10 @@ export const ROUTE_SINCE = {
626
626
  "(authed)/account/inbox/[kind]/[id]": "0.30.0",
627
627
  "(authed)/account/settings": "0.30.0",
628
628
  "(authed)/account/support": "0.30.0",
629
+ // #7920 — the member file read a message's file action opens. Genuinely
630
+ // new, so it attaches at today's fixed-group version, the same rule #7430
631
+ // applied above.
632
+ "(authed)/account/inbox/message/[id]/file": "0.37.0",
629
633
  // The leads inbox (#5678). The ROUTE ships in every scaffold, Free
630
634
  // included — lead capture is a never-gate surface, so reading a lead back
631
635
  // is one too. Whether it shows a ROW is a separate question with two
@@ -756,6 +760,9 @@ export const ROUTE_DEPENDENCIES = {
756
760
  `${ROUTE_ROOT}/(authed)/account/error-row.tsx`,
757
761
  `${ROUTE_ROOT}/(authed)/account/retry-button.tsx`,
758
762
  ],
763
+ // #7920 — the member file read re-serves the hub's bytes with the
764
+ // credential `forwardedCookieInit` returns.
765
+ "(authed)/account/inbox/message/[id]/file": ["apps/web/lib/server-api.ts"],
759
766
  "(authed)/account/settings": [
760
767
  "apps/web/lib/account-config.ts",
761
768
  "apps/web/lib/auth-env.ts",
@@ -31,9 +31,11 @@ import { tmpdir } from "node:os";
31
31
  import { basename, dirname, join, resolve, sep } from "node:path";
32
32
  import { parseArgs } from "node:util";
33
33
  import { copyTemplate } from "./copy.js";
34
+ import { appendEnvRows } from "./lib/declared-env.js";
35
+ import { ENV_CATALOG } from "./lib/env-catalog.js";
34
36
  import { substituteMarkers } from "./substitute.js";
35
37
  import { GATEABLE_SURFACES, NON_ROUTE_DIRS, ROUTE_DEPENDENCIES, ROUTE_MESSAGE_NAMESPACES, ROUTE_SINCE, surfaceForRouteDir, } from "./surfaces.js";
36
- import { isSpokeExcluded, readSiteTopology } from "./topology.js";
38
+ import { SPOKE_SEAM_FILES, isSpokeExcluded, readSpokeShape, } from "./topology.js";
37
39
  /** Template-relative prefix every `apps/web` route directory sits under. */
38
40
  const ROUTE_ROOT = "apps/web/app/[locale]";
39
41
  /**
@@ -99,9 +101,9 @@ function reportFor(route) {
99
101
  * byte-for-byte in, the target's copy. Non-recursive: nested directories are
100
102
  * route dirs of their own, or `__tests__`.
101
103
  */
102
- function isModified(expectedDir, targetDir) {
104
+ function isModified(expectedDir, targetDir, skip = () => false) {
103
105
  for (const entry of readdirSync(expectedDir, { withFileTypes: true })) {
104
- if (!entry.isFile())
106
+ if (!entry.isFile() || skip(entry.name))
105
107
  continue;
106
108
  const targetFile = join(targetDir, entry.name);
107
109
  if (!existsSync(targetFile))
@@ -167,10 +169,17 @@ export function syncRoutes(opts) {
167
169
  // #7426 D13 — on a spoke, a route dir the spoke does not carry (the
168
170
  // `(auth)` and `(authed)` groups) is out of scope: neither reported nor
169
171
  // copied. This is the one filter point for add-module, add-route and the
170
- // sync-routes subcommand alike; a hub's scope is unchanged.
171
- const spoke = readSiteTopology(targetAbs) === "spoke"
172
- ? { mcp: existsSync(join(targetAbs, "apps", "mcp")) }
173
- : undefined;
172
+ // sync-routes subcommand alike; a hub's scope is unchanged. The spoke's
173
+ // sign-in mode rides along (#7894 D1): a hub sign-in spoke keeps its
174
+ // `(authed)` account area in scope and its operator surfaces out.
175
+ const spoke = readSpokeShape(targetAbs);
176
+ // A spoke's own render of a seam, and a file it does not carry, are not
177
+ // "modified" (#7894 D8): the template holds the hub's.
178
+ const spokeSkip = (route) => (name) => {
179
+ const path = `${ROUTE_ROOT}/${route}/${name}`;
180
+ return (spoke !== undefined &&
181
+ (SPOKE_SEAM_FILES.includes(path) || isSpokeExcluded(path, spoke)));
182
+ };
174
183
  const inScope = listTemplateRouteDirs(opts.templatesDir)
175
184
  .map(reportFor)
176
185
  // "authed" (#5014): the `(authed)` route group ships unconditionally in
@@ -202,7 +211,7 @@ export function syncRoutes(opts) {
202
211
  if (!existsSync(targetRoute)) {
203
212
  missing.push(report);
204
213
  }
205
- else if (isModified(join(scratch, report.route), targetRoute)) {
214
+ else if (isModified(join(scratch, report.route), targetRoute, spokeSkip(report.route))) {
206
215
  modified.push(report);
207
216
  }
208
217
  }
@@ -250,6 +259,12 @@ export function syncRoutes(opts) {
250
259
  copyDependencyFile(join(opts.templatesDir, dep), dst, markers);
251
260
  appliedDependencies.push(dep);
252
261
  }
262
+ // #7874 D9 — a spoke declares what the copied routes read, as the last
263
+ // write. A malformed env_contract block throws EnvContractError naming
264
+ // its line; the files copied above stay.
265
+ if (spoke !== undefined) {
266
+ appendEnvRows(targetAbs, { topology: "spoke", catalog: ENV_CATALOG });
267
+ }
253
268
  }
254
269
  return {
255
270
  targetDir: targetAbs,
@@ -10,16 +10,48 @@ export declare class TopologyError extends Error {
10
10
  export declare const SPOKE_REMOVED_ROOTS: readonly string[];
11
11
  /** The root `--without-mcp` removes. */
12
12
  export declare const SPOKE_MCP_ROOT = "apps/mcp";
13
+ /** Commerce's apps/web files that reach the hub's own checkout (spoke-intake Rule 3, #7838). */
14
+ export declare const SPOKE_HUB_CHECKOUT_SEEDS: readonly string[];
13
15
  /**
14
- * The sign-in and database files a spoke does not carry — files and
15
- * directories, scaffold-relative. Every other file that imports one of them
16
- * goes with it (`spokeExclusions`).
16
+ * The files a spoke does not carry — files and directories, scaffold-relative:
17
+ * the sign-in and database seeds, then the commerce files that call the hub's
18
+ * own checkout (`SPOKE_HUB_CHECKOUT_SEEDS`; a spoke keeps its own, #7838).
19
+ * Every other file that imports one of them goes with it (`spokeExclusions`).
17
20
  */
18
21
  export declare const SPOKE_REMOVAL_SEEDS: readonly string[];
19
- /** The two generated seam files (D5). A seam is rewritten, never removed. */
22
+ /**
23
+ * Module paths a spoke drops even when it carries the module (#7876 D8,
24
+ * clarified 2026-10-09). `signal-collectors`' report reader links a
25
+ * grant-gated report's bytes on the hub from `NEXT_PUBLIC_AILK_API_URL`,
26
+ * which a spoke refuses (#7874: its hub origin is server-only), and a
27
+ * deliverable is hub-side anyway — so a spoke carries neither the
28
+ * `/reports` routes nor the report seeds that would have no route.
29
+ */
30
+ export declare const SPOKE_EXCLUDED_MODULE_PATHS: readonly string[];
31
+ /**
32
+ * The `(authed)` seeds of the sign-in-through-the-hub mode (#7894 D9): the
33
+ * whole `(authed)` group goes in the default mode; in the hub mode its account
34
+ * area, layout and gate seam stay, and only these go — the operator surfaces,
35
+ * the Better Auth gate and the suites that mock `auth.ts` by path.
36
+ */
37
+ export declare const HUB_MODE_AUTHED_SEEDS: readonly string[];
38
+ /** The generated seam files (D5; #7874 D12). A seam is rewritten, never removed. */
20
39
  export declare const WEB_SEAM_PATH = "apps/web/lib/topology.generated.ts";
21
40
  export declare const MCP_SEAM_PATH = "apps/mcp/src/lib/topology.generated.ts";
41
+ /** The browser API origin seam (#7874 D12): a spoke's render reads no env. */
42
+ export declare const API_ORIGIN_SEAM_PATH = "apps/web/lib/api-origin.generated.ts";
43
+ /**
44
+ * The `(authed)` layout's signed-out gate (#7894 D8). Under the `(authed)`
45
+ * seed, so a default-mode spoke drops it with its group and never renders it.
46
+ */
47
+ export declare const AUTHED_GATE_SEAM_PATH = "apps/web/app/[locale]/(authed)/signed-out-gate.generated.tsx";
22
48
  export declare const SPOKE_SEAM_FILES: readonly string[];
49
+ /**
50
+ * Template files only a spoke carries (#7874 D11): the declared-env check.
51
+ * `scaffoldSync` excludes them from a hub's copy (`copyTemplate`'s
52
+ * `excludePaths` matches an exact file path).
53
+ */
54
+ export declare const SPOKE_ONLY_FILES: readonly string[];
23
55
  /**
24
56
  * The closure's allow-list: a file `spokeExclusions` removes must sit under
25
57
  * one of these prefixes, or under a `__tests__/` directory. A member outside
@@ -27,14 +59,26 @@ export declare const SPOKE_SEAM_FILES: readonly string[];
27
59
  * file — and `spokeExclusions` throws naming it rather than cut it silently.
28
60
  */
29
61
  export declare const SPOKE_REMOVABLE_PREFIXES: readonly string[];
62
+ /**
63
+ * A spoke's sign-in modes (#7894 D1): `off` — the default, no sign-in — and
64
+ * `hub`, signing a visitor in through the hub (`--hub-sign-in <origin>`).
65
+ */
66
+ export type SignInMode = "off" | "hub";
30
67
  export interface SpokeShapeOptions {
31
68
  /** Whether the spoke keeps `apps/mcp` (the default; `--without-mcp` drops it). */
32
69
  mcp: boolean;
70
+ /** The sign-in mode (#7894 D1). Absent means `"off"`, so every existing caller is the default mode. */
71
+ signIn?: SignInMode;
33
72
  }
34
73
  /** The removed roots for these options. */
35
74
  export declare function spokeRemovedRoots(opts: SpokeShapeOptions): string[];
36
- /** Whether a closure member is one the registry allows a spoke to drop. */
37
- export declare function isRemovableClosureMember(path: string): boolean;
75
+ /**
76
+ * Whether a closure member is one the registry allows a spoke to drop: under
77
+ * a seed of THAT sign-in mode, or a test. In the hub mode a surviving account
78
+ * file that imports `auth.ts` is therefore not removable, and
79
+ * `spokeExclusions` throws naming it (#7894 D9).
80
+ */
81
+ export declare function isRemovableClosureMember(path: string, opts?: Pick<SpokeShapeOptions, "signIn">): boolean;
38
82
  /**
39
83
  * Every path a spoke does not carry, computed over the built `templates/`
40
84
  * tree: the removed roots, the seeds that apply, and every source file under
@@ -69,14 +113,56 @@ export interface IsSpokeExcludedOptions extends SpokeShapeOptions {
69
113
  * generator contract). Scaffold-relative, `/`- or `\`-separated.
70
114
  */
71
115
  export declare function isSpokeExcluded(path: string, opts: IsSpokeExcludedOptions): boolean;
72
- /** `apps/web/lib/topology.generated.ts` for `topology` (D5). */
73
- export declare function renderWebSeam(topology: Topology): string;
116
+ /**
117
+ * Where a module bundle's `installMap` destination lands on a spoke (#7876
118
+ * D10): `database/flows/<name>.flow.json` becomes `flows/<name>.json` — a
119
+ * declared flow `create-ailk seed-flows` sends to the hub — and every other
120
+ * destination is returned unchanged. Applied before the spoke filter, so a
121
+ * remapped flow survives it while the rest of `database/` is dropped. A hub
122
+ * install passes no remap.
123
+ */
124
+ export declare function spokeDestination(destination: string): string;
125
+ /**
126
+ * `apps/web/lib/topology.generated.ts` for `topology` (D5) and, on a spoke,
127
+ * its sign-in mode (#7894 D8). The hub render loads `auth.ts` only when a
128
+ * session is read, so a module that imports this seam for a constant never
129
+ * loads Better Auth.
130
+ */
131
+ export declare function renderWebSeam(topology: Topology, signIn?: SignInMode): string;
132
+ /**
133
+ * `apps/web/app/[locale]/(authed)/signed-out-gate.generated.tsx` (#7894 D8).
134
+ * The hub render is the `(authed)` layout's no-session branch, moved; the
135
+ * `signIn: "hub"` render imports only the hub-session gate. A default-mode
136
+ * spoke carries no `(authed)` group, so it has no render.
137
+ * @throws TopologyError for a spoke in the default mode.
138
+ */
139
+ export declare function renderAuthedGateSeam(topology: Topology, signIn?: SignInMode): string;
74
140
  /** `apps/mcp/src/lib/topology.generated.ts` for `topology` (D5). */
75
141
  export declare function renderMcpSeam(topology: Topology): string;
142
+ /** `apps/web/lib/api-origin.generated.ts` for `topology` (#7874 D12). */
143
+ export declare function renderApiOriginSeam(topology: Topology): string;
76
144
  /** `pnpm-workspace.yaml` without its `database` package entry. */
77
145
  export declare function transformWorkspaceYaml(text: string): string;
78
146
  /** The root `package.json` without its `db:*` scripts. */
79
147
  export declare function transformRootPackageJson(text: string): string;
148
+ /** The spoke's declared-env script (#7874 D11) — named apart from the monorepo's `env:check`. */
149
+ export declare const ENV_DECLARED_SCRIPT = "env:declared";
150
+ /**
151
+ * The spoke's root `package.json` with the declared-env check (#7874 D11):
152
+ * an `env:declared` script after `claude-md:check`, and `preflight` running
153
+ * it right after `claude-md:check`. Applied after `transformRootPackageJson`.
154
+ * @throws TopologyError when either anchor is missing.
155
+ */
156
+ export declare function addEnvCheckScripts(text: string): string;
157
+ /**
158
+ * The spoke's `.github/workflows/ci.yml` (#7874 D11): a `pnpm env:declared`
159
+ * step after `pnpm claude-md:check`, and no `pnpm db:generate` step — a
160
+ * spoke has no `database/` and no `db:*` script, so that step would fail
161
+ * first (#7962). The comment block directly above the dropped step goes with
162
+ * it.
163
+ * @throws TopologyError when either anchor is missing.
164
+ */
165
+ export declare function transformCiWorkflow(text: string): string;
80
166
  /** The root `tsconfig.json` without a reference under a removed root. */
81
167
  export declare function transformRootTsconfig(text: string, opts: SpokeShapeOptions): string;
82
168
  /**
@@ -87,7 +173,8 @@ export declare function transformAppPackageJson(text: string, label: string, req
87
173
  /**
88
174
  * Apply every D6 root-file transform to the spoke at `targetDir` (after the
89
175
  * base copy, before any module or template layer), and write the spoke
90
- * render of each seam it carries (D5).
176
+ * render of each seam it carries (D5). Also the declared-env check's root
177
+ * script and CI step (#7874 D11), and the browser API origin seam (D12).
91
178
  * @throws TopologyError naming the file whose anchor is missing.
92
179
  */
93
180
  export declare function applySpokeTransforms(targetDir: string, opts: SpokeShapeOptions): void;
@@ -101,10 +188,24 @@ export declare const HUB_ORIGIN_ENV_VAR = "AILK_API_URL";
101
188
  * the hub issues those.
102
189
  */
103
190
  export declare function writeHubOriginEnv(targetDir: string, origin: string): void;
191
+ /** The env var naming the hub's web origin a hub-mode spoke signs in at (#7894 D11). */
192
+ export declare const HUB_SIGN_IN_URL_ENV_VAR = "AILK_HUB_SIGN_IN_URL";
193
+ /** The env var holding a hub-mode spoke's own cookie key material (#7894 D7, D11). */
194
+ export declare const HUB_SESSION_SECRET_ENV_VAR = "AILK_HUB_SESSION_SECRET";
195
+ /**
196
+ * Write a hub-mode spoke's sign-in env (#7894 D11) to `apps/web/.env.local`,
197
+ * under `writeHubOriginEnv`'s never-clobber rule: the hub's web origin, and a
198
+ * fresh session secret (32 random bytes, 43 base64url characters). Never the
199
+ * client id or client secret — the hub issues those (`spokes sign-in-client`)
200
+ * — and never `BETTER_AUTH_SECRET` or a database URL.
201
+ */
202
+ export declare function writeHubSignInEnv(targetDir: string, origin: string): void;
104
203
  /** The `hard_floors:` block a spoke's `project.yaml` carries (D9). */
105
204
  export declare function renderSpokeHardFloors(opts: SpokeShapeOptions): string;
106
205
  /** `project.yaml` with its `hard_floors:` block replaced by the spoke's (D9). */
107
206
  export declare function transformProjectYaml(text: string, opts: SpokeShapeOptions): string;
207
+ /** Why a module is not admitted on a spoke, and where its admission lives. */
208
+ export declare const SPOKE_REFUSAL_REASONS: Partial<Record<ModuleName, string>>;
108
209
  /** The modules a spoke may carry — every slice declaring `spoke: true`. */
109
210
  export declare function spokeModules(): ModuleName[];
110
211
  /** The refusal for a module a spoke cannot carry, or undefined when admitted. */
@@ -115,6 +216,14 @@ export declare function spokeModuleRefusal(name: ModuleName): string | undefined
115
216
  * @throws TopologyError on an unreadable manifest or an unknown value.
116
217
  */
117
218
  export declare function readSiteTopology(targetDir: string): Topology;
219
+ /**
220
+ * The spoke options `<targetDir>` records (#7894 D1), for the later commands
221
+ * (`sync-routes`, `add-module`): `undefined` on a hub; on a spoke, `mcp` from
222
+ * whether it still carries `apps/mcp` and `signIn` from the manifest's
223
+ * `signIn` key — absent reads as `"off"`, every spoke before #7894.
224
+ * @throws TopologyError on an unreadable manifest or an unknown value.
225
+ */
226
+ export declare function readSpokeShape(targetDir: string): SpokeShapeOptions | undefined;
118
227
  /**
119
228
  * Hosts a spoke may reach over plain `http:` — the set
120
229
  * `packages/api-client/src/http.ts` sends the spoke key under. Named here
@@ -129,3 +238,9 @@ export declare const LOOPBACK_HOSTS: readonly string[];
129
238
  * is not such an origin. Never echoes a query or fragment.
130
239
  */
131
240
  export declare function normalizeHubUrl(raw: string | undefined): string;
241
+ /**
242
+ * The hub's WEB origin a hub-mode spoke signs in at (`--hub-sign-in`, #7894
243
+ * D1) — `normalizeHubUrl`'s rules, each refusal naming the flag.
244
+ * @throws TopologyError when `raw` is not such an origin.
245
+ */
246
+ export declare function normalizeHubSignInUrl(raw: string): string;