create-ailk 0.15.0 → 0.17.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 (128) 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 +67 -0
  29. package/dist/spoke-checkout.js +562 -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 +451 -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 +57 -24
  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 +33 -0
  93. package/templates/apps/web/lib/__tests__/server-api.test.ts +37 -28
  94. package/templates/apps/web/lib/account-config.ts +130 -1
  95. package/templates/apps/web/lib/api-origin.generated.ts +11 -0
  96. package/templates/apps/web/lib/hub-session/__tests__/hub-session.test.ts +262 -0
  97. package/templates/apps/web/lib/hub-session/config.ts +104 -0
  98. package/templates/apps/web/lib/hub-session/index.ts +23 -0
  99. package/templates/apps/web/lib/hub-session/seal.ts +82 -0
  100. package/templates/apps/web/lib/hub-session/session.ts +180 -0
  101. package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +79 -0
  102. package/templates/apps/web/lib/listings-config.ts +31 -0
  103. package/templates/apps/web/lib/listings.tsx +7 -3
  104. package/templates/apps/web/lib/meeting-slugs.ts +11 -1
  105. package/templates/apps/web/lib/next-path.ts +35 -0
  106. package/templates/apps/web/lib/relay.ts +3 -1
  107. package/templates/apps/web/lib/server-api.ts +11 -1
  108. package/templates/apps/web/lib/topology.generated.ts +15 -5
  109. package/templates/apps/web/messages/en.json +32 -3
  110. package/templates/database/CHANGELOG.md +26 -0
  111. package/templates/database/__tests__/agents.db.test.ts +25 -5
  112. package/templates/database/__tests__/fixtures/person-backfill.json +264 -0
  113. package/templates/database/__tests__/member-area.db.test.ts +245 -0
  114. package/templates/database/__tests__/person.db.test.ts +584 -0
  115. package/templates/database/auth/schema.prisma +3 -2
  116. package/templates/database/config/schema.prisma +13 -0
  117. package/templates/database/inbox/schema.prisma +98 -0
  118. package/templates/database/migrations/20261010120000_person/migration.sql +106 -0
  119. package/templates/database/migrations/20261010121000_person_backfill/migration.sql +132 -0
  120. package/templates/database/migrations/20261010130000_agent_grants_people_read/migration.sql +20 -0
  121. package/templates/database/migrations/20261011120000_member_area/migration.sql +158 -0
  122. package/templates/database/migrations/20261011130000_agent_grants_member_pieces_manage/migration.sql +21 -0
  123. package/templates/database/package.json +1 -1
  124. package/templates/database/scripts/person-backfill.sql +132 -0
  125. package/templates/database/tenancy.ts +53 -0
  126. package/templates/tools/check-declared-env.ts +37 -0
  127. package/templates/tools/declared-env.ts +1163 -0
  128. package/versions.json +35 -35
package/dist/topology.js CHANGED
@@ -16,13 +16,17 @@
16
16
  * - the two generated seam files (D5) — the only place hub and spoke code
17
17
  * differ, rendered here for either topology;
18
18
  * - the root-file transforms (D6), the hard floors (D9), the module
19
- * admission (D7), the manifest reader (D10) and the hub origin (D2).
19
+ * admission (D7), the manifest reader (D10) and the hub origin (D2);
20
+ * - the sign-in-through-the-hub mode (`signIn: "hub"`, #7894): its seeds,
21
+ * its renders of the web and `(authed)` gate seams, its env and floors.
20
22
  *
21
- * Spec: docs/specs/7426-spoke-topology-option-spec.md.
23
+ * Specs: docs/specs/7426-spoke-topology-option-spec.md,
24
+ * docs/specs/7894-the-hub-signs-in-its-spec.md (D1, D8, D9, D11).
22
25
  *
23
26
  * Pure module: no `import.meta.url`, so jest imports it directly. The
24
27
  * package's own `templates/` dir is supplied by the caller.
25
28
  */
29
+ import { randomBytes } from "node:crypto";
26
30
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
27
31
  import { join } from "node:path";
28
32
  import { ENV_LOCAL_IGNORE_LINES } from "./lib/auth-secret-file.js";
@@ -44,10 +48,17 @@ export class TopologyError extends Error {
44
48
  export const SPOKE_REMOVED_ROOTS = ["apps/api", "database"];
45
49
  /** The root `--without-mcp` removes. */
46
50
  export const SPOKE_MCP_ROOT = "apps/mcp";
51
+ /** Commerce's apps/web files that reach the hub's own checkout (spoke-intake Rule 3, #7838). */
52
+ export const SPOKE_HUB_CHECKOUT_SEEDS = [
53
+ "apps/web/app/[locale]/flows/[slug]/checkout.actions.ts",
54
+ "apps/web/app/[locale]/flows/[slug]/results",
55
+ "apps/web/app/[locale]/dev/purchase",
56
+ ];
47
57
  /**
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`).
58
+ * The files a spoke does not carry — files and directories, scaffold-relative:
59
+ * the sign-in and database seeds, then the commerce files that call the hub's
60
+ * own checkout (`SPOKE_HUB_CHECKOUT_SEEDS`; a spoke keeps its own, #7838).
61
+ * Every other file that imports one of them goes with it (`spokeExclusions`).
51
62
  */
52
63
  export const SPOKE_REMOVAL_SEEDS = [
53
64
  "apps/web/auth.ts",
@@ -59,13 +70,63 @@ export const SPOKE_REMOVAL_SEEDS = [
59
70
  "apps/mcp/src/lib/prisma.ts",
60
71
  "apps/mcp/src/lib/public-workspace.ts",
61
72
  "apps/mcp/src/lib/license-key-store.ts",
73
+ ...SPOKE_HUB_CHECKOUT_SEEDS,
74
+ ];
75
+ /**
76
+ * Module paths a spoke drops even when it carries the module (#7876 D8,
77
+ * clarified 2026-10-09). `signal-collectors`' report reader links a
78
+ * grant-gated report's bytes on the hub from `NEXT_PUBLIC_AILK_API_URL`,
79
+ * which a spoke refuses (#7874: its hub origin is server-only), and a
80
+ * deliverable is hub-side anyway — so a spoke carries neither the
81
+ * `/reports` routes nor the report seeds that would have no route.
82
+ */
83
+ export const SPOKE_EXCLUDED_MODULE_PATHS = [
84
+ "apps/web/app/[locale]/reports",
85
+ "content/en/ReportPage",
86
+ "content/en/ReportsList",
87
+ ];
88
+ /**
89
+ * The `(authed)` seeds of the sign-in-through-the-hub mode (#7894 D9): the
90
+ * whole `(authed)` group goes in the default mode; in the hub mode its account
91
+ * area, layout and gate seam stay, and only these go — the operator surfaces,
92
+ * the Better Auth gate and the suites that mock `auth.ts` by path.
93
+ */
94
+ export const HUB_MODE_AUTHED_SEEDS = [
95
+ "apps/web/app/[locale]/(authed)/__tests__",
96
+ "apps/web/app/[locale]/(authed)/account/__tests__",
97
+ "apps/web/app/[locale]/(authed)/admin",
98
+ "apps/web/app/[locale]/(authed)/appointments",
99
+ "apps/web/app/[locale]/(authed)/edit",
100
+ "apps/web/app/[locale]/(authed)/leads",
101
+ "apps/web/app/[locale]/(authed)/waitlist",
102
+ "apps/web/app/[locale]/(authed)/sign-in-gate.tsx",
62
103
  ];
63
- /** The two generated seam files (D5). A seam is rewritten, never removed. */
104
+ /** The default mode's one `(authed)` seed, which the hub mode replaces. */
105
+ const AUTHED_SEED = "apps/web/app/[locale]/(authed)";
106
+ /** The generated seam files (D5; #7874 D12). A seam is rewritten, never removed. */
64
107
  export const WEB_SEAM_PATH = "apps/web/lib/topology.generated.ts";
65
108
  export const MCP_SEAM_PATH = "apps/mcp/src/lib/topology.generated.ts";
109
+ /** The browser API origin seam (#7874 D12): a spoke's render reads no env. */
110
+ export const API_ORIGIN_SEAM_PATH = "apps/web/lib/api-origin.generated.ts";
111
+ /**
112
+ * The `(authed)` layout's signed-out gate (#7894 D8). Under the `(authed)`
113
+ * seed, so a default-mode spoke drops it with its group and never renders it.
114
+ */
115
+ export const AUTHED_GATE_SEAM_PATH = "apps/web/app/[locale]/(authed)/signed-out-gate.generated.tsx";
66
116
  export const SPOKE_SEAM_FILES = [
67
117
  WEB_SEAM_PATH,
68
118
  MCP_SEAM_PATH,
119
+ API_ORIGIN_SEAM_PATH,
120
+ AUTHED_GATE_SEAM_PATH,
121
+ ];
122
+ /**
123
+ * Template files only a spoke carries (#7874 D11): the declared-env check.
124
+ * `scaffoldSync` excludes them from a hub's copy (`copyTemplate`'s
125
+ * `excludePaths` matches an exact file path).
126
+ */
127
+ export const SPOKE_ONLY_FILES = [
128
+ "tools/declared-env.ts",
129
+ "tools/check-declared-env.ts",
69
130
  ];
70
131
  /**
71
132
  * The closure's allow-list: a file `spokeExclusions` removes must sit under
@@ -74,6 +135,12 @@ export const SPOKE_SEAM_FILES = [
74
135
  * file — and `spokeExclusions` throws naming it rather than cut it silently.
75
136
  */
76
137
  export const SPOKE_REMOVABLE_PREFIXES = SPOKE_REMOVAL_SEEDS;
138
+ /** The seeds for a sign-in mode, before the mcp filter. */
139
+ function modeSeeds(signIn) {
140
+ return signIn === "hub"
141
+ ? SPOKE_REMOVAL_SEEDS.flatMap((seed) => seed === AUTHED_SEED ? [...HUB_MODE_AUTHED_SEEDS] : [seed])
142
+ : [...SPOKE_REMOVAL_SEEDS];
143
+ }
77
144
  function toPosix(path) {
78
145
  return path.split("\\").join("/").replace(/^\.\//, "");
79
146
  }
@@ -88,15 +155,24 @@ export function spokeRemovedRoots(opts) {
88
155
  ? [...SPOKE_REMOVED_ROOTS]
89
156
  : [...SPOKE_REMOVED_ROOTS, SPOKE_MCP_ROOT];
90
157
  }
91
- /** The seeds that apply — an mcp seed is moot when `apps/mcp` itself goes. */
158
+ /**
159
+ * The seeds that apply — an mcp seed is moot when `apps/mcp` itself goes; in
160
+ * the hub sign-in mode the `(authed)` seed is replaced by eight (#7894 D9).
161
+ */
92
162
  function spokeSeeds(opts) {
93
- return SPOKE_REMOVAL_SEEDS.filter((seed) => opts.mcp || !underPrefix(seed, SPOKE_MCP_ROOT));
163
+ return modeSeeds(opts.signIn).filter((seed) => opts.mcp || !underPrefix(seed, SPOKE_MCP_ROOT));
94
164
  }
95
- /** Whether a closure member is one the registry allows a spoke to drop. */
96
- export function isRemovableClosureMember(path) {
165
+ /**
166
+ * Whether a closure member is one the registry allows a spoke to drop: under
167
+ * a seed of THAT sign-in mode, or a test. In the hub mode a surviving account
168
+ * file that imports `auth.ts` is therefore not removable, and
169
+ * `spokeExclusions` throws naming it (#7894 D9).
170
+ */
171
+ export function isRemovableClosureMember(path, opts = {}) {
97
172
  const p = toPosix(path);
173
+ const prefixes = opts.signIn === "hub" ? modeSeeds("hub") : SPOKE_REMOVABLE_PREFIXES;
98
174
  return (p.split("/").includes("__tests__") ||
99
- SPOKE_REMOVABLE_PREFIXES.some((prefix) => underPrefix(p, prefix)));
175
+ prefixes.some((prefix) => underPrefix(p, prefix)));
100
176
  }
101
177
  /**
102
178
  * Every path a spoke does not carry, computed over the built `templates/`
@@ -146,7 +222,7 @@ export function spokeExclusions(templatesDir, opts) {
146
222
  break;
147
223
  }
148
224
  const outside = [...closed]
149
- .filter((file) => !isRemovableClosureMember(file))
225
+ .filter((file) => !isRemovableClosureMember(file, opts))
150
226
  .sort();
151
227
  if (outside.length > 0) {
152
228
  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.`);
@@ -170,15 +246,30 @@ export function isSpokeExcluded(path, opts) {
170
246
  ...spokeRemovedRoots(opts),
171
247
  ...spokeSeeds(opts),
172
248
  ];
173
- return prefixes.some((prefix) => underPrefix(p, prefix));
249
+ return [...prefixes, ...SPOKE_EXCLUDED_MODULE_PATHS].some((prefix) => underPrefix(p, prefix));
250
+ }
251
+ /** A module flow file a hub installs under `database/` (#7876 D10). */
252
+ const DATABASE_FLOW_FILE = /^database\/flows\/([^/]+)\.flow\.json$/;
253
+ /**
254
+ * Where a module bundle's `installMap` destination lands on a spoke (#7876
255
+ * D10): `database/flows/<name>.flow.json` becomes `flows/<name>.json` — a
256
+ * declared flow `create-ailk seed-flows` sends to the hub — and every other
257
+ * destination is returned unchanged. Applied before the spoke filter, so a
258
+ * remapped flow survives it while the rest of `database/` is dropped. A hub
259
+ * install passes no remap.
260
+ */
261
+ export function spokeDestination(destination) {
262
+ const match = DATABASE_FLOW_FILE.exec(toPosix(destination));
263
+ return match ? `flows/${match[1]}.json` : destination;
174
264
  }
175
265
  // ── D5 — the generated seam files ───────────────────────────────────────────
176
266
  const WEB_SEAM_HEADER = `// topology.generated.ts — how this site reads a session and registers its stores.
177
267
  //
178
268
  // 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.`;
269
+ // every scaffold rewrites it. A hub reads the session through Better Auth.
270
+ // A spoke (\`--topology spoke\`) has no database of its own: by default it
271
+ // has no sign-in, so its render reads no session and imports nothing; with
272
+ // \`--hub-sign-in\` (#7894) it reads the sealed hub-session cookie.`;
182
273
  const MCP_SEAM_HEADER = `// topology.generated.ts — how this MCP server resolves its workspace store.
183
274
  //
184
275
  // GENERATED by create-ailk for this site's topology (#7426). Do not edit:
@@ -186,20 +277,39 @@ const MCP_SEAM_HEADER = `// topology.generated.ts — how this MCP server resolv
186
277
  // database; a spoke (\`--topology spoke\`) has none, so its content tools
187
278
  // read the spoke's own MDX and its writes go to the hub at AILK_API_URL.`;
188
279
  const SITE_SESSION_TYPE = `export type SiteSession = {
189
- user: { name?: string | null; email: string };
280
+ user: {
281
+ name?: string | null;
282
+ email: string;
283
+ emailVerified: boolean;
284
+ createdAt?: Date | string | null;
285
+ };
190
286
  } | null;`;
191
- /** `apps/web/lib/topology.generated.ts` for `topology` (D5). */
192
- export function renderWebSeam(topology) {
287
+ /** The sign-in exports every web seam render carries (#7894 D8). */
288
+ function signInExports(values) {
289
+ const literal = (value) => value === null ? "null" : JSON.stringify(value);
290
+ return `export const SIGN_IN_ENABLED: boolean = ${values.enabled};
291
+ export const SIGN_IN_PROVIDER: "site" | "hub" | "none" = ${JSON.stringify(values.provider)};
292
+ export const SIGN_IN_PATH: string | null = ${literal(values.signInPath)};
293
+ export const SIGN_UP_PATH: string | null = ${literal(values.signUpPath)};
294
+ export const SIGN_OUT_ENDPOINT: string | null = ${literal(values.signOutEndpoint)};`;
295
+ }
296
+ /**
297
+ * `apps/web/lib/topology.generated.ts` for `topology` (D5) and, on a spoke,
298
+ * its sign-in mode (#7894 D8). The hub render loads `auth.ts` only when a
299
+ * session is read, so a module that imports this seam for a constant never
300
+ * loads Better Auth.
301
+ */
302
+ export function renderWebSeam(topology, signIn = "off") {
193
303
  if (topology === "hub") {
194
304
  return `${WEB_SEAM_HEADER}
195
- import { auth } from "../auth";
196
305
 
197
306
  export const TOPOLOGY: "hub" | "spoke" = "hub";
198
- export const SIGN_IN_ENABLED: boolean = true;
307
+ ${signInExports({ enabled: true, provider: "site", signInPath: "/sign-in", signUpPath: "/sign-up", signOutEndpoint: null })}
199
308
 
200
309
  ${SITE_SESSION_TYPE}
201
310
 
202
311
  export async function readSession(headers: Headers): Promise<SiteSession> {
312
+ const { auth } = await import("../auth");
203
313
  return auth.api.getSession({ headers });
204
314
  }
205
315
 
@@ -207,12 +317,30 @@ export async function registerSiteStores(): Promise<void> {
207
317
  const { registerLicenseKeyStore } = await import("./license-key-store");
208
318
  await registerLicenseKeyStore();
209
319
  }
320
+ `;
321
+ }
322
+ if (signIn === "hub") {
323
+ return `${WEB_SEAM_HEADER}
324
+ import { readHubSession } from "./hub-session/session";
325
+
326
+ export const TOPOLOGY: "hub" | "spoke" = "spoke";
327
+ ${signInExports({ enabled: true, provider: "hub", signInPath: "/account", signUpPath: null, signOutEndpoint: "/api/hub-session/sign-out" })}
328
+
329
+ ${SITE_SESSION_TYPE}
330
+
331
+ export function readSession(headers: Headers): Promise<SiteSession> {
332
+ return readHubSession(headers);
333
+ }
334
+
335
+ export function registerSiteStores(): Promise<void> {
336
+ return Promise.resolve();
337
+ }
210
338
  `;
211
339
  }
212
340
  return `${WEB_SEAM_HEADER}
213
341
 
214
342
  export const TOPOLOGY: "hub" | "spoke" = "spoke";
215
- export const SIGN_IN_ENABLED: boolean = false;
343
+ ${signInExports({ enabled: false, provider: "none", signInPath: null, signUpPath: null, signOutEndpoint: null })}
216
344
 
217
345
  ${SITE_SESSION_TYPE}
218
346
 
@@ -226,6 +354,135 @@ export function registerSiteStores(): Promise<void> {
226
354
  }
227
355
  `;
228
356
  }
357
+ const AUTHED_GATE_SEAM_HEADER = `// signed-out-gate.generated.tsx — what the (authed) group shows a signed-out visitor.
358
+ //
359
+ // GENERATED by create-ailk for this site's topology and sign-in mode (#7894).
360
+ // Do not edit: every scaffold rewrites it. A hub renders its own sign-in —
361
+ // the operator gate, or the account area's member gate; a spoke that signs in
362
+ // through its hub (\`--hub-sign-in\`) renders the hub-session gate.`;
363
+ /**
364
+ * The packages the gate seam imports. Interpolated, not written inline, for
365
+ * the reason `CONTENT_ADAPTERS_PACKAGE` below is.
366
+ */
367
+ const UI_PACKAGE = "@working-theory/ui";
368
+ const WEB_I18N_PACKAGE = "@working-theory/web-i18n";
369
+ const NEXT_INTL_SERVER = "next-intl/server";
370
+ const REACT_PACKAGE = "react";
371
+ const AUTHED_GATE_PROPS = `export interface SignedOutGateProps {
372
+ locale: string;
373
+ /** The incoming request's headers (\`x-pathname\` names the requested page). */
374
+ requestHeaders: Headers;
375
+ }`;
376
+ /**
377
+ * `apps/web/app/[locale]/(authed)/signed-out-gate.generated.tsx` (#7894 D8).
378
+ * The hub render is the `(authed)` layout's no-session branch, moved; the
379
+ * `signIn: "hub"` render imports only the hub-session gate. A default-mode
380
+ * spoke carries no `(authed)` group, so it has no render.
381
+ * @throws TopologyError for a spoke in the default mode.
382
+ */
383
+ export function renderAuthedGateSeam(topology, signIn = "off") {
384
+ if (topology === "hub") {
385
+ return `${AUTHED_GATE_SEAM_HEADER}
386
+ import { localeHref } from "${UI_PACKAGE}";
387
+ import { Button, Heading, Link, Text } from "${UI_PACKAGE}/primitives";
388
+ import type { AuthPanelProvider } from "${UI_PACKAGE}/sections";
389
+ import { readLocales } from "${WEB_I18N_PACKAGE}";
390
+ import { getTranslations } from "${NEXT_INTL_SERVER}";
391
+ import type { ReactElement } from "${REACT_PACKAGE}";
392
+
393
+ import { resolveSignInCardText } from "../(auth)/sign-in/sign-in-text";
394
+ import { getActiveProviderIds } from "../../../lib/auth-env";
395
+ import { isAccountPath } from "../../../lib/auth-routes";
396
+
397
+ import { SignInGate } from "./sign-in-gate";
398
+
399
+ const SUPPORTED_PROVIDERS: readonly AuthPanelProvider[] = ["google", "github"];
400
+
401
+ ${AUTHED_GATE_PROPS}
402
+
403
+ export async function SignedOutGate({
404
+ locale,
405
+ requestHeaders,
406
+ }: SignedOutGateProps): Promise<ReactElement> {
407
+ // No \`x-pathname\`, no locale read: a render outside the middleware keeps
408
+ // the operator gate without touching the locale config.
409
+ const pathname = requestHeaders.get("x-pathname");
410
+ const isAccount =
411
+ pathname !== null && isAccountPath(pathname, readLocales());
412
+ const t = await getTranslations({
413
+ locale,
414
+ namespace: isAccount ? "account.gate" : "waitlistDashboard.gate",
415
+ });
416
+ const active = getActiveProviderIds();
417
+ const emailLink = active.includes("magic-link")
418
+ ? { text: await resolveSignInCardText(locale) }
419
+ : undefined;
420
+ const providers = active.filter((id): id is AuthPanelProvider =>
421
+ (SUPPORTED_PROVIDERS as readonly string[]).includes(id),
422
+ );
423
+ const signInPath = localeHref(locale, "/sign-in");
424
+
425
+ if (isAccount && providers.length === 0 && !emailLink) {
426
+ return (
427
+ <div
428
+ className="flex min-h-screen flex-col items-center justify-center gap-4 py-section text-center"
429
+ data-testid="account-sign-in-gate"
430
+ >
431
+ <Heading level={1} visual={4}>
432
+ {t("emptyHeadline")}
433
+ </Heading>
434
+ <Text variant="body-sm" tone="muted" className="max-w-xs">
435
+ {t("emptyDescription")}
436
+ </Text>
437
+ {/* With no sign-in method on, /sign-in has nothing for a member
438
+ either, so the one action goes back to the site. Outline
439
+ action, 44px on a phone and 36px from md up. */}
440
+ <Button asChild variant="secondary" className="max-md:min-h-11">
441
+ <Link
442
+ href={localeHref(locale, "/")}
443
+ variant="bare"
444
+ data-testid="account-sign-in-gate-home"
445
+ >
446
+ {t("homeLink")}
447
+ </Link>
448
+ </Button>
449
+ </div>
450
+ );
451
+ }
452
+
453
+ return (
454
+ <SignInGate
455
+ labels={{
456
+ emptyHeadline: t("emptyHeadline"),
457
+ emptyDescription: t("emptyDescription"),
458
+ signInLinkLabel: t("signInLink"),
459
+ }}
460
+ signInPath={signInPath}
461
+ providers={providers}
462
+ {...(emailLink ? { emailLink } : {})}
463
+ />
464
+ );
465
+ }
466
+ `;
467
+ }
468
+ if (signIn !== "hub") {
469
+ throw new TopologyError(`${AUTHED_GATE_SEAM_PATH}: a spoke without --hub-sign-in carries no (authed) group, so it has no gate to render`);
470
+ }
471
+ return `${AUTHED_GATE_SEAM_HEADER}
472
+ import type { ReactElement } from "${REACT_PACKAGE}";
473
+
474
+ import { HubSignInGate } from "../../../lib/hub-session/sign-in-gate";
475
+
476
+ ${AUTHED_GATE_PROPS}
477
+
478
+ export function SignedOutGate({
479
+ locale,
480
+ requestHeaders,
481
+ }: SignedOutGateProps): Promise<ReactElement> {
482
+ return HubSignInGate({ locale, requestHeaders });
483
+ }
484
+ `;
485
+ }
229
486
  /**
230
487
  * The package the spoke mcp seam takes its one type from. Interpolated, not
231
488
  * written inline: an inline specifier in this template would read as an
@@ -280,6 +537,30 @@ export function registerLicenseKeyStore(): Promise<void> {
280
537
  }
281
538
  `;
282
539
  }
540
+ const API_ORIGIN_SEAM_HEADER = `// api-origin.generated.ts — the browser-facing API origin this site's server reads.
541
+ //
542
+ // GENERATED by create-ailk for this site's topology (#7874). Do not edit:
543
+ // every scaffold rewrites it. A hub reads NEXT_PUBLIC_AILK_API_URL as a
544
+ // fallback API origin; a spoke (\`--topology spoke\`) has no browser-facing
545
+ // API origin — its hub origin is server-only (AILK_API_URL) — so its render
546
+ // reads no environment variable.`;
547
+ /** `apps/web/lib/api-origin.generated.ts` for `topology` (#7874 D12). */
548
+ export function renderApiOriginSeam(topology) {
549
+ if (topology === "hub") {
550
+ return `${API_ORIGIN_SEAM_HEADER}
551
+
552
+ export function browserApiOrigin(): string | undefined {
553
+ return process.env.NEXT_PUBLIC_AILK_API_URL;
554
+ }
555
+ `;
556
+ }
557
+ return `${API_ORIGIN_SEAM_HEADER}
558
+
559
+ export function browserApiOrigin(): string | undefined {
560
+ return undefined;
561
+ }
562
+ `;
563
+ }
283
564
  // ── D6 — the root-file transforms ───────────────────────────────────────────
284
565
  /**
285
566
  * `text` with every line `drop` matches removed, then the dangling comma a
@@ -315,6 +596,66 @@ export function transformRootPackageJson(text) {
315
596
  const scriptLine = /^\s*"db:[^"]*"\s*:/;
316
597
  return dropLines(text, (line) => scriptLine.test(line), "package.json", true);
317
598
  }
599
+ /** The spoke's declared-env script (#7874 D11) — named apart from the monorepo's `env:check`. */
600
+ export const ENV_DECLARED_SCRIPT = "env:declared";
601
+ const ENV_DECLARED_COMMAND = "tsx tools/check-declared-env.ts";
602
+ /**
603
+ * The spoke's root `package.json` with the declared-env check (#7874 D11):
604
+ * an `env:declared` script after `claude-md:check`, and `preflight` running
605
+ * it right after `claude-md:check`. Applied after `transformRootPackageJson`.
606
+ * @throws TopologyError when either anchor is missing.
607
+ */
608
+ export function addEnvCheckScripts(text) {
609
+ const lines = text.split("\n");
610
+ const anchor = lines.findIndex((line) => /^\s*"claude-md:check"\s*:/.test(line));
611
+ if (anchor === -1) {
612
+ throw new TopologyError(`package.json: no "claude-md:check" script to add ${ENV_DECLARED_SCRIPT} after`);
613
+ }
614
+ const preflightAt = lines.findIndex((line) => /^\s*"preflight"\s*:/.test(line));
615
+ const step = "pnpm claude-md:check && ";
616
+ if (preflightAt === -1 || !(lines[preflightAt] ?? "").includes(step)) {
617
+ throw new TopologyError(`package.json: no "preflight" script running "${step.trim()}" to add ${ENV_DECLARED_SCRIPT} to`);
618
+ }
619
+ lines[preflightAt] = (lines[preflightAt] ?? "").replace(step, `${step}pnpm ${ENV_DECLARED_SCRIPT} && `);
620
+ const indent = /^\s*/.exec(lines[anchor] ?? "")?.[0] ?? "";
621
+ lines.splice(anchor + 1, 0, `${indent}${JSON.stringify(ENV_DECLARED_SCRIPT)}: ${JSON.stringify(ENV_DECLARED_COMMAND)},`);
622
+ const next = lines.join("\n");
623
+ try {
624
+ JSON.parse(next);
625
+ }
626
+ catch (err) {
627
+ throw new TopologyError(`package.json: adding ${ENV_DECLARED_SCRIPT} left it unparseable: ${err.message}`);
628
+ }
629
+ return next;
630
+ }
631
+ /**
632
+ * The spoke's `.github/workflows/ci.yml` (#7874 D11): a `pnpm env:declared`
633
+ * step after `pnpm claude-md:check`, and no `pnpm db:generate` step — a
634
+ * spoke has no `database/` and no `db:*` script, so that step would fail
635
+ * first (#7962). The comment block directly above the dropped step goes with
636
+ * it.
637
+ * @throws TopologyError when either anchor is missing.
638
+ */
639
+ export function transformCiWorkflow(text) {
640
+ const lines = text.split("\n");
641
+ const dbAt = lines.findIndex((line) => /^\s*- run: pnpm db:generate\s*$/.test(line));
642
+ if (dbAt === -1) {
643
+ throw new TopologyError(`.github/workflows/ci.yml: no "- run: pnpm db:generate" step to remove`);
644
+ }
645
+ let start = dbAt;
646
+ while (start > 0 && /^\s*#/.test(lines[start - 1] ?? ""))
647
+ start--;
648
+ // Drop the blank line that separated the removed block from the step above.
649
+ const dropBlank = start > 0 && (lines[start - 1] ?? "").trim() === "" ? 1 : 0;
650
+ lines.splice(start - dropBlank, dbAt - start + 1 + dropBlank);
651
+ const anchor = lines.findIndex((line) => /^\s*- run: pnpm claude-md:check\s*$/.test(line));
652
+ if (anchor === -1) {
653
+ throw new TopologyError(`.github/workflows/ci.yml: no "- run: pnpm claude-md:check" step to add ${ENV_DECLARED_SCRIPT} after`);
654
+ }
655
+ const indent = /^\s*/.exec(lines[anchor] ?? "")?.[0] ?? "";
656
+ lines.splice(anchor + 1, 0, "", `${indent}# A process.env read with no project.yaml env_contract row — the same check \`pnpm preflight\` runs locally.`, `${indent}- run: pnpm ${ENV_DECLARED_SCRIPT}`);
657
+ return lines.join("\n");
658
+ }
318
659
  /** The root `tsconfig.json` without a reference under a removed root. */
319
660
  export function transformRootTsconfig(text, opts) {
320
661
  const roots = spokeRemovedRoots(opts);
@@ -339,7 +680,8 @@ export function transformAppPackageJson(text, label, required) {
339
680
  /**
340
681
  * Apply every D6 root-file transform to the spoke at `targetDir` (after the
341
682
  * base copy, before any module or template layer), and write the spoke
342
- * render of each seam it carries (D5).
683
+ * render of each seam it carries (D5). Also the declared-env check's root
684
+ * script and CI step (#7874 D11), and the browser API origin seam (D12).
343
685
  * @throws TopologyError naming the file whose anchor is missing.
344
686
  */
345
687
  export function applySpokeTransforms(targetDir, opts) {
@@ -354,14 +696,21 @@ export function applySpokeTransforms(targetDir, opts) {
354
696
  writeFileSync(path, next);
355
697
  };
356
698
  edit("pnpm-workspace.yaml", transformWorkspaceYaml);
357
- edit("package.json", transformRootPackageJson);
699
+ edit("package.json", (text) => addEnvCheckScripts(transformRootPackageJson(text)));
700
+ edit(".github/workflows/ci.yml", transformCiWorkflow);
358
701
  edit("tsconfig.json", (text) => transformRootTsconfig(text, opts));
359
702
  edit("apps/web/package.json", (text) => transformAppPackageJson(text, "apps/web/package.json", true));
360
703
  if (opts.mcp) {
361
704
  edit("apps/mcp/package.json", (text) => transformAppPackageJson(text, "apps/mcp/package.json", false));
362
705
  }
363
706
  edit("project.yaml", (text) => transformProjectYaml(text, opts));
364
- writeFileSync(join(targetDir, WEB_SEAM_PATH), renderWebSeam("spoke"));
707
+ writeFileSync(join(targetDir, WEB_SEAM_PATH), renderWebSeam("spoke", opts.signIn));
708
+ // #7894 D8 — the gate seam lives in the `(authed)` group only the hub
709
+ // sign-in mode keeps.
710
+ if (opts.signIn === "hub") {
711
+ writeFileSync(join(targetDir, AUTHED_GATE_SEAM_PATH), renderAuthedGateSeam("spoke", "hub"));
712
+ }
713
+ writeFileSync(join(targetDir, API_ORIGIN_SEAM_PATH), renderApiOriginSeam("spoke"));
365
714
  if (opts.mcp) {
366
715
  writeFileSync(join(targetDir, MCP_SEAM_PATH), renderMcpSeam("spoke"));
367
716
  }
@@ -383,16 +732,24 @@ const NEXT_ENV_FILES = [
383
732
  * the hub issues those.
384
733
  */
385
734
  export function writeHubOriginEnv(targetDir, origin) {
735
+ writeWebEnv(targetDir, HUB_ORIGIN_ENV_VAR, () => origin);
736
+ }
737
+ /**
738
+ * `<name>=<value()>` into the spoke's `apps/web/.env.local`, unless any env
739
+ * file Next loads for `apps/web` already assigns `name` (`value` is then never
740
+ * called); and `.env.local` gitignored either way.
741
+ */
742
+ function writeWebEnv(targetDir, name, value) {
386
743
  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);
744
+ const assigned = new RegExp(`^\\s*(?:export\\s+)?${name}\\s*=\\s*\\S`, "m");
745
+ const already = NEXT_ENV_FILES.some((file) => {
746
+ const path = join(webDir, file);
390
747
  return existsSync(path) && assigned.test(readFileSync(path, "utf8"));
391
748
  });
392
749
  if (!already) {
393
750
  const file = join(webDir, ".env.local");
394
751
  const existing = existsSync(file) ? readFileSync(file, "utf8") : "";
395
- writeOwnerOnly(file, appendLine(existing, `${HUB_ORIGIN_ENV_VAR}=${origin}`));
752
+ writeOwnerOnly(file, appendLine(existing, `${name}=${value()}`));
396
753
  }
397
754
  const ignorePath = join(targetDir, ".gitignore");
398
755
  const ignore = existsSync(ignorePath) ? readFileSync(ignorePath, "utf8") : "";
@@ -402,6 +759,21 @@ export function writeHubOriginEnv(targetDir, origin) {
402
759
  if (!covered)
403
760
  writeFileSync(ignorePath, appendLine(ignore, ".env.local"));
404
761
  }
762
+ /** The env var naming the hub's web origin a hub-mode spoke signs in at (#7894 D11). */
763
+ export const HUB_SIGN_IN_URL_ENV_VAR = "AILK_HUB_SIGN_IN_URL";
764
+ /** The env var holding a hub-mode spoke's own cookie key material (#7894 D7, D11). */
765
+ export const HUB_SESSION_SECRET_ENV_VAR = "AILK_HUB_SESSION_SECRET";
766
+ /**
767
+ * Write a hub-mode spoke's sign-in env (#7894 D11) to `apps/web/.env.local`,
768
+ * under `writeHubOriginEnv`'s never-clobber rule: the hub's web origin, and a
769
+ * fresh session secret (32 random bytes, 43 base64url characters). Never the
770
+ * client id or client secret — the hub issues those (`spokes sign-in-client`)
771
+ * — and never `BETTER_AUTH_SECRET` or a database URL.
772
+ */
773
+ export function writeHubSignInEnv(targetDir, origin) {
774
+ writeWebEnv(targetDir, HUB_SIGN_IN_URL_ENV_VAR, () => origin);
775
+ writeWebEnv(targetDir, HUB_SESSION_SECRET_ENV_VAR, () => randomBytes(32).toString("base64url"));
776
+ }
405
777
  // ── D9 — the spoke's hard floors ────────────────────────────────────────────
406
778
  /** The `hard_floors:` block a spoke's `project.yaml` carries (D9). */
407
779
  export function renderSpokeHardFloors(opts) {
@@ -410,19 +782,29 @@ export function renderSpokeHardFloors(opts) {
410
782
  " # Surfaces that always require human review on merge, regardless of",
411
783
  " # automation level. This site is a SPOKE (create-ailk --topology spoke):",
412
784
  " # 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.",
785
+ " # Stripe webhook route, the checkout route, its price map and its e2e",
786
+ " # workflow, and the sign-in paths are floored so their arrival here is a",
787
+ " # human merge.",
415
788
  " - kind: pricing",
416
789
  " - kind: stripe_live_mode",
417
790
  " - paths:",
418
791
  ' - "apps/web/app/api/webhooks/stripe/**"',
419
792
  " reason: Stripe webhook route — payment state requires human review",
420
793
  " - paths:",
794
+ ' - "apps/web/app/api/checkout/**"',
795
+ ' - "apps/web/lib/checkout-config.ts"',
796
+ ' - ".github/workflows/checkout-e2e.yml"',
797
+ " reason: Spoke checkout — the buy route, the price map and the checkout e2e workflow require human review",
798
+ " - paths:",
421
799
  ' - "apps/web/auth.ts"',
422
800
  ' - "apps/web/app/api/auth/**"',
423
801
  ' - "apps/web/app/[locale]/(auth)/**"',
424
802
  ' - "apps/web/app/[locale]/(authed)/**"',
425
- " reason: sign-in paths — absent on a spoke; their return requires human review",
803
+ ' - "apps/web/lib/hub-session/**"',
804
+ ' - "apps/web/app/api/hub-session/**"',
805
+ opts.signIn === "hub"
806
+ ? " reason: sign-in paths — this spoke signs in through its hub; every change requires human review"
807
+ : " reason: sign-in paths — absent on a spoke; their return requires human review",
426
808
  ];
427
809
  if (opts.mcp) {
428
810
  lines.push(" - paths:", ' - "apps/mcp/src/tools/**"', " reason: MCP tool surface — signatures require human review");
@@ -447,12 +829,9 @@ export function transformProjectYaml(text, opts) {
447
829
  }
448
830
  // ── D7 — the module admission ───────────────────────────────────────────────
449
831
  /** 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",
832
+ export const SPOKE_REFUSAL_REASONS = {
453
833
  "multi-tenant": "it needs a database or the Admin app",
454
834
  workspace: "it needs a database or the Admin app",
455
- "signal-collectors": "it needs a database or the Admin app",
456
835
  };
457
836
  /** The modules a spoke may carry — every slice declaring `spoke: true`. */
458
837
  export function spokeModules() {
@@ -493,6 +872,26 @@ export function readSiteTopology(targetDir) {
493
872
  }
494
873
  throw new TopologyError(`${SITE_MANIFEST_PATH} records an unknown topology ${JSON.stringify(value)} (expected ${TOPOLOGIES.join(" or ")})`);
495
874
  }
875
+ /**
876
+ * The spoke options `<targetDir>` records (#7894 D1), for the later commands
877
+ * (`sync-routes`, `add-module`): `undefined` on a hub; on a spoke, `mcp` from
878
+ * whether it still carries `apps/mcp` and `signIn` from the manifest's
879
+ * `signIn` key — absent reads as `"off"`, every spoke before #7894.
880
+ * @throws TopologyError on an unreadable manifest or an unknown value.
881
+ */
882
+ export function readSpokeShape(targetDir) {
883
+ if (readSiteTopology(targetDir) !== "spoke")
884
+ return undefined;
885
+ const doc = JSON.parse(readFileSync(join(targetDir, SITE_MANIFEST_PATH), "utf8"));
886
+ const value = doc["signIn"];
887
+ if (value !== undefined && value !== "hub") {
888
+ throw new TopologyError(`${SITE_MANIFEST_PATH} records an unknown sign-in mode ${JSON.stringify(value)} (expected "hub", or no key)`);
889
+ }
890
+ return {
891
+ mcp: existsSync(join(targetDir, SPOKE_MCP_ROOT)),
892
+ signIn: value === "hub" ? "hub" : "off",
893
+ };
894
+ }
496
895
  // ── D2 — the hub origin ─────────────────────────────────────────────────────
497
896
  /**
498
897
  * Hosts a spoke may reach over plain `http:` — the set
@@ -512,22 +911,34 @@ export function normalizeHubUrl(raw) {
512
911
  if (raw === undefined || raw.trim() === "") {
513
912
  throw new TopologyError(`a spoke needs its hub's origin, and none was given. ${inputs} (e.g. https://hub.example.com).`);
514
913
  }
914
+ return normalizeOrigin(raw, "the hub origin", inputs);
915
+ }
916
+ /**
917
+ * The hub's WEB origin a hub-mode spoke signs in at (`--hub-sign-in`, #7894
918
+ * D1) — `normalizeHubUrl`'s rules, each refusal naming the flag.
919
+ * @throws TopologyError when `raw` is not such an origin.
920
+ */
921
+ export function normalizeHubSignInUrl(raw) {
922
+ return normalizeOrigin(raw, "the --hub-sign-in origin", "Pass --hub-sign-in <origin>");
923
+ }
924
+ /** `raw` normalized to an https (or loopback http) origin; refusals name `what` and `inputs`. */
925
+ function normalizeOrigin(raw, what, inputs) {
515
926
  let url;
516
927
  try {
517
928
  url = new URL(raw.trim());
518
929
  }
519
930
  catch {
520
- throw new TopologyError(`the hub origin is not a URL. ${inputs} to an origin such as https://hub.example.com.`);
931
+ throw new TopologyError(`${what} is not a URL. ${inputs} to an origin such as https://hub.example.com.`);
521
932
  }
522
933
  const loopback = LOOPBACK_HOSTS.includes(url.hostname);
523
934
  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}.`);
935
+ throw new TopologyError(`${what} must be https: (http: only on ${LOOPBACK_HOSTS.join(" or ")}), got ${url.protocol}//${url.host}. ${inputs}.`);
525
936
  }
526
937
  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}.`);
938
+ throw new TopologyError(`${what} must be an origin only — no path, query or fragment. ${inputs} to ${url.origin}.`);
528
939
  }
529
940
  if (url.username !== "" || url.password !== "") {
530
- throw new TopologyError(`the hub origin must not carry credentials. ${inputs} to ${url.protocol}//${url.host}.`);
941
+ throw new TopologyError(`${what} must not carry credentials. ${inputs} to ${url.protocol}//${url.host}.`);
531
942
  }
532
943
  return url.origin;
533
944
  }