@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.
- package/dist/core/core-ports.d.ts +7 -0
- package/dist/core/core-ports.d.ts.map +1 -1
- package/dist/core/core-ports.js.map +1 -1
- package/dist/core/create-core-server.d.ts.map +1 -1
- package/dist/core/create-core-server.js +24 -11
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/core/create-core-services.d.ts +7 -2
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js +39 -18
- package/dist/core/create-core-services.js.map +1 -1
- package/dist/modules/access/access.routes.d.ts +3 -1
- package/dist/modules/access/access.routes.d.ts.map +1 -1
- package/dist/modules/access/access.routes.js +4 -2
- package/dist/modules/access/access.routes.js.map +1 -1
- package/dist/modules/access/render-roles-yaml.d.ts +22 -0
- package/dist/modules/access/render-roles-yaml.d.ts.map +1 -0
- package/dist/modules/access/render-roles-yaml.js +56 -0
- package/dist/modules/access/render-roles-yaml.js.map +1 -0
- package/dist/modules/access/roles-admin.service.d.ts +15 -1
- package/dist/modules/access/roles-admin.service.d.ts.map +1 -1
- package/dist/modules/access/roles-admin.service.js +30 -15
- package/dist/modules/access/roles-admin.service.js.map +1 -1
- package/dist/modules/settings/setup.routes.d.ts +10 -1
- package/dist/modules/settings/setup.routes.d.ts.map +1 -1
- package/dist/modules/settings/setup.routes.js +111 -6
- package/dist/modules/settings/setup.routes.js.map +1 -1
- package/dist/modules/workspace/startup/kb-git.d.ts +23 -0
- package/dist/modules/workspace/startup/kb-git.d.ts.map +1 -0
- package/dist/modules/workspace/startup/kb-git.js +86 -0
- package/dist/modules/workspace/startup/kb-git.js.map +1 -0
- package/dist/modules/workspace/startup/kb-startup-runner.d.ts +74 -0
- package/dist/modules/workspace/startup/kb-startup-runner.d.ts.map +1 -0
- package/dist/modules/workspace/startup/kb-startup-runner.js +528 -0
- package/dist/modules/workspace/startup/kb-startup-runner.js.map +1 -0
- package/dist/modules/workspace/startup/on-server-start.d.ts +105 -0
- package/dist/modules/workspace/startup/on-server-start.d.ts.map +1 -0
- package/dist/modules/workspace/startup/on-server-start.js +21 -0
- package/dist/modules/workspace/startup/on-server-start.js.map +1 -0
- package/dist/modules/workspace/startup/steps/groups-to-plugins.step.d.ts +46 -0
- package/dist/modules/workspace/startup/steps/groups-to-plugins.step.d.ts.map +1 -0
- package/dist/modules/workspace/startup/steps/groups-to-plugins.step.js +492 -0
- package/dist/modules/workspace/startup/steps/groups-to-plugins.step.js.map +1 -0
- package/dist/modules/workspace/startup/steps/roles-yaml.step.d.ts +23 -0
- package/dist/modules/workspace/startup/steps/roles-yaml.step.d.ts.map +1 -0
- package/dist/modules/workspace/startup/steps/roles-yaml.step.js +69 -0
- package/dist/modules/workspace/startup/steps/roles-yaml.step.js.map +1 -0
- package/dist/modules/workspace/startup/steps/seed-tree.d.ts +17 -0
- package/dist/modules/workspace/startup/steps/seed-tree.d.ts.map +1 -0
- package/dist/modules/workspace/startup/steps/seed-tree.js +109 -0
- package/dist/modules/workspace/startup/steps/seed-tree.js.map +1 -0
- package/dist/modules/workspace/startup/steps/template-files.step.d.ts +103 -0
- package/dist/modules/workspace/startup/steps/template-files.step.d.ts.map +1 -0
- package/dist/modules/workspace/startup/steps/template-files.step.js +337 -0
- package/dist/modules/workspace/startup/steps/template-files.step.js.map +1 -0
- package/dist/modules/workspace/workspace.service.d.ts +0 -35
- package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.service.js +2 -101
- package/dist/modules/workspace/workspace.service.js.map +1 -1
- package/kb-template/gitignore.template +16 -0
- package/package.json +3 -3
- package/src/core/core-ports.ts +7 -0
- package/src/core/create-core-server.ts +30 -11
- package/src/core/create-core-services.ts +43 -22
- package/src/modules/access/__tests__/roles-admin.service.test.ts +24 -2
- package/src/modules/access/access.routes.ts +3 -0
- package/src/modules/access/render-roles-yaml.ts +65 -0
- package/src/modules/access/roles-admin.service.ts +32 -14
- package/src/modules/settings/__tests__/setup.routes.test.ts +124 -3
- package/src/modules/settings/setup.routes.ts +112 -5
- package/src/modules/workspace/__tests__/workspace.service.test.ts +5 -94
- package/src/modules/workspace/startup/__tests__/kb-startup-runner.test.ts +495 -0
- package/src/modules/workspace/startup/kb-git.ts +94 -0
- package/src/modules/workspace/startup/kb-startup-runner.ts +597 -0
- package/src/modules/workspace/startup/on-server-start.ts +97 -0
- package/src/modules/workspace/startup/steps/__tests__/steps.test.ts +636 -0
- package/src/modules/workspace/{plugins-migration.ts → startup/steps/groups-to-plugins.step.ts} +342 -260
- package/src/modules/workspace/startup/steps/roles-yaml.step.ts +71 -0
- package/src/modules/workspace/startup/steps/seed-tree.ts +115 -0
- package/src/modules/workspace/startup/steps/template-files.step.ts +360 -0
- package/src/modules/workspace/workspace.service.ts +2 -106
- package/src/modules/workspace/__tests__/kb-seed.service.test.ts +0 -512
- package/src/modules/workspace/__tests__/plugins-migration.test.ts +0 -427
- package/src/modules/workspace/kb-seed.interface.ts +0 -36
- 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
|
-
|
|
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
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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:
|
|
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(
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
479
|
-
|
|
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(
|
|
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
|
|