@bevel-software/platform-core-backend 0.7.5 → 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/LICENSE +202 -202
- package/THIRD-PARTY-NOTICES.md +428 -454
- package/dist/core/core-ports.d.ts +8 -1
- 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 +75 -18
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/core/create-core-services.d.ts +12 -5
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js +69 -36
- package/dist/core/create-core-services.js.map +1 -1
- package/dist/modules/access/access-control.interface.d.ts +8 -7
- package/dist/modules/access/access-control.interface.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.d.ts +1 -1
- package/dist/modules/access/access-control.service.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.js +6 -6
- package/dist/modules/access/access-control.service.js.map +1 -1
- package/dist/modules/access/access-declarations.d.ts +5 -5
- package/dist/modules/access/access-declarations.js +3 -3
- package/dist/modules/access/access-mutation.service.d.ts +3 -3
- package/dist/modules/access/access-mutation.service.d.ts.map +1 -1
- package/dist/modules/access/access-mutation.service.js +3 -3
- package/dist/modules/access/access-mutation.service.js.map +1 -1
- package/dist/modules/access/access-splice.js +4 -4
- package/dist/modules/access/access-splice.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 +22 -20
- package/dist/modules/access/access.routes.js.map +1 -1
- package/dist/modules/access/creator-access.d.ts +2 -2
- package/dist/modules/access/creator-access.js +5 -5
- package/dist/modules/access/creator-access.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 +33 -5
- package/dist/modules/access/roles-admin.service.d.ts.map +1 -1
- package/dist/modules/access/roles-admin.service.js +44 -19
- package/dist/modules/access/roles-admin.service.js.map +1 -1
- package/dist/modules/code-mode/code-mode-names.d.ts +5 -13
- package/dist/modules/code-mode/code-mode-names.d.ts.map +1 -1
- package/dist/modules/code-mode/code-mode-names.js +5 -27
- package/dist/modules/code-mode/code-mode-names.js.map +1 -1
- package/dist/modules/code-mode/code-mode.tool.d.ts.map +1 -1
- package/dist/modules/code-mode/code-mode.tool.js +29 -7
- package/dist/modules/code-mode/code-mode.tool.js.map +1 -1
- package/dist/modules/mcp/mcp.service.d.ts +11 -52
- package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
- package/dist/modules/mcp/mcp.service.js +33 -395
- package/dist/modules/mcp/mcp.service.js.map +1 -1
- package/dist/modules/plugins/index.d.ts +7 -0
- package/dist/modules/plugins/index.d.ts.map +1 -0
- package/dist/modules/plugins/index.js +6 -0
- package/dist/modules/plugins/index.js.map +1 -0
- package/dist/modules/plugins/join-proposals.d.ts +53 -0
- package/dist/modules/plugins/join-proposals.d.ts.map +1 -0
- package/dist/modules/plugins/join-proposals.js +67 -0
- package/dist/modules/plugins/join-proposals.js.map +1 -0
- package/dist/modules/plugins/join-requests.service.d.ts +81 -0
- package/dist/modules/plugins/join-requests.service.d.ts.map +1 -0
- package/dist/modules/plugins/join-requests.service.js +135 -0
- package/dist/modules/plugins/join-requests.service.js.map +1 -0
- package/dist/modules/plugins/plugin-provision.service.d.ts +134 -0
- package/dist/modules/plugins/plugin-provision.service.d.ts.map +1 -0
- package/dist/modules/plugins/plugin-provision.service.js +344 -0
- package/dist/modules/plugins/plugin-provision.service.js.map +1 -0
- package/dist/modules/plugins/plugins.contract.d.ts +106 -0
- package/dist/modules/plugins/plugins.contract.d.ts.map +1 -0
- package/dist/modules/plugins/plugins.contract.js +36 -0
- package/dist/modules/plugins/plugins.contract.js.map +1 -0
- package/dist/modules/plugins/plugins.routes.d.ts +42 -0
- package/dist/modules/plugins/plugins.routes.d.ts.map +1 -0
- package/dist/modules/plugins/plugins.routes.js +379 -0
- package/dist/modules/plugins/plugins.routes.js.map +1 -0
- package/dist/modules/plugins/plugins.service.d.ts +60 -0
- package/dist/modules/plugins/plugins.service.d.ts.map +1 -0
- package/dist/modules/plugins/plugins.service.js +172 -0
- package/dist/modules/plugins/plugins.service.js.map +1 -0
- 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/skills/pending-skills.service.d.ts +2 -2
- package/dist/modules/skills/pending-skills.service.js +7 -7
- package/dist/modules/skills/pending-skills.service.js.map +1 -1
- package/dist/modules/skills/skills.contract.d.ts +4 -4
- 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.service.js +5 -5
- package/dist/modules/skills/skills.service.js.map +1 -1
- package/dist/modules/tool-manuals/mcp-json-discovery.d.ts +65 -0
- package/dist/modules/tool-manuals/mcp-json-discovery.d.ts.map +1 -0
- package/dist/modules/tool-manuals/mcp-json-discovery.js +276 -0
- package/dist/modules/tool-manuals/mcp-json-discovery.js.map +1 -0
- package/dist/modules/tool-manuals/mcp-server-edit.service.d.ts +92 -0
- package/dist/modules/tool-manuals/mcp-server-edit.service.d.ts.map +1 -0
- package/dist/modules/tool-manuals/mcp-server-edit.service.js +328 -0
- package/dist/modules/tool-manuals/mcp-server-edit.service.js.map +1 -0
- package/dist/modules/tool-manuals/tool-manuals.contract.d.ts +38 -12
- 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 +13 -2
- package/dist/modules/tool-manuals/tool-manuals.routes.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.routes.js +233 -2
- package/dist/modules/tool-manuals/tool-manuals.routes.js.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.service.js +74 -37
- package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.tools.js +6 -3
- package/dist/modules/tool-manuals/tool-manuals.tools.js.map +1 -1
- package/dist/modules/workflow/git/git.service.js +2 -2
- package/dist/modules/workflow/git/git.service.js.map +1 -1
- package/dist/modules/workspace/kb-seed.service.d.ts +2 -2
- package/dist/modules/workspace/kb-seed.service.d.ts.map +1 -1
- package/dist/modules/workspace/kb-seed.service.js +43 -10
- package/dist/modules/workspace/kb-seed.service.js.map +1 -1
- package/dist/modules/workspace/plugins-migration.d.ts +50 -0
- package/dist/modules/workspace/plugins-migration.d.ts.map +1 -0
- package/dist/modules/workspace/plugins-migration.js +379 -0
- package/dist/modules/workspace/plugins-migration.js.map +1 -0
- 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.routes.js +3 -3
- package/dist/modules/workspace/workspace.routes.js.map +1 -1
- package/dist/modules/workspace/workspace.service.d.ts +0 -9
- package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.service.js +2 -30
- package/dist/modules/workspace/workspace.service.js.map +1 -1
- package/dist/shared/kb-layout.test.js +3 -3
- package/dist/shared/kb-layout.test.js.map +1 -1
- package/dist/shared/utcp-namespace.d.ts +6 -27
- package/dist/shared/utcp-namespace.d.ts.map +1 -1
- package/dist/shared/utcp-namespace.js +6 -63
- package/dist/shared/utcp-namespace.js.map +1 -1
- package/dist/shared/variable-refs.d.ts +42 -0
- package/dist/shared/variable-refs.d.ts.map +1 -0
- package/dist/shared/variable-refs.js +60 -0
- package/dist/shared/variable-refs.js.map +1 -0
- package/kb-template/.bevelignore +1 -1
- package/kb-template/AGENTS.md +88 -35
- package/kb-template/KnowledgeBase/How to get started.md +10 -10
- package/kb-template/access.md +36 -36
- package/kb-template/gitignore.template +16 -0
- package/migrations/meta/0000_snapshot.json +1479 -1479
- package/package.json +5 -4
- package/src/assets.ts +25 -25
- package/src/core/core-ports.ts +113 -106
- package/src/core/create-core-server.ts +85 -20
- package/src/core/create-core-services.ts +83 -42
- package/src/index.ts +69 -69
- package/src/modules/access/__tests__/access-control.atref-batch.test.ts +98 -98
- package/src/modules/access/__tests__/access-declarations.test.ts +28 -28
- package/src/modules/access/__tests__/access-md-format.test.ts +18 -18
- package/src/modules/access/__tests__/access-mutation.service.test.ts +5 -5
- package/src/modules/access/__tests__/access-splice.test.ts +2 -2
- package/src/modules/access/__tests__/access.routes.overrides.test.ts +16 -16
- package/src/modules/access/__tests__/grant-sources.test.ts +12 -12
- package/src/modules/access/__tests__/roles-admin.service.test.ts +36 -2
- package/src/modules/access/access-control.interface.ts +8 -7
- package/src/modules/access/access-control.service.ts +7 -7
- package/src/modules/access/access-declarations.ts +5 -5
- package/src/modules/access/access-mutation.service.ts +3 -3
- package/src/modules/access/access-splice.ts +4 -4
- package/src/modules/access/access.routes.ts +23 -20
- package/src/modules/access/creator-access.ts +5 -5
- package/src/modules/access/render-roles-yaml.ts +65 -0
- package/src/modules/access/roles-admin.service.ts +45 -16
- package/src/modules/admin/admin-access.routes.ts +29 -29
- package/src/modules/auth/__tests__/auth.routes.test.ts +91 -91
- package/src/modules/auth/__tests__/rate-limit.test.ts +36 -36
- package/src/modules/auth/rate-limit.ts +45 -45
- package/src/modules/code-mode/__tests__/code-mode.tool.test.ts +67 -0
- package/src/modules/code-mode/code-mode-names.ts +10 -36
- package/src/modules/code-mode/code-mode.tool.ts +27 -7
- package/src/modules/database/connection.ts +15 -15
- package/src/modules/database/schema.ts +11 -11
- package/src/modules/diff/__tests__/diff.routes.rejectPathsLocked.test.ts +150 -150
- package/src/modules/mcp/mcp.service.ts +57 -435
- package/src/modules/{groups → plugins}/__tests__/join-proposals.test.ts +1 -1
- package/src/modules/{groups → plugins}/__tests__/join-requests.service.test.ts +7 -7
- package/src/modules/{groups/__tests__/group-index.service.test.ts → plugins/__tests__/plugin-index.service.test.ts} +41 -41
- package/src/modules/plugins/__tests__/plugin-provision.service.test.ts +312 -0
- package/src/modules/{groups/__tests__/groups.routes.test.ts → plugins/__tests__/plugins.routes.test.ts} +100 -100
- package/src/modules/plugins/index.ts +17 -0
- package/src/modules/{groups → plugins}/join-proposals.ts +2 -2
- package/src/modules/{groups → plugins}/join-requests.service.ts +8 -8
- package/src/modules/{groups/group-provision.service.ts → plugins/plugin-provision.service.ts} +139 -69
- package/src/modules/{groups/groups.contract.ts → plugins/plugins.contract.ts} +26 -26
- package/src/modules/{groups/groups.routes.ts → plugins/plugins.routes.ts} +102 -102
- package/src/modules/{groups/groups.service.ts → plugins/plugins.service.ts} +43 -43
- package/src/modules/secrets-vault/__tests__/tool-owner-gate.route.test.ts +143 -143
- package/src/modules/settings/__tests__/setup.routes.test.ts +124 -3
- package/src/modules/settings/setup.routes.ts +112 -5
- package/src/modules/skills/__tests__/pending-skills.service.test.ts +14 -14
- package/src/modules/skills/__tests__/skills.service.test.ts +13 -13
- package/src/modules/skills/pending-skills.service.ts +7 -7
- package/src/modules/skills/skills.contract.ts +4 -4
- package/src/modules/skills/skills.service.ts +5 -5
- package/src/modules/tool-auth/llm-usage-meter.ts +19 -19
- package/src/modules/tool-manuals/__tests__/mcp-json-discovery.test.ts +198 -0
- package/src/modules/tool-manuals/__tests__/mcp-server-edit.service.test.ts +346 -0
- package/src/modules/tool-manuals/__tests__/tool-manuals.archive.route.test.ts +101 -0
- package/src/modules/tool-manuals/__tests__/tool-manuals.detail.route.test.ts +3 -3
- 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 +27 -27
- package/src/modules/tool-manuals/__tests__/tool-manuals.tools.test.ts +3 -3
- package/src/modules/tool-manuals/mcp-json-discovery.ts +328 -0
- package/src/modules/tool-manuals/mcp-server-edit.service.ts +434 -0
- package/src/modules/tool-manuals/tool-manuals.contract.ts +35 -12
- package/src/modules/tool-manuals/tool-manuals.routes.ts +222 -1
- package/src/modules/tool-manuals/tool-manuals.service.ts +82 -42
- package/src/modules/tool-manuals/tool-manuals.tools.ts +6 -3
- package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +1 -1
- package/src/modules/workflow/git/__tests__/branch-name.test.ts +3 -3
- package/src/modules/workflow/git/__tests__/git.service.accessGating.test.ts +3 -3
- package/src/modules/workflow/git/__tests__/git.service.commitFile.test.ts +13 -8
- package/src/modules/workflow/git/__tests__/git.service.deleteBranch.test.ts +1 -1
- package/src/modules/workflow/git/__tests__/pull-request.service.test.ts +1 -1
- package/src/modules/workflow/git/git.service.ts +2 -2
- package/src/modules/workflow/review-workflow/__tests__/approval-states.test.ts +1 -1
- package/src/modules/workflow/review-workflow/__tests__/cancel-pr.test.ts +1 -1
- package/src/modules/workflow/workflow-hooks.ts +101 -101
- package/src/modules/workspace/__tests__/session-ontology.gate.test.ts +237 -237
- package/src/modules/workspace/__tests__/workspace.routes.create-grant.test.ts +236 -236
- package/src/modules/workspace/__tests__/workspace.routes.delete.test.ts +179 -179
- package/src/modules/workspace/__tests__/workspace.routes.download.test.ts +320 -320
- package/src/modules/workspace/__tests__/workspace.routes.read-gate.test.ts +337 -337
- package/src/modules/workspace/__tests__/workspace.service.test.ts +27 -0
- package/src/modules/workspace/bevel-ignore.ts +66 -66
- package/src/modules/workspace/session-sink.ts +25 -25
- 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/startup/steps/groups-to-plugins.step.ts +561 -0
- 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.routes.ts +3 -3
- package/src/modules/workspace/workspace.service.ts +2 -35
- package/src/modules/workspace/workspace.tools.ts +922 -922
- package/src/shared/__tests__/join-request.test.ts +13 -13
- package/src/shared/__tests__/kb-layout.plugin.test.ts +45 -0
- package/src/shared/kb-layout.test.ts +3 -3
- package/src/shared/utcp-namespace.ts +10 -68
- package/src/shared/variable-refs.ts +64 -0
- package/src/modules/groups/__tests__/group-provision.service.test.ts +0 -247
- package/src/modules/groups/index.ts +0 -17
- package/src/modules/workspace/__tests__/kb-seed.service.test.ts +0 -444
- package/src/modules/workspace/kb-seed.interface.ts +0 -36
- package/src/modules/workspace/kb-seed.service.ts +0 -555
- package/src/shared/__tests__/kb-layout.group.test.ts +0 -45
- /package/kb-template/{Groups → Plugins}/.gitkeep +0 -0
|
@@ -49,7 +49,7 @@ import { WorkflowDomainError } from '../workflow/workflow.errors.js';
|
|
|
49
49
|
/** Whether the dialog target is a folder (edit folder access.md) or a file (edit node frontmatter). */
|
|
50
50
|
export type TargetKind = 'folder' | 'file';
|
|
51
51
|
|
|
52
|
-
/** Bad-request-class mutation failure (invalid principal, unknown
|
|
52
|
+
/** Bad-request-class mutation failure (invalid principal, unknown plugin, lockout). */
|
|
53
53
|
export class AccessMutationError extends WorkflowDomainError {
|
|
54
54
|
constructor(message: string, status = 400, payload?: Record<string, unknown>) {
|
|
55
55
|
super(message, status, payload);
|
|
@@ -305,7 +305,7 @@ export class AccessMutationService {
|
|
|
305
305
|
* access — not just "is there a removable file entry."
|
|
306
306
|
*
|
|
307
307
|
* For a USER, that distinction matters: effective access includes admin-rescue
|
|
308
|
-
* on `access.md`/`roles.yaml`,
|
|
308
|
+
* on `access.md`/`roles.yaml`, plugin membership, and the built-in `everyone` —
|
|
309
309
|
* none of which `grantSources` reports (it is MECE over file-backed
|
|
310
310
|
* direct/ancestor entries only). So we ask the same `canRead/canWrite/
|
|
311
311
|
* canDownload/canOwner` resolver every real access decision uses; if the
|
|
@@ -351,7 +351,7 @@ export class AccessMutationService {
|
|
|
351
351
|
* asynchronously in the route (it needs the roles model). Here we only run the
|
|
352
352
|
* cheap injection/shape validation so a malformed principal never reaches the
|
|
353
353
|
* splice. Role-exists-in-roles.yaml is the route's job (so it can offer
|
|
354
|
-
* "Create
|
|
354
|
+
* "Create Plugin" for an unknown plugin, an admin-only flow).
|
|
355
355
|
*/
|
|
356
356
|
private assertPrincipalSafe(principal: Principal): void {
|
|
357
357
|
try {
|
|
@@ -122,7 +122,7 @@ export function validatePrincipal(p: Principal): Principal {
|
|
|
122
122
|
}
|
|
123
123
|
const role = p.role.trim();
|
|
124
124
|
const canonical = canonicalRoleName(role);
|
|
125
|
-
if (!canonical) throw new AccessSpliceError('
|
|
125
|
+
if (!canonical) throw new AccessSpliceError('plugin grant needs a name');
|
|
126
126
|
if (
|
|
127
127
|
CONTROL_CHARS.test(role) ||
|
|
128
128
|
role.includes('<') ||
|
|
@@ -130,16 +130,16 @@ export function validatePrincipal(p: Principal): Principal {
|
|
|
130
130
|
role.includes(':') ||
|
|
131
131
|
role.includes('#')
|
|
132
132
|
) {
|
|
133
|
-
throw new AccessSpliceError(`invalid
|
|
133
|
+
throw new AccessSpliceError(`invalid plugin name: ${JSON.stringify(p.role)}`);
|
|
134
134
|
}
|
|
135
135
|
// `deny` is never a grantee — it's the denial prefix. `everyone` IS grantable
|
|
136
136
|
// (the built-in public role); the access route restricts it to the read verb.
|
|
137
137
|
if (canonical === 'deny') {
|
|
138
|
-
throw new AccessSpliceError(`'${role}' is a reserved name and cannot be granted as a
|
|
138
|
+
throw new AccessSpliceError(`'${role}' is a reserved name and cannot be granted as a plugin`);
|
|
139
139
|
}
|
|
140
140
|
const round = parseAccessEntry(role);
|
|
141
141
|
if (!round.ok || round.entry.kind !== 'role' || round.entry.deny) {
|
|
142
|
-
throw new AccessSpliceError(`
|
|
142
|
+
throw new AccessSpliceError(`plugin grant does not round-trip: ${JSON.stringify(p)}`);
|
|
143
143
|
}
|
|
144
144
|
return { kind: 'role', role };
|
|
145
145
|
}
|
|
@@ -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);
|
|
@@ -69,19 +71,20 @@ export function createAccessRoutes(
|
|
|
69
71
|
workflowService,
|
|
70
72
|
accessControl,
|
|
71
73
|
kbDirName,
|
|
72
|
-
DEFAULT_BRANCH,
|
|
74
|
+
() => DEFAULT_BRANCH,
|
|
73
75
|
eventBus,
|
|
76
|
+
recoveryAdmins,
|
|
74
77
|
);
|
|
75
78
|
|
|
76
|
-
// Roles (
|
|
77
|
-
// screen only ever edits roles.yaml there. Resolve the
|
|
79
|
+
// Roles (plugins) are authoritative on the DEFAULT branch — the Roles admin
|
|
80
|
+
// screen only ever edits roles.yaml there. Resolve the plugin list from the
|
|
78
81
|
// default-branch workspace so the share dialog suggests (and the grant route
|
|
79
82
|
// accepts) the same roles the Roles screen manages, regardless of which branch
|
|
80
83
|
// the caller is viewing. People stay branch-local (the caller's own workspace).
|
|
81
|
-
const
|
|
84
|
+
const defaultBranchPlugins = async (): Promise<string[]> => {
|
|
82
85
|
await workspaceService.getOrCreateForBranch(DEFAULT_BRANCH);
|
|
83
|
-
const {
|
|
84
|
-
return
|
|
86
|
+
const { plugins } = await accessControl.kbPrincipals(workspaceIdForBranch(DEFAULT_BRANCH));
|
|
87
|
+
return plugins;
|
|
85
88
|
};
|
|
86
89
|
|
|
87
90
|
// Authentication gate only. Per PLAN §3 the workspace `:id` is a branch
|
|
@@ -177,7 +180,7 @@ export function createAccessRoutes(
|
|
|
177
180
|
*
|
|
178
181
|
* Every access declaration living INSIDE a folder — the descendant
|
|
179
182
|
* `access.md` files and the node frontmatter that override the folder's own
|
|
180
|
-
* rules for the principals they name. Display-only: the
|
|
183
|
+
* rules for the principals they name. Display-only: the plugin access surface
|
|
181
184
|
* shows it so nobody reads a folder's share list as the whole story.
|
|
182
185
|
*
|
|
183
186
|
* Gating is stricter than the sibling `GET /access`, which hands its eligible
|
|
@@ -267,9 +270,9 @@ export function createAccessRoutes(
|
|
|
267
270
|
|
|
268
271
|
/**
|
|
269
272
|
* GET /api/workspace/:id/access/suggest?q=<query>
|
|
270
|
-
* Autocomplete for the share dialog. Returns matching
|
|
273
|
+
* Autocomplete for the share dialog. Returns matching plugins (roles.yaml role
|
|
271
274
|
* names — resolved from the DEFAULT branch, the authoritative roles source)
|
|
272
|
-
* and people (branch-local to `:id`).
|
|
275
|
+
* and people (branch-local to `:id`). Plugins are small + non-sensitive so they
|
|
273
276
|
* always show;
|
|
274
277
|
* PEOPLE are withheld until `q` is ≥ 2 chars, so an empty query can't dump the
|
|
275
278
|
* whole directory (email-harvesting guard). People are the union of the
|
|
@@ -283,10 +286,10 @@ export function createAccessRoutes(
|
|
|
283
286
|
const q = (typeof req.query.q === 'string' ? req.query.q : '').trim().toLowerCase();
|
|
284
287
|
const CAP = 15;
|
|
285
288
|
try {
|
|
286
|
-
const
|
|
289
|
+
const plugins = await defaultBranchPlugins();
|
|
287
290
|
const { people: kbPeople } = await accessControl.kbPrincipals(req.params.id);
|
|
288
291
|
|
|
289
|
-
const
|
|
292
|
+
const matchedPlugins = plugins
|
|
290
293
|
.filter((g) => !q || g.toLowerCase().includes(q))
|
|
291
294
|
.slice(0, CAP);
|
|
292
295
|
|
|
@@ -311,7 +314,7 @@ export function createAccessRoutes(
|
|
|
311
314
|
.slice(0, CAP);
|
|
312
315
|
}
|
|
313
316
|
|
|
314
|
-
res.json({
|
|
317
|
+
res.json({ plugins: matchedPlugins, people, peopleWithheld: q.length < 2 });
|
|
315
318
|
} catch (err) {
|
|
316
319
|
const { status, body } = toHttpError(err);
|
|
317
320
|
res.status(status).json(body);
|
|
@@ -542,19 +545,19 @@ export function createAccessRoutes(
|
|
|
542
545
|
}
|
|
543
546
|
const { repoRelTarget, kind, principal } = parseMutationBody(req.body);
|
|
544
547
|
|
|
545
|
-
// For a
|
|
546
|
-
// existing
|
|
548
|
+
// For a plugin grant, the role must exist in roles.yaml (Slice 1 grants
|
|
549
|
+
// existing plugins only; Create Plugin is Slice 2). Reject loudly otherwise.
|
|
547
550
|
// Validate against the DEFAULT-branch roles (same authoritative source the
|
|
548
551
|
// suggest autocomplete uses) so a default-branch role can't be suggested
|
|
549
552
|
// then rejected here on a feature branch whose roles.yaml has diverged.
|
|
550
553
|
if (principal.kind === 'role') {
|
|
551
|
-
const
|
|
552
|
-
const known =
|
|
554
|
+
const plugins = await defaultBranchPlugins();
|
|
555
|
+
const known = plugins.some((g) => canonicalRoleName(g) === canonicalRoleName(principal.role));
|
|
553
556
|
if (!known) {
|
|
554
557
|
throw new AccessMutationError(
|
|
555
|
-
`No
|
|
558
|
+
`No plugin named "${principal.role}". Pick an existing plugin, or ask an admin to create it.`,
|
|
556
559
|
404,
|
|
557
|
-
{ kind: 'unknown-
|
|
560
|
+
{ kind: 'unknown-plugin', role: principal.role },
|
|
558
561
|
);
|
|
559
562
|
}
|
|
560
563
|
// The built-in `everyone` role is grantable from the share UI for READ
|
|
@@ -563,7 +566,7 @@ export function createAccessRoutes(
|
|
|
563
566
|
// access.md edit.
|
|
564
567
|
if (canonicalRoleName(principal.role) === EVERYONE_CANONICAL && verb !== 'read') {
|
|
565
568
|
throw new AccessMutationError(
|
|
566
|
-
'"Everyone" can only be granted read access (public). Grant other access levels to specific people or
|
|
569
|
+
'"Everyone" can only be granted read access (public). Grant other access levels to specific people or plugins.',
|
|
567
570
|
);
|
|
568
571
|
}
|
|
569
572
|
}
|
|
@@ -735,7 +738,7 @@ export function createAccessRoutes(
|
|
|
735
738
|
// any source left is an `ancestor`. Distinguish "inherited" (still named in
|
|
736
739
|
// an ancestor access.md — offer cascade/deny) from "no removable source
|
|
737
740
|
// here" (nothing named in any file we can edit — a genuine no-op). A
|
|
738
|
-
// principal who only RESOLVES via a
|
|
741
|
+
// principal who only RESOLVES via a plugin / everyone / admin-rescue has no
|
|
739
742
|
// source at all (it's not their own file entry) and so is correctly a 200
|
|
740
743
|
// no-op: there is nothing to remove here and no Remove was ever offered for
|
|
741
744
|
// them in the dialog. NEVER a silent unchanged-success when there IS a
|
|
@@ -88,8 +88,8 @@ export interface ICreatorAccess {
|
|
|
88
88
|
}
|
|
89
89
|
|
|
90
90
|
/**
|
|
91
|
-
* The splice-safe principal for a creator — exported because
|
|
92
|
-
* provisioning (`
|
|
91
|
+
* The splice-safe principal for a creator — exported because plugin
|
|
92
|
+
* provisioning (`PluginProvisionService`) writes the same `Name <email>`
|
|
93
93
|
* entries into the access.md files it seeds, and two spellings of the same
|
|
94
94
|
* person would read as two people.
|
|
95
95
|
*/
|
|
@@ -169,9 +169,9 @@ export class CreatorAccessService implements ICreatorAccess {
|
|
|
169
169
|
warnSkipped(rel, err);
|
|
170
170
|
return null;
|
|
171
171
|
}
|
|
172
|
-
// NOTE: a direct
|
|
173
|
-
// here.
|
|
174
|
-
// provisioning endpoint (`
|
|
172
|
+
// NOTE: a direct plugin folder (`Plugins/<Name>`) is no longer special
|
|
173
|
+
// here. Plugins — and personal folders — are made by the dedicated
|
|
174
|
+
// provisioning endpoint (`PluginProvisionService`), which writes the
|
|
175
175
|
// full ownership template itself; this generic read-grant only covers
|
|
176
176
|
// ad-hoc folder creation elsewhere in the tree.
|
|
177
177
|
return {
|
|
@@ -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 {
|
|
@@ -119,8 +121,14 @@ export class RolesAdminService {
|
|
|
119
121
|
private readonly workflowService: IWorkflowService,
|
|
120
122
|
private readonly accessControl: IAccessControl,
|
|
121
123
|
private readonly kbDirName: string,
|
|
122
|
-
/**
|
|
123
|
-
|
|
124
|
+
/**
|
|
125
|
+
* The branch whose roles.yaml is authoritative for admin status, read
|
|
126
|
+
* through a thunk rather than taken by value. `DEFAULT_BRANCH` is an ES
|
|
127
|
+
* module live binding that stays empty until `configureBranchModel()` runs
|
|
128
|
+
* at boot; a service constructed before that would freeze the empty string
|
|
129
|
+
* and every roles route would fail branch validation until a restart.
|
|
130
|
+
*/
|
|
131
|
+
private readonly defaultBranchOf: () => string,
|
|
124
132
|
/**
|
|
125
133
|
* Optional so existing tests can construct the service without an event
|
|
126
134
|
* bus. When present, every committed write announces itself so already-open
|
|
@@ -129,8 +137,20 @@ export class RolesAdminService {
|
|
|
129
137
|
* open tab keeps a now-stale read verdict until a manual reload.
|
|
130
138
|
*/
|
|
131
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[] = [],
|
|
132
147
|
) {}
|
|
133
148
|
|
|
149
|
+
/** Resolved per call — see the constructor note on `defaultBranchOf`. */
|
|
150
|
+
private get defaultBranch(): string {
|
|
151
|
+
return this.defaultBranchOf();
|
|
152
|
+
}
|
|
153
|
+
|
|
134
154
|
private get workspaceId(): string {
|
|
135
155
|
return workspaceIdForBranch(this.defaultBranch);
|
|
136
156
|
}
|
|
@@ -329,6 +349,15 @@ export class RolesAdminService {
|
|
|
329
349
|
);
|
|
330
350
|
}
|
|
331
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
|
+
|
|
332
361
|
await this.assertRolesUnlocked(workspaceId, actor);
|
|
333
362
|
|
|
334
363
|
// Back up the corrupted bytes and restore the good default atomically. The
|
|
@@ -338,7 +367,7 @@ export class RolesAdminService {
|
|
|
338
367
|
fsys.writeFiles(
|
|
339
368
|
[
|
|
340
369
|
{ path: `${this.kbDirName}/${OLD_ROLES_YAML}`, content: current },
|
|
341
|
-
{ path: `${this.kbDirName}/${ROLES_YAML}`, content:
|
|
370
|
+
{ path: `${this.kbDirName}/${ROLES_YAML}`, content: renderRecoveryRolesYaml(this.recoveryAdmins) },
|
|
342
371
|
],
|
|
343
372
|
'Bevel recovery: reset corrupted roles.yaml',
|
|
344
373
|
),
|
|
@@ -1,29 +1,29 @@
|
|
|
1
|
-
import express from 'express';
|
|
2
|
-
import type { IAdminAccessService } from './admin.interface.js';
|
|
3
|
-
import '../auth/auth.middleware.js'; // Express Request augmentation
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* The CORE slice of the admin surface: just the admin-status resolver the
|
|
7
|
-
* frontend branches on (AdminMenu's "Admin only" section, the roles-recovery
|
|
8
|
-
* banner). The full admin router (feedback inbox, user administration /
|
|
9
|
-
* GDPR erasure) is an enterprise extension mounted via
|
|
10
|
-
* `ServerExtensions.authed`.
|
|
11
|
-
*
|
|
12
|
-
* NOTE (core split): in bevel-platform this endpoint lives in the enterprise
|
|
13
|
-
* `admin.routes.ts`; core serves it itself so a core-only deployment can
|
|
14
|
-
* resolve admin status. Express keeps first-match-wins, so an enterprise
|
|
15
|
-
* overlay's own `/admin/access` (mounted later) never shadows this one —
|
|
16
|
-
* both resolve through the same {@link IAdminAccessService}.
|
|
17
|
-
*/
|
|
18
|
-
export function createAdminAccessRoutes(adminAccess: IAdminAccessService): express.Router {
|
|
19
|
-
const router = express.Router();
|
|
20
|
-
|
|
21
|
-
// Open to any authenticated user — returns `false` for non-admins instead of
|
|
22
|
-
// 403 so the frontend can branch on the answer without treating a normal
|
|
23
|
-
// user as an error case.
|
|
24
|
-
router.get('/admin/access', async (req, res) => {
|
|
25
|
-
res.json({ isAdmin: await adminAccess.isAdmin(req.userEmail) });
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
return router;
|
|
29
|
-
}
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import type { IAdminAccessService } from './admin.interface.js';
|
|
3
|
+
import '../auth/auth.middleware.js'; // Express Request augmentation
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The CORE slice of the admin surface: just the admin-status resolver the
|
|
7
|
+
* frontend branches on (AdminMenu's "Admin only" section, the roles-recovery
|
|
8
|
+
* banner). The full admin router (feedback inbox, user administration /
|
|
9
|
+
* GDPR erasure) is an enterprise extension mounted via
|
|
10
|
+
* `ServerExtensions.authed`.
|
|
11
|
+
*
|
|
12
|
+
* NOTE (core split): in bevel-platform this endpoint lives in the enterprise
|
|
13
|
+
* `admin.routes.ts`; core serves it itself so a core-only deployment can
|
|
14
|
+
* resolve admin status. Express keeps first-match-wins, so an enterprise
|
|
15
|
+
* overlay's own `/admin/access` (mounted later) never shadows this one —
|
|
16
|
+
* both resolve through the same {@link IAdminAccessService}.
|
|
17
|
+
*/
|
|
18
|
+
export function createAdminAccessRoutes(adminAccess: IAdminAccessService): express.Router {
|
|
19
|
+
const router = express.Router();
|
|
20
|
+
|
|
21
|
+
// Open to any authenticated user — returns `false` for non-admins instead of
|
|
22
|
+
// 403 so the frontend can branch on the answer without treating a normal
|
|
23
|
+
// user as an error case.
|
|
24
|
+
router.get('/admin/access', async (req, res) => {
|
|
25
|
+
res.json({ isAdmin: await adminAccess.isAdmin(req.userEmail) });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
return router;
|
|
29
|
+
}
|
|
@@ -1,91 +1,91 @@
|
|
|
1
|
-
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
-
import express from 'express';
|
|
3
|
-
import type { Server } from 'node:http';
|
|
4
|
-
import { createAuthRoutes } from '../auth.routes.js';
|
|
5
|
-
import type { AuthService } from '../auth.service.js';
|
|
6
|
-
|
|
7
|
-
const authService = {
|
|
8
|
-
loginWithPassword: vi.fn(async () => {
|
|
9
|
-
throw new Error('Invalid credentials');
|
|
10
|
-
}),
|
|
11
|
-
changePassword: vi.fn(async () => {
|
|
12
|
-
throw new Error('Current password is incorrect');
|
|
13
|
-
}),
|
|
14
|
-
getUserById: vi.fn(async () => null),
|
|
15
|
-
} as unknown as AuthService;
|
|
16
|
-
|
|
17
|
-
const fakeAuthMiddleware: express.RequestHandler = (req, _res, next) => {
|
|
18
|
-
req.userId = 'user-1';
|
|
19
|
-
req.userEmail = 'alice@example.com';
|
|
20
|
-
next();
|
|
21
|
-
};
|
|
22
|
-
|
|
23
|
-
let server: Server;
|
|
24
|
-
beforeEach(() => {
|
|
25
|
-
vi.mocked(authService.loginWithPassword).mockClear();
|
|
26
|
-
});
|
|
27
|
-
afterEach(() => {
|
|
28
|
-
server?.close();
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
async function listen(): Promise<string> {
|
|
32
|
-
const app = express();
|
|
33
|
-
app.use(express.json());
|
|
34
|
-
app.use('/api', createAuthRoutes(authService, fakeAuthMiddleware));
|
|
35
|
-
await new Promise<void>((resolve) => {
|
|
36
|
-
server = app.listen(0, resolve);
|
|
37
|
-
});
|
|
38
|
-
const address = server.address();
|
|
39
|
-
if (typeof address === 'string' || !address) throw new Error('no port');
|
|
40
|
-
return `http://127.0.0.1:${address.port}`;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
function postLogin(base: string, email: string, password = 'wrong') {
|
|
44
|
-
return fetch(`${base}/api/auth/login`, {
|
|
45
|
-
method: 'POST',
|
|
46
|
-
headers: { 'Content-Type': 'application/json' },
|
|
47
|
-
body: JSON.stringify({ email, password }),
|
|
48
|
-
});
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
describe('auth routes — rate limiting', () => {
|
|
52
|
-
it('429s login for an (ip, email) pair after repeated failures; other emails unaffected', async () => {
|
|
53
|
-
const base = await listen();
|
|
54
|
-
for (let i = 0; i < 10; i++) {
|
|
55
|
-
expect((await postLogin(base, 'alice@example.com')).status).toBe(401);
|
|
56
|
-
}
|
|
57
|
-
const limited = await postLogin(base, 'alice@example.com');
|
|
58
|
-
expect(limited.status).toBe(429);
|
|
59
|
-
// The service is not even consulted once the limit is hit.
|
|
60
|
-
expect(authService.loginWithPassword).toHaveBeenCalledTimes(10);
|
|
61
|
-
// A different email from the same address still has pair budget (the
|
|
62
|
-
// wider per-ip budget is 50).
|
|
63
|
-
expect((await postLogin(base, 'bob@example.com')).status).toBe(401);
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
it('a successful login resets the pair budget', async () => {
|
|
67
|
-
const base = await listen();
|
|
68
|
-
for (let i = 0; i < 9; i++) await postLogin(base, 'alice@example.com');
|
|
69
|
-
vi.mocked(authService.loginWithPassword).mockResolvedValueOnce({
|
|
70
|
-
token: 'jwt',
|
|
71
|
-
user: { id: 'user-1', email: 'alice@example.com', name: 'Alice' },
|
|
72
|
-
});
|
|
73
|
-
expect((await postLogin(base, 'alice@example.com', 'right')).status).toBe(200);
|
|
74
|
-
// Budget is fresh again — failures start counting from zero.
|
|
75
|
-
expect((await postLogin(base, 'alice@example.com')).status).toBe(401);
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
it('429s change-password per account after repeated failures', async () => {
|
|
79
|
-
const base = await listen();
|
|
80
|
-
const post = () =>
|
|
81
|
-
fetch(`${base}/api/auth/change-password`, {
|
|
82
|
-
method: 'POST',
|
|
83
|
-
headers: { 'Content-Type': 'application/json' },
|
|
84
|
-
body: JSON.stringify({ currentPassword: 'wrong', newPassword: 'new-password-1' }),
|
|
85
|
-
});
|
|
86
|
-
for (let i = 0; i < 10; i++) {
|
|
87
|
-
expect((await post()).status).toBe(400);
|
|
88
|
-
}
|
|
89
|
-
expect((await post()).status).toBe(429);
|
|
90
|
-
});
|
|
91
|
-
});
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import express from 'express';
|
|
3
|
+
import type { Server } from 'node:http';
|
|
4
|
+
import { createAuthRoutes } from '../auth.routes.js';
|
|
5
|
+
import type { AuthService } from '../auth.service.js';
|
|
6
|
+
|
|
7
|
+
const authService = {
|
|
8
|
+
loginWithPassword: vi.fn(async () => {
|
|
9
|
+
throw new Error('Invalid credentials');
|
|
10
|
+
}),
|
|
11
|
+
changePassword: vi.fn(async () => {
|
|
12
|
+
throw new Error('Current password is incorrect');
|
|
13
|
+
}),
|
|
14
|
+
getUserById: vi.fn(async () => null),
|
|
15
|
+
} as unknown as AuthService;
|
|
16
|
+
|
|
17
|
+
const fakeAuthMiddleware: express.RequestHandler = (req, _res, next) => {
|
|
18
|
+
req.userId = 'user-1';
|
|
19
|
+
req.userEmail = 'alice@example.com';
|
|
20
|
+
next();
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
let server: Server;
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
vi.mocked(authService.loginWithPassword).mockClear();
|
|
26
|
+
});
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
server?.close();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
async function listen(): Promise<string> {
|
|
32
|
+
const app = express();
|
|
33
|
+
app.use(express.json());
|
|
34
|
+
app.use('/api', createAuthRoutes(authService, fakeAuthMiddleware));
|
|
35
|
+
await new Promise<void>((resolve) => {
|
|
36
|
+
server = app.listen(0, resolve);
|
|
37
|
+
});
|
|
38
|
+
const address = server.address();
|
|
39
|
+
if (typeof address === 'string' || !address) throw new Error('no port');
|
|
40
|
+
return `http://127.0.0.1:${address.port}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function postLogin(base: string, email: string, password = 'wrong') {
|
|
44
|
+
return fetch(`${base}/api/auth/login`, {
|
|
45
|
+
method: 'POST',
|
|
46
|
+
headers: { 'Content-Type': 'application/json' },
|
|
47
|
+
body: JSON.stringify({ email, password }),
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe('auth routes — rate limiting', () => {
|
|
52
|
+
it('429s login for an (ip, email) pair after repeated failures; other emails unaffected', async () => {
|
|
53
|
+
const base = await listen();
|
|
54
|
+
for (let i = 0; i < 10; i++) {
|
|
55
|
+
expect((await postLogin(base, 'alice@example.com')).status).toBe(401);
|
|
56
|
+
}
|
|
57
|
+
const limited = await postLogin(base, 'alice@example.com');
|
|
58
|
+
expect(limited.status).toBe(429);
|
|
59
|
+
// The service is not even consulted once the limit is hit.
|
|
60
|
+
expect(authService.loginWithPassword).toHaveBeenCalledTimes(10);
|
|
61
|
+
// A different email from the same address still has pair budget (the
|
|
62
|
+
// wider per-ip budget is 50).
|
|
63
|
+
expect((await postLogin(base, 'bob@example.com')).status).toBe(401);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('a successful login resets the pair budget', async () => {
|
|
67
|
+
const base = await listen();
|
|
68
|
+
for (let i = 0; i < 9; i++) await postLogin(base, 'alice@example.com');
|
|
69
|
+
vi.mocked(authService.loginWithPassword).mockResolvedValueOnce({
|
|
70
|
+
token: 'jwt',
|
|
71
|
+
user: { id: 'user-1', email: 'alice@example.com', name: 'Alice' },
|
|
72
|
+
});
|
|
73
|
+
expect((await postLogin(base, 'alice@example.com', 'right')).status).toBe(200);
|
|
74
|
+
// Budget is fresh again — failures start counting from zero.
|
|
75
|
+
expect((await postLogin(base, 'alice@example.com')).status).toBe(401);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('429s change-password per account after repeated failures', async () => {
|
|
79
|
+
const base = await listen();
|
|
80
|
+
const post = () =>
|
|
81
|
+
fetch(`${base}/api/auth/change-password`, {
|
|
82
|
+
method: 'POST',
|
|
83
|
+
headers: { 'Content-Type': 'application/json' },
|
|
84
|
+
body: JSON.stringify({ currentPassword: 'wrong', newPassword: 'new-password-1' }),
|
|
85
|
+
});
|
|
86
|
+
for (let i = 0; i < 10; i++) {
|
|
87
|
+
expect((await post()).status).toBe(400);
|
|
88
|
+
}
|
|
89
|
+
expect((await post()).status).toBe(429);
|
|
90
|
+
});
|
|
91
|
+
});
|