@bevel-software/platform-core-backend 0.4.0 → 0.5.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 +15 -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 +40 -6
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/core/create-core-services.d.ts +17 -1
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js +153 -29
- package/dist/core/create-core-services.js.map +1 -1
- package/dist/core-config.d.ts +70 -21
- package/dist/core-config.d.ts.map +1 -1
- package/dist/core-config.js +134 -58
- package/dist/core-config.js.map +1 -1
- package/dist/modules/access/access-control.interface.d.ts +6 -0
- package/dist/modules/access/access-control.interface.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.d.ts +74 -5
- package/dist/modules/access/access-control.service.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.js +116 -15
- package/dist/modules/access/access-control.service.js.map +1 -1
- package/dist/modules/access/access-declarations.d.ts +93 -0
- package/dist/modules/access/access-declarations.d.ts.map +1 -0
- package/dist/modules/access/access-declarations.js +168 -0
- package/dist/modules/access/access-declarations.js.map +1 -0
- package/dist/modules/access/access-mutation.service.js +4 -4
- package/dist/modules/access/access-mutation.service.js.map +1 -1
- package/dist/modules/access/access-splice.d.ts +16 -1
- package/dist/modules/access/access-splice.d.ts.map +1 -1
- package/dist/modules/access/access-splice.js +40 -21
- package/dist/modules/access/access-splice.js.map +1 -1
- package/dist/modules/access/access.routes.d.ts.map +1 -1
- package/dist/modules/access/access.routes.js +51 -0
- package/dist/modules/access/access.routes.js.map +1 -1
- package/dist/modules/access/creator-access.d.ts +8 -0
- package/dist/modules/access/creator-access.d.ts.map +1 -1
- package/dist/modules/access/creator-access.js +21 -2
- package/dist/modules/access/creator-access.js.map +1 -1
- package/dist/modules/admin/admin-access.service.d.ts +1 -1
- package/dist/modules/admin/admin-access.service.d.ts.map +1 -1
- package/dist/modules/admin/admin-access.service.js +3 -2
- package/dist/modules/admin/admin-access.service.js.map +1 -1
- package/dist/modules/auth/auth.routes.d.ts.map +1 -1
- package/dist/modules/auth/auth.routes.js +50 -1
- package/dist/modules/auth/auth.routes.js.map +1 -1
- package/dist/modules/auth/auth.service.d.ts +39 -10
- package/dist/modules/auth/auth.service.d.ts.map +1 -1
- package/dist/modules/auth/auth.service.js +38 -16
- package/dist/modules/auth/auth.service.js.map +1 -1
- package/dist/modules/database/core-schema.d.ts +124 -0
- package/dist/modules/database/core-schema.d.ts.map +1 -1
- package/dist/modules/database/core-schema.js +45 -0
- package/dist/modules/database/core-schema.js.map +1 -1
- package/dist/modules/groups/group-provision.service.d.ts +130 -0
- package/dist/modules/groups/group-provision.service.d.ts.map +1 -0
- package/dist/modules/groups/group-provision.service.js +288 -0
- package/dist/modules/groups/group-provision.service.js.map +1 -0
- package/dist/modules/groups/groups.contract.d.ts +106 -0
- package/dist/modules/groups/groups.contract.d.ts.map +1 -0
- package/dist/modules/groups/groups.contract.js +36 -0
- package/dist/modules/groups/groups.contract.js.map +1 -0
- package/dist/modules/groups/groups.routes.d.ts +42 -0
- package/dist/modules/groups/groups.routes.d.ts.map +1 -0
- package/dist/modules/groups/groups.routes.js +379 -0
- package/dist/modules/groups/groups.routes.js.map +1 -0
- package/dist/modules/groups/groups.service.d.ts +60 -0
- package/dist/modules/groups/groups.service.d.ts.map +1 -0
- package/dist/modules/groups/groups.service.js +172 -0
- package/dist/modules/groups/groups.service.js.map +1 -0
- package/dist/modules/groups/index.d.ts +7 -0
- package/dist/modules/groups/index.d.ts.map +1 -0
- package/dist/modules/groups/index.js +6 -0
- package/dist/modules/groups/index.js.map +1 -0
- package/dist/modules/groups/join-proposals.d.ts +53 -0
- package/dist/modules/groups/join-proposals.d.ts.map +1 -0
- package/dist/modules/groups/join-proposals.js +67 -0
- package/dist/modules/groups/join-proposals.js.map +1 -0
- package/dist/modules/groups/join-requests.service.d.ts +81 -0
- package/dist/modules/groups/join-requests.service.d.ts.map +1 -0
- package/dist/modules/groups/join-requests.service.js +135 -0
- package/dist/modules/groups/join-requests.service.js.map +1 -0
- package/dist/modules/secrets-vault/secrets-vault.routes.d.ts +20 -0
- package/dist/modules/secrets-vault/secrets-vault.routes.d.ts.map +1 -1
- package/dist/modules/secrets-vault/secrets-vault.routes.js +59 -11
- package/dist/modules/secrets-vault/secrets-vault.routes.js.map +1 -1
- package/dist/modules/settings/deployment-settings.service.d.ts +129 -0
- package/dist/modules/settings/deployment-settings.service.d.ts.map +1 -0
- package/dist/modules/settings/deployment-settings.service.js +368 -0
- package/dist/modules/settings/deployment-settings.service.js.map +1 -0
- package/dist/modules/settings/setup.routes.d.ts +49 -0
- package/dist/modules/settings/setup.routes.d.ts.map +1 -0
- package/dist/modules/settings/setup.routes.js +292 -0
- package/dist/modules/settings/setup.routes.js.map +1 -0
- package/dist/modules/skills/index.d.ts +2 -1
- package/dist/modules/skills/index.d.ts.map +1 -1
- package/dist/modules/skills/index.js +1 -0
- package/dist/modules/skills/index.js.map +1 -1
- package/dist/modules/skills/pending-skills.service.d.ts +40 -0
- package/dist/modules/skills/pending-skills.service.d.ts.map +1 -0
- package/dist/modules/skills/pending-skills.service.js +147 -0
- package/dist/modules/skills/pending-skills.service.js.map +1 -0
- package/dist/modules/skills/skills.contract.d.ts +32 -3
- package/dist/modules/skills/skills.contract.d.ts.map +1 -1
- package/dist/modules/skills/skills.contract.js +1 -1
- package/dist/modules/skills/skills.routes.d.ts +3 -2
- package/dist/modules/skills/skills.routes.d.ts.map +1 -1
- package/dist/modules/skills/skills.routes.js +18 -1
- package/dist/modules/skills/skills.routes.js.map +1 -1
- package/dist/modules/skills/skills.service.d.ts +22 -0
- package/dist/modules/skills/skills.service.d.ts.map +1 -1
- package/dist/modules/skills/skills.service.js +24 -12
- package/dist/modules/skills/skills.service.js.map +1 -1
- package/dist/modules/tool-manuals/index.d.ts +1 -1
- package/dist/modules/tool-manuals/index.d.ts.map +1 -1
- package/dist/modules/tool-manuals/index.js.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.contract.d.ts +47 -2
- package/dist/modules/tool-manuals/tool-manuals.contract.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.contract.js +1 -1
- package/dist/modules/tool-manuals/tool-manuals.routes.d.ts +4 -0
- package/dist/modules/tool-manuals/tool-manuals.routes.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.routes.js +23 -0
- package/dist/modules/tool-manuals/tool-manuals.routes.js.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.service.d.ts +3 -1
- package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.service.js +84 -17
- package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.tools.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.tools.js +6 -2
- package/dist/modules/tool-manuals/tool-manuals.tools.js.map +1 -1
- package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
- package/dist/modules/workflow/agent-tools/workflow.tools.js +4 -2
- package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
- package/dist/modules/workflow/event-bus.d.ts +11 -0
- package/dist/modules/workflow/event-bus.d.ts.map +1 -1
- package/dist/modules/workflow/event-bus.js +22 -0
- package/dist/modules/workflow/event-bus.js.map +1 -1
- package/dist/modules/workflow/file-lock.service.d.ts +9 -0
- package/dist/modules/workflow/file-lock.service.d.ts.map +1 -1
- package/dist/modules/workflow/file-lock.service.js +16 -0
- package/dist/modules/workflow/file-lock.service.js.map +1 -1
- package/dist/modules/workflow/git/branch-name.d.ts +1 -1
- package/dist/modules/workflow/git/branch-name.d.ts.map +1 -1
- package/dist/modules/workflow/git/branch-name.js +1 -1
- package/dist/modules/workflow/git/branch-name.js.map +1 -1
- package/dist/modules/workflow/git/git.service.d.ts +31 -2
- package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/git.service.js +157 -54
- package/dist/modules/workflow/git/git.service.js.map +1 -1
- package/dist/modules/workflow/git/pull-request.service.d.ts +12 -6
- package/dist/modules/workflow/git/pull-request.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/pull-request.service.js +19 -9
- package/dist/modules/workflow/git/pull-request.service.js.map +1 -1
- package/dist/modules/workflow/review-workflow/review-workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/review-workflow/review-workflow.service.js +12 -3
- package/dist/modules/workflow/review-workflow/review-workflow.service.js.map +1 -1
- package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.routes.js +95 -14
- package/dist/modules/workflow/workflow.routes.js.map +1 -1
- package/dist/modules/workflow/workflow.service.d.ts +97 -5
- package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.service.js +393 -114
- package/dist/modules/workflow/workflow.service.js.map +1 -1
- package/dist/modules/workspace/bevel-ignore.d.ts +5 -0
- package/dist/modules/workspace/bevel-ignore.d.ts.map +1 -1
- package/dist/modules/workspace/bevel-ignore.js +5 -1
- package/dist/modules/workspace/bevel-ignore.js.map +1 -1
- package/dist/modules/workspace/kb-seed.interface.d.ts +1 -1
- package/dist/modules/workspace/kb-seed.service.d.ts +60 -5
- package/dist/modules/workspace/kb-seed.service.d.ts.map +1 -1
- package/dist/modules/workspace/kb-seed.service.js +228 -51
- package/dist/modules/workspace/kb-seed.service.js.map +1 -1
- package/dist/modules/workspace/workspace.routes.d.ts +2 -1
- package/dist/modules/workspace/workspace.routes.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.routes.js +51 -18
- package/dist/modules/workspace/workspace.routes.js.map +1 -1
- package/dist/modules/workspace/workspace.service.d.ts +16 -4
- package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.service.js +36 -9
- package/dist/modules/workspace/workspace.service.js.map +1 -1
- package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.tools.js +8 -3
- package/dist/modules/workspace/workspace.tools.js.map +1 -1
- package/dist/shared/kb-layout.test.js +2 -2
- package/dist/test-setup.d.ts +2 -0
- package/dist/test-setup.d.ts.map +1 -0
- package/dist/test-setup.js +18 -0
- package/dist/test-setup.js.map +1 -0
- package/kb-template/.bevelignore +6 -2
- package/kb-template/AGENTS.md +272 -0
- package/kb-template/KnowledgeBase/How to get started.md +65 -0
- package/kb-template/access.md +36 -36
- package/migrations/0002_user_onboarding_done.sql +1 -0
- package/migrations/0003_deployment_settings.sql +9 -0
- package/migrations/meta/0001_snapshot.json +1486 -0
- package/migrations/meta/0002_snapshot.json +1493 -0
- package/migrations/meta/0003_snapshot.json +1552 -0
- package/migrations/meta/_journal.json +15 -1
- package/package.json +3 -3
- package/src/__tests__/branch-model.test.ts +79 -0
- package/src/__tests__/core-config.admin.test.ts +114 -0
- package/src/__tests__/database-url.test.ts +70 -0
- package/src/core/core-ports.ts +15 -0
- package/src/core/create-core-server.ts +55 -6
- package/src/core/create-core-services.ts +201 -36
- package/src/core-config.ts +139 -59
- package/src/modules/access/__tests__/access-declarations.test.ts +195 -0
- package/src/modules/access/__tests__/access-md-format.test.ts +191 -0
- package/src/modules/access/__tests__/access-splice.test.ts +21 -0
- package/src/modules/access/__tests__/access.routes.overrides.test.ts +214 -0
- package/src/modules/access/access-control.interface.ts +11 -0
- package/src/modules/access/access-control.service.ts +113 -15
- package/src/modules/access/access-declarations.ts +251 -0
- package/src/modules/access/access-mutation.service.ts +4 -4
- package/src/modules/access/access-splice.ts +64 -21
- package/src/modules/access/access.routes.ts +61 -0
- package/src/modules/access/creator-access.ts +22 -2
- package/src/modules/admin/admin-access.service.ts +4 -3
- package/src/modules/auth/__tests__/auth.service.test.ts +57 -0
- package/src/modules/auth/__tests__/onboarding-done.route.test.ts +147 -0
- package/src/modules/auth/auth.routes.ts +50 -1
- package/src/modules/auth/auth.service.ts +55 -17
- package/src/modules/database/core-schema.ts +46 -0
- package/src/modules/groups/__tests__/group-index.service.test.ts +225 -0
- package/src/modules/groups/__tests__/group-provision.service.test.ts +247 -0
- package/src/modules/groups/__tests__/groups.routes.test.ts +493 -0
- package/src/modules/groups/__tests__/join-proposals.test.ts +101 -0
- package/src/modules/groups/__tests__/join-requests.service.test.ts +175 -0
- package/src/modules/groups/group-provision.service.ts +328 -0
- package/src/modules/groups/groups.contract.ts +107 -0
- package/src/modules/groups/groups.routes.ts +433 -0
- package/src/modules/groups/groups.service.ts +193 -0
- package/src/modules/groups/index.ts +17 -0
- package/src/modules/groups/join-proposals.ts +114 -0
- package/src/modules/groups/join-requests.service.ts +185 -0
- package/src/modules/secrets-vault/__tests__/oauth-return-to.route.test.ts +283 -0
- package/src/modules/secrets-vault/secrets-vault.routes.ts +68 -12
- package/src/modules/settings/__tests__/deployment-settings.service.test.ts +256 -0
- package/src/modules/settings/__tests__/setup.routes.test.ts +201 -0
- package/src/modules/settings/deployment-settings.service.ts +419 -0
- package/src/modules/settings/setup.routes.ts +317 -0
- package/src/modules/skills/__tests__/pending-skills.service.test.ts +240 -0
- package/src/modules/skills/__tests__/skills.service.test.ts +13 -13
- package/src/modules/skills/index.ts +3 -0
- package/src/modules/skills/pending-skills.service.ts +168 -0
- package/src/modules/skills/skills.contract.ts +34 -3
- package/src/modules/skills/skills.routes.ts +23 -2
- package/src/modules/skills/skills.service.ts +24 -16
- package/src/modules/tool-manuals/__tests__/tool-manuals.detail.route.test.ts +262 -0
- package/src/modules/tool-manuals/__tests__/tool-manuals.mcp-oauth.test.ts +2 -2
- package/src/modules/tool-manuals/__tests__/tool-manuals.service.test.ts +61 -23
- package/src/modules/tool-manuals/__tests__/tool-manuals.tools.test.ts +156 -156
- package/src/modules/tool-manuals/index.ts +2 -0
- package/src/modules/tool-manuals/tool-manuals.contract.ts +50 -2
- package/src/modules/tool-manuals/tool-manuals.routes.ts +21 -0
- package/src/modules/tool-manuals/tool-manuals.service.ts +86 -17
- package/src/modules/tool-manuals/tool-manuals.tools.ts +206 -202
- package/src/modules/workflow/__tests__/event-bus.test.ts +35 -0
- package/src/modules/workflow/__tests__/pending-commits.enqueue-if-absent.test.ts +82 -72
- package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +295 -5
- package/src/modules/workflow/agent-tools/workflow.tools.ts +4 -2
- package/src/modules/workflow/event-bus.ts +25 -0
- package/src/modules/workflow/file-lock.service.ts +17 -0
- package/src/modules/workflow/git/__tests__/git.service.accessGating.test.ts +9 -49
- package/src/modules/workflow/git/__tests__/git.service.changedFilesForPr.test.ts +101 -0
- package/src/modules/workflow/git/__tests__/git.service.commitFile.test.ts +219 -211
- package/src/modules/workflow/git/__tests__/git.service.fileContentsAtCommit.test.ts +132 -0
- package/src/modules/workflow/git/__tests__/git.service.logForFile.test.ts +4 -2
- package/src/modules/workflow/git/__tests__/git.service.postMergePull.test.ts +275 -272
- package/src/modules/workflow/git/__tests__/git.service.pull.test.ts +311 -306
- package/src/modules/workflow/git/__tests__/pull-request.service.viewer-can-cancel.test.ts +44 -0
- package/src/modules/workflow/git/branch-name.ts +5 -1
- package/src/modules/workflow/git/git.service.ts +172 -64
- package/src/modules/workflow/git/pull-request.service.ts +22 -11
- package/src/modules/workflow/review-workflow/__tests__/cancel-pr.test.ts +4 -1
- package/src/modules/workflow/review-workflow/review-workflow.service.ts +12 -3
- package/src/modules/workflow/workflow.routes.ts +91 -13
- package/src/modules/workflow/workflow.service.ts +365 -19
- package/src/modules/workspace/__tests__/kb-seed.service.test.ts +255 -24
- package/src/modules/workspace/__tests__/workspace.routes.create-grant.test.ts +236 -233
- package/src/modules/workspace/__tests__/workspace.routes.delete.test.ts +179 -176
- package/src/modules/workspace/__tests__/workspace.routes.download.test.ts +320 -303
- package/src/modules/workspace/__tests__/workspace.routes.read-gate.test.ts +337 -276
- package/src/modules/workspace/__tests__/workspace.tools.test.ts +14 -6
- package/src/modules/workspace/bevel-ignore.ts +66 -62
- package/src/modules/workspace/kb-seed.interface.ts +1 -1
- package/src/modules/workspace/kb-seed.service.ts +247 -53
- package/src/modules/workspace/workspace.routes.ts +54 -18
- package/src/modules/workspace/workspace.service.ts +45 -7
- package/src/modules/workspace/workspace.tools.ts +922 -917
- package/src/shared/__tests__/frontmatter-id.test.ts +53 -0
- package/src/shared/__tests__/join-request.test.ts +96 -0
- package/src/shared/__tests__/kb-layout.group.test.ts +45 -0
- package/src/shared/kb-layout.test.ts +2 -2
- package/src/test-setup.ts +18 -0
- package/kb-template/Agents/README.md +0 -38
- package/kb-template/CLAUDE.md +0 -419
- package/kb-template/Data/.gitkeep +0 -0
- package/kb-template/Data/README.md +0 -39
- package/kb-template/KnowledgeBase/README.md +0 -37
- package/kb-template/Pipelines/.gitkeep +0 -0
- package/kb-template/Pipelines/README.md +0 -38
- package/kb-template/Skills/.gitkeep +0 -0
- package/kb-template/Skills/README.md +0 -32
- package/kb-template/Tools/.gitkeep +0 -0
- package/kb-template/Tools/README.md +0 -42
- /package/kb-template/{Agents → Groups}/.gitkeep +0 -0
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import type { Server as HttpServer } from 'node:http';
|
|
2
|
+
import type { AddressInfo } from 'node:net';
|
|
3
|
+
import express from 'express';
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
import { createAuthRoutes } from '../auth.routes.js';
|
|
6
|
+
import type { AuthService } from '../auth.service.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* POST /auth/onboarding-done — the one write behind the welcome page's Done
|
|
10
|
+
* AND the reminder pill's ×. The contract worth pinning:
|
|
11
|
+
*
|
|
12
|
+
* - it acts on the AUTHENTICATED user (req.userId), never on a body param —
|
|
13
|
+
* there is no way to conclude someone else's onboarding;
|
|
14
|
+
* - it REQUIRES the caller to name the account it means, and refuses anything
|
|
15
|
+
* that is not an exact match — including a claim that is absent or not a
|
|
16
|
+
* string, since "said nothing" is not the same as "meant this account";
|
|
17
|
+
* - it is idempotent from the caller's view (the service call is an UPDATE
|
|
18
|
+
* to the value it may already have), so both buttons can fire it blind;
|
|
19
|
+
* - unauthenticated callers never reach the service.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const USER_ID = 'user-123';
|
|
23
|
+
|
|
24
|
+
let server: HttpServer | null = null;
|
|
25
|
+
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
server?.close();
|
|
28
|
+
server = null;
|
|
29
|
+
// In a hook, not at the end of a test body: a failing assertion would
|
|
30
|
+
// otherwise leave a `console.error` spy installed and silence the rest of
|
|
31
|
+
// the file.
|
|
32
|
+
vi.restoreAllMocks();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Mount the auth router on a throwaway port with a stand-in auth middleware,
|
|
37
|
+
* and return its base URL. `authed: false` makes that middleware reject like
|
|
38
|
+
* the real one, which is how the "never reaches the service" case is proved
|
|
39
|
+
* rather than assumed.
|
|
40
|
+
*/
|
|
41
|
+
function listen(service: Pick<AuthService, 'markOnboardingDone'>, authed = true): string {
|
|
42
|
+
const app = express();
|
|
43
|
+
app.use(express.json());
|
|
44
|
+
const middleware: express.RequestHandler = (req, res, next) => {
|
|
45
|
+
if (!authed) {
|
|
46
|
+
res.status(401).json({ error: 'Unauthorized' });
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
req.userId = USER_ID;
|
|
50
|
+
next();
|
|
51
|
+
};
|
|
52
|
+
app.use('/api', createAuthRoutes(service as AuthService, middleware));
|
|
53
|
+
server = app.listen(0);
|
|
54
|
+
const { port } = server.address() as AddressInfo;
|
|
55
|
+
return `http://127.0.0.1:${port}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
describe('POST /auth/onboarding-done', () => {
|
|
59
|
+
it('marks the caller done when the claimed account matches the token', async () => {
|
|
60
|
+
const markOnboardingDone = vi.fn().mockResolvedValue(undefined);
|
|
61
|
+
const base = listen({ markOnboardingDone });
|
|
62
|
+
const res = await fetch(`${base}/api/auth/onboarding-done`, {
|
|
63
|
+
method: 'POST',
|
|
64
|
+
headers: { 'content-type': 'application/json' },
|
|
65
|
+
body: JSON.stringify({ userId: USER_ID }),
|
|
66
|
+
});
|
|
67
|
+
expect(res.status).toBe(200);
|
|
68
|
+
expect(await res.json()).toEqual({ ok: true });
|
|
69
|
+
expect(markOnboardingDone).toHaveBeenCalledTimes(1);
|
|
70
|
+
expect(markOnboardingDone).toHaveBeenCalledWith(USER_ID);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* A caller that names no account has not expressed the intent this route
|
|
75
|
+
* needs. Accepting `{}` as consent would reopen the stale-tab hole from the
|
|
76
|
+
* other side — an old or malformed client posting an empty body while
|
|
77
|
+
* rendering account A would conclude account B's onboarding unopposed — so
|
|
78
|
+
* the absent claim is refused exactly like a wrong one.
|
|
79
|
+
*/
|
|
80
|
+
it('refuses a caller that claims nothing, and writes nothing', async () => {
|
|
81
|
+
const markOnboardingDone = vi.fn();
|
|
82
|
+
const base = listen({ markOnboardingDone });
|
|
83
|
+
const res = await fetch(`${base}/api/auth/onboarding-done`, { method: 'POST' });
|
|
84
|
+
expect(res.status).toBe(409);
|
|
85
|
+
expect(markOnboardingDone).not.toHaveBeenCalled();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// Same rule for a claim of the wrong TYPE: only an exact string match is
|
|
89
|
+
// consent, so a number that happens to stringify to the user id is not.
|
|
90
|
+
it('refuses a claim that is not a string', async () => {
|
|
91
|
+
const markOnboardingDone = vi.fn();
|
|
92
|
+
const base = listen({ markOnboardingDone });
|
|
93
|
+
const res = await fetch(`${base}/api/auth/onboarding-done`, {
|
|
94
|
+
method: 'POST',
|
|
95
|
+
headers: { 'content-type': 'application/json' },
|
|
96
|
+
body: JSON.stringify({ userId: { toString: () => USER_ID } }),
|
|
97
|
+
});
|
|
98
|
+
expect(res.status).toBe(409);
|
|
99
|
+
expect(markOnboardingDone).not.toHaveBeenCalled();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The stale-tab case. Two accounts share one bearer-token key in
|
|
104
|
+
* localStorage: a tab still rendering A, after B signs in elsewhere, sends
|
|
105
|
+
* A's intent with B's token. Concluding B's onboarding there would be
|
|
106
|
+
* irreversible — there is no API to undo it — so the mismatch is refused
|
|
107
|
+
* and NOTHING is written.
|
|
108
|
+
*/
|
|
109
|
+
it('refuses when the claimed account is not the token holder, and writes nothing', async () => {
|
|
110
|
+
const markOnboardingDone = vi.fn();
|
|
111
|
+
const base = listen({ markOnboardingDone });
|
|
112
|
+
const res = await fetch(`${base}/api/auth/onboarding-done`, {
|
|
113
|
+
method: 'POST',
|
|
114
|
+
headers: { 'content-type': 'application/json' },
|
|
115
|
+
body: JSON.stringify({ userId: 'a-different-account' }),
|
|
116
|
+
});
|
|
117
|
+
expect(res.status).toBe(409);
|
|
118
|
+
expect(markOnboardingDone).not.toHaveBeenCalled();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('never reaches the service unauthenticated', async () => {
|
|
122
|
+
const markOnboardingDone = vi.fn();
|
|
123
|
+
const base = listen({ markOnboardingDone }, false);
|
|
124
|
+
const res = await fetch(`${base}/api/auth/onboarding-done`, { method: 'POST' });
|
|
125
|
+
expect(res.status).toBe(401);
|
|
126
|
+
expect(markOnboardingDone).not.toHaveBeenCalled();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// A failed write must not be reported as success — and must not narrate the
|
|
130
|
+
// database to the client either. Same rule as /auth/login.
|
|
131
|
+
it('reports a failed write as a generic 500, keeping the detail server-side', async () => {
|
|
132
|
+
const detail = 'connection to 10.0.0.4:5432 refused';
|
|
133
|
+
const markOnboardingDone = vi.fn().mockRejectedValue(new Error(detail));
|
|
134
|
+
const logged = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
135
|
+
const base = listen({ markOnboardingDone });
|
|
136
|
+
const res = await fetch(`${base}/api/auth/onboarding-done`, {
|
|
137
|
+
method: 'POST',
|
|
138
|
+
headers: { 'content-type': 'application/json' },
|
|
139
|
+
body: JSON.stringify({ userId: USER_ID }),
|
|
140
|
+
});
|
|
141
|
+
expect(res.status).toBe(500);
|
|
142
|
+
const body = (await res.json()) as { error: string };
|
|
143
|
+
expect(body.error).toBe('Could not save that');
|
|
144
|
+
expect(body.error).not.toContain(detail);
|
|
145
|
+
expect(logged).toHaveBeenCalledWith('Onboarding-done error:', detail);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
@@ -65,8 +65,19 @@ export function createAuthRoutes(
|
|
|
65
65
|
return;
|
|
66
66
|
}
|
|
67
67
|
const ip = clientIp(req);
|
|
68
|
+
// IP budget FIRST, and it returns before the pair key exists. Order is the
|
|
69
|
+
// whole guard here: `consume` inserts an entry for any key it has not seen,
|
|
70
|
+
// and that entry lives for the full 15-minute window. Consuming the pair
|
|
71
|
+
// budget first therefore let an address that was ALREADY over its IP limit
|
|
72
|
+
// keep minting `ip|email` entries, one per attacker-chosen email — and
|
|
73
|
+
// since `consume` scans the whole map on every call, that growth is also a
|
|
74
|
+
// latency tax on every legitimate login for the rest of the window.
|
|
75
|
+
if (!loginIpLimiter.consume(ip)) {
|
|
76
|
+
res.status(429).json({ error: 'Too many attempts. Try again later.' });
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
68
79
|
const pairKey = `${ip}|${email.trim().toLowerCase()}`;
|
|
69
|
-
if (!loginPairLimiter.consume(pairKey)
|
|
80
|
+
if (!loginPairLimiter.consume(pairKey)) {
|
|
70
81
|
res.status(429).json({ error: 'Too many attempts. Try again later.' });
|
|
71
82
|
return;
|
|
72
83
|
}
|
|
@@ -136,6 +147,44 @@ export function createAuthRoutes(
|
|
|
136
147
|
}
|
|
137
148
|
});
|
|
138
149
|
|
|
150
|
+
// POST /api/auth/onboarding-done — conclude the connect-your-agent
|
|
151
|
+
// onboarding for the caller (protected). One-way and idempotent; the
|
|
152
|
+
// welcome page's Done and the reminder pill's × are its two callers.
|
|
153
|
+
router.post('/auth/onboarding-done', authMiddleware, async (req, res) => {
|
|
154
|
+
try {
|
|
155
|
+
// The caller must state WHICH account it believes it is concluding, and
|
|
156
|
+
// anything but an exact match is refused rather than applied to the
|
|
157
|
+
// token's owner.
|
|
158
|
+
//
|
|
159
|
+
// The bearer token lives in one shared localStorage key, so two accounts
|
|
160
|
+
// in two tabs share it: a stale tab still rendering A, after B signs in
|
|
161
|
+
// elsewhere, would otherwise send A's intent with B's token and
|
|
162
|
+
// irreversibly conclude B's onboarding — an account B has no way to
|
|
163
|
+
// reopen. `userId` is an assertion about intent, never an authorization:
|
|
164
|
+
// the write still targets `req.userId` alone.
|
|
165
|
+
//
|
|
166
|
+
// REQUIRED, not merely checked-when-present. Treating an absent claim as
|
|
167
|
+
// consent reopens the same hole from the other side: a stale or malformed
|
|
168
|
+
// client that posts `{}` while rendering A would conclude B's onboarding
|
|
169
|
+
// unopposed. A caller that cannot name the account it means has not
|
|
170
|
+
// expressed the intent this route needs.
|
|
171
|
+
const claimed = (req.body as { userId?: unknown } | undefined)?.userId;
|
|
172
|
+
if (typeof claimed !== 'string' || claimed !== req.userId) {
|
|
173
|
+
res.status(409).json({ error: 'Session changed — sign in again' });
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
await authService.markOnboardingDone(req.userId!);
|
|
177
|
+
res.json({ ok: true });
|
|
178
|
+
} catch (error) {
|
|
179
|
+
// Logged in full, returned generic — same rule as `/auth/login` above.
|
|
180
|
+
// A raw driver message here would hand an unauthenticated-adjacent
|
|
181
|
+
// caller the schema, the host, or the connection string.
|
|
182
|
+
const msg = error instanceof Error ? error.message : 'Unknown error';
|
|
183
|
+
console.error('Onboarding-done error:', msg);
|
|
184
|
+
res.status(500).json({ error: 'Could not save that' });
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
|
|
139
188
|
// GET /api/auth/me — validate stored JWT, return user info (protected)
|
|
140
189
|
router.get('/auth/me', authMiddleware, async (req, res) => {
|
|
141
190
|
try {
|
|
@@ -27,24 +27,46 @@ function decoyHash(): Promise<string> {
|
|
|
27
27
|
return decoyHashPromise;
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Narrow a `users` row down to what a client is allowed to see. One place
|
|
32
|
+
* rather than three inline object literals, so a column added to the table is
|
|
33
|
+
* never accidentally shipped to the browser — and so every session-issuing
|
|
34
|
+
* path returns the same shape.
|
|
35
|
+
*/
|
|
30
36
|
function toAuthUser(user: {
|
|
31
37
|
id: string;
|
|
32
38
|
email: string;
|
|
33
39
|
name: string;
|
|
34
40
|
avatarUrl: string | null;
|
|
41
|
+
onboardingDone: boolean;
|
|
35
42
|
}): AuthUser {
|
|
36
43
|
return {
|
|
37
44
|
id: user.id,
|
|
38
45
|
email: user.email,
|
|
39
46
|
name: user.name,
|
|
40
47
|
avatarUrl: user.avatarUrl ?? undefined,
|
|
48
|
+
onboardingDone: user.onboardingDone,
|
|
41
49
|
};
|
|
42
50
|
}
|
|
43
51
|
|
|
52
|
+
/**
|
|
53
|
+
* What this service needs from the deployment's configuration, and nothing
|
|
54
|
+
* more. Narrowed from `CoreConfig` so the caller can supply a value that is
|
|
55
|
+
* RESOLVED rather than read straight off the environment — the SSO domain
|
|
56
|
+
* allow-list is settable from the setup screen, and taking the whole config
|
|
57
|
+
* object would have quietly pinned it to the env-only half.
|
|
58
|
+
*/
|
|
59
|
+
export interface AuthConfig {
|
|
60
|
+
jwtSecret: string;
|
|
61
|
+
adminEmail: string;
|
|
62
|
+
adminPassword: string;
|
|
63
|
+
allowedEmailDomains: string[];
|
|
64
|
+
}
|
|
65
|
+
|
|
44
66
|
export class AuthService {
|
|
45
67
|
constructor(
|
|
46
68
|
private readonly db: Database,
|
|
47
|
-
private readonly config:
|
|
69
|
+
private readonly config: AuthConfig,
|
|
48
70
|
) {}
|
|
49
71
|
|
|
50
72
|
/**
|
|
@@ -71,7 +93,6 @@ export class AuthService {
|
|
|
71
93
|
if (!EMAIL_REGEX.test(normalizedEmail)) {
|
|
72
94
|
throw new Error('Invalid credentials');
|
|
73
95
|
}
|
|
74
|
-
this.assertAllowedDomain(normalizedEmail);
|
|
75
96
|
|
|
76
97
|
const provided = password ?? '';
|
|
77
98
|
const isEnvAdmin =
|
|
@@ -139,7 +160,6 @@ export class AuthService {
|
|
|
139
160
|
if (!EMAIL_REGEX.test(normalizedEmail)) {
|
|
140
161
|
throw new Error('Invalid email');
|
|
141
162
|
}
|
|
142
|
-
this.assertAllowedDomain(normalizedEmail);
|
|
143
163
|
this.assertPasswordPolicy(password);
|
|
144
164
|
const suppliedName = (name ?? '').trim();
|
|
145
165
|
const displayName = suppliedName || normalizedEmail.split('@')[0] || normalizedEmail;
|
|
@@ -280,21 +300,31 @@ export class AuthService {
|
|
|
280
300
|
|
|
281
301
|
if (!user) return null;
|
|
282
302
|
|
|
283
|
-
return
|
|
284
|
-
id: user.id,
|
|
285
|
-
email: user.email,
|
|
286
|
-
name: user.name,
|
|
287
|
-
avatarUrl: user.avatarUrl ?? undefined,
|
|
288
|
-
};
|
|
303
|
+
return toAuthUser(user);
|
|
289
304
|
}
|
|
290
305
|
|
|
291
306
|
/**
|
|
292
|
-
*
|
|
293
|
-
*
|
|
294
|
-
*
|
|
295
|
-
*
|
|
296
|
-
*
|
|
297
|
-
|
|
307
|
+
* Conclude the connect-your-agent onboarding for `userId`. Idempotent by
|
|
308
|
+
* construction (an UPDATE to the value it already has), so the welcome
|
|
309
|
+
* page's Done and the reminder pill's dismiss can both call it without
|
|
310
|
+
* coordinating. There is deliberately no way back to false over the API:
|
|
311
|
+
* "not onboarded again" is not a state a user can be put in.
|
|
312
|
+
*/
|
|
313
|
+
async markOnboardingDone(userId: string): Promise<void> {
|
|
314
|
+
await this.db
|
|
315
|
+
.update(users)
|
|
316
|
+
.set({ onboardingDone: true, updatedAt: new Date() })
|
|
317
|
+
.where(eq(users.id, userId));
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Whether `email` passes the optional `ALLOWED_EMAIL_DOMAINS` guard — the
|
|
322
|
+
* SSO allow-list. Returns true when no guard is configured (the default).
|
|
323
|
+
* Matches the email's domain exactly or as a subdomain, so `bevel.software`
|
|
324
|
+
* admits `a@bevel.software` and `a@eu.bevel.software` but not
|
|
325
|
+
* `a@notbevel.software`. When a guard IS configured, a missing/blank email
|
|
326
|
+
* fails closed. Exposed so non-login surfaces (the embed panel) can apply
|
|
327
|
+
* the same gate.
|
|
298
328
|
*/
|
|
299
329
|
isEmailDomainAllowed(email: string): boolean {
|
|
300
330
|
const allowed = this.config.allowedEmailDomains;
|
|
@@ -305,9 +335,17 @@ export class AuthService {
|
|
|
305
335
|
}
|
|
306
336
|
|
|
307
337
|
/**
|
|
308
|
-
* Enforce {@link isEmailDomainAllowed}
|
|
309
|
-
*
|
|
338
|
+
* Enforce {@link isEmailDomainAllowed} on SSO sign-in. No-op when the guard
|
|
339
|
+
* is unset. Expects an already-normalized (trimmed, lower-cased,
|
|
310
340
|
* regex-validated) email.
|
|
341
|
+
*
|
|
342
|
+
* SSO ONLY, deliberately. It exists because SSO auto-provisions — an account
|
|
343
|
+
* appears the first time the issuer authenticates someone, with nobody
|
|
344
|
+
* approving it. The other two entry points do not have that property: an
|
|
345
|
+
* admin-created account is vetted by the act of creating it, and password
|
|
346
|
+
* login can only reach an account that already exists. Applying it there
|
|
347
|
+
* gated nothing, and could lock out a bootstrap admin whose own address sits
|
|
348
|
+
* outside the list.
|
|
311
349
|
*/
|
|
312
350
|
private assertAllowedDomain(normalizedEmail: string): void {
|
|
313
351
|
if (!this.isEmailDomainAllowed(normalizedEmail)) {
|
|
@@ -24,6 +24,19 @@ export const users = pgTable('users', {
|
|
|
24
24
|
* until an admin (or the user, from their Account page) sets one.
|
|
25
25
|
*/
|
|
26
26
|
passwordHash: text('password_hash'),
|
|
27
|
+
/**
|
|
28
|
+
* The one onboarding fact the server keeps: has this person concluded the
|
|
29
|
+
* connect-your-agent setup (the welcome page's Done, or the reminder
|
|
30
|
+
* pill's dismiss — one field, both doors). False drives the pill in every
|
|
31
|
+
* browser the account signs into; true ends it everywhere at once, which
|
|
32
|
+
* is exactly what localStorage could not promise.
|
|
33
|
+
*
|
|
34
|
+
* Existing accounts are NOT backfilled to true: the column simply defaults
|
|
35
|
+
* to false, so everyone is greeted once and anyone already connected
|
|
36
|
+
* clicks Done. A plain `ADD COLUMN` is worth more than the branching a
|
|
37
|
+
* backfill would need, and being shown the setup once costs a click.
|
|
38
|
+
*/
|
|
39
|
+
onboardingDone: boolean('onboarding_done').default(false).notNull(),
|
|
27
40
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
|
28
41
|
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
|
29
42
|
});
|
|
@@ -435,3 +448,36 @@ export const sessionOntologyTouches = pgTable('session_ontology_touches', {
|
|
|
435
448
|
// Backstop sweep of abandoned runs via `touched_at < cutoff`.
|
|
436
449
|
byTouchedAt: index('session_ontology_touches_by_touched_at').on(t.touchedAt),
|
|
437
450
|
}));
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Deployment-wide settings an admin can change from the setup screen, so a
|
|
454
|
+
* fresh install does not have to be told everything through the environment
|
|
455
|
+
* before it will boot.
|
|
456
|
+
*
|
|
457
|
+
* KEY/VALUE rather than typed columns: the set grows (the KB remote first, the
|
|
458
|
+
* branch model after it), and a migration per setting buys nothing when every
|
|
459
|
+
* value arrives from a text input anyway. Validation lives with the setting's
|
|
460
|
+
* definition, not in the column type.
|
|
461
|
+
*
|
|
462
|
+
* The ENVIRONMENT still wins over anything stored here — a row is a fallback,
|
|
463
|
+
* never an override. That is what lets an existing deployment adopt this table
|
|
464
|
+
* with no behaviour change, and stops a value typed once in a browser from
|
|
465
|
+
* quietly outranking the infrastructure config that is under review.
|
|
466
|
+
*/
|
|
467
|
+
export const deploymentSettings = pgTable('deployment_settings', {
|
|
468
|
+
key: text('key').primaryKey(),
|
|
469
|
+
/** Ciphertext when `encrypted`, plain text otherwise. */
|
|
470
|
+
value: text('value').notNull(),
|
|
471
|
+
/**
|
|
472
|
+
* Whether `value` is sealed with the secrets key. Stored per row rather than
|
|
473
|
+
* inferred from the key name so a reader never has to know the catalogue to
|
|
474
|
+
* know whether it is holding a secret.
|
|
475
|
+
*/
|
|
476
|
+
encrypted: boolean('encrypted').default(false).notNull(),
|
|
477
|
+
/**
|
|
478
|
+
* Who last wrote it. `set null` on delete rather than cascade: erasing a
|
|
479
|
+
* person must not take the deployment's configuration with them.
|
|
480
|
+
*/
|
|
481
|
+
updatedBy: uuid('updated_by').references(() => users.id, { onDelete: 'set null' }),
|
|
482
|
+
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
|
483
|
+
});
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
|
+
import { mkdtemp, rm, mkdir, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { DEFAULT_BRANCH } from '@bevel-software/platform-shared';
|
|
6
|
+
import { GroupIndexService } from '../groups.service.js';
|
|
7
|
+
import { workspaceIdForBranch } from '../../workspace/workspace.service.js';
|
|
8
|
+
import type { WorkspaceService } from '../../workspace/workspace.service.js';
|
|
9
|
+
import type { IAccessControl } from '../../access/access-control.interface.js';
|
|
10
|
+
import type { ISkillService, SkillSummary } from '../../skills/skills.contract.js';
|
|
11
|
+
import type { IToolManualService, ToolManualSummary } from '../../tool-manuals/tool-manuals.contract.js';
|
|
12
|
+
|
|
13
|
+
const KB_DIR = 'knowledge-base';
|
|
14
|
+
const wsId = workspaceIdForBranch(DEFAULT_BRANCH);
|
|
15
|
+
|
|
16
|
+
const OLGA = { name: 'Olga Ivanova', email: 'olga@bevel.software' };
|
|
17
|
+
|
|
18
|
+
function skills(...paths: string[]): SkillSummary[] {
|
|
19
|
+
return paths.map((path) => ({ name: path.split('/').pop()!, description: '', path }));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function tools(...paths: string[]): ToolManualSummary[] {
|
|
23
|
+
return paths.map((path) => {
|
|
24
|
+
const name = path.split('/').pop()!.replace(/\.tool$/, '');
|
|
25
|
+
return { slug: name, name, path, type: 'inline' as const };
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe('GroupIndexService', () => {
|
|
30
|
+
let root: string;
|
|
31
|
+
|
|
32
|
+
const workspaceService = {
|
|
33
|
+
getOrCreateForBranch: async () => ({ id: wsId }),
|
|
34
|
+
getWorkspacePath: async (id: string) => join(root, id),
|
|
35
|
+
} as unknown as WorkspaceService;
|
|
36
|
+
|
|
37
|
+
const principals: IAccessControl = {
|
|
38
|
+
eligibleOwners: async () => ({ roles: [], users: [OLGA] }),
|
|
39
|
+
eligibleWriters: async () => ({ roles: ['Admin'], users: [] }),
|
|
40
|
+
eligibleReaders: async () => ({ restricted: true, roles: ['GTM Team'], users: [] }),
|
|
41
|
+
} as unknown as IAccessControl;
|
|
42
|
+
|
|
43
|
+
const skillService = (list: SkillSummary[] = []): ISkillService =>
|
|
44
|
+
({ listSkills: async () => list }) as unknown as ISkillService;
|
|
45
|
+
|
|
46
|
+
const toolService = (list: ToolManualSummary[] = []): IToolManualService =>
|
|
47
|
+
({ listAllSummaries: async () => list }) as unknown as IToolManualService;
|
|
48
|
+
|
|
49
|
+
const svc = (opts: {
|
|
50
|
+
access?: IAccessControl;
|
|
51
|
+
skills?: SkillSummary[];
|
|
52
|
+
tools?: ToolManualSummary[];
|
|
53
|
+
workspace?: WorkspaceService;
|
|
54
|
+
} = {}) =>
|
|
55
|
+
new GroupIndexService(
|
|
56
|
+
opts.workspace ?? workspaceService,
|
|
57
|
+
opts.access ?? principals,
|
|
58
|
+
skillService(opts.skills),
|
|
59
|
+
toolService(opts.tools),
|
|
60
|
+
KB_DIR,
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
const kb = () => join(root, wsId, KB_DIR);
|
|
64
|
+
|
|
65
|
+
/** A REAL group: a folder carrying the access.md that makes it exist. */
|
|
66
|
+
const groupDir = async (name: string) => {
|
|
67
|
+
await mkdir(join(kb(), 'Groups', name), { recursive: true });
|
|
68
|
+
await writeFile(join(kb(), 'Groups', name, 'access.md'), '---\nread:\n - everyone\n---\n');
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
beforeEach(async () => {
|
|
72
|
+
root = await mkdtemp(join(tmpdir(), 'group-index-'));
|
|
73
|
+
await mkdir(kb(), { recursive: true });
|
|
74
|
+
});
|
|
75
|
+
afterEach(() => rm(root, { recursive: true, force: true }));
|
|
76
|
+
|
|
77
|
+
test('enumerates Groups/ folders carrying an access.md, sorted by name', async () => {
|
|
78
|
+
await groupDir('GTM');
|
|
79
|
+
await groupDir('Engineering');
|
|
80
|
+
|
|
81
|
+
const catalog = await svc().catalog();
|
|
82
|
+
expect(catalog.map((g) => g.name)).toEqual(['Engineering', 'GTM']);
|
|
83
|
+
expect(catalog[1].folders).toEqual(['Groups/GTM']);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('a folder without an access.md is not a group', async () => {
|
|
87
|
+
// The residue a deleted group leaves behind: git cannot record an empty
|
|
88
|
+
// directory, so the folder outlives its files on live checkouts — and a
|
|
89
|
+
// folder with content but no access.md is just as much a non-group.
|
|
90
|
+
await mkdir(join(kb(), 'Groups', 'Ghost'), { recursive: true });
|
|
91
|
+
await mkdir(join(kb(), 'Groups', 'Residue'), { recursive: true });
|
|
92
|
+
await writeFile(join(kb(), 'Groups', 'Residue', 'notes.md'), 'leftover');
|
|
93
|
+
await groupDir('Real');
|
|
94
|
+
|
|
95
|
+
const catalog = await svc().catalog();
|
|
96
|
+
expect(catalog.map((g) => g.name)).toEqual(['Real']);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('the retired Skills/ and Tools/ roots are NOT group roots', async () => {
|
|
100
|
+
await mkdir(join(kb(), 'Skills', 'GTM'), { recursive: true });
|
|
101
|
+
await mkdir(join(kb(), 'Tools', 'GTM'), { recursive: true });
|
|
102
|
+
await groupDir('Engineering');
|
|
103
|
+
|
|
104
|
+
const catalog = await svc().catalog();
|
|
105
|
+
expect(catalog.map((g) => g.name)).toEqual(['Engineering']);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('ignores loose files and dot-dirs under the group root', async () => {
|
|
109
|
+
// The dot filter wins even over a folder that carries an access.md.
|
|
110
|
+
await mkdir(join(kb(), 'Groups', '.hidden'), { recursive: true });
|
|
111
|
+
await writeFile(join(kb(), 'Groups', '.hidden', 'access.md'), '---\nread:\n - everyone\n---\n');
|
|
112
|
+
await groupDir('GTM');
|
|
113
|
+
await writeFile(join(kb(), 'Groups', 'slack.tool'), '{}');
|
|
114
|
+
|
|
115
|
+
const catalog = await svc().catalog();
|
|
116
|
+
expect(catalog.map((g) => g.name)).toEqual(['GTM']);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('counts skills and tools from the global catalogs by groupOfPath', async () => {
|
|
120
|
+
await groupDir('GTM');
|
|
121
|
+
await groupDir('Product');
|
|
122
|
+
|
|
123
|
+
const catalog = await svc({
|
|
124
|
+
skills: skills('Groups/GTM/outreach', 'Groups/GTM/newsletter', 'Groups/Product/roadmap'),
|
|
125
|
+
// `Groups/slack.tool` is ungrouped (two segments) — it counts nowhere.
|
|
126
|
+
tools: tools('Groups/GTM/heyreach.tool', 'Groups/slack.tool'),
|
|
127
|
+
}).catalog();
|
|
128
|
+
|
|
129
|
+
const gtm = catalog.find((g) => g.name === 'GTM')!;
|
|
130
|
+
expect(gtm.skillCount).toBe(2);
|
|
131
|
+
expect(gtm.toolCount).toBe(1);
|
|
132
|
+
const product = catalog.find((g) => g.name === 'Product')!;
|
|
133
|
+
expect(product.skillCount).toBe(1);
|
|
134
|
+
expect(product.toolCount).toBe(0);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('resolves principals on the group folder', async () => {
|
|
138
|
+
await groupDir('GTM');
|
|
139
|
+
|
|
140
|
+
const seen: string[] = [];
|
|
141
|
+
const access = {
|
|
142
|
+
eligibleOwners: async (_w: string, p: string) => {
|
|
143
|
+
seen.push(p);
|
|
144
|
+
return { roles: [], users: [OLGA] };
|
|
145
|
+
},
|
|
146
|
+
eligibleWriters: async () => ({ roles: [], users: [] }),
|
|
147
|
+
eligibleReaders: async () => ({ restricted: false, roles: [], users: [] }),
|
|
148
|
+
} as unknown as IAccessControl;
|
|
149
|
+
|
|
150
|
+
const catalog = await svc({ access }).catalog();
|
|
151
|
+
expect(catalog[0].folders).toEqual(['Groups/GTM']);
|
|
152
|
+
expect(seen).toEqual(['Groups/GTM']);
|
|
153
|
+
expect(catalog[0].owners.users).toEqual([OLGA]);
|
|
154
|
+
expect(catalog[0].readers).toEqual({ restricted: false, roles: [], users: [] });
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test('a missing Groups/ root yields an empty list, not an error', async () => {
|
|
158
|
+
await expect(svc().catalog()).resolves.toEqual([]);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test('degrades to [] when the workspace cannot be created', async () => {
|
|
162
|
+
const broken = {
|
|
163
|
+
getOrCreateForBranch: async () => {
|
|
164
|
+
throw new Error('no clone');
|
|
165
|
+
},
|
|
166
|
+
getWorkspacePath: async () => root,
|
|
167
|
+
} as unknown as WorkspaceService;
|
|
168
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
169
|
+
await expect(svc({ workspace: broken }).catalog()).resolves.toEqual([]);
|
|
170
|
+
expect(warn).toHaveBeenCalled();
|
|
171
|
+
warn.mockRestore();
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test('a failed scan is served but NOT cached — the next call retries', async () => {
|
|
175
|
+
await groupDir('GTM');
|
|
176
|
+
let fail = true;
|
|
177
|
+
const flaky = {
|
|
178
|
+
// Fails once, the way a default-branch clone mid-creation does, then
|
|
179
|
+
// recovers. If the empty result were cached, GTM would stay hidden from
|
|
180
|
+
// every user for the full TTL after the cause was gone.
|
|
181
|
+
getOrCreateForBranch: async () => {
|
|
182
|
+
if (fail) throw new Error('clone in progress');
|
|
183
|
+
return { id: wsId };
|
|
184
|
+
},
|
|
185
|
+
getWorkspacePath: async (id: string) => join(root, id),
|
|
186
|
+
} as unknown as WorkspaceService;
|
|
187
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
188
|
+
const service = svc({ workspace: flaky });
|
|
189
|
+
|
|
190
|
+
await expect(service.catalog()).resolves.toEqual([]);
|
|
191
|
+
|
|
192
|
+
fail = false;
|
|
193
|
+
expect((await service.catalog()).map((g) => g.name)).toEqual(['GTM']);
|
|
194
|
+
warn.mockRestore();
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test('a genuinely empty KB IS cached — an empty result is a fact, not a failure', async () => {
|
|
198
|
+
let scans = 0;
|
|
199
|
+
const counting = {
|
|
200
|
+
getOrCreateForBranch: async () => {
|
|
201
|
+
scans += 1;
|
|
202
|
+
return { id: wsId };
|
|
203
|
+
},
|
|
204
|
+
getWorkspacePath: async (id: string) => join(root, id),
|
|
205
|
+
} as unknown as WorkspaceService;
|
|
206
|
+
const service = svc({ workspace: counting });
|
|
207
|
+
|
|
208
|
+
await expect(service.catalog()).resolves.toEqual([]);
|
|
209
|
+
await expect(service.catalog()).resolves.toEqual([]);
|
|
210
|
+
expect(scans).toBe(1);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test('caches for the TTL and rescans after invalidate()', async () => {
|
|
214
|
+
await groupDir('GTM');
|
|
215
|
+
const service = svc();
|
|
216
|
+
|
|
217
|
+
expect((await service.catalog()).map((g) => g.name)).toEqual(['GTM']);
|
|
218
|
+
// A group added out of band is NOT seen while the cache holds…
|
|
219
|
+
await groupDir('Finance');
|
|
220
|
+
expect((await service.catalog()).map((g) => g.name)).toEqual(['GTM']);
|
|
221
|
+
// …and IS seen once the file-change subscriber drops it.
|
|
222
|
+
service.invalidate();
|
|
223
|
+
expect((await service.catalog()).map((g) => g.name)).toEqual(['Finance', 'GTM']);
|
|
224
|
+
});
|
|
225
|
+
});
|