@bevel-software/platform-core-backend 0.8.0 → 0.9.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 (84) hide show
  1. package/dist/core/core-ports.d.ts +7 -0
  2. package/dist/core/core-ports.d.ts.map +1 -1
  3. package/dist/core/core-ports.js.map +1 -1
  4. package/dist/core/create-core-server.d.ts.map +1 -1
  5. package/dist/core/create-core-server.js +24 -11
  6. package/dist/core/create-core-server.js.map +1 -1
  7. package/dist/core/create-core-services.d.ts +7 -2
  8. package/dist/core/create-core-services.d.ts.map +1 -1
  9. package/dist/core/create-core-services.js +39 -18
  10. package/dist/core/create-core-services.js.map +1 -1
  11. package/dist/modules/access/access.routes.d.ts +3 -1
  12. package/dist/modules/access/access.routes.d.ts.map +1 -1
  13. package/dist/modules/access/access.routes.js +4 -2
  14. package/dist/modules/access/access.routes.js.map +1 -1
  15. package/dist/modules/access/render-roles-yaml.d.ts +22 -0
  16. package/dist/modules/access/render-roles-yaml.d.ts.map +1 -0
  17. package/dist/modules/access/render-roles-yaml.js +56 -0
  18. package/dist/modules/access/render-roles-yaml.js.map +1 -0
  19. package/dist/modules/access/roles-admin.service.d.ts +15 -1
  20. package/dist/modules/access/roles-admin.service.d.ts.map +1 -1
  21. package/dist/modules/access/roles-admin.service.js +30 -15
  22. package/dist/modules/access/roles-admin.service.js.map +1 -1
  23. package/dist/modules/settings/setup.routes.d.ts +10 -1
  24. package/dist/modules/settings/setup.routes.d.ts.map +1 -1
  25. package/dist/modules/settings/setup.routes.js +111 -6
  26. package/dist/modules/settings/setup.routes.js.map +1 -1
  27. package/dist/modules/workspace/startup/kb-git.d.ts +23 -0
  28. package/dist/modules/workspace/startup/kb-git.d.ts.map +1 -0
  29. package/dist/modules/workspace/startup/kb-git.js +86 -0
  30. package/dist/modules/workspace/startup/kb-git.js.map +1 -0
  31. package/dist/modules/workspace/startup/kb-startup-runner.d.ts +74 -0
  32. package/dist/modules/workspace/startup/kb-startup-runner.d.ts.map +1 -0
  33. package/dist/modules/workspace/startup/kb-startup-runner.js +528 -0
  34. package/dist/modules/workspace/startup/kb-startup-runner.js.map +1 -0
  35. package/dist/modules/workspace/startup/on-server-start.d.ts +105 -0
  36. package/dist/modules/workspace/startup/on-server-start.d.ts.map +1 -0
  37. package/dist/modules/workspace/startup/on-server-start.js +21 -0
  38. package/dist/modules/workspace/startup/on-server-start.js.map +1 -0
  39. package/dist/modules/workspace/startup/steps/groups-to-plugins.step.d.ts +46 -0
  40. package/dist/modules/workspace/startup/steps/groups-to-plugins.step.d.ts.map +1 -0
  41. package/dist/modules/workspace/startup/steps/groups-to-plugins.step.js +492 -0
  42. package/dist/modules/workspace/startup/steps/groups-to-plugins.step.js.map +1 -0
  43. package/dist/modules/workspace/startup/steps/roles-yaml.step.d.ts +23 -0
  44. package/dist/modules/workspace/startup/steps/roles-yaml.step.d.ts.map +1 -0
  45. package/dist/modules/workspace/startup/steps/roles-yaml.step.js +69 -0
  46. package/dist/modules/workspace/startup/steps/roles-yaml.step.js.map +1 -0
  47. package/dist/modules/workspace/startup/steps/seed-tree.d.ts +17 -0
  48. package/dist/modules/workspace/startup/steps/seed-tree.d.ts.map +1 -0
  49. package/dist/modules/workspace/startup/steps/seed-tree.js +109 -0
  50. package/dist/modules/workspace/startup/steps/seed-tree.js.map +1 -0
  51. package/dist/modules/workspace/startup/steps/template-files.step.d.ts +103 -0
  52. package/dist/modules/workspace/startup/steps/template-files.step.d.ts.map +1 -0
  53. package/dist/modules/workspace/startup/steps/template-files.step.js +337 -0
  54. package/dist/modules/workspace/startup/steps/template-files.step.js.map +1 -0
  55. package/dist/modules/workspace/workspace.service.d.ts +0 -35
  56. package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
  57. package/dist/modules/workspace/workspace.service.js +2 -101
  58. package/dist/modules/workspace/workspace.service.js.map +1 -1
  59. package/kb-template/gitignore.template +16 -0
  60. package/package.json +3 -3
  61. package/src/core/core-ports.ts +7 -0
  62. package/src/core/create-core-server.ts +30 -11
  63. package/src/core/create-core-services.ts +43 -22
  64. package/src/modules/access/__tests__/roles-admin.service.test.ts +24 -2
  65. package/src/modules/access/access.routes.ts +3 -0
  66. package/src/modules/access/render-roles-yaml.ts +65 -0
  67. package/src/modules/access/roles-admin.service.ts +32 -14
  68. package/src/modules/settings/__tests__/setup.routes.test.ts +124 -3
  69. package/src/modules/settings/setup.routes.ts +112 -5
  70. package/src/modules/workspace/__tests__/workspace.service.test.ts +5 -94
  71. package/src/modules/workspace/startup/__tests__/kb-startup-runner.test.ts +495 -0
  72. package/src/modules/workspace/startup/kb-git.ts +94 -0
  73. package/src/modules/workspace/startup/kb-startup-runner.ts +597 -0
  74. package/src/modules/workspace/startup/on-server-start.ts +97 -0
  75. package/src/modules/workspace/startup/steps/__tests__/steps.test.ts +636 -0
  76. package/src/modules/workspace/{plugins-migration.ts → startup/steps/groups-to-plugins.step.ts} +342 -260
  77. package/src/modules/workspace/startup/steps/roles-yaml.step.ts +71 -0
  78. package/src/modules/workspace/startup/steps/seed-tree.ts +115 -0
  79. package/src/modules/workspace/startup/steps/template-files.step.ts +360 -0
  80. package/src/modules/workspace/workspace.service.ts +2 -106
  81. package/src/modules/workspace/__tests__/kb-seed.service.test.ts +0 -512
  82. package/src/modules/workspace/__tests__/plugins-migration.test.ts +0 -427
  83. package/src/modules/workspace/kb-seed.interface.ts +0 -36
  84. package/src/modules/workspace/kb-seed.service.ts +0 -584
@@ -160,7 +160,9 @@ describe('RolesAdminService', () => {
160
160
  access = new AccessControlService(ws, KB);
161
161
  workflow = stubWorkflow();
162
162
  bus = stubEventBus();
163
- svc = new RolesAdminService(ws, workflow.svc, access, KB, () => DEFAULT_BRANCH, bus.bus);
163
+ svc = new RolesAdminService(ws, workflow.svc, access, KB, () => DEFAULT_BRANCH, bus.bus, [
164
+ 'recovery-admin@example.com',
165
+ ]);
164
166
  });
165
167
 
166
168
  afterEach(async () => {
@@ -219,11 +221,31 @@ describe('RolesAdminService', () => {
219
221
  const restored = await readRoles();
220
222
  expect(access.validateRolesYaml(restored).ok).toBe(true);
221
223
  expect(roster.find((r) => r.canonical === 'admin')?.isAdmin).toBe(true);
222
- expect(roster.find((r) => r.canonical === 'admin')!.members.length).toBeGreaterThan(0);
224
+ // The roster is THIS deployment's configured admin — never a list baked
225
+ // into the build (hard-coded emails would land one company's admins in
226
+ // every customer's recovered file).
227
+ expect(roster.find((r) => r.canonical === 'admin')!.members).toEqual([
228
+ 'recovery-admin@example.com',
229
+ ]);
230
+ expect(restored).not.toContain('bevel.software');
223
231
  // And the file is now healthy.
224
232
  expect(await svc.getHealth()).toEqual({ ok: true, errors: [] });
225
233
  });
226
234
 
235
+ it('recover refuses when no admin is configured — an adminless roster is the disease, not the cure', async () => {
236
+ const corrupt = 'roles:\n Admin:\n - a@x.eu\n Admin:\n - b@x.eu\n';
237
+ await write(repo, 'roles.yaml', corrupt);
238
+ access.invalidate(WS);
239
+ const adminless = new RolesAdminService(ws, workflow.svc, access, KB, () => DEFAULT_BRANCH, bus.bus);
240
+ await expect(adminless.recover(ADMIN)).rejects.toMatchObject({
241
+ status: 500,
242
+ payload: { kind: 'no-recovery-admins' },
243
+ });
244
+ // Nothing parked, nothing overwritten.
245
+ await expect(fs.readFile(path.join(repo, 'old-roles.yaml'), 'utf-8')).rejects.toThrow();
246
+ expect(await fs.readFile(path.join(repo, 'roles.yaml'), 'utf-8')).toBe(corrupt);
247
+ });
248
+
227
249
  it("getRoster's referencedBy is sound — finds node-frontmatter refs, not just folder access.md", async () => {
228
250
  // A folder access.md grant (the only thing the old advisory scan saw)...
229
251
  await write(repo, 'team/access.md', '---\nread:\n - Sales\n---\n');
@@ -61,6 +61,8 @@ export function createAccessRoutes(
61
61
  eventBus: WorkflowEventBus,
62
62
  db: Database,
63
63
  kbDirName: string,
64
+ /** This deployment's configured admins — the break-glass recovery roster. */
65
+ recoveryAdmins: readonly string[] = [],
64
66
  ): express.Router {
65
67
  const router = express.Router({ mergeParams: true });
66
68
  const mutation = new AccessMutationService(workspaceService, accessControl, kbDirName);
@@ -71,6 +73,7 @@ export function createAccessRoutes(
71
73
  kbDirName,
72
74
  () => DEFAULT_BRANCH,
73
75
  eventBus,
76
+ recoveryAdmins,
74
77
  );
75
78
 
76
79
  // Roles (plugins) are authoritative on the DEFAULT branch — the Roles admin
@@ -0,0 +1,65 @@
1
+ import { parseRolesYaml } from './access-control.service.js';
2
+
3
+ /**
4
+ * Render a `roles.yaml` granting Admin to each email — the ONE renderer every
5
+ * generated roles.yaml goes through (empty-remote seeding, the startup
6
+ * top-up, and the break-glass recovery), so none of them can drift on
7
+ * validation and each fails with the same actionable message.
8
+ *
9
+ * Each email is validated first, because a malformed one renders a file with
10
+ * no working Admin — SILENTLY: a leading `#` turns the entry into a YAML
11
+ * comment, an embedded newline breaks the list, an all-whitespace value
12
+ * renders an empty entry. A bad ADMIN_EMAIL is operator error, and the
13
+ * callers' fail-closed contracts want a message naming the fix instead of an
14
+ * un-administrable knowledge base (or a write-validator rejection deep in the
15
+ * recovery path).
16
+ */
17
+ export function renderRolesYaml(
18
+ adminEmails: readonly string[],
19
+ /**
20
+ * Provenance comment appended to the header. The seed/top-up default names
21
+ * where the file came from; recovery passes `null` — claiming "generated at
22
+ * KB-seed time" on a recovery-restored file would mislead whoever reads it.
23
+ */
24
+ provenance: string | null = 'Generated by the Bevel platform at KB-seed time from ADMIN_EMAIL.',
25
+ ): string {
26
+ const emails = adminEmails.map((raw) => {
27
+ const email = raw.trim();
28
+ if (email === '') {
29
+ throw new Error('Seed admin email is empty — set ADMIN_EMAIL to a real address.');
30
+ }
31
+ if (email.startsWith('#')) {
32
+ throw new Error(
33
+ `Seed admin email ${JSON.stringify(raw)} starts with "#", which YAML reads as a comment — fix ADMIN_EMAIL.`,
34
+ );
35
+ }
36
+ if (/\s/.test(email)) {
37
+ throw new Error(
38
+ `Seed admin email ${JSON.stringify(raw)} contains whitespace, which would corrupt roles.yaml — fix ADMIN_EMAIL.`,
39
+ );
40
+ }
41
+ return email;
42
+ });
43
+ const lines = emails.map((e) => ` - ${e}`).join('\n');
44
+ const text =
45
+ '# Identity → role mapping for access control.\n' +
46
+ '# Role names are case- and whitespace-insensitive. The `Admin` role is special:\n' +
47
+ '# only Admins may edit this file, and at least one Admin must always exist.\n' +
48
+ (provenance !== null ? `#\n# ${provenance}\n` : '') +
49
+ 'roles:\n' +
50
+ ' Admin:\n' +
51
+ `${lines}\n`;
52
+ // Validate by PARSING with the real access parser, not by re-implementing
53
+ // its email grammar: whatever the parser would reject (a `<Name <email>`
54
+ // shape, a non-email string, tomorrow's new rule) must fail here, before an
55
+ // un-administrable roles.yaml gets committed. The checks above exist only
56
+ // to give the common mistakes sharper messages.
57
+ const parsed = parseRolesYaml(text);
58
+ if (!parsed.ok) {
59
+ throw new Error(
60
+ `Generated roles.yaml from ADMIN_EMAIL would not parse — fix the configured address(es). ` +
61
+ parsed.errors.join('; '),
62
+ );
63
+ }
64
+ return text;
65
+ }
@@ -54,6 +54,7 @@ import {
54
54
  parseAccessEntry,
55
55
  } from './access-control.service.js';
56
56
  import { makeRolesYamlWriteValidator } from './roles-yaml-guard.js';
57
+ import { renderRolesYaml } from './render-roles-yaml.js';
57
58
  import {
58
59
  parseRolesModel,
59
60
  createRole as editCreateRole,
@@ -92,20 +93,21 @@ const ROLES_YAML = 'roles.yaml';
92
93
  const OLD_ROLES_YAML = 'old-roles.yaml';
93
94
 
94
95
  /**
95
- * The known-good `roles.yaml` the break-glass recovery restores. Mirrors the
96
- * canonical Bevel roster — it is the ONLY content recovery ever writes, so it
97
- * MUST parse (the post-recovery resolver loads it immediately). Keep the Admin
98
- * list in sync with the canonical `osapiens-kb/roles.yaml`.
96
+ * The known-good `roles.yaml` the break-glass recovery restores — generated
97
+ * from THIS deployment's configured admins, exactly like ordinary seeding
98
+ * (`ADMIN_EMAIL`), never a roster baked into the build: hard-coded emails
99
+ * would land one company's admins in every customer's recovered file. It is
100
+ * the ONLY content recovery ever writes, so it MUST parse (the post-recovery
101
+ * resolver loads it immediately) — guaranteed by delegating to the shared
102
+ * validated renderer, which parse-backs its output and throws an actionable
103
+ * message on a malformed ADMIN_EMAIL (instead of the write-validator
104
+ * rejecting the finished file deep in the write path). `null` provenance:
105
+ * the seed-time "generated at KB-seed time" comment would mislead on a
106
+ * recovery-restored file.
99
107
  */
100
- const RECOVERY_DEFAULT_ROLES_YAML = `# Identity → role mapping for access control.
101
- # Role names are case- and whitespace-insensitive. The \`Admin\` role is special:
102
- # only Admins may edit this file, and at least one Admin must always exist.
103
- roles:
104
- Admin:
105
- - razvan.radulescu@bevel.software
106
- - ali.raza@bevel.software
107
- - juan@bevel.software
108
- `;
108
+ function renderRecoveryRolesYaml(admins: readonly string[]): string {
109
+ return renderRolesYaml(admins, null);
110
+ }
109
111
 
110
112
  /** Health of the default-branch roles.yaml: does the resolver's parser accept it? */
111
113
  export interface RolesConfigHealth {
@@ -135,6 +137,13 @@ export class RolesAdminService {
135
137
  * open tab keeps a now-stale read verdict until a manual reload.
136
138
  */
137
139
  private readonly eventBus?: WorkflowEventBus,
140
+ /**
141
+ * This deployment's configured admins (`ADMIN_EMAIL`), the roster the
142
+ * break-glass recovery restores. Recovery refuses outright when empty: a
143
+ * recovered roles.yaml with no Admin is exactly the unusable state
144
+ * recovery exists to escape.
145
+ */
146
+ private readonly recoveryAdmins: readonly string[] = [],
138
147
  ) {}
139
148
 
140
149
  /** Resolved per call — see the constructor note on `defaultBranchOf`. */
@@ -340,6 +349,15 @@ export class RolesAdminService {
340
349
  );
341
350
  }
342
351
 
352
+ if (this.recoveryAdmins.length === 0) {
353
+ throw new RolesAdminError(
354
+ 'Recovery needs a configured admin (ADMIN_EMAIL) to restore — a roles.yaml ' +
355
+ 'with no Admin is exactly the unusable state recovery exists to escape.',
356
+ 500,
357
+ { kind: 'no-recovery-admins' },
358
+ );
359
+ }
360
+
343
361
  await this.assertRolesUnlocked(workspaceId, actor);
344
362
 
345
363
  // Back up the corrupted bytes and restore the good default atomically. The
@@ -349,7 +367,7 @@ export class RolesAdminService {
349
367
  fsys.writeFiles(
350
368
  [
351
369
  { path: `${this.kbDirName}/${OLD_ROLES_YAML}`, content: current },
352
- { path: `${this.kbDirName}/${ROLES_YAML}`, content: RECOVERY_DEFAULT_ROLES_YAML },
370
+ { path: `${this.kbDirName}/${ROLES_YAML}`, content: renderRecoveryRolesYaml(this.recoveryAdmins) },
353
371
  ],
354
372
  'Bevel recovery: reset corrupted roles.yaml',
355
373
  ),
@@ -18,7 +18,7 @@ let server: HttpServer | null = null;
18
18
  * that runs later in the same worker would otherwise see a different
19
19
  * environment than the one it was written against.
20
20
  */
21
- const KB_ENV = ['KB_REPO_URL', 'GIT_TOKEN', 'GIT_USERNAME', 'KB_DIR_NAME'] as const;
21
+ const KB_ENV = ['KB_REPO_URL', 'GIT_TOKEN', 'GIT_USERNAME', 'KB_DIR_NAME', 'GITHUB_TOKEN'] as const;
22
22
  let savedEnv: Partial<Record<(typeof KB_ENV)[number], string | undefined>> = {};
23
23
 
24
24
  beforeEach(() => {
@@ -40,7 +40,7 @@ afterEach(() => {
40
40
  });
41
41
 
42
42
  /** Mount the setup router on a throwaway port and hand back its base URL. */
43
- function listen(isAdmin = true) {
43
+ function listen(isAdmin = true, runAll: () => Promise<void> = async () => {}) {
44
44
  const db = {
45
45
  select: () => ({ from: () => Promise.resolve([]) }),
46
46
  insert: () => ({ values: () => ({ onConflictDoUpdate: () => Promise.resolve() }) }),
@@ -54,7 +54,16 @@ function listen(isAdmin = true) {
54
54
  req.userId = 'user-1';
55
55
  next();
56
56
  });
57
- app.use('/api', createSetupRoutes(settings, { isAdmin: async () => isAdmin } as IAdminAccessService));
57
+ app.use(
58
+ '/api',
59
+ createSetupRoutes(
60
+ settings,
61
+ { isAdmin: async () => isAdmin } as IAdminAccessService,
62
+ // Default no-op: most suites never complete setup, so the runner is
63
+ // never reached. The completion-transition suite passes its own spy.
64
+ { runAll },
65
+ ),
66
+ );
58
67
  server = app.listen(0);
59
68
  const { port } = server.address() as AddressInfo;
60
69
  return { base: `http://127.0.0.1:${port}`, settings };
@@ -182,6 +191,118 @@ describe('POST /setup/test-connection — the command git is given', () => {
182
191
  });
183
192
  });
184
193
 
194
+ /**
195
+ * The KB startup phase's SECOND quiet moment: the save that completes setup.
196
+ * Completion is a false→true TRANSITION (whichever field arrives last, on
197
+ * whichever save), and a failed setup-time run keeps the deployment gated —
198
+ * settings saved, KB uninitialized — until a retry succeeds.
199
+ *
200
+ * The branch model rides in from the environment the vitest config pins
201
+ * (DEFAULT_BRANCH / PROTECTED_BRANCHES) and is configured by test-setup, so
202
+ * completing setup here means storing the repository URL and token — exactly
203
+ * the "branch model configured on an earlier save" gap.
204
+ */
205
+ describe('POST /setup/settings — the completion transition and the KB startup phase', () => {
206
+ const completing = { kbRepoUrl: 'https://example.com/acme/kb.git', gitToken: 'ghp_x' };
207
+
208
+ it('runs the phase exactly once: on the save that completes setup, not before, not after', async () => {
209
+ let runs = 0;
210
+ const { base } = listen(true, async () => {
211
+ runs++;
212
+ });
213
+ // First save: an incomplete configuration — no phase.
214
+ let res = await post(base, '/api/setup/settings', { settings: { gitUsername: 'x-access-token' } });
215
+ expect(res.status).toBe(200);
216
+ expect(runs).toBe(0);
217
+ // Second save completes setup — the transition runs the phase.
218
+ res = await post(base, '/api/setup/settings', { settings: completing });
219
+ expect(res.status).toBe(200);
220
+ expect((await res.json()).complete).toBe(true);
221
+ expect(runs).toBe(1);
222
+ // A re-save of a complete, healthy setup is NOT a quiet moment.
223
+ res = await post(base, '/api/setup/settings', { settings: { gitUsername: 'x-access-token' } });
224
+ expect(res.status).toBe(200);
225
+ expect(runs).toBe(1);
226
+ });
227
+
228
+ it('keeps setup gated after a failed run; a re-save retries, and success clears the gate', async () => {
229
+ const consoleError = console.error;
230
+ console.error = () => {};
231
+ try {
232
+ let attempts = 0;
233
+ let fail = true;
234
+ const { base } = listen(true, async () => {
235
+ attempts++;
236
+ if (fail) throw new Error('remote said no');
237
+ });
238
+ const res = await post(base, '/api/setup/settings', { settings: completing });
239
+ expect(res.status).toBe(500);
240
+ expect((await res.json()).error).toMatch(/could not be initialized/i);
241
+ expect(attempts).toBe(1);
242
+ // The gate stays shut: status reports incomplete, with the admin's hint.
243
+ let status = await (await fetch(`${base}/api/setup/status`)).json();
244
+ expect(status.complete).toBe(false);
245
+ expect(status.kbInitError).toMatch(/remote said no/);
246
+ // Any save while the failure stands retries the phase.
247
+ fail = false;
248
+ const retry = await post(base, '/api/setup/settings', {
249
+ settings: { gitUsername: 'x-access-token' },
250
+ });
251
+ expect(retry.status).toBe(200);
252
+ expect(attempts).toBe(2);
253
+ expect((await retry.json()).complete).toBe(true);
254
+ status = await (await fetch(`${base}/api/setup/status`)).json();
255
+ expect(status.complete).toBe(true);
256
+ expect(status.kbInitError).toBeUndefined();
257
+ } finally {
258
+ console.error = consoleError;
259
+ }
260
+ });
261
+
262
+ it('keeps the gate shut while the phase runs, and a save landing mid-run waits the phase out', async () => {
263
+ let runs = 0;
264
+ let release!: () => void;
265
+ const running = new Promise<void>((r) => (release = r));
266
+ const { base } = listen(true, async () => {
267
+ runs++;
268
+ await running;
269
+ });
270
+ // The completing save blocks inside the phase...
271
+ const first = post(base, '/api/setup/settings', { settings: completing });
272
+ // release() in a finally: a mid-test assertion failure must still unblock
273
+ // the phase, or `first`/`second` hang pending until the vitest timeout.
274
+ let second!: Promise<Response>;
275
+ try {
276
+ await new Promise((r) => setTimeout(r, 50));
277
+ // ...during which the settings read complete but the GATE must not open —
278
+ // the phase is still mutating branch trees.
279
+ const status = await (await fetch(`${base}/api/setup/status`)).json();
280
+ expect(status.complete).toBe(false);
281
+ // A save landing mid-run is HELD until the phase settles: the runner
282
+ // reads its configuration through live getters, and a save applied under
283
+ // it would split the run across two configurations. It must neither
284
+ // resolve early nor start a second run over the same trees.
285
+ let secondSettled = false;
286
+ second = post(base, '/api/setup/settings', {
287
+ settings: { gitUsername: 'x-access-token' },
288
+ }).then((r) => {
289
+ secondSettled = true;
290
+ return r;
291
+ });
292
+ await new Promise((r) => setTimeout(r, 100));
293
+ expect(secondSettled).toBe(false);
294
+ expect(runs).toBe(1);
295
+ } finally {
296
+ release();
297
+ }
298
+ const [res1, res2] = await Promise.all([first, second]);
299
+ expect(res1.status).toBe(200);
300
+ expect(res2.status).toBe(200);
301
+ expect(runs).toBe(1);
302
+ expect((await (await fetch(`${base}/api/setup/status`)).json()).complete).toBe(true);
303
+ });
304
+ });
305
+
185
306
  describe('GET /setup/status', () => {
186
307
  it('tells a non-admin whether setup is done, and nothing else', async () => {
187
308
  const { base } = listen(false);
@@ -30,9 +30,40 @@ const execFileAsync = promisify(execFile);
30
30
  export function createSetupRoutes(
31
31
  settings: DeploymentSettingsService,
32
32
  adminAccess: IAdminAccessService,
33
+ /**
34
+ * The KB startup phase, invoked at its SECOND quiet moment: the save that
35
+ * completes first-time setup. The app is gated shut until exactly then
36
+ * (`isComplete`), so no session can be holding a working clone the phase
37
+ * would race.
38
+ */
39
+ kbStartupRunner: { runAll(): Promise<void> },
33
40
  ): express.Router {
34
41
  const router = express.Router();
35
42
 
43
+ /**
44
+ * Whether the last setup-time run of the KB startup phase FAILED. While
45
+ * true the deployment stays GATED: the settings are saved but the KB was
46
+ * never initialized, and reporting setup complete would open the app over
47
+ * an unmaintained (possibly unseeded) knowledge base. Saving the setup
48
+ * form again retries the phase; a server restart retries it at boot; a
49
+ * success clears the flag. Per-process state, like the gate itself.
50
+ */
51
+ let kbInitFailed = false;
52
+ /** The failure's message, surfaced to the ADMIN on the status endpoint. */
53
+ let kbInitError: string | null = null;
54
+ /**
55
+ * The setup-time run currently executing, if any. Its jobs: (a) keeping the
56
+ * status gate SHUT while a run executes (the settings read complete the
57
+ * moment they save, but the phase is still mutating branch trees — no
58
+ * workspace request may start yet), and (b) defense in depth via the `??=`
59
+ * at the run site, should a second invoker ever appear. It is NOT what
60
+ * serializes saves — the `saveTurn` chain below runs handlers strictly one
61
+ * at a time, phase included, so no second run can start while one executes.
62
+ */
63
+ let kbInitInFlight: Promise<void> | null = null;
64
+ /** The app-gate answer: settings complete AND the KB phase settled clean. */
65
+ const kbReady = () => isComplete(settings) && !kbInitFailed && kbInitInFlight === null;
66
+
36
67
  const requireAdmin: express.RequestHandler = async (req, res, next) => {
37
68
  if (!(await adminAccess.isAdmin(req.userEmail))) {
38
69
  res.status(403).json({ error: 'Admins only' });
@@ -51,25 +82,51 @@ export function createSetupRoutes(
51
82
  * secret's value.
52
83
  */
53
84
  router.get('/setup/status', async (req, res) => {
54
- const complete = isComplete(settings);
85
+ // A failed OR still-running setup-time KB initialization keeps setup
86
+ // INCOMPLETE: the frontend gates the app on this answer, and opening it
87
+ // over an uninitialized (or mid-mutation) KB would be worse than keeping
88
+ // the setup screen up.
89
+ //
90
+ // `kbReady()` is read fresh AFTER the admin check, immediately before
91
+ // building each response: the check awaits, a completion save can start
92
+ // the phase during that await, and a snapshot taken before it would
93
+ // resurrect a pre-phase `true` — opening the gate mid-mutation.
55
94
  if (!(await adminAccess.isAdmin(req.userEmail))) {
56
- res.json({ complete, isAdmin: false });
95
+ res.json({ complete: kbReady(), isAdmin: false });
57
96
  return;
58
97
  }
59
98
  res.json({
60
- complete,
99
+ complete: kbReady(),
61
100
  awaitingRestart: awaitingRestart(settings),
62
101
  isAdmin: true,
63
102
  settings: settings.describe(),
103
+ ...(kbInitFailed ? { kbInitError } : {}),
64
104
  });
65
105
  });
66
106
 
107
+ /**
108
+ * Setup saves run strictly ONE AT A TIME, phase included: `settings.save`
109
+ * updates the live cache write by write, and the completing save runs the
110
+ * KB startup phase, which reads its configuration through live getters —
111
+ * a save interleaving with either would hand half-updated state to the
112
+ * other. A plain promise chain is enough: saves are a setup-screen rarity,
113
+ * not a hot path, and a save arriving mid-run simply waits the previous
114
+ * save (and its phase) out before proceeding.
115
+ */
116
+ let saveTurn: Promise<unknown> = Promise.resolve();
117
+
67
118
  /**
68
119
  * Save settings. Validated and written as ONE batch — a repository URL
69
120
  * stored without the token that reads it is a deployment that fails at its
70
121
  * first clone, so a partial write is never better than none.
71
122
  */
72
123
  router.post('/setup/settings', requireAdmin, async (req, res) => {
124
+ const turn = saveTurn.then(() => handleSave(req, res));
125
+ saveTurn = turn.catch(() => {});
126
+ await turn;
127
+ });
128
+
129
+ async function handleSave(req: express.Request, res: express.Response): Promise<void> {
73
130
  const body = (req.body ?? {}) as { settings?: Record<string, unknown> };
74
131
  const entries: Record<string, string> = {};
75
132
  for (const [key, value] of Object.entries(body.settings ?? {})) {
@@ -80,6 +137,11 @@ export function createSetupRoutes(
80
137
  entries[key] = value;
81
138
  }
82
139
  try {
140
+ // Completion is a TRANSITION, so it is measured BEFORE the save: the
141
+ // save that flips it false→true — whichever field arrives last — is the
142
+ // one that must run the KB startup phase, regardless of which save
143
+ // configured the branch model.
144
+ const wasComplete = isComplete(settings);
83
145
  const { restartRequired } = await settings.save(entries, req.userId ?? null);
84
146
  /**
85
147
  * Apply the branch model to THIS process, so pressing Save finishes
@@ -102,10 +164,55 @@ export function createSetupRoutes(
102
164
  };
103
165
  if (!validateBranchModel(model)) configureBranchModel(model);
104
166
  }
167
+ /**
168
+ * The save that COMPLETES setup is the KB startup phase's SECOND quiet
169
+ * moment (the other is boot): the app was gated shut until this very
170
+ * response, so the trees are provably quiet. Run the phase now —
171
+ * seeding the remote, scaffolding and migrating every branch — so the
172
+ * first workspace request that follows finds a maintained KB.
173
+ *
174
+ * Runs on the false→true completion transition — including when the
175
+ * branch model was configured by an EARLIER save and the repository
176
+ * URL arrives on a later one — and again on any save while a previous
177
+ * setup-time run stands failed (`kbInitFailed`), so saving the form is
178
+ * the retry. Never on a re-save of a complete, healthy setup: with the
179
+ * gate open, sessions may be live and that is no longer a quiet moment.
180
+ */
181
+ if ((!wasComplete || kbInitFailed) && isComplete(settings)) {
182
+ try {
183
+ // One run at a time. The save chain already serializes handlers
184
+ // whole, so no second run can start while one executes; the `??=`
185
+ // is defense in depth should another invoker ever appear.
186
+ kbInitInFlight ??= kbStartupRunner.runAll();
187
+ await kbInitInFlight;
188
+ // Under KB_SAFE_BOOT a run that abandoned the phase still resolves
189
+ // and opens the gate DELIBERATELY — booting unmaintained so the
190
+ // operator can get in and fix things is exactly what the
191
+ // break-glass is for.
192
+ kbInitFailed = false;
193
+ kbInitError = null;
194
+ } catch (initErr) {
195
+ // The settings ARE saved — only the KB initialization failed. The
196
+ // deployment stays gated (see the status endpoint) until a retry
197
+ // succeeds. Logged in full, returned actionable.
198
+ const msg = initErr instanceof Error ? initErr.message : String(initErr);
199
+ console.error('[setup] KB initialization failed after setup completed:', msg);
200
+ kbInitFailed = true;
201
+ kbInitError = msg;
202
+ res.status(500).json({
203
+ error:
204
+ 'Settings saved, but the knowledge base could not be initialized. ' +
205
+ 'Saving the setup form again retries; restarting the server retries too.',
206
+ });
207
+ return;
208
+ } finally {
209
+ kbInitInFlight = null;
210
+ }
211
+ }
105
212
  res.json({
106
213
  ok: true,
107
214
  restartRequired,
108
- complete: isComplete(settings),
215
+ complete: kbReady(),
109
216
  awaitingRestart: awaitingRestart(settings),
110
217
  settings: settings.describe(),
111
218
  });
@@ -119,7 +226,7 @@ export function createSetupRoutes(
119
226
  console.error('[setup] save failed:', err instanceof Error ? err.message : String(err));
120
227
  res.status(500).json({ error: 'Could not save these settings.' });
121
228
  }
122
- });
229
+ }
123
230
 
124
231
  /**
125
232
  * Try the credentials against the real remote, BEFORE anything is saved.
@@ -435,32 +435,9 @@ describe('WorkspaceService — clone bootstrap & sibling reference', () => {
435
435
  await svc.getOrCreateForBranch('alice/draft');
436
436
  expect(cloned).toEqual([workspaceIdForBranch('alice/draft')]);
437
437
  });
438
-
439
- // An upgraded deployment REUSES the persistent clone, so a top-up bound to
440
- // fresh clones alone never runs a new build's scaffolding or migrations —
441
- // the Groups→Plugins rename sat out an upgrade exactly this way. Every boot
442
- // must offer the top-up to an existing clone once.
443
- it('offers the scaffolding top-up to an existing clone once per process', async () => {
444
- const seed = {
445
- ensureRemoteSeeded: vi.fn(async () => {}),
446
- topUpWorkspace: vi.fn(async () => {}),
447
- };
448
- const firstBoot = new WorkspaceService(workspacesRoot, upstream, 'knowledge-base');
449
- firstBoot.setSeedService(seed);
450
- await firstBoot.getOrCreateForBranch('target-company-state');
451
- expect(seed.topUpWorkspace).toHaveBeenCalledTimes(1); // the fresh clone
452
-
453
- // A new process over the same workspaces dir — the deployed-upgrade case:
454
- // the clone exists, and the top-up must still run, once, not per access.
455
- const nextBoot = new WorkspaceService(workspacesRoot, upstream, 'knowledge-base');
456
- nextBoot.setSeedService(seed);
457
- await nextBoot.getOrCreateForBranch('target-company-state');
458
- await nextBoot.getOrCreateForBranch('target-company-state');
459
- expect(seed.topUpWorkspace).toHaveBeenCalledTimes(2);
460
- });
461
438
  });
462
439
 
463
- describe('WorkspaceService — scaffolding top-up on restart-survivor clones', () => {
440
+ describe('WorkspaceService.sweepOrphanedWorkspaces', () => {
464
441
  let root: string;
465
442
 
466
443
  beforeEach(async () => {
@@ -475,81 +452,15 @@ describe('WorkspaceService — scaffolding top-up on restart-survivor clones', (
475
452
  await fs.rm(root, { recursive: true, force: true });
476
453
  });
477
454
 
478
- // The top-up MOVES files (the Groups→Plugins migration runs inside it), so a
479
- // caller handed the workspace mid-run reads a tree with both halves missing.
480
- it('makes a concurrent opener wait out the in-flight top-up instead of returning mid-migration', async () => {
481
- await seedBranchWorkspace(root, 'target-company-state');
482
- const svc = new WorkspaceService(root, 'https://github.com/Bevel-Software/knowledge-base.git', 'knowledge-base');
483
- let release!: () => void;
484
- const gate = new Promise<void>((r) => { release = r; });
485
- const seed = {
486
- ensureRemoteSeeded: vi.fn(async () => {}),
487
- topUpWorkspace: vi.fn(() => gate),
488
- };
489
- svc.setSeedService(seed);
490
-
491
- let aDone = false;
492
- let bDone = false;
493
- const a = svc.getOrCreateForBranch('target-company-state').then((v) => { aDone = true; return v; });
494
- const b = svc.getOrCreateForBranch('target-company-state').then((v) => { bDone = true; return v; });
495
- // Wait until the top-up has started (the fake clone makes the preceding
496
- // git-config repair fail slowly, so poll rather than sleep), then give
497
- // both callers time to settle against the gate.
498
- await vi.waitFor(() => expect(seed.topUpWorkspace).toHaveBeenCalledTimes(1));
499
- await new Promise((r) => setTimeout(r, 30));
500
- // Neither caller has been handed the workspace while the top-up runs —
501
- // and the second did not start a rival run.
502
- expect(aDone).toBe(false);
503
- expect(bDone).toBe(false);
504
- expect(seed.topUpWorkspace).toHaveBeenCalledTimes(1);
505
-
506
- release();
507
- const [infoA, infoB] = await Promise.all([a, b]);
508
- expect(infoA.id).toBe(infoB.id);
509
- });
510
-
511
- it('re-offers the top-up after deleteWorkspace — the claim died with the clone', async () => {
512
- await seedBranchWorkspace(root, 'target-company-state');
513
- const svc = new WorkspaceService(root, 'https://github.com/Bevel-Software/knowledge-base.git', 'knowledge-base');
514
- const seed = {
515
- ensureRemoteSeeded: vi.fn(async () => {}),
516
- topUpWorkspace: vi.fn(async () => {}),
517
- };
518
- svc.setSeedService(seed);
519
-
520
- await svc.getOrCreateForBranch('target-company-state');
521
- expect(seed.topUpWorkspace).toHaveBeenCalledTimes(1);
522
-
523
- await svc.deleteWorkspace(workspaceIdForBranch('target-company-state'));
524
- // A later bootstrap re-creates the clone (seeded by hand here); it must
525
- // be offered the top-up afresh — the old claim was about a clone that no
526
- // longer exists.
527
- await seedBranchWorkspace(root, 'target-company-state');
528
- await svc.getOrCreateForBranch('target-company-state');
529
- expect(seed.topUpWorkspace).toHaveBeenCalledTimes(2);
530
- });
531
-
532
- it('re-offers the top-up after the orphan sweep removes the clone — same eviction rule', async () => {
533
- await seedBranchWorkspace(root, 'target-company-state');
455
+ it('removes the clone of a branch that is not in the known set', async () => {
456
+ const { workspaceId, workspaceDir } = await seedBranchWorkspace(root, 'target-company-state');
534
457
  const svc = new WorkspaceService(root, 'https://github.com/Bevel-Software/knowledge-base.git', 'knowledge-base');
535
- const seed = {
536
- ensureRemoteSeeded: vi.fn(async () => {}),
537
- topUpWorkspace: vi.fn(async () => {}),
538
- };
539
- svc.setSeedService(seed);
540
-
541
458
  await svc.getOrCreateForBranch('target-company-state');
542
- expect(seed.topUpWorkspace).toHaveBeenCalledTimes(1);
543
459
 
544
460
  // The branch vanishes from the known set; the sweep reclaims its clone.
545
461
  const { removed } = await svc.sweepOrphanedWorkspaces([]);
546
- expect(removed).toContain(workspaceIdForBranch('target-company-state'));
547
-
548
- // Re-created before any restart: the fresh clone must still get its one
549
- // top-up — the claim died with the directory the sweep removed.
550
- await seedBranchWorkspace(root, 'target-company-state');
551
- await svc.getOrCreateForBranch('target-company-state');
552
- expect(seed.topUpWorkspace).toHaveBeenCalledTimes(2);
462
+ expect(removed).toContain(workspaceId);
463
+ await expect(fs.stat(workspaceDir)).rejects.toThrow();
553
464
  });
554
465
  });
555
466