create-ailk 0.14.0 → 0.15.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 (95) hide show
  1. package/component-catalog.json +3 -3
  2. package/dist/add-module.d.ts +7 -0
  3. package/dist/add-module.js +34 -2
  4. package/dist/add-route.js +18 -2
  5. package/dist/apply-design.d.ts +12 -1
  6. package/dist/apply-design.js +36 -4
  7. package/dist/cli.js +23 -3
  8. package/dist/derive-architecture.d.ts +15 -2
  9. package/dist/derive-architecture.js +34 -6
  10. package/dist/design-package.js +3 -2
  11. package/dist/index.d.ts +1 -1
  12. package/dist/index.js +1 -1
  13. package/dist/lib/chrome-readers.d.ts +21 -0
  14. package/dist/lib/chrome-readers.js +100 -0
  15. package/dist/lib/extract-module-bundle.d.ts +11 -0
  16. package/dist/lib/extract-module-bundle.js +36 -6
  17. package/dist/lib/license-key-files.d.ts +6 -1
  18. package/dist/lib/license-key-files.js +9 -4
  19. package/dist/module-architecture.d.ts +9 -0
  20. package/dist/module-architecture.js +6 -0
  21. package/dist/parse-args.d.ts +22 -0
  22. package/dist/parse-args.js +73 -0
  23. package/dist/programmatic.d.ts +20 -0
  24. package/dist/programmatic.js +110 -9
  25. package/dist/site-chrome.d.ts +4 -0
  26. package/dist/site-chrome.js +34 -3
  27. package/dist/site-manifest.d.ts +12 -5
  28. package/dist/site-manifest.js +25 -11
  29. package/dist/sync-routes.js +11 -2
  30. package/dist/topology.d.ts +131 -0
  31. package/dist/topology.js +533 -0
  32. package/package.json +3 -3
  33. package/site-templates/designs/minimalism/DESIGN.md +3 -3
  34. package/templates/apps/api/CLAUDE.md +4 -2
  35. package/templates/apps/api/package.json +1 -0
  36. package/templates/apps/api/src/__tests__/factories/agent-caller.ts +5 -1
  37. package/templates/apps/api/src/lib/__mocks__/prisma.ts +10 -0
  38. package/templates/apps/api/src/lib/__tests__/agent-store.test.ts +114 -1
  39. package/templates/apps/api/src/lib/__tests__/site.test.ts +54 -0
  40. package/templates/apps/api/src/lib/agent-store.ts +95 -3
  41. package/templates/apps/api/src/lib/site.ts +37 -0
  42. package/templates/apps/api/src/lib/spoke-store.ts +18 -2
  43. package/templates/apps/api/src/middleware/__tests__/auth.bearer-path.test.ts +4 -0
  44. package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +22 -0
  45. package/templates/apps/api/src/middleware/auth.ts +3 -0
  46. package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +23 -0
  47. package/templates/apps/api/src/openapi/spec.ts +38 -2
  48. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +50 -2
  49. package/templates/apps/api/src/routes/checkout/sessions.ts +7 -0
  50. package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +275 -0
  51. package/templates/apps/api/src/routes/leads/index.ts +69 -6
  52. package/templates/apps/api/src/routes/webhooks/README.md +39 -0
  53. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +366 -0
  54. package/templates/apps/api/src/routes/webhooks/stripe.ts +61 -1
  55. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +199 -0
  56. package/templates/apps/api/src/services/default-tenant.ts +41 -7
  57. package/templates/apps/api/src/services/lead-routing.ts +148 -29
  58. package/templates/apps/api/src/services/public-site-resolver.ts +48 -0
  59. package/templates/apps/mcp/__tests__/index.test.ts +5 -5
  60. package/templates/apps/mcp/src/index.ts +6 -4
  61. package/templates/apps/mcp/src/lib/content-adapter.ts +2 -2
  62. package/templates/apps/mcp/src/lib/topology.generated.ts +24 -0
  63. package/templates/apps/web/CLAUDE.md +1 -0
  64. package/templates/apps/web/app/[locale]/[slug]/[detail]/__tests__/page.test.tsx +3 -0
  65. package/templates/apps/web/app/[locale]/[slug]/__tests__/page.test.tsx +3 -0
  66. package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +66 -14
  67. package/templates/apps/web/app/[locale]/contact/__tests__/site-contact-lines.test.ts +105 -11
  68. package/templates/apps/web/app/[locale]/contact/site-contact-lines.ts +12 -8
  69. package/templates/apps/web/app/[locale]/layout.tsx +71 -19
  70. package/templates/apps/web/app/[locale]/newsletter/__tests__/page.test.tsx +3 -0
  71. package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +5 -2
  72. package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +3 -0
  73. package/templates/apps/web/app/openapi.json/__tests__/route.test.ts +89 -1
  74. package/templates/apps/web/app/openapi.json/route.ts +69 -1
  75. package/templates/apps/web/instrumentation.ts +7 -4
  76. package/templates/apps/web/lib/__tests__/closing-footer-path.test.ts +96 -0
  77. package/templates/apps/web/lib/__tests__/relay.test.ts +290 -0
  78. package/templates/apps/web/lib/__tests__/site-brand.test.ts +52 -0
  79. package/templates/apps/web/lib/closing-footer-path.ts +50 -0
  80. package/templates/apps/web/lib/relay.ts +206 -2
  81. package/templates/apps/web/lib/site-brand.tsx +22 -1
  82. package/templates/apps/web/lib/site-chrome.ts +3 -0
  83. package/templates/apps/web/lib/topology.generated.ts +23 -0
  84. package/templates/apps/web/package.json +1 -1
  85. package/templates/database/CHANGELOG.md +25 -0
  86. package/templates/database/__tests__/agents.db.test.ts +65 -0
  87. package/templates/database/__tests__/lead-idempotency-key.db.test.ts +92 -0
  88. package/templates/database/auth/schema.prisma +23 -0
  89. package/templates/database/inbox/schema.prisma +13 -0
  90. package/templates/database/jest.config.cjs +7 -11
  91. package/templates/database/migrations/20261008120000_user_last_workspace/migration.sql +35 -0
  92. package/templates/database/migrations/20261008130000_lead_idempotency_key/migration.sql +20 -0
  93. package/templates/database/migrations/20261009120000_agent_grants/migration.sql +33 -0
  94. package/templates/database/package.json +1 -1
  95. package/versions.json +34 -33
@@ -0,0 +1,533 @@
1
+ /**
2
+ * The spoke topology (#7426) — `create-ailk --topology spoke`.
3
+ *
4
+ * A spoke is the existing scaffold with nodes REMOVED, never a second
5
+ * template tree: no `apps/api`, no `database/`, no sign-in, and `apps/mcp`
6
+ * kept as a stateless front unless `--without-mcp`. Its forms, flows and
7
+ * listings reach a hub at `AILK_API_URL` from server code. Everything that
8
+ * decides what a spoke is lives here, in one registry, so the spoke tree
9
+ * cannot drift from the hub tree it is cut from:
10
+ *
11
+ * - `SPOKE_REMOVED_ROOTS` / `SPOKE_REMOVAL_SEEDS` and `spokeExclusions` —
12
+ * the paths a spoke does not carry: the removed roots, the sign-in and
13
+ * database seeds, and every file that imports a seed (D4);
14
+ * - `isSpokeExcluded` — the ONE predicate every writer (the base copy,
15
+ * each module bundle, the template layer, the later commands) asks;
16
+ * - the two generated seam files (D5) — the only place hub and spoke code
17
+ * differ, rendered here for either topology;
18
+ * - the root-file transforms (D6), the hard floors (D9), the module
19
+ * admission (D7), the manifest reader (D10) and the hub origin (D2).
20
+ *
21
+ * Spec: docs/specs/7426-spoke-topology-option-spec.md.
22
+ *
23
+ * Pure module: no `import.meta.url`, so jest imports it directly. The
24
+ * package's own `templates/` dir is supplied by the caller.
25
+ */
26
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
27
+ import { join } from "node:path";
28
+ import { ENV_LOCAL_IGNORE_LINES } from "./lib/auth-secret-file.js";
29
+ import { listFilesUnder, relativeImportsOf, resolveRelativeImport, } from "./lib/import-graph.js";
30
+ import { appendLine, writeOwnerOnly } from "./lib/license-key-files.js";
31
+ import { MODULE_SLICES } from "./module-architecture.js";
32
+ import { RECOGNIZED_MODULES } from "./parse-args.js";
33
+ /** The two topologies. `hub` is the default and the only one before #7426. */
34
+ export const TOPOLOGIES = ["hub", "spoke"];
35
+ /** A refusal or a malformed input this module names. */
36
+ export class TopologyError extends Error {
37
+ constructor(message) {
38
+ super(message);
39
+ this.name = "TopologyError";
40
+ }
41
+ }
42
+ // ── D4 — the removal registry ──────────────────────────────────────────────
43
+ /** Roots no spoke carries. `apps/mcp` joins them with `--without-mcp`. */
44
+ export const SPOKE_REMOVED_ROOTS = ["apps/api", "database"];
45
+ /** The root `--without-mcp` removes. */
46
+ export const SPOKE_MCP_ROOT = "apps/mcp";
47
+ /**
48
+ * The sign-in and database files a spoke does not carry — files and
49
+ * directories, scaffold-relative. Every other file that imports one of them
50
+ * goes with it (`spokeExclusions`).
51
+ */
52
+ export const SPOKE_REMOVAL_SEEDS = [
53
+ "apps/web/auth.ts",
54
+ "apps/web/lib/prisma-for-auth.ts",
55
+ "apps/web/lib/license-key-store.ts",
56
+ "apps/web/app/api/auth",
57
+ "apps/web/app/[locale]/(auth)",
58
+ "apps/web/app/[locale]/(authed)",
59
+ "apps/mcp/src/lib/prisma.ts",
60
+ "apps/mcp/src/lib/public-workspace.ts",
61
+ "apps/mcp/src/lib/license-key-store.ts",
62
+ ];
63
+ /** The two generated seam files (D5). A seam is rewritten, never removed. */
64
+ export const WEB_SEAM_PATH = "apps/web/lib/topology.generated.ts";
65
+ export const MCP_SEAM_PATH = "apps/mcp/src/lib/topology.generated.ts";
66
+ export const SPOKE_SEAM_FILES = [
67
+ WEB_SEAM_PATH,
68
+ MCP_SEAM_PATH,
69
+ ];
70
+ /**
71
+ * The closure's allow-list: a file `spokeExclusions` removes must sit under
72
+ * one of these prefixes, or under a `__tests__/` directory. A member outside
73
+ * both is a defect — a shared file started importing a sign-in or database
74
+ * file — and `spokeExclusions` throws naming it rather than cut it silently.
75
+ */
76
+ export const SPOKE_REMOVABLE_PREFIXES = SPOKE_REMOVAL_SEEDS;
77
+ function toPosix(path) {
78
+ return path.split("\\").join("/").replace(/^\.\//, "");
79
+ }
80
+ /** `path` equals `prefix` or sits below it, at a directory boundary. */
81
+ function underPrefix(path, prefix) {
82
+ const p = prefix.replace(/\/+$/, "");
83
+ return path === p || path.startsWith(`${p}/`);
84
+ }
85
+ /** The removed roots for these options. */
86
+ export function spokeRemovedRoots(opts) {
87
+ return opts.mcp
88
+ ? [...SPOKE_REMOVED_ROOTS]
89
+ : [...SPOKE_REMOVED_ROOTS, SPOKE_MCP_ROOT];
90
+ }
91
+ /** The seeds that apply — an mcp seed is moot when `apps/mcp` itself goes. */
92
+ function spokeSeeds(opts) {
93
+ return SPOKE_REMOVAL_SEEDS.filter((seed) => opts.mcp || !underPrefix(seed, SPOKE_MCP_ROOT));
94
+ }
95
+ /** Whether a closure member is one the registry allows a spoke to drop. */
96
+ export function isRemovableClosureMember(path) {
97
+ const p = toPosix(path);
98
+ return (p.split("/").includes("__tests__") ||
99
+ SPOKE_REMOVABLE_PREFIXES.some((prefix) => underPrefix(p, prefix)));
100
+ }
101
+ /**
102
+ * Every path a spoke does not carry, computed over the built `templates/`
103
+ * tree: the removed roots, the seeds that apply, and every source file under
104
+ * the kept apps that reaches a seed through relative imports, to a fixed
105
+ * point. Sorted; directories and files alike, matched at a directory
106
+ * boundary by `isSpokeExcluded` and `copyTemplate`'s `excludePaths`.
107
+ *
108
+ * The walk is the relative-import resolution `importClosure` uses
109
+ * (`lib/import-graph.ts`), with ONE difference: a seam file is never a
110
+ * candidate, so the walk never crosses it. The hub render of a seam imports
111
+ * a seed by design (D5); its importers keep their place on a spoke because
112
+ * the spoke render of the same file imports none.
113
+ *
114
+ * @throws TopologyError naming every closure member outside
115
+ * `SPOKE_REMOVABLE_PREFIXES` and not a test — a new shared file importing
116
+ * a sign-in or database file, which removing would silently break the
117
+ * spoke (spec § 5.3: escalate, never widen).
118
+ */
119
+ export function spokeExclusions(templatesDir, opts) {
120
+ const roots = ["apps/web", ...(opts.mcp ? [SPOKE_MCP_ROOT] : [])];
121
+ const seeds = spokeSeeds(opts);
122
+ const seams = new Set(SPOKE_SEAM_FILES);
123
+ const candidates = listFilesUnder(templatesDir, roots);
124
+ for (const seam of seams)
125
+ candidates.delete(seam);
126
+ const closed = new Set();
127
+ for (const file of candidates) {
128
+ if (seeds.some((seed) => underPrefix(file, seed)))
129
+ closed.add(file);
130
+ }
131
+ for (;;) {
132
+ let grew = false;
133
+ for (const file of candidates) {
134
+ if (closed.has(file))
135
+ continue;
136
+ for (const spec of relativeImportsOf(join(templatesDir, file))) {
137
+ const target = resolveRelativeImport(file, spec, candidates);
138
+ if (target !== undefined && closed.has(target)) {
139
+ closed.add(file);
140
+ grew = true;
141
+ break;
142
+ }
143
+ }
144
+ }
145
+ if (!grew)
146
+ break;
147
+ }
148
+ const outside = [...closed]
149
+ .filter((file) => !isRemovableClosureMember(file))
150
+ .sort();
151
+ if (outside.length > 0) {
152
+ throw new TopologyError(`a spoke cannot drop ${outside.join(", ")}: ${outside.length === 1 ? "it imports" : "each imports"} a sign-in or database file, and ${outside.length === 1 ? "it is" : "they are"} not a sign-in path or a test. Move the import behind the generated seam (${WEB_SEAM_PATH} / ${MCP_SEAM_PATH}) rather than widening SPOKE_REMOVABLE_PREFIXES.`);
153
+ }
154
+ const covered = (file) => seeds.some((seed) => underPrefix(file, seed));
155
+ return [
156
+ ...new Set([
157
+ ...spokeRemovedRoots(opts),
158
+ ...seeds,
159
+ ...[...closed].filter((file) => !covered(file)),
160
+ ]),
161
+ ].sort();
162
+ }
163
+ /**
164
+ * The one predicate for "a spoke does not carry this path" (the Phase 2
165
+ * generator contract). Scaffold-relative, `/`- or `\`-separated.
166
+ */
167
+ export function isSpokeExcluded(path, opts) {
168
+ const p = toPosix(path);
169
+ const prefixes = opts.exclusions ?? [
170
+ ...spokeRemovedRoots(opts),
171
+ ...spokeSeeds(opts),
172
+ ];
173
+ return prefixes.some((prefix) => underPrefix(p, prefix));
174
+ }
175
+ // ── D5 — the generated seam files ───────────────────────────────────────────
176
+ const WEB_SEAM_HEADER = `// topology.generated.ts — how this site reads a session and registers its stores.
177
+ //
178
+ // GENERATED by create-ailk for this site's topology (#7426). Do not edit:
179
+ // every scaffold rewrites it. A hub reads the session through Better Auth;
180
+ // a spoke (\`--topology spoke\`) has no sign-in and no database of its own,
181
+ // so its render reads no session and imports nothing.`;
182
+ const MCP_SEAM_HEADER = `// topology.generated.ts — how this MCP server resolves its workspace store.
183
+ //
184
+ // GENERATED by create-ailk for this site's topology (#7426). Do not edit:
185
+ // every scaffold rewrites it. A hub resolves its workspace through the
186
+ // database; a spoke (\`--topology spoke\`) has none, so its content tools
187
+ // read the spoke's own MDX and its writes go to the hub at AILK_API_URL.`;
188
+ const SITE_SESSION_TYPE = `export type SiteSession = {
189
+ user: { name?: string | null; email: string };
190
+ } | null;`;
191
+ /** `apps/web/lib/topology.generated.ts` for `topology` (D5). */
192
+ export function renderWebSeam(topology) {
193
+ if (topology === "hub") {
194
+ return `${WEB_SEAM_HEADER}
195
+ import { auth } from "../auth";
196
+
197
+ export const TOPOLOGY: "hub" | "spoke" = "hub";
198
+ export const SIGN_IN_ENABLED: boolean = true;
199
+
200
+ ${SITE_SESSION_TYPE}
201
+
202
+ export async function readSession(headers: Headers): Promise<SiteSession> {
203
+ return auth.api.getSession({ headers });
204
+ }
205
+
206
+ export async function registerSiteStores(): Promise<void> {
207
+ const { registerLicenseKeyStore } = await import("./license-key-store");
208
+ await registerLicenseKeyStore();
209
+ }
210
+ `;
211
+ }
212
+ return `${WEB_SEAM_HEADER}
213
+
214
+ export const TOPOLOGY: "hub" | "spoke" = "spoke";
215
+ export const SIGN_IN_ENABLED: boolean = false;
216
+
217
+ ${SITE_SESSION_TYPE}
218
+
219
+ export function readSession(headers: Headers): Promise<SiteSession> {
220
+ void headers;
221
+ return Promise.resolve(null);
222
+ }
223
+
224
+ export function registerSiteStores(): Promise<void> {
225
+ return Promise.resolve();
226
+ }
227
+ `;
228
+ }
229
+ /**
230
+ * The package the spoke mcp seam takes its one type from. Interpolated, not
231
+ * written inline: an inline specifier in this template would read as an
232
+ * import create-ailk itself makes (`tools/lint-architecture.ts`), and
233
+ * create-ailk carries no runtime dependency.
234
+ */
235
+ const CONTENT_ADAPTERS_PACKAGE = "@working-theory/content-adapters";
236
+ /** `apps/mcp/src/lib/topology.generated.ts` for `topology` (D5). */
237
+ export function renderMcpSeam(topology) {
238
+ if (topology === "hub") {
239
+ return `${MCP_SEAM_HEADER}
240
+ import {
241
+ initMcpPublicWorkspace,
242
+ mcpPublicWorkspace,
243
+ type McpWorkspaceResolution,
244
+ } from "./public-workspace.js";
245
+
246
+ export { registerLicenseKeyStore } from "./license-key-store.js";
247
+ export type { McpWorkspaceResolution };
248
+
249
+ export const TOPOLOGY: "hub" | "spoke" = "hub";
250
+
251
+ export async function initPublicWorkspace(): Promise<void> {
252
+ await initMcpPublicWorkspace();
253
+ }
254
+
255
+ export function publicWorkspace(): McpWorkspaceResolution {
256
+ return mcpPublicWorkspace();
257
+ }
258
+ `;
259
+ }
260
+ return `${MCP_SEAM_HEADER}
261
+ import type { ArtifactPrismaClient } from "${CONTENT_ADAPTERS_PACKAGE}";
262
+
263
+ export type McpWorkspaceResolution =
264
+ | { kind: "not-required" }
265
+ | { kind: "resolved"; workspaceId: string; prisma: ArtifactPrismaClient }
266
+ | { kind: "unresolved"; reason: "ambiguous" };
267
+
268
+ export const TOPOLOGY: "hub" | "spoke" = "spoke";
269
+
270
+ export function initPublicWorkspace(): Promise<void> {
271
+ return Promise.resolve();
272
+ }
273
+
274
+ export function publicWorkspace(): McpWorkspaceResolution {
275
+ return { kind: "not-required" };
276
+ }
277
+
278
+ export function registerLicenseKeyStore(): Promise<void> {
279
+ return Promise.resolve();
280
+ }
281
+ `;
282
+ }
283
+ // ── D6 — the root-file transforms ───────────────────────────────────────────
284
+ /**
285
+ * `text` with every line `drop` matches removed, then the dangling comma a
286
+ * removed last member leaves fixed. Validated as JSON when `json` is set.
287
+ */
288
+ function dropLines(text, drop, label, json) {
289
+ const kept = text.split("\n").filter((line) => !drop(line));
290
+ const next = kept.join("\n").replace(/,(\s*\n\s*[}\]])/g, "$1");
291
+ if (json) {
292
+ try {
293
+ JSON.parse(next);
294
+ }
295
+ catch (err) {
296
+ throw new TopologyError(`${label}: the spoke transform left it unparseable: ${err.message}`);
297
+ }
298
+ }
299
+ return next;
300
+ }
301
+ /** `pnpm-workspace.yaml` without its `database` package entry. */
302
+ export function transformWorkspaceYaml(text) {
303
+ const entry = /^\s*-\s*["']?database["']?\s*$/;
304
+ if (!text.split("\n").some((line) => entry.test(line))) {
305
+ throw new TopologyError(`pnpm-workspace.yaml: no "database" package entry to remove`);
306
+ }
307
+ return dropLines(text, (line) => entry.test(line), "pnpm-workspace.yaml", false);
308
+ }
309
+ /** The root `package.json` without its `db:*` scripts. */
310
+ export function transformRootPackageJson(text) {
311
+ const pkg = JSON.parse(text);
312
+ if (pkg.scripts === undefined) {
313
+ throw new TopologyError(`package.json: no "scripts" block to remove the db:* scripts from`);
314
+ }
315
+ const scriptLine = /^\s*"db:[^"]*"\s*:/;
316
+ return dropLines(text, (line) => scriptLine.test(line), "package.json", true);
317
+ }
318
+ /** The root `tsconfig.json` without a reference under a removed root. */
319
+ export function transformRootTsconfig(text, opts) {
320
+ const roots = spokeRemovedRoots(opts);
321
+ const refLine = /"path"\s*:\s*"([^"]+)"/;
322
+ return dropLines(text, (line) => {
323
+ const m = refLine.exec(line);
324
+ return (m !== null &&
325
+ roots.some((root) => underPrefix(toPosix(m[1] ?? ""), root)));
326
+ }, "tsconfig.json", true);
327
+ }
328
+ /**
329
+ * An app's `package.json` without its `@working-theory/database` dependency.
330
+ * @throws TopologyError when `required` and the dependency is absent.
331
+ */
332
+ export function transformAppPackageJson(text, label, required) {
333
+ const depLine = /^\s*"@working-theory\/database"\s*:/;
334
+ if (required && !text.split("\n").some((line) => depLine.test(line))) {
335
+ throw new TopologyError(`${label}: no "@working-theory/database" dependency to remove`);
336
+ }
337
+ return dropLines(text, (line) => depLine.test(line), label, true);
338
+ }
339
+ /**
340
+ * Apply every D6 root-file transform to the spoke at `targetDir` (after the
341
+ * base copy, before any module or template layer), and write the spoke
342
+ * render of each seam it carries (D5).
343
+ * @throws TopologyError naming the file whose anchor is missing.
344
+ */
345
+ export function applySpokeTransforms(targetDir, opts) {
346
+ const edit = (rel, transform) => {
347
+ const path = join(targetDir, rel);
348
+ if (!existsSync(path)) {
349
+ throw new TopologyError(`${rel}: missing, so the spoke transform cannot run`);
350
+ }
351
+ const text = readFileSync(path, "utf8");
352
+ const next = transform(text);
353
+ if (next !== text)
354
+ writeFileSync(path, next);
355
+ };
356
+ edit("pnpm-workspace.yaml", transformWorkspaceYaml);
357
+ edit("package.json", transformRootPackageJson);
358
+ edit("tsconfig.json", (text) => transformRootTsconfig(text, opts));
359
+ edit("apps/web/package.json", (text) => transformAppPackageJson(text, "apps/web/package.json", true));
360
+ if (opts.mcp) {
361
+ edit("apps/mcp/package.json", (text) => transformAppPackageJson(text, "apps/mcp/package.json", false));
362
+ }
363
+ edit("project.yaml", (text) => transformProjectYaml(text, opts));
364
+ writeFileSync(join(targetDir, WEB_SEAM_PATH), renderWebSeam("spoke"));
365
+ if (opts.mcp) {
366
+ writeFileSync(join(targetDir, MCP_SEAM_PATH), renderMcpSeam("spoke"));
367
+ }
368
+ }
369
+ /** The env var a spoke reaches its hub through. */
370
+ export const HUB_ORIGIN_ENV_VAR = "AILK_API_URL";
371
+ /** Env files `next start` loads for an app, highest precedence first. */
372
+ const NEXT_ENV_FILES = [
373
+ ".env.production.local",
374
+ ".env.local",
375
+ ".env.production",
376
+ ".env",
377
+ ];
378
+ /**
379
+ * Write `AILK_API_URL=<origin>` to the spoke's `apps/web/.env.local` (D2) —
380
+ * the `auth-secret-file.ts` never-clobber rule: an assignment already in any
381
+ * env file Next loads for `apps/web` is kept, and `.env.local` stays
382
+ * gitignored. Never writes `AILK_SPOKE_KEY` or `AILK_VISITOR_FORWARD_KEY`:
383
+ * the hub issues those.
384
+ */
385
+ export function writeHubOriginEnv(targetDir, origin) {
386
+ const webDir = join(targetDir, "apps", "web");
387
+ const assigned = new RegExp(`^\\s*(?:export\\s+)?${HUB_ORIGIN_ENV_VAR}\\s*=\\s*\\S`, "m");
388
+ const already = NEXT_ENV_FILES.some((name) => {
389
+ const path = join(webDir, name);
390
+ return existsSync(path) && assigned.test(readFileSync(path, "utf8"));
391
+ });
392
+ if (!already) {
393
+ const file = join(webDir, ".env.local");
394
+ const existing = existsSync(file) ? readFileSync(file, "utf8") : "";
395
+ writeOwnerOnly(file, appendLine(existing, `${HUB_ORIGIN_ENV_VAR}=${origin}`));
396
+ }
397
+ const ignorePath = join(targetDir, ".gitignore");
398
+ const ignore = existsSync(ignorePath) ? readFileSync(ignorePath, "utf8") : "";
399
+ const covered = ignore
400
+ .split("\n")
401
+ .some((line) => ENV_LOCAL_IGNORE_LINES.has(line.trim()));
402
+ if (!covered)
403
+ writeFileSync(ignorePath, appendLine(ignore, ".env.local"));
404
+ }
405
+ // ── D9 — the spoke's hard floors ────────────────────────────────────────────
406
+ /** The `hard_floors:` block a spoke's `project.yaml` carries (D9). */
407
+ export function renderSpokeHardFloors(opts) {
408
+ const lines = [
409
+ "hard_floors:",
410
+ " # Surfaces that always require human review on merge, regardless of",
411
+ " # automation level. This site is a SPOKE (create-ailk --topology spoke):",
412
+ " # it has no apps/api, no database and no sign-in of its own. Pricing, the",
413
+ " # Stripe webhook route and the sign-in paths are floored so their arrival",
414
+ " # here is a human merge.",
415
+ " - kind: pricing",
416
+ " - kind: stripe_live_mode",
417
+ " - paths:",
418
+ ' - "apps/web/app/api/webhooks/stripe/**"',
419
+ " reason: Stripe webhook route — payment state requires human review",
420
+ " - paths:",
421
+ ' - "apps/web/auth.ts"',
422
+ ' - "apps/web/app/api/auth/**"',
423
+ ' - "apps/web/app/[locale]/(auth)/**"',
424
+ ' - "apps/web/app/[locale]/(authed)/**"',
425
+ " reason: sign-in paths — absent on a spoke; their return requires human review",
426
+ ];
427
+ if (opts.mcp) {
428
+ lines.push(" - paths:", ' - "apps/mcp/src/tools/**"', " reason: MCP tool surface — signatures require human review");
429
+ }
430
+ return `${lines.join("\n")}\n`;
431
+ }
432
+ /** `project.yaml` with its `hard_floors:` block replaced by the spoke's (D9). */
433
+ export function transformProjectYaml(text, opts) {
434
+ const lines = text.split("\n");
435
+ const start = lines.findIndex((line) => line === "hard_floors:");
436
+ if (start === -1 || lines.indexOf("hard_floors:", start + 1) !== -1) {
437
+ throw new TopologyError(`project.yaml: no single top-level "hard_floors:" block to replace`);
438
+ }
439
+ let end = start + 1;
440
+ while (end < lines.length && !/^\S/.test(lines[end] ?? ""))
441
+ end++;
442
+ // Keep the blank line(s) that separate the block from the next key.
443
+ while (end > start + 1 && (lines[end - 1] ?? "").trim() === "")
444
+ end--;
445
+ const block = renderSpokeHardFloors(opts).replace(/\n$/, "").split("\n");
446
+ return [...lines.slice(0, start), ...block, ...lines.slice(end)].join("\n");
447
+ }
448
+ // ── D7 — the module admission ───────────────────────────────────────────────
449
+ /** Why a module is not admitted on a spoke, and where its admission lives. */
450
+ const SPOKE_REFUSAL_REASONS = {
451
+ scheduling: "its spoke admission lands with #7872",
452
+ commerce: "its spoke admission lands with #7838",
453
+ "multi-tenant": "it needs a database or the Admin app",
454
+ workspace: "it needs a database or the Admin app",
455
+ "signal-collectors": "it needs a database or the Admin app",
456
+ };
457
+ /** The modules a spoke may carry — every slice declaring `spoke: true`. */
458
+ export function spokeModules() {
459
+ return RECOGNIZED_MODULES.filter((name) => MODULE_SLICES[name].spoke === true);
460
+ }
461
+ /** The refusal for a module a spoke cannot carry, or undefined when admitted. */
462
+ export function spokeModuleRefusal(name) {
463
+ if (MODULE_SLICES[name].spoke === true)
464
+ return undefined;
465
+ const reason = SPOKE_REFUSAL_REASONS[name] ?? "it is not admitted on a spoke";
466
+ return `module "${name}" cannot be added to a spoke: ${reason}. A spoke carries ${spokeModules().join(", ")}.`;
467
+ }
468
+ // ── D10 — reading a site's topology ─────────────────────────────────────────
469
+ /** The manifest a templated scaffold writes (`site-roster.ts` `SITE_ROSTER_PATH`). */
470
+ const SITE_MANIFEST_PATH = ".ailk-site-manifest.json";
471
+ /**
472
+ * The topology `<targetDir>` records. A missing manifest, or one without a
473
+ * `topology` key, reads as `"hub"` — every site scaffolded before #7426.
474
+ * @throws TopologyError on an unreadable manifest or an unknown value.
475
+ */
476
+ export function readSiteTopology(targetDir) {
477
+ const path = join(targetDir, SITE_MANIFEST_PATH);
478
+ if (!existsSync(path))
479
+ return "hub";
480
+ let doc;
481
+ try {
482
+ doc = JSON.parse(readFileSync(path, "utf8"));
483
+ }
484
+ catch (err) {
485
+ throw new TopologyError(`${SITE_MANIFEST_PATH} is not JSON, so this site's topology is unknown: ${err.message}`);
486
+ }
487
+ const value = doc["topology"];
488
+ if (value === undefined)
489
+ return "hub";
490
+ if (typeof value === "string" &&
491
+ TOPOLOGIES.includes(value)) {
492
+ return value;
493
+ }
494
+ throw new TopologyError(`${SITE_MANIFEST_PATH} records an unknown topology ${JSON.stringify(value)} (expected ${TOPOLOGIES.join(" or ")})`);
495
+ }
496
+ // ── D2 — the hub origin ─────────────────────────────────────────────────────
497
+ /**
498
+ * Hosts a spoke may reach over plain `http:` — the set
499
+ * `packages/api-client/src/http.ts` sends the spoke key under. Named here
500
+ * rather than imported: create-ailk carries no runtime dependency.
501
+ */
502
+ export const LOOPBACK_HOSTS = ["localhost", "127.0.0.1"];
503
+ /**
504
+ * The hub origin for a spoke: `--hub-url`, else `AILK_API_URL`. An `https:`
505
+ * URL, or `http:` on a loopback host, with no path but `/`, no query and no
506
+ * fragment — normalized to its origin with no trailing slash.
507
+ * @throws TopologyError naming both inputs when neither is set or the value
508
+ * is not such an origin. Never echoes a query or fragment.
509
+ */
510
+ export function normalizeHubUrl(raw) {
511
+ const inputs = "Pass --hub-url <origin>, or set AILK_API_URL";
512
+ if (raw === undefined || raw.trim() === "") {
513
+ throw new TopologyError(`a spoke needs its hub's origin, and none was given. ${inputs} (e.g. https://hub.example.com).`);
514
+ }
515
+ let url;
516
+ try {
517
+ url = new URL(raw.trim());
518
+ }
519
+ catch {
520
+ throw new TopologyError(`the hub origin is not a URL. ${inputs} to an origin such as https://hub.example.com.`);
521
+ }
522
+ const loopback = LOOPBACK_HOSTS.includes(url.hostname);
523
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) {
524
+ throw new TopologyError(`the hub origin must be https: (http: only on ${LOOPBACK_HOSTS.join(" or ")}), got ${url.protocol}//${url.host}. ${inputs}.`);
525
+ }
526
+ if (url.pathname !== "/" || url.search !== "" || url.hash !== "") {
527
+ throw new TopologyError(`the hub origin must be an origin only — no path, query or fragment. ${inputs} to ${url.origin}.`);
528
+ }
529
+ if (url.username !== "" || url.password !== "") {
530
+ throw new TopologyError(`the hub origin must not carry credentials. ${inputs} to ${url.protocol}//${url.host}.`);
531
+ }
532
+ return url.origin;
533
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-ailk",
3
- "version": "0.14.0",
3
+ "version": "0.15.0",
4
4
  "description": "Scaffolder for AI Launch Kit — `pnpm create ailk@latest <dir>` produces a starter repo consuming @working-theory/* from npm (per ADR 0007).",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -27,8 +27,8 @@
27
27
  "tar": "^7.5.21"
28
28
  },
29
29
  "devDependencies": {
30
- "@working-theory/schema": "0.36.0",
31
- "@working-theory/templates": "0.36.0"
30
+ "@working-theory/schema": "0.37.0",
31
+ "@working-theory/templates": "0.37.0"
32
32
  },
33
33
  "repository": {
34
34
  "type": "git",
@@ -325,7 +325,7 @@ Copy density: sparse — minimalism pages carry far fewer words than a standard
325
325
 
326
326
  <!-- generated:end -->
327
327
 
328
- Square corners everywhere (`theme.corners: square`) — buttons, cards, the auth card, image placeholders; the tokens above are the base foundation's defaults a site overrides.
328
+ Square corners everywhere (`theme.corners: square`) — cards, the auth card, image placeholders; the tokens above are the base foundation's defaults a site overrides.
329
329
 
330
330
  ## Components
331
331
 
@@ -354,7 +354,7 @@ Mark-only: no link row. On the deck home the header is bare: no bar, no rule and
354
354
 
355
355
  ### Footer
356
356
 
357
- On the deck home the footer is the deck's closing slide (#7742): one viewport tall with its content centred, the wordmark (an image wordmark at the display size), at most two contact lines and one muted legal line (copyright, Privacy, Terms, Cookie settings, FAQ), and an up-chevron button at its foot that returns to the first slide. No nav links, social icons, theme switch, language switcher or Sign in / Sign up. Every other page ends in one bar: the copyright, the legal links and Sign in / Sign up (the Account link when signed in). The cookie banner sits on the page ground with a hairline above it and no shadow, and the deck's chevron rises above it while it shows.
357
+ On the deck home the footer is the deck's closing slide (#7742): one viewport tall with its content centred, the wordmark (an image wordmark at the display size), at most two contact lines and one muted legal line (copyright, Privacy, Terms, Cookie settings, FAQ), up to two actions (the first a button, the second a text link: the closing slide's exception to the home-slide one-button rule, capped there; a third is not shown), and an up-chevron button at its foot that returns to the first slide. No nav links, social icons, theme switch, language switcher or Sign in / Sign up. Every other page ends in one bar: the copyright, the legal links and Sign in / Sign up (the Account link when signed in). The cookie banner sits on the page ground with a hairline above it and no shadow; its Accept is a text link beside the Decline, not a button, so the one-button rule holds. On the deck home it waits for the first slide to leave view (slide 1 is a clean landing) and first shows on slide 2, where the deck's chevron rises above it; on every other page it shows on load.
358
358
 
359
359
  ### Index and profile pages
360
360
 
@@ -366,7 +366,7 @@ The foundation's pinned direction is the pacing of a classic wireframe marketing
366
366
 
367
367
  | Trait | How it is set |
368
368
  | ------------------------------------------------------ | ------------------------------------------------------------------------------------------------- |
369
- | Square corners on buttons, cards and inputs | `theme.corners: square` in `_site.mdx` |
369
+ | Square corners on cards and inputs | `theme.corners: square` in `_site.mdx` |
370
370
  | Section label in sentence case, no tracking | `theme.eyebrowCase: sentence` |
371
371
  | Solid grey image placeholders, no outline | `theme.placeholders: fill` |
372
372
  | Bare check and cross marks, no circle badge | `theme.checkmarks: plain` |
@@ -55,7 +55,7 @@ Routes are versioned under `/v1/*`:
55
55
  - `/v1/workspaces/:workspaceId/submissions` (GET, `?limit=&state=unhandled&magnetId=`), `/v1/workspaces/:workspaceId/submissions/:leadId` (GET, PATCH) — the Inbox submissions API (#7535, `src/routes/workspaces/submissions.ts` over `src/services/inbox-submissions.ts`): every flow completion and waitlist signup of the caller's active workspace, newest first and bounded (`?limit=` 1–200, default 50), the item read (handled or not), and the one-way idempotent handled stamp (`{ "handled": true }` only, `.strict()`; a second PATCH returns the first `handledAt`). Same registration, scope and tenant guard as the messages bullet above. A submission is a `Lead` whose `source` is a fixed submission source (`SUBMISSION_FIXED_SOURCE_LIST` in `@working-theory/validation`) or `flow:<slug>` for a NON-newsletter magnet of THIS workspace — decided only in `tenantDb(tenant).leads`, never in the route; a `flow:` row naming no magnet of the workspace never lists. The list row is seven keys — the message row's six plus `magnet: { slug, title } | null` — and never `payload`. `?magnetId=` narrows to one magnet (limit 1–50) and adds `answers`, `answersTruncated` and `deliverableSentAt` per row; the item read serves the same three. `answers` is `payload` narrowed by `toAnswers`: server-owned keys dropped, control and bidi characters stripped, every key, value and the entry count bounded. `?state=unhandled` filters `handledAt IS NULL` in the query (the Admin's For-you read); any other `state` is a 400. Every miss on the item read and the PATCH — a malformed, absent or foreign id, a message, an application, a newsletter-magnet lead — is the SAME `404 No such submission`; a `?magnetId=` that is absent, foreign or a newsletter magnet is `404 No such magnet`. Every success body is re-parsed against its own `.strict()` schema before it is sent. Role floor: `inbox-read` on the GETs and `inbox-act` on PATCH (`@working-theory/validation`'s permission table (#7636 D2) — every role, a member included), checked before any read or write. The handlers and the service never log a lead id, email, name or answer. No anonymous MCP twin — identity-bound tenant routes. Each route's twin is a signed-in tool on `/mcp/workspace` (#3591 D13): `list_inbox_submissions` (list), `get_inbox_submission` (item read) and `mark_inbox_submission_done` (PATCH).
56
56
  - `/v1/workspaces/:workspaceId/subscribers` (GET, `?limit=`) — the workspace's newsletter signups, read-only (#7535, `src/routes/workspaces/subscribers.ts`): `{ count, items }`, where `count` is every signup and `items` the newest (1–200, default 50), each `{ id, email, source, subscribedAt, magnet: { slug, title } | null }`. A signup is a `Subscriber` row of this workspace or the `Lead` a newsletter-kind magnet of this workspace wrote, both from `tenantDb(tenant).subscribers`, called directly. No item route and no write: a subscriber is never an item to open or mark handled. Same registration, scope, tenant guard and `inbox-read` floor as the submissions bullet above. No MCP twin — identity-bound tenant route (#3591 exception).
57
57
  - `/v1/workspaces/:workspaceId/members` (GET) — the org's member roster (`userId`, `name`, `email`, `role`), the Pro `workspace` module's Team tab (#5371). Read-only by design: granting a Membership is the invite flow, which is its own spec with a mandatory security review (§2.3), so no POST/PUT/DELETE is routed at this path. Scoped to `request.tenant.organizationId` — Membership is org-level — server-resolved from the validated session. Role floor: `members-read` (owner, admin — `@working-theory/validation`'s permission table (#7636 D2)): the roster carries every member's email, and who the other members are is not a member's job. No MCP twin — identity-bound tenant route (#3591 exception).
58
- - `/v1/workspaces/:workspaceId/agents` (GET `?userId=`, POST), `/v1/workspaces/:workspaceId/agents/:agentId/key` (POST), `/v1/workspaces/:workspaceId/agents/:agentId` (PATCH `{ enabled }`), and `/v1/agent/context` (GET) — the agent seat (#3591, `src/routes/workspaces/agents.ts` over `src/lib/agent-store.ts`, `// oss: false`). An agent is a stored delegate of one person, bound to one workspace. The four management routes refuse an agent caller outright (403), check `agents-manage` (owner, admin) before any read, and scope every read and write to `tenant.organizationId`, never a body or query value; an agent of another organization is the same 404 as a missing one. Create returns 201 with the key shown once; re-key returns the new key once. Each successful create, re-key and switch writes an `agents.*` audit record. `/v1/agent/context` is the agent's own read and needs no permission beyond the workspace guard. Same registration scope as the routes above (`workspace || multi-tenant`).
58
+ - `/v1/workspaces/:workspaceId/agents` (GET `?userId=`, POST), `/v1/workspaces/:workspaceId/agents/:agentId/key` (POST), `/v1/workspaces/:workspaceId/agents/:agentId` (PATCH `{ enabled }`), `PUT /v1/workspaces/:workspaceId/agents/:agentId/grants` (`{ grants }`, #7864), and `/v1/agent/context` (GET) — the agent seat (#3591, `src/routes/workspaces/agents.ts` over `src/lib/agent-store.ts`, `// oss: false`). An agent is a stored delegate of one person, bound to one workspace. The five management routes refuse an agent caller outright (403), check `agents-manage` (owner, admin) before any read, and scope every read and write to `tenant.organizationId`, never a body or query value; an agent of another organization is the same 404 as a missing one. Create returns 201 with the key shown once; re-key returns the new key once. Each successful create, re-key, switch and grant change writes an `agents.*` audit record. **Grants (#7864, `docs/specs/7864-agent-key-grants-spec.md`).** An agent acts at `member`, plus any of five permissions an owner or admin granted it one by one — `content-edit`, `content-publish`, `assets-write`, `magnets-manage`, `meeting-types-manage` (`@working-theory/validation`'s `AGENT_GRANTABLE_PERMISSIONS`, held to that set by the `agents_grants_allowed` CHECK). The grant PUT replaces the whole set (an empty list removes every grant) and refuses a grant the agent's person's own role does not hold, `409 grant_exceeds_person`. The clamp is one function both tenant guards call (`services/default-tenant.ts`'s `agentTenant`): an agent holds a stored grant only while its person's role holds it, so a demoted person's agent keeps the row but not the permission. The routes for the five check `allows(tenant, "<permission>")`, which reads the grants; every other check keeps `can(tenant.role, …)`, so no other owner or admin permission is reachable. Every agent write on content, assets and meeting types writes one `recordAgentWrite` row naming the agent and its person (magnets already did); a person's writes there stay unaudited. No grant raises the ceiling. `/v1/agent/context` is the agent's own read, unchanged by grants, and needs no permission beyond the workspace guard. Same registration scope as the routes above (`workspace || multi-tenant`).
59
59
  - `/v1/workspaces/:workspaceId/content` (GET, POST), `/v1/workspaces/:workspaceId/content/:type/:slug` (GET, PATCH), `/v1/workspaces/:workspaceId/content/:type/:slug/{publish,unpublish}` (POST) — the tenant-guarded content editor's write surface (#5853, `src/routes/workspaces/content.ts`): list, create, read/save the draft tip (under `If-Match`), and publish/unpublish, all over `Artifact`/`ArtifactVersion`. `:type` is validated against the page-type registry (`z.enum(SCHEMA_ORG_TYPES)`) rather than accepted as a free string on every addressed operation — an unregistered type is a 400 before any lookup, since it is echoed back and passed to `notifyRevalidate` as the site's cache tag. Role floors from `@working-theory/validation`'s permission table (#7636 D2): publish and unpublish need `content-publish`, and create, read and save need `content-edit` (both owner, admin — an admin builds, a member operates). The list serves `content-edit` the editor's full view and a caller holding only `docs-read` (a member) a published-only projection (D4): draft rows dropped, every row `status: "published"`, `version: null`, `valid: true`, and a `published_with_draft` page's `type`/`tags` read from its PUBLISHED version — applied before every filter, so a member's `?status=draft` or a draft tip's `?tag=` matches nothing. **The GET list is a UNION of page artifacts and DOCUMENT assets** (#6093, D1/D9 — "one store, two views"): every row carries `tags: string[]` (never null, read from the draft tip's stored content for a page, from `Asset.tags` for a file) and a DERIVED `kind: "page" | "post" | "file"` (`BlogPosting` alone maps to `"post"`; an asset row is always `"file"`) — an optional `?tag=<slug>` filters the union AFTER it is built, so a file is returned under either its own tag or a shared one. The asset half of the union (`DOCUMENT_CONTENT_TYPES`, i.e. `application/pdf`) is skipped entirely when `locale`/`type` is supplied or `status` names anything but `"published"`, since a file row can never satisfy those. A foreign `:workspaceId` costs zero artifact **and** zero asset queries. No MCP twin — identity-bound tenant routes (#3591 exception, same shape as the sibling `/v1/workspaces/*` routes).
60
60
  - `/v1/workspaces/:workspaceId/assets` (GET, POST), `/v1/workspaces/:workspaceId/assets/:assetId` (PATCH, DELETE), `/v1/assets/:assetId` (GET, public/anonymous) — the workspace asset store (#5854 upload/read; #6093 list/PATCH/DELETE, `src/routes/assets/{index,upload,read,manage}.ts`). Upload accepts an allowlisted union of three named classes (`src/routes/assets/index.ts`): five RASTER images (`image/png`, `image/jpeg`, `image/gif`, `image/webp`, `image/avif`), one VECTOR image (`image/svg+xml`, admitted under the operator's 2026-09-18 `allow-svg-assets` ruling — issue #6093 comment `5731573595` — superseding #5854's original SVG exclusion) and one DOCUMENT type (`application/pdf`), all under the existing 20 MB cap; `?tags=<slug>[,<slug>…]` stores tags on the row. The public read serves a RASTER type `inline` exactly as before; every OTHER admitted type (SVG, PDF) is served `attachment`, with the filename's extension pinned to the stored content type (closing extension spoofing) and a five-directive `Content-Security-Policy` (`default-src 'none'; sandbox; frame-ancestors 'none'; form-action 'none'; base-uri 'none'`) that makes the bytes never a document on this origin — this is what makes admitting SVG safe with NO server-side sanitizer. `GET …/assets` (`assets-read` — every role, a member included) lists a workspace's assets, metadata only, never `bytes`, with an optional `?tag=<slug>` filter; this is what narrows the store's prior "nothing lists assets" premise to its precise form — no ANONYMOUS and no CROSS-TENANT enumeration, both still true. `PATCH …/assets/:assetId` updates `title`/`description`/`tags` (`assets-write`, owner and admin, the upload's permission too; `null` clears a text field to SQL `NULL`, `""` stores empty; the asset's `id` and public URL are untouched). `DELETE …/assets/:assetId` (`assets-write`) removes the row and the bytes at the ORIGIN only — already-cached copies elsewhere are not recallable under the year-long `immutable` promise, and no reference check protects a page that still embeds the URL; absent and foreign ids both take the same 404. Every tenant route here is identity-bound; no MCP twin (#3591 exception). The public read has no MCP twin either — it serves bytes to an `<img>`/download, the same boundary `upload.ts` records for its own sibling.
61
61
  - `/v1/billing` (GET), `/v1/billing/portal-session` (POST) — org-scoped billing read + Stripe customer-portal session creation. The read stays open to every member (the Admin's plan gate reads it); the portal needs `billing-manage` — the owner alone (`@working-theory/validation`'s permission table (#7636 D2); an admin is refused before any Stripe call). `/v1/billing/usage` (GET) needs `billing-read` (owner, admin).
@@ -162,7 +162,9 @@ sweep's own DB constraint (never a caller-side lock).
162
162
  - Don't put session state in `apps/api`. It's stateless.
163
163
  - Don't bundle `apps/api` (esbuild, rollup, webpack). Slice specifiers cannot survive it — see `## Deploy path`.
164
164
 
165
- Last updated: 2026-10-05 — doc-sync: added the agent seat routes (#3591, #7678) to Endpoint conventions — verified against `apps/api/src/routes/workspaces/agents.ts`.
165
+ Last updated: 2026-10-08 — agent key grants (#7864): the agent-seat bullet names `PUT /v1/workspaces/:workspaceId/agents/:agentId/grants`, the five grantable permissions and the person clamp — verified against `apps/api/src/routes/workspaces/agents.ts`, `apps/api/src/services/default-tenant.ts` and `packages/validation/src/schemas/permissions.ts`.
166
+
167
+ Prior: 2026-10-05 — doc-sync: added the agent seat routes (#3591, #7678) to Endpoint conventions — verified against `apps/api/src/routes/workspaces/agents.ts`.
166
168
 
167
169
  Prior: 2026-10-04 — doc-sync: the agent seat, step E (#7684) — the messages and submissions bullets now name each route's signed-in MCP twin on `/mcp/workspace` (#3591 D13) in place of the retired no-twin exception — verified against `apps/api/src/routes/workspaces/{messages,submissions}.ts`, `apps/mcp/src/workspace-server.ts` and `tests/parity/api-parity/kinds/site.ts`.
168
170
 
@@ -15,6 +15,7 @@
15
15
  "deliverable:resend": "tsx src/bin/deliverable-resend.ts",
16
16
  "seed:presets": "tsx src/bin/seed-presets.ts",
17
17
  "spokes": "tsx src/bin/spokes.ts",
18
+ "flows:copy": "tsx src/bin/flows-copy.ts",
18
19
  "build": "tsc",
19
20
  "build:vercel": "cd ../../database && pnpm exec prisma generate && cd ../.. && pnpm --filter @working-theory/validation build && pnpm --filter @working-theory/api build && mkdir -p apps/api/public && echo '{}' > apps/api/public/.vercel",
20
21
  "start": "node dist/cli.js",