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
@@ -0,0 +1,362 @@
1
+ /**
2
+ * An in-memory stand-in for the handful of Prisma delegates the hub sign-in
3
+ * flow touches (#7894): `spoke`, `oAuthClient`, `oAuthAuthorization`,
4
+ * `verification` and `user`. Test-only.
5
+ *
6
+ * It lets the suites drive the REAL store, service, routes and preHandler end
7
+ * to end — a code minted by one call is consumed (once) by the next — while
8
+ * the Prisma client itself, the external boundary, stays faked. Every delegate
9
+ * method answers only the `where` shapes the production code uses; an
10
+ * unexpected shape throws, so a code change that adds a query is a failing
11
+ * test and not a silent miss.
12
+ *
13
+ * Install it on the shared `__mocks__/prisma.ts` double:
14
+ *
15
+ * jest.mock("../../lib/prisma.js");
16
+ * const fake = installHubSignInFakeDb(jest.mocked(prisma));
17
+ */
18
+ import { randomUUID } from "node:crypto";
19
+
20
+ import { hashSpokeKey, mintSpokeKey } from "../../lib/spoke-store.js";
21
+ import {
22
+ HUB_CALLBACK_PATH,
23
+ mintClientSecret,
24
+ sha256Hex,
25
+ } from "../../lib/hub-sign-in-store.js";
26
+
27
+ type Row = Record<string, unknown>;
28
+ type Where = Record<string, unknown>;
29
+
30
+ export interface FakeUser {
31
+ id: string;
32
+ name: string | null;
33
+ email: string;
34
+ emailVerified: boolean;
35
+ createdAt: Date;
36
+ }
37
+
38
+ export interface FakeSpoke {
39
+ id: string;
40
+ workspaceId: string;
41
+ name: string;
42
+ credentialHash: string;
43
+ revokedAt: Date | null;
44
+ }
45
+
46
+ export interface FakeClient {
47
+ id: string;
48
+ clientId: string;
49
+ clientSecret: string;
50
+ redirectUris: string[];
51
+ enabled: boolean;
52
+ }
53
+
54
+ export interface FakeVerification {
55
+ id: string;
56
+ identifier: string;
57
+ value: string;
58
+ expiresAt: Date;
59
+ }
60
+
61
+ export interface FakeAuthorization {
62
+ id: string;
63
+ userId: string;
64
+ clientId: string;
65
+ scopes: string[];
66
+ accessToken: string;
67
+ expiresAt: Date;
68
+ revokedAt: Date | null;
69
+ }
70
+
71
+ export interface FakeHubDb {
72
+ users: Map<string, FakeUser>;
73
+ spokes: Map<string, FakeSpoke>;
74
+ clients: Map<string, FakeClient>;
75
+ verifications: FakeVerification[];
76
+ authorizations: FakeAuthorization[];
77
+ /** Every `prisma.session.*` call — the token route must make none. */
78
+ sessionCalls: string[];
79
+ }
80
+
81
+ /** A seeded spoke with a registered sign-in client. */
82
+ export interface SeededSpoke {
83
+ spoke: FakeSpoke;
84
+ client: FakeClient;
85
+ /** The spoke key, in clear (the test sends it). */
86
+ key: string;
87
+ /** The client secret, in clear. */
88
+ secret: string;
89
+ redirectUri: string;
90
+ /** `Authorization: Basic …` for the token and revoke routes. */
91
+ basic: string;
92
+ }
93
+
94
+ function eq(row: object, where: Where): boolean {
95
+ return Object.entries(where).every(([key, expected]) => {
96
+ if (expected !== null && typeof expected === "object") {
97
+ throw new Error(`fake db: unsupported where clause on "${key}"`);
98
+ }
99
+ return (row as Record<string, unknown>)[key] === expected;
100
+ });
101
+ }
102
+
103
+ function pickWhere(where: Where, allowed: string[]): void {
104
+ for (const key of Object.keys(where)) {
105
+ if (!allowed.includes(key)) {
106
+ throw new Error(`fake db: unexpected where key "${key}"`);
107
+ }
108
+ }
109
+ }
110
+
111
+ type DelegateMock = {
112
+ mockImplementation: (fn: (arg: never) => unknown) => void;
113
+ };
114
+
115
+ function impl(mock: unknown, fn: (arg: never) => unknown): void {
116
+ (mock as DelegateMock).mockImplementation(fn);
117
+ }
118
+
119
+ export function installHubSignInFakeDb(db: {
120
+ spoke: { findUnique: unknown };
121
+ oAuthClient: { findUnique: unknown; upsert: unknown; updateMany: unknown };
122
+ oAuthAuthorization: {
123
+ findUnique: unknown;
124
+ upsert: unknown;
125
+ updateMany: unknown;
126
+ };
127
+ verification: { create: unknown; findFirst: unknown; deleteMany: unknown };
128
+ user: { findUnique: unknown };
129
+ session: { findUnique: unknown; create: unknown };
130
+ }): FakeHubDb {
131
+ const state: FakeHubDb = {
132
+ users: new Map(),
133
+ spokes: new Map(),
134
+ clients: new Map(),
135
+ verifications: [],
136
+ authorizations: [],
137
+ sessionCalls: [],
138
+ };
139
+
140
+ impl(
141
+ db.user.findUnique,
142
+ async ({ where }: { where: Where }) =>
143
+ state.users.get(String(where.id)) ?? null,
144
+ );
145
+
146
+ impl(db.spoke.findUnique, async ({ where }: { where: Where }) => {
147
+ pickWhere(where, ["id", "credentialHash"]);
148
+ for (const spoke of state.spokes.values()) {
149
+ if (eq(spoke, where)) return spoke;
150
+ }
151
+ return null;
152
+ });
153
+
154
+ impl(db.oAuthClient.findUnique, async ({ where }: { where: Where }) => {
155
+ pickWhere(where, ["clientId"]);
156
+ return state.clients.get(String(where.clientId)) ?? null;
157
+ });
158
+
159
+ impl(
160
+ db.oAuthClient.upsert,
161
+ async (args: {
162
+ where: Where;
163
+ create: Omit<FakeClient, "id">;
164
+ update: Partial<FakeClient>;
165
+ }) => {
166
+ const clientId = String(args.where.clientId);
167
+ const existing = state.clients.get(clientId);
168
+ const next: FakeClient = existing
169
+ ? { ...existing, ...args.update }
170
+ : { id: `oauthc_${randomUUID()}`, ...args.create };
171
+ state.clients.set(clientId, next);
172
+ return next;
173
+ },
174
+ );
175
+
176
+ impl(
177
+ db.oAuthClient.updateMany,
178
+ async ({ where, data }: { where: Where; data: Row }) => {
179
+ pickWhere(where, ["clientId"]);
180
+ const client = state.clients.get(String(where.clientId));
181
+ if (!client) return { count: 0 };
182
+ Object.assign(client, data);
183
+ return { count: 1 };
184
+ },
185
+ );
186
+
187
+ impl(
188
+ db.verification.create,
189
+ async ({ data }: { data: Omit<FakeVerification, "id"> }) => {
190
+ const row: FakeVerification = { id: `verify_${randomUUID()}`, ...data };
191
+ state.verifications.push(row);
192
+ return row;
193
+ });
194
+
195
+ impl(db.verification.findFirst, async ({ where }: { where: Where }) => {
196
+ pickWhere(where, ["identifier"]);
197
+ return (
198
+ state.verifications.find((v) => v.identifier === where.identifier) ?? null
199
+ );
200
+ });
201
+
202
+ impl(db.verification.deleteMany, async ({ where }: { where: Where }) => {
203
+ const before = state.verifications.length;
204
+ if (typeof where.id === "string") {
205
+ state.verifications = state.verifications.filter(
206
+ (v) => v.id !== where.id,
207
+ );
208
+ } else {
209
+ const startsWith = (where.identifier as { startsWith: string })
210
+ .startsWith;
211
+ const lte = (where.expiresAt as { lte: Date }).lte;
212
+ state.verifications = state.verifications.filter(
213
+ (v) => !(v.identifier.startsWith(startsWith) && v.expiresAt <= lte),
214
+ );
215
+ }
216
+ return { count: before - state.verifications.length };
217
+ });
218
+
219
+ impl(
220
+ db.oAuthAuthorization.upsert,
221
+ async (args: {
222
+ where: { userId_clientId: { userId: string; clientId: string } };
223
+ create: Omit<FakeAuthorization, "id" | "revokedAt">;
224
+ update: Partial<FakeAuthorization>;
225
+ }) => {
226
+ const { userId, clientId } = args.where.userId_clientId;
227
+ const existing = state.authorizations.find(
228
+ (a) => a.userId === userId && a.clientId === clientId,
229
+ );
230
+ if (existing) {
231
+ Object.assign(existing, args.update);
232
+ return existing;
233
+ }
234
+ const row: FakeAuthorization = {
235
+ id: `oautha_${randomUUID()}`,
236
+ revokedAt: null,
237
+ ...args.create,
238
+ };
239
+ state.authorizations.push(row);
240
+ return row;
241
+ },
242
+ );
243
+
244
+ impl(
245
+ db.oAuthAuthorization.updateMany,
246
+ async ({ where, data }: { where: Where; data: Row }) => {
247
+ // Two production shapes: the revoke route (a client's credential by
248
+ // digest) and the web app's password reset (every live grant of a user).
249
+ const matches = state.authorizations.filter((a) => {
250
+ if ("accessToken" in where && a.accessToken !== where.accessToken)
251
+ return false;
252
+ if ("clientId" in where && a.clientId !== where.clientId) return false;
253
+ if ("userId" in where && a.userId !== where.userId) return false;
254
+ if ("revokedAt" in where && a.revokedAt !== where.revokedAt)
255
+ return false;
256
+ return true;
257
+ });
258
+ for (const row of matches) Object.assign(row, data);
259
+ return { count: matches.length };
260
+ },
261
+ );
262
+
263
+ impl(
264
+ db.oAuthAuthorization.findUnique,
265
+ async ({ where }: { where: Where }) => {
266
+ pickWhere(where, ["accessToken"]);
267
+ const row = state.authorizations.find(
268
+ (a) => a.accessToken === where.accessToken,
269
+ );
270
+ if (!row) return null;
271
+ const client = [...state.clients.values()].find(
272
+ (c) => c.id === row.clientId,
273
+ );
274
+ const user = state.users.get(row.userId);
275
+ if (!client || !user) return null;
276
+ return {
277
+ userId: row.userId,
278
+ expiresAt: row.expiresAt,
279
+ revokedAt: row.revokedAt,
280
+ client: { clientId: client.clientId, enabled: client.enabled },
281
+ user: { emailVerified: user.emailVerified },
282
+ };
283
+ },
284
+ );
285
+
286
+ impl(db.session.findUnique, async () => {
287
+ state.sessionCalls.push("findUnique");
288
+ return null;
289
+ });
290
+ impl(db.session.create, async () => {
291
+ state.sessionCalls.push("create");
292
+ throw new Error("fake db: a hub sign-in must never create a Session");
293
+ });
294
+
295
+ return state;
296
+ }
297
+
298
+ let counter = 0;
299
+
300
+ export function seedUser(
301
+ state: FakeHubDb,
302
+ overrides: Partial<FakeUser> = {},
303
+ ): FakeUser {
304
+ counter += 1;
305
+ const user: FakeUser = {
306
+ id: `user_${counter}`,
307
+ name: "Pat Person",
308
+ email: `pat${counter}@example.com`,
309
+ emailVerified: true,
310
+ createdAt: new Date("2026-10-01T00:00:00.000Z"),
311
+ ...overrides,
312
+ };
313
+ state.users.set(user.id, user);
314
+ return user;
315
+ }
316
+
317
+ export function seedSpoke(
318
+ state: FakeHubDb,
319
+ host = "spoke.example.com",
320
+ ): SeededSpoke {
321
+ counter += 1;
322
+ const key = mintSpokeKey();
323
+ const spoke: FakeSpoke = {
324
+ id: `spoke_${counter}`,
325
+ workspaceId: `wksp_${counter}`,
326
+ name: `Spoke ${counter}`,
327
+ credentialHash: hashSpokeKey(key),
328
+ revokedAt: null,
329
+ };
330
+ state.spokes.set(spoke.id, spoke);
331
+
332
+ const secret = mintClientSecret();
333
+ const redirectUri = `https://${host}${HUB_CALLBACK_PATH}`;
334
+ const client: FakeClient = {
335
+ id: `oauthc_${counter}`,
336
+ clientId: spoke.id,
337
+ clientSecret: sha256Hex(secret),
338
+ redirectUris: [redirectUri],
339
+ enabled: true,
340
+ };
341
+ state.clients.set(client.clientId, client);
342
+
343
+ return {
344
+ spoke,
345
+ client,
346
+ key,
347
+ secret,
348
+ redirectUri,
349
+ basic: `Basic ${Buffer.from(`${spoke.id}:${secret}`).toString("base64")}`,
350
+ };
351
+ }
352
+
353
+ /** A PKCE pair: the verifier a spoke generates and the S256 challenge of it. */
354
+ export function pkcePair(): { verifier: string; challenge: string } {
355
+ const verifier = Buffer.from(randomUUID() + randomUUID())
356
+ .toString("base64url")
357
+ .slice(0, 64);
358
+ const challenge = Buffer.from(sha256Hex(verifier), "hex").toString(
359
+ "base64url",
360
+ );
361
+ return { verifier, challenge };
362
+ }
@@ -21,10 +21,24 @@
21
21
  * list
22
22
  * Every spoke: id, name, workspace, key prefix, created, revoked. Never
23
23
  * the digest.
24
+ * sign-in-client --id <spoke_id> --redirect-uri <url>
25
+ * Register (or rotate) the spoke's sign-in client (#7894 D3): the hub
26
+ * signs that spoke's visitors in, and the callback is the ONE registered
27
+ * URL — `https:` (or `http:` on a loopback host), path exactly
28
+ * `/api/hub-session/callback`, no query, fragment or userinfo. Prints the
29
+ * client id and secret ONCE; re-running rotates the secret. Refuses an
30
+ * unknown or revoked spoke.
31
+ * sign-in-client --id <spoke_id> --disable
32
+ * Switch that spoke's sign-in client off: its codes and credentials stop
33
+ * at once.
24
34
  *
25
35
  * A key rotates by registering a second spoke for the workspace, deploying
26
36
  * its key, then revoking the first. There is no HTTP route for any of this.
27
37
  */
38
+ import {
39
+ disableSignInClient,
40
+ registerSignInClient,
41
+ } from "../lib/hub-sign-in-store.js";
28
42
  import { prisma } from "../lib/prisma.js";
29
43
  import {
30
44
  listSpokes,
@@ -36,12 +50,15 @@ const USAGE = [
36
50
  "usage: spokes register --workspace <wksp_id> --name <label>",
37
51
  " spokes revoke --id <spoke_id>",
38
52
  " spokes list",
53
+ " spokes sign-in-client --id <spoke_id> --redirect-uri <url> | --disable",
39
54
  ].join("\n");
40
55
 
41
56
  type Flags = {
42
57
  workspace?: string | undefined;
43
58
  name?: string | undefined;
44
59
  id?: string | undefined;
60
+ redirectUri?: string | undefined;
61
+ disable?: boolean | undefined;
45
62
  };
46
63
 
47
64
  /** Minimal `--flag value` parser — no external dependency for a 3-flag CLI. */
@@ -53,7 +70,11 @@ function parseFlags(argv: string[]): Flags {
53
70
  if (flag === "--workspace") out.workspace = value;
54
71
  else if (flag === "--name") out.name = value;
55
72
  else if (flag === "--id") out.id = value;
56
- else continue;
73
+ else if (flag === "--redirect-uri") out.redirectUri = value;
74
+ else if (flag === "--disable") {
75
+ out.disable = true;
76
+ continue; // a bare flag: it takes no value
77
+ } else continue;
57
78
  i += 1;
58
79
  }
59
80
  return out;
@@ -96,6 +117,45 @@ async function runRevoke(flags: Flags): Promise<void> {
96
117
  console.log(`[spokes] revoked spoke="${flags.id}"`);
97
118
  }
98
119
 
120
+ async function runSignInClient(flags: Flags): Promise<void> {
121
+ if (!flags.id || (!flags.disable && !flags.redirectUri)) {
122
+ console.error(`[spokes] ${USAGE}`);
123
+ process.exitCode = 1;
124
+ return;
125
+ }
126
+ if (flags.disable) {
127
+ const disabled = await disableSignInClient(flags.id);
128
+ if (!disabled) {
129
+ console.error(`[spokes] no sign-in client for spoke "${flags.id}"`);
130
+ process.exitCode = 1;
131
+ return;
132
+ }
133
+ console.log(`[spokes] disabled the sign-in client of spoke="${flags.id}"`);
134
+ return;
135
+ }
136
+ const result = await registerSignInClient({
137
+ spokeId: flags.id,
138
+ redirectUri: flags.redirectUri ?? "",
139
+ });
140
+ if (!result.ok) {
141
+ const why = {
142
+ "invalid-redirect-uri":
143
+ "the redirect URI must be https (or http on localhost / 127.0.0.1), its path exactly /api/hub-session/callback, with no query, fragment or userinfo",
144
+ "spoke-not-found": `no spoke "${flags.id}"`,
145
+ "spoke-revoked": `spoke "${flags.id}" is revoked`,
146
+ }[result.reason];
147
+ console.error(`[spokes] ${result.reason}: ${why}`);
148
+ process.exitCode = 1;
149
+ return;
150
+ }
151
+ console.log(`[spokes] sign-in client ready for spoke="${result.spokeId}"`);
152
+ console.log(`AILK_HUB_CLIENT_ID=${result.spokeId}`);
153
+ console.log(`AILK_HUB_CLIENT_SECRET=${result.secret}`);
154
+ console.log(
155
+ "[spokes] This secret will not be shown again. Set both as the spoke's AILK_HUB_CLIENT_ID / AILK_HUB_CLIENT_SECRET now. Re-running rotates the secret.",
156
+ );
157
+ }
158
+
99
159
  async function runList(): Promise<void> {
100
160
  const spokes = await listSpokes();
101
161
  if (spokes.length === 0) {
@@ -122,6 +182,7 @@ async function main(): Promise<void> {
122
182
  if (command === "register") return runRegister(flags);
123
183
  if (command === "revoke") return runRevoke(flags);
124
184
  if (command === "list") return runList();
185
+ if (command === "sign-in-client") return runSignInClient(flags);
125
186
  console.error(`[spokes] ${USAGE}`);
126
187
  process.exitCode = 1;
127
188
  }
@@ -119,3 +119,78 @@ describe('AILK_HUB_REQUIRE_SPOKE_KEY (#7532 D7, #7633 D2)', () => {
119
119
  expect(config).toMatchObject({ AILK_HUB_REQUIRE_SPOKE_KEY: mode });
120
120
  });
121
121
  });
122
+
123
+ /**
124
+ * `AILK_SPOKE_FLOW_SEED` — whether a hub lets a spoke key write its own
125
+ * workspace's flow definitions (#7876 D3, AC-3). Off unless set: unset, empty
126
+ * or blank is `false`; `true`/`1` is `true`; `false`/`0` is `false` —
127
+ * case-insensitively, after trimming. Any other value refuses the boot with a
128
+ * message naming the variable, never the value. `true` warns once at boot.
129
+ */
130
+ describe('AILK_SPOKE_FLOW_SEED (#7876 D3)', () => {
131
+ const originalEnv = process.env;
132
+
133
+ beforeEach(() => {
134
+ jest.resetModules();
135
+ process.env = { ...originalEnv };
136
+ delete process.env.AILK_SPOKE_FLOW_SEED;
137
+ delete process.env.AILK_HUB_REQUIRE_SPOKE_KEY;
138
+ process.env.DATABASE_URL = 'postgresql://test:test@localhost/test';
139
+ jest.clearAllMocks();
140
+ });
141
+
142
+ afterEach(() => {
143
+ process.env = originalEnv;
144
+ jest.restoreAllMocks();
145
+ });
146
+
147
+ function seedWarnings(): unknown[][] {
148
+ return mockLoggerWarn.mock.calls.filter((call) =>
149
+ JSON.stringify(call).includes('AILK_SPOKE_FLOW_SEED'),
150
+ );
151
+ }
152
+
153
+ it.each([
154
+ [undefined, false],
155
+ ['', false],
156
+ [' ', false],
157
+ ['false', false],
158
+ ['0', false],
159
+ ['true', true],
160
+ ['TRUE', true],
161
+ ['1', true],
162
+ [' True ', true],
163
+ ['yes', null],
164
+ ])('parseSpokeFlowSeed(%j) is %j', (raw, expected) => {
165
+ const { parseSpokeFlowSeed } = require('../index');
166
+ expect(parseSpokeFlowSeed(raw)).toBe(expected);
167
+ });
168
+
169
+ it('is off by default and warns nothing', () => {
170
+ const { config } = require('../index');
171
+ expect(config).toMatchObject({ AILK_SPOKE_FLOW_SEED: false });
172
+ expect(seedWarnings()).toHaveLength(0);
173
+ });
174
+
175
+ it('on warns once at boot', () => {
176
+ process.env.AILK_SPOKE_FLOW_SEED = 'true';
177
+ const { config } = require('../index');
178
+ expect(config).toMatchObject({ AILK_SPOKE_FLOW_SEED: true });
179
+ expect(seedWarnings()).toHaveLength(1);
180
+ });
181
+
182
+ it('"yes" refuses the boot, naming the variable and never the value', () => {
183
+ process.env.AILK_SPOKE_FLOW_SEED = 'yes';
184
+ const exitSpy = jest.spyOn(process, 'exit').mockImplementation(() => {
185
+ throw new Error('Process exit called');
186
+ });
187
+ const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined);
188
+
189
+ expect(() => require('../index')).toThrow('Process exit called');
190
+ expect(exitSpy).toHaveBeenCalledWith(1);
191
+
192
+ const logged = JSON.stringify([mockLoggerError.mock.calls, consoleSpy.mock.calls]);
193
+ expect(logged).toContain('AILK_SPOKE_FLOW_SEED must be true, 1, false, 0 or unset');
194
+ expect(logged).not.toContain('yes');
195
+ });
196
+ });
@@ -122,6 +122,11 @@ export interface Config {
122
122
  // string, so no reader may test it by truthiness. Parsed by
123
123
  // parseHubRequireSpokeKey() below; read by services/public-site-resolver.ts.
124
124
  AILK_HUB_REQUIRE_SPOKE_KEY: HubSpokeKeyMode;
125
+ // Whether a registered spoke key may write flow definitions into its own
126
+ // workspace through `PUT /v1/flows/:slug/definition` (#7876 D3). Off unless
127
+ // the hub's operator turns it on; warned at boot when on. Parsed by
128
+ // parseSpokeFlowSeed() below; read by routes/flows/index.ts.
129
+ AILK_SPOKE_FLOW_SEED: boolean;
125
130
  }
126
131
 
127
132
  /** `AILK_HUB_REQUIRE_SPOKE_KEY`'s three states (#7633 D2). */
@@ -267,6 +272,19 @@ export function parseHubRequireSpokeKey(raw: string | undefined): HubSpokeKeyMod
267
272
  return null;
268
273
  }
269
274
 
275
+ /**
276
+ * `AILK_SPOKE_FLOW_SEED` (#7876 D3): unset, empty or blank → `false`;
277
+ * `true`/`1` → `true`; `false`/`0` → `false` — case-insensitive, after
278
+ * trimming. `null` for any other value, which `validateConfig()` refuses at
279
+ * boot.
280
+ */
281
+ export function parseSpokeFlowSeed(raw: string | undefined): boolean | null {
282
+ const value = (raw ?? '').trim().toLowerCase();
283
+ if (value === '' || value === 'false' || value === '0') return false;
284
+ if (value === 'true' || value === '1') return true;
285
+ return null;
286
+ }
287
+
270
288
  /** Enabled modules whose state lives in the database (see validateConfig). */
271
289
  const DATABASE_MODULES = [
272
290
  'commerce',
@@ -374,6 +392,22 @@ function validateConfig(): Config {
374
392
  );
375
393
  }
376
394
 
395
+ // Flow seeding is a write a spoke key could not do before (#7876 D3): a
396
+ // typo must never read as either state, and turning it on is never silent.
397
+ // The message names the variable, never its value.
398
+ const spokeFlowSeed = parseSpokeFlowSeed(process.env.AILK_SPOKE_FLOW_SEED);
399
+ if (spokeFlowSeed === null) {
400
+ const message = '❌ AILK_SPOKE_FLOW_SEED must be true, 1, false, 0 or unset';
401
+ logger.error(message);
402
+ console.error(message);
403
+ process.exit(1);
404
+ }
405
+ if (spokeFlowSeed) {
406
+ logger.warn(
407
+ "AILK_SPOKE_FLOW_SEED=true — a registered spoke key may write its own workspace's flow definitions",
408
+ );
409
+ }
410
+
377
411
  return {
378
412
  NODE_ENV: nodeEnv,
379
413
  PORT: parseInt(process.env.PORT || '3002', 10),
@@ -446,6 +480,7 @@ function validateConfig(): Config {
446
480
  SCHEDULING_CREDENTIAL_KEY: schedulingKey.key,
447
481
  }),
448
482
  AILK_HUB_REQUIRE_SPOKE_KEY: hubRequireSpokeKey,
483
+ AILK_SPOKE_FLOW_SEED: spokeFlowSeed,
449
484
  };
450
485
  }
451
486