@bevel-software/platform-core-backend 0.9.1 → 0.11.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/THIRD-PARTY-NOTICES.md +2 -2
- package/dist/core/create-core-server.d.ts.map +1 -1
- package/dist/core/create-core-server.js +16 -1
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/core/create-core-services.d.ts +25 -0
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js +45 -1
- package/dist/core/create-core-services.js.map +1 -1
- package/dist/core-config.d.ts +19 -1
- package/dist/core-config.d.ts.map +1 -1
- package/dist/core-config.js +44 -4
- package/dist/core-config.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/modules/access/access-control.interface.d.ts +54 -28
- package/dist/modules/access/access-control.interface.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.d.ts +129 -14
- package/dist/modules/access/access-control.service.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.js +435 -66
- package/dist/modules/access/access-control.service.js.map +1 -1
- package/dist/modules/access/access-declarations.d.ts.map +1 -1
- package/dist/modules/access/access-declarations.js +5 -3
- package/dist/modules/access/access-declarations.js.map +1 -1
- package/dist/modules/access/access-mutation.service.d.ts +39 -6
- package/dist/modules/access/access-mutation.service.d.ts.map +1 -1
- package/dist/modules/access/access-mutation.service.js +78 -18
- package/dist/modules/access/access-mutation.service.js.map +1 -1
- package/dist/modules/access/access-splice.d.ts +31 -4
- package/dist/modules/access/access-splice.d.ts.map +1 -1
- package/dist/modules/access/access-splice.js +40 -16
- 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 +204 -82
- package/dist/modules/access/access.routes.js.map +1 -1
- package/dist/modules/access/admin-locked-commit.d.ts +134 -0
- package/dist/modules/access/admin-locked-commit.d.ts.map +1 -0
- package/dist/modules/access/admin-locked-commit.js +277 -0
- package/dist/modules/access/admin-locked-commit.js.map +1 -0
- package/dist/modules/access/admin-route-helpers.d.ts +32 -0
- package/dist/modules/access/admin-route-helpers.d.ts.map +1 -0
- package/dist/modules/access/admin-route-helpers.js +44 -0
- package/dist/modules/access/admin-route-helpers.js.map +1 -0
- package/dist/modules/access/capability-registry.d.ts +41 -0
- package/dist/modules/access/capability-registry.d.ts.map +1 -0
- package/dist/modules/access/capability-registry.js +46 -0
- package/dist/modules/access/capability-registry.js.map +1 -0
- package/dist/modules/access/directory-sync-bot.d.ts +13 -0
- package/dist/modules/access/directory-sync-bot.d.ts.map +1 -0
- package/dist/modules/access/directory-sync-bot.js +64 -0
- package/dist/modules/access/directory-sync-bot.js.map +1 -0
- package/dist/modules/access/group-files.d.ts +83 -0
- package/dist/modules/access/group-files.d.ts.map +1 -0
- package/dist/modules/access/group-files.js +167 -0
- package/dist/modules/access/group-files.js.map +1 -0
- package/dist/modules/access/groups-admin.routes.d.ts +19 -0
- package/dist/modules/access/groups-admin.routes.d.ts.map +1 -0
- package/dist/modules/access/groups-admin.routes.js +98 -0
- package/dist/modules/access/groups-admin.routes.js.map +1 -0
- package/dist/modules/access/groups-admin.service.d.ts +166 -0
- package/dist/modules/access/groups-admin.service.d.ts.map +1 -0
- package/dist/modules/access/groups-admin.service.js +442 -0
- package/dist/modules/access/groups-admin.service.js.map +1 -0
- package/dist/modules/access/groups-edit.d.ts +58 -0
- package/dist/modules/access/groups-edit.d.ts.map +1 -0
- package/dist/modules/access/groups-edit.js +162 -0
- package/dist/modules/access/groups-edit.js.map +1 -0
- package/dist/modules/access/reference-scan.d.ts +141 -0
- package/dist/modules/access/reference-scan.d.ts.map +1 -0
- package/dist/modules/access/reference-scan.js +440 -0
- package/dist/modules/access/reference-scan.js.map +1 -0
- package/dist/modules/access/roles-admin.service.d.ts +88 -119
- package/dist/modules/access/roles-admin.service.d.ts.map +1 -1
- package/dist/modules/access/roles-admin.service.js +230 -384
- package/dist/modules/access/roles-admin.service.js.map +1 -1
- package/dist/modules/access/roles-edit.d.ts +51 -25
- package/dist/modules/access/roles-edit.d.ts.map +1 -1
- package/dist/modules/access/roles-edit.js +133 -59
- package/dist/modules/access/roles-edit.js.map +1 -1
- package/dist/modules/access/synced-groups-committer.d.ts +28 -0
- package/dist/modules/access/synced-groups-committer.d.ts.map +1 -0
- package/dist/modules/access/synced-groups-committer.js +139 -0
- package/dist/modules/access/synced-groups-committer.js.map +1 -0
- package/dist/modules/access/synced-groups-writer.d.ts +78 -0
- package/dist/modules/access/synced-groups-writer.d.ts.map +1 -0
- package/dist/modules/access/synced-groups-writer.js +219 -0
- package/dist/modules/access/synced-groups-writer.js.map +1 -0
- package/dist/modules/database/core-schema.d.ts +17 -0
- package/dist/modules/database/core-schema.d.ts.map +1 -1
- package/dist/modules/database/core-schema.js +9 -0
- package/dist/modules/database/core-schema.js.map +1 -1
- package/dist/modules/mcp/mcp-auth.middleware.d.ts +13 -2
- package/dist/modules/mcp/mcp-auth.middleware.d.ts.map +1 -1
- package/dist/modules/mcp/mcp-auth.middleware.js +61 -2
- package/dist/modules/mcp/mcp-auth.middleware.js.map +1 -1
- package/dist/modules/mcp/mcp.routes.d.ts +9 -3
- package/dist/modules/mcp/mcp.routes.d.ts.map +1 -1
- package/dist/modules/mcp/mcp.routes.js +126 -2
- package/dist/modules/mcp/mcp.routes.js.map +1 -1
- package/dist/modules/mcp/mcp.service.d.ts +14 -0
- package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
- package/dist/modules/mcp/mcp.service.js +6 -1
- package/dist/modules/mcp/mcp.service.js.map +1 -1
- package/dist/modules/update-check/update-check.routes.d.ts +13 -0
- package/dist/modules/update-check/update-check.routes.d.ts.map +1 -0
- package/dist/modules/update-check/update-check.routes.js +21 -0
- package/dist/modules/update-check/update-check.routes.js.map +1 -0
- package/dist/modules/update-check/update-check.service.d.ts +57 -0
- package/dist/modules/update-check/update-check.service.d.ts.map +1 -0
- package/dist/modules/update-check/update-check.service.js +109 -0
- package/dist/modules/update-check/update-check.service.js.map +1 -0
- package/dist/modules/workflow/file-lock.service.d.ts +11 -1
- package/dist/modules/workflow/file-lock.service.d.ts.map +1 -1
- package/dist/modules/workflow/file-lock.service.js +15 -1
- package/dist/modules/workflow/file-lock.service.js.map +1 -1
- package/dist/modules/workflow/git/git.service.d.ts +34 -11
- package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/git.service.js +173 -37
- package/dist/modules/workflow/git/git.service.js.map +1 -1
- package/dist/modules/workflow/locking-filesystem.d.ts +4 -0
- package/dist/modules/workflow/locking-filesystem.d.ts.map +1 -1
- package/dist/modules/workflow/locking-filesystem.js +181 -28
- package/dist/modules/workflow/locking-filesystem.js.map +1 -1
- package/dist/modules/workflow/pending-commits.service.d.ts +10 -0
- package/dist/modules/workflow/pending-commits.service.d.ts.map +1 -1
- package/dist/modules/workflow/pending-commits.service.js +19 -1
- package/dist/modules/workflow/pending-commits.service.js.map +1 -1
- package/dist/modules/workflow/workflow.service.d.ts +17 -2
- package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.service.js +113 -19
- package/dist/modules/workflow/workflow.service.js.map +1 -1
- package/dist/version.d.ts +14 -0
- package/dist/version.d.ts.map +1 -1
- package/dist/version.js +25 -0
- package/dist/version.js.map +1 -1
- package/kb-template/AGENTS.md +4 -1
- package/migrations/0004_file_lock_mode.sql +2 -0
- package/migrations/meta/0004_snapshot.json +1564 -0
- package/migrations/meta/_journal.json +7 -0
- package/package.json +4 -4
- package/src/__tests__/core-config.domain.test.ts +92 -0
- package/src/core/create-core-server.ts +21 -0
- package/src/core/create-core-services.ts +82 -0
- package/src/core-config.ts +48 -4
- package/src/index.ts +10 -0
- package/src/modules/access/__tests__/access-control.service.test.ts +9 -3
- package/src/modules/access/__tests__/access-groups.test.ts +427 -0
- package/src/modules/access/__tests__/access-mutation.service.test.ts +171 -5
- package/src/modules/access/__tests__/access-splice.test.ts +65 -0
- package/src/modules/access/__tests__/access.routes.group-grant.test.ts +337 -0
- package/src/modules/access/__tests__/access.routes.revoke.test.ts +48 -0
- package/src/modules/access/__tests__/admin-locked-commit.test.ts +221 -0
- package/src/modules/access/__tests__/admin-route-helpers.test.ts +61 -0
- package/src/modules/access/__tests__/directory-sync-bot.test.ts +106 -0
- package/src/modules/access/__tests__/grant-sources.test.ts +67 -0
- package/src/modules/access/__tests__/groups-admin.service.test.ts +440 -0
- package/src/modules/access/__tests__/reference-scan.test.ts +288 -0
- package/src/modules/access/__tests__/roles-admin.service.test.ts +161 -83
- package/src/modules/access/__tests__/roles-capabilities.test.ts +325 -0
- package/src/modules/access/__tests__/roles-edit.test.ts +104 -28
- package/src/modules/access/__tests__/roles.routes.test.ts +63 -40
- package/src/modules/access/__tests__/synced-groups-committer.test.ts +238 -0
- package/src/modules/access/__tests__/synced-groups-writer.test.ts +249 -0
- package/src/modules/access/access-control.interface.ts +66 -32
- package/src/modules/access/access-control.service.ts +536 -73
- package/src/modules/access/access-declarations.ts +5 -2
- package/src/modules/access/access-mutation.service.ts +88 -14
- package/src/modules/access/access-splice.ts +55 -17
- package/src/modules/access/access.routes.ts +227 -93
- package/src/modules/access/admin-locked-commit.ts +331 -0
- package/src/modules/access/admin-route-helpers.ts +55 -0
- package/src/modules/access/capability-registry.ts +74 -0
- package/src/modules/access/directory-sync-bot.ts +76 -0
- package/src/modules/access/group-files.ts +212 -0
- package/src/modules/access/groups-admin.routes.ts +113 -0
- package/src/modules/access/groups-admin.service.ts +551 -0
- package/src/modules/access/groups-edit.ts +187 -0
- package/src/modules/access/reference-scan.ts +513 -0
- package/src/modules/access/roles-admin.service.ts +290 -418
- package/src/modules/access/roles-edit.ts +134 -61
- package/src/modules/access/synced-groups-committer.ts +177 -0
- package/src/modules/access/synced-groups-writer.ts +303 -0
- package/src/modules/database/core-schema.ts +9 -0
- package/src/modules/mcp/__tests__/mcp-auth.middleware.test.ts +116 -0
- package/src/modules/mcp/__tests__/mcp.e2e.test.ts +3 -1
- package/src/modules/mcp/__tests__/mcp.routes.delete.test.ts +6 -1
- package/src/modules/mcp/__tests__/mcp.routes.local-token.test.ts +301 -0
- package/src/modules/mcp/mcp-auth.middleware.ts +61 -1
- package/src/modules/mcp/mcp.routes.ts +137 -2
- package/src/modules/mcp/mcp.service.ts +6 -1
- package/src/modules/update-check/__tests__/update-check.routes.test.ts +69 -0
- package/src/modules/update-check/__tests__/update-check.service.test.ts +170 -0
- package/src/modules/update-check/update-check.routes.ts +28 -0
- package/src/modules/update-check/update-check.service.ts +130 -0
- package/src/modules/workflow/__tests__/locking-filesystem.test.ts +336 -0
- package/src/modules/workflow/__tests__/preserve-roles-yaml.test.ts +1 -1
- package/src/modules/workflow/__tests__/workflow.service.commitFileWhileLocked.test.ts +32 -0
- package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +13 -2
- package/src/modules/workflow/__tests__/workflow.service.releaseLock.test.ts +139 -1
- package/src/modules/workflow/file-lock.service.ts +15 -0
- package/src/modules/workflow/git/__tests__/git.service.accessGating.test.ts +0 -1
- package/src/modules/workflow/git/__tests__/git.service.commitChanges.test.ts +132 -0
- package/src/modules/workflow/git/__tests__/git.service.deleteBranch.test.ts +0 -1
- package/src/modules/workflow/git/__tests__/pull-request.service.test.ts +0 -1
- package/src/modules/workflow/git/git.service.ts +182 -35
- package/src/modules/workflow/locking-filesystem.ts +188 -26
- package/src/modules/workflow/pending-commits.service.ts +27 -1
- package/src/modules/workflow/review-workflow/__tests__/approval-states.test.ts +0 -1
- package/src/modules/workflow/review-workflow/__tests__/cancel-pr.test.ts +0 -1
- package/src/modules/workflow/workflow.service.ts +140 -20
- package/src/version.ts +28 -0
|
@@ -19,9 +19,15 @@ import {
|
|
|
19
19
|
accessMdPathForFolder,
|
|
20
20
|
type TargetKind,
|
|
21
21
|
} from './access-mutation.service.js';
|
|
22
|
-
import {
|
|
22
|
+
import {
|
|
23
|
+
canonicalRoleName,
|
|
24
|
+
EVERYONE_CANONICAL,
|
|
25
|
+
ROLE_TOKEN_PREFIX,
|
|
26
|
+
type Verb,
|
|
27
|
+
} from './access-control.service.js';
|
|
23
28
|
import { listAccessDeclarationsUnder } from './access-declarations.js';
|
|
24
|
-
import {
|
|
29
|
+
import { toHttpError as sharedToHttpError, requireNonEmptyString as sharedRequireNonEmptyString } from './admin-route-helpers.js';
|
|
30
|
+
import { RolesAdminService } from './roles-admin.service.js';
|
|
25
31
|
import type { Principal } from './access-splice.js';
|
|
26
32
|
import type { Database } from '../database/connection.js';
|
|
27
33
|
import { users } from '../database/schema.js';
|
|
@@ -30,27 +36,16 @@ import '../auth/auth.middleware.js';
|
|
|
30
36
|
/** Verbs the share UI may grant. Verbs are independent — `download` is grantable on its own. */
|
|
31
37
|
const GRANTABLE_VERBS = new Set<string>(['read', 'write', 'owner', 'download']);
|
|
32
38
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
}
|
|
39
|
-
const message = err instanceof Error ? err.message : 'Unknown error';
|
|
40
|
-
return { status: 500, body: { error: message } };
|
|
39
|
+
/** Shared access-family error shape (see admin-route-helpers): typed domain
|
|
40
|
+
* errors render themselves; anything else is logged and answered generically
|
|
41
|
+
* — a raw `err.message` never leaks through a 500. */
|
|
42
|
+
function toHttpError(err: unknown): { status: number; body: Record<string, unknown> } {
|
|
43
|
+
return sharedToHttpError(err, 'access');
|
|
41
44
|
}
|
|
42
45
|
|
|
43
|
-
/**
|
|
44
|
-
* Coerce a request-body field that must be a non-empty string. Rejects arrays,
|
|
45
|
-
* objects, numbers, null — anything `String()` would silently stringify into a
|
|
46
|
-
* bogus role name / email (`[object Object]`, `"a,b"`) — with a 400 instead of
|
|
47
|
-
* persisting garbage. Returns the raw string (untrimmed; the service trims).
|
|
48
|
-
*/
|
|
46
|
+
/** Shared non-empty-string coercion; 400s render as RolesAdminError-family. */
|
|
49
47
|
function requireNonEmptyString(value: unknown, field: string): string {
|
|
50
|
-
|
|
51
|
-
throw new RolesAdminError(`${field} must be a non-empty string`, 400);
|
|
52
|
-
}
|
|
53
|
-
return value;
|
|
48
|
+
return sharedRequireNonEmptyString(value, field);
|
|
54
49
|
}
|
|
55
50
|
|
|
56
51
|
export function createAccessRoutes(
|
|
@@ -76,15 +71,62 @@ export function createAccessRoutes(
|
|
|
76
71
|
recoveryAdmins,
|
|
77
72
|
);
|
|
78
73
|
|
|
79
|
-
// Roles
|
|
80
|
-
// screen only ever edits roles.yaml there
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
// the
|
|
84
|
-
|
|
74
|
+
// Roles and groups are authoritative on the DEFAULT branch — the Roles
|
|
75
|
+
// admin screen only ever edits roles.yaml there, and the ACTIVE group
|
|
76
|
+
// source (IdP-synced or manual) lives there. Resolve both from the
|
|
77
|
+
// default-branch workspace's CACHED resolver model (one call, no per-
|
|
78
|
+
// keystroke file reads) so the share dialog suggests — and the grant route
|
|
79
|
+
// accepts — the same principals the admin screens manage, regardless of
|
|
80
|
+
// which branch the caller is viewing. People stay branch-local (the
|
|
81
|
+
// caller's own workspace).
|
|
82
|
+
const defaultBranchPrincipals = async (): Promise<{ roles: string[]; groups: string[] }> => {
|
|
85
83
|
await workspaceService.getOrCreateForBranch(DEFAULT_BRANCH);
|
|
86
|
-
const {
|
|
87
|
-
return
|
|
84
|
+
const { roles, groups } = await accessControl.kbPrincipals(workspaceIdForBranch(DEFAULT_BRANCH));
|
|
85
|
+
return { roles, groups };
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* What the mutation routes accept as a principal. `group` exists only at
|
|
90
|
+
* this boundary: in the access.md entry grammar a group grant is a
|
|
91
|
+
* bare-name token (bare names resolve GROUP-FIRST, then fall back to the
|
|
92
|
+
* role), so it is spliced as a role-shaped principal — the separate kind
|
|
93
|
+
* buys validation against the right namespace and honest 404s.
|
|
94
|
+
*/
|
|
95
|
+
type RoutePrincipal = Principal | { kind: 'group'; group: string };
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The role-shaped principal the splice layer actually writes/matches.
|
|
99
|
+
* - group → its bare name (group-first precedence resolves it).
|
|
100
|
+
* - role → the explicit `role/<Name>` token — the grant route WRITES
|
|
101
|
+
* roles that way from now on (bare role tokens remain
|
|
102
|
+
* read-compatible). The built-in `everyone` stays bare: it
|
|
103
|
+
* is not a roles.yaml role and has no `role/` alias.
|
|
104
|
+
*/
|
|
105
|
+
const asSplicePrincipal = (p: RoutePrincipal): Principal => {
|
|
106
|
+
if (p.kind === 'group') return { kind: 'role', role: p.group };
|
|
107
|
+
if (p.kind === 'role') {
|
|
108
|
+
const canonical = canonicalRoleName(p.role);
|
|
109
|
+
const bare = canonical.startsWith(ROLE_TOKEN_PREFIX)
|
|
110
|
+
? canonical.slice(ROLE_TOKEN_PREFIX.length)
|
|
111
|
+
: canonical;
|
|
112
|
+
// The built-in `everyone` has NO `role/` alias in the principal index
|
|
113
|
+
// (it is not a roles.yaml role), so `role/everyone` would be a DEAD
|
|
114
|
+
// token — granted but resolving to nothing. Normalize either spelling
|
|
115
|
+
// to the bare built-in (keeping the caller's casing, e.g. `Everyone`),
|
|
116
|
+
// on grant and revoke symmetrically.
|
|
117
|
+
if (bare === EVERYONE_CANONICAL) {
|
|
118
|
+
const raw = p.role.trim();
|
|
119
|
+
return {
|
|
120
|
+
kind: 'role',
|
|
121
|
+
role: canonical.startsWith(ROLE_TOKEN_PREFIX)
|
|
122
|
+
? raw.slice(ROLE_TOKEN_PREFIX.length).trim()
|
|
123
|
+
: raw,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
if (canonical.startsWith(ROLE_TOKEN_PREFIX)) return p;
|
|
127
|
+
return { kind: 'role', role: `${ROLE_TOKEN_PREFIX}${p.role.trim()}` };
|
|
128
|
+
}
|
|
129
|
+
return p;
|
|
88
130
|
};
|
|
89
131
|
|
|
90
132
|
// Authentication gate only. Per PLAN §3 the workspace `:id` is a branch
|
|
@@ -180,7 +222,7 @@ export function createAccessRoutes(
|
|
|
180
222
|
*
|
|
181
223
|
* Every access declaration living INSIDE a folder — the descendant
|
|
182
224
|
* `access.md` files and the node frontmatter that override the folder's own
|
|
183
|
-
* rules for the principals they name. Display-only: the
|
|
225
|
+
* rules for the principals they name. Display-only: the folder access surface
|
|
184
226
|
* shows it so nobody reads a folder's share list as the whole story.
|
|
185
227
|
*
|
|
186
228
|
* Gating is stricter than the sibling `GET /access`, which hands its eligible
|
|
@@ -270,14 +312,19 @@ export function createAccessRoutes(
|
|
|
270
312
|
|
|
271
313
|
/**
|
|
272
314
|
* GET /api/workspace/:id/access/suggest?q=<query>
|
|
273
|
-
* Autocomplete for the share dialog. Returns matching
|
|
274
|
-
* names — resolved from the DEFAULT branch, the authoritative
|
|
275
|
-
*
|
|
276
|
-
*
|
|
277
|
-
*
|
|
278
|
-
*
|
|
279
|
-
*
|
|
280
|
-
*
|
|
315
|
+
* Autocomplete for the share dialog. Returns matching roles (roles.yaml role
|
|
316
|
+
* names — resolved from the DEFAULT branch, the authoritative source),
|
|
317
|
+
* groups (the ACTIVE group source, same authority), and people (branch-local
|
|
318
|
+
* to `:id`). Roles/groups come from the resolver's CACHED model — no fresh
|
|
319
|
+
* file reads per keystroke. Roles + groups are small + non-sensitive so they
|
|
320
|
+
* always show; PEOPLE are withheld until `q` is ≥ 2 chars, so an empty query
|
|
321
|
+
* can't dump the whole directory (email-harvesting guard). People are the
|
|
322
|
+
* union of the KB-canonical set (roles.yaml + access.md grants) and the
|
|
323
|
+
* `users` table (logged-in users). Results are capped.
|
|
324
|
+
*
|
|
325
|
+
* A name shared by a group and a role is offered as BOTH — nothing is
|
|
326
|
+
* withheld: grant precedence resolves the collision (bare token = the
|
|
327
|
+
* group; the role is written as `role/<Name>`).
|
|
281
328
|
*/
|
|
282
329
|
router.get('/workspace/:id/access/suggest', async (req, res) => {
|
|
283
330
|
const user = await requireUser(req, res);
|
|
@@ -286,10 +333,20 @@ export function createAccessRoutes(
|
|
|
286
333
|
const q = (typeof req.query.q === 'string' ? req.query.q : '').trim().toLowerCase();
|
|
287
334
|
const CAP = 15;
|
|
288
335
|
try {
|
|
289
|
-
|
|
290
|
-
|
|
336
|
+
// Independent lookups batched in ONE Promise.all: the default-branch
|
|
337
|
+
// principals (cached model), the branch-local KB people (cached model),
|
|
338
|
+
// and the users table (only consulted once the query is long enough).
|
|
339
|
+
const [{ roles, groups }, { people: kbPeople }, userRows] = await Promise.all([
|
|
340
|
+
defaultBranchPrincipals(),
|
|
341
|
+
accessControl.kbPrincipals(req.params.id),
|
|
342
|
+
q.length >= 2 ? db.select().from(users) : Promise.resolve([]),
|
|
343
|
+
]);
|
|
344
|
+
|
|
345
|
+
const matchedRoles = roles
|
|
346
|
+
.filter((g) => !q || g.toLowerCase().includes(q))
|
|
347
|
+
.slice(0, CAP);
|
|
291
348
|
|
|
292
|
-
const
|
|
349
|
+
const matchedGroups = groups
|
|
293
350
|
.filter((g) => !q || g.toLowerCase().includes(q))
|
|
294
351
|
.slice(0, CAP);
|
|
295
352
|
|
|
@@ -298,7 +355,6 @@ export function createAccessRoutes(
|
|
|
298
355
|
// Union the KB-canonical people with the login-only users table.
|
|
299
356
|
const byEmail = new Map<string, { name: string; email: string }>();
|
|
300
357
|
for (const p of kbPeople) byEmail.set(p.email.toLowerCase(), p);
|
|
301
|
-
const userRows = await db.select().from(users);
|
|
302
358
|
for (const u of userRows) {
|
|
303
359
|
const key = u.email.toLowerCase();
|
|
304
360
|
// Prefer a real display name over the email local-part default: only
|
|
@@ -314,7 +370,16 @@ export function createAccessRoutes(
|
|
|
314
370
|
.slice(0, CAP);
|
|
315
371
|
}
|
|
316
372
|
|
|
317
|
-
res.json({
|
|
373
|
+
res.json({
|
|
374
|
+
roles: matchedRoles,
|
|
375
|
+
groups: matchedGroups,
|
|
376
|
+
people,
|
|
377
|
+
peopleWithheld: q.length < 2,
|
|
378
|
+
// DEPRECATED alias of `roles` — the shipped share dialog still reads
|
|
379
|
+
// `plugins`. Kept populated for ONE release; remove in 0.2.0 together
|
|
380
|
+
// with the dialog's rename to `roles`.
|
|
381
|
+
plugins: matchedRoles,
|
|
382
|
+
});
|
|
318
383
|
} catch (err) {
|
|
319
384
|
const { status, body } = toHttpError(err);
|
|
320
385
|
res.status(status).json(body);
|
|
@@ -328,7 +393,7 @@ export function createAccessRoutes(
|
|
|
328
393
|
function parseMutationBody(body: unknown): {
|
|
329
394
|
repoRelTarget: string;
|
|
330
395
|
kind: TargetKind;
|
|
331
|
-
principal:
|
|
396
|
+
principal: RoutePrincipal;
|
|
332
397
|
} {
|
|
333
398
|
const b = (body ?? {}) as Record<string, unknown>;
|
|
334
399
|
const rawPath = b.path;
|
|
@@ -343,7 +408,7 @@ export function createAccessRoutes(
|
|
|
343
408
|
if (!principalRaw || typeof principalRaw !== 'object') {
|
|
344
409
|
throw new AccessMutationError('principal is required');
|
|
345
410
|
}
|
|
346
|
-
let principal:
|
|
411
|
+
let principal: RoutePrincipal;
|
|
347
412
|
if (principalRaw.kind === 'user') {
|
|
348
413
|
if (typeof principalRaw.email !== 'string' || typeof principalRaw.displayName !== 'string') {
|
|
349
414
|
throw new AccessMutationError('user principal needs email + displayName');
|
|
@@ -358,8 +423,13 @@ export function createAccessRoutes(
|
|
|
358
423
|
throw new AccessMutationError('role principal needs a role name');
|
|
359
424
|
}
|
|
360
425
|
principal = { kind: 'role', role: principalRaw.role };
|
|
426
|
+
} else if (principalRaw.kind === 'group') {
|
|
427
|
+
if (typeof principalRaw.group !== 'string') {
|
|
428
|
+
throw new AccessMutationError('group principal needs a group name');
|
|
429
|
+
}
|
|
430
|
+
principal = { kind: 'group', group: principalRaw.group };
|
|
361
431
|
} else {
|
|
362
|
-
throw new AccessMutationError("principal.kind must be 'user' or 'role'");
|
|
432
|
+
throw new AccessMutationError("principal.kind must be 'user', 'group', or 'role'");
|
|
363
433
|
}
|
|
364
434
|
const targetKind = kind as TargetKind;
|
|
365
435
|
const repoRelTarget = toRepoRelative(rawPath);
|
|
@@ -433,30 +503,44 @@ export function createAccessRoutes(
|
|
|
433
503
|
]);
|
|
434
504
|
|
|
435
505
|
// Per-principal, per-verb origin (direct / ancestor — MECE over editable
|
|
436
|
-
// files). Keyed `u:<email>` / `r:<role>` to match the
|
|
437
|
-
// each row can show where its access comes from and
|
|
438
|
-
// removable here.
|
|
439
|
-
//
|
|
440
|
-
//
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
506
|
+
// files). Keyed `u:<email>` / `r:<role>` / `g:<group>` to match the
|
|
507
|
+
// dialog's row keys, so each row can show where its access comes from and
|
|
508
|
+
// which verbs are removable here. Groups get their OWN `g:` namespace: a
|
|
509
|
+
// group and a role sharing a name are DIFFERENT principals (bare token vs
|
|
510
|
+
// `role/<name>`), and one shared `r:` entry could only describe one of
|
|
511
|
+
// them. Each kind resolves through the token spelling that IS that
|
|
512
|
+
// principal — a group through its bare token (group-first precedence), a
|
|
513
|
+
// role through its explicit `role/<name>` alias (correct whether or not a
|
|
514
|
+
// group shadows the name; the built-in `everyone` keeps its bare spelling,
|
|
515
|
+
// it has no alias). A row whose verbs resolve only via a
|
|
516
|
+
// group/everyone/rescue has no source (the verb is absent) and renders
|
|
517
|
+
// non-actionable. Built over the union of every principal in the four
|
|
518
|
+
// eligible lists (kinded `principals`, with the name-only `roles` list as
|
|
519
|
+
// the all-roles fallback).
|
|
520
|
+
const collectives = new Map<string, { name: string; kind: 'role' | 'group' }>();
|
|
521
|
+
for (const list of [eligible, readers, owners, downloaders]) {
|
|
522
|
+
const kinded =
|
|
523
|
+
list.principals ?? list.roles.map((name) => ({ name, kind: 'role' as const }));
|
|
524
|
+
for (const p of kinded) {
|
|
525
|
+
const key = `${p.kind === 'group' ? 'g' : 'r'}:${p.name.toLowerCase()}`;
|
|
526
|
+
if (!collectives.has(key)) collectives.set(key, p);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
447
529
|
const userSet = new Map<string, { name: string; email: string }>();
|
|
448
530
|
for (const u of [...eligible.users, ...readers.users, ...owners.users, ...downloaders.users]) {
|
|
449
531
|
if (!userSet.has(u.email.toLowerCase())) userSet.set(u.email.toLowerCase(), u);
|
|
450
532
|
}
|
|
451
533
|
const sources: Record<string, Awaited<ReturnType<IAccessControl['grantSources']>>> = {};
|
|
452
534
|
await Promise.all([
|
|
453
|
-
...[...
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
535
|
+
...[...collectives.entries()].map(async ([key, p]) => {
|
|
536
|
+
const token =
|
|
537
|
+
p.kind === 'role' && canonicalRoleName(p.name) !== EVERYONE_CANONICAL
|
|
538
|
+
? `${ROLE_TOKEN_PREFIX}${p.name}`
|
|
539
|
+
: p.name;
|
|
540
|
+
sources[key] = await accessControl.grantSources(workspaceId, kind, repoRelTarget, {
|
|
541
|
+
kind: 'role',
|
|
542
|
+
role: token,
|
|
543
|
+
});
|
|
460
544
|
}),
|
|
461
545
|
...[...userSet.values()].map(async (u) => {
|
|
462
546
|
sources[`u:${u.email.toLowerCase()}`] = await accessControl.grantSources(
|
|
@@ -526,7 +610,7 @@ export function createAccessRoutes(
|
|
|
526
610
|
|
|
527
611
|
/**
|
|
528
612
|
* POST /api/workspace/:id/access/grant
|
|
529
|
-
* Body: `{ path, kind: 'folder'|'file', verb: 'write'|'owner', principal }`.
|
|
613
|
+
* Body: `{ path, kind: 'folder'|'file', verb: 'read'|'write'|'download'|'owner', principal }`.
|
|
530
614
|
* Grants the principal the verb on the target (folder → its access.md, file →
|
|
531
615
|
* its own frontmatter). Gated on write to the access config (fail-closed on
|
|
532
616
|
* protected branches). Returns the fresh resolved access for the target.
|
|
@@ -545,31 +629,54 @@ export function createAccessRoutes(
|
|
|
545
629
|
}
|
|
546
630
|
const { repoRelTarget, kind, principal } = parseMutationBody(req.body);
|
|
547
631
|
|
|
548
|
-
// For a
|
|
549
|
-
//
|
|
550
|
-
//
|
|
551
|
-
//
|
|
552
|
-
//
|
|
632
|
+
// For a role grant, the role must exist in roles.yaml. Reject loudly
|
|
633
|
+
// otherwise. Validate against the DEFAULT-branch roles (same
|
|
634
|
+
// authoritative source the suggest autocomplete uses) so a
|
|
635
|
+
// default-branch role can't be suggested then rejected here on a
|
|
636
|
+
// feature branch whose roles.yaml has diverged. No collision refusals:
|
|
637
|
+
// a name shared with a group is fine — the grant is WRITTEN as the
|
|
638
|
+
// explicit `role/<Name>` token, which precedence always resolves to the
|
|
639
|
+
// role.
|
|
553
640
|
if (principal.kind === 'role') {
|
|
554
|
-
const
|
|
555
|
-
const
|
|
641
|
+
const { roles } = await defaultBranchPrincipals();
|
|
642
|
+
const bare = canonicalRoleName(principal.role).startsWith(ROLE_TOKEN_PREFIX)
|
|
643
|
+
? canonicalRoleName(principal.role).slice(ROLE_TOKEN_PREFIX.length)
|
|
644
|
+
: canonicalRoleName(principal.role);
|
|
645
|
+
const known = roles.some((g) => canonicalRoleName(g) === bare);
|
|
556
646
|
if (!known) {
|
|
557
647
|
throw new AccessMutationError(
|
|
558
|
-
`No
|
|
648
|
+
`No role named "${principal.role}". Pick an existing role.`,
|
|
559
649
|
404,
|
|
560
|
-
{ kind: 'unknown-
|
|
650
|
+
{ kind: 'unknown-role', role: principal.role },
|
|
561
651
|
);
|
|
562
652
|
}
|
|
563
653
|
// The built-in `everyone` role is grantable from the share UI for READ
|
|
564
654
|
// only (public read). Write/owner/download to everyone would make the
|
|
565
655
|
// node world-writable from a casual dialog, so those stay a direct
|
|
566
656
|
// access.md edit.
|
|
567
|
-
if (
|
|
657
|
+
if (bare === EVERYONE_CANONICAL && verb !== 'read') {
|
|
658
|
+
throw new AccessMutationError(
|
|
659
|
+
'"Everyone" can only be granted read access (public). Grant other access levels to specific people or roles.',
|
|
660
|
+
);
|
|
661
|
+
}
|
|
662
|
+
} else if (principal.kind === 'group') {
|
|
663
|
+
// A group grant must name a group in the ACTIVE source (IdP-synced or
|
|
664
|
+
// manual) on the default branch — same authority the suggest list
|
|
665
|
+
// uses. No role-collision refusal: bare tokens resolve GROUP-FIRST,
|
|
666
|
+
// so the written bare name IS the group.
|
|
667
|
+
const { groups } = await defaultBranchPrincipals();
|
|
668
|
+
const canonical = canonicalRoleName(principal.group);
|
|
669
|
+
if (!groups.some((g) => canonicalRoleName(g) === canonical)) {
|
|
568
670
|
throw new AccessMutationError(
|
|
569
|
-
|
|
671
|
+
`No group named "${principal.group}". Pick an existing group.`,
|
|
672
|
+
404,
|
|
673
|
+
{ kind: 'unknown-group', group: principal.group },
|
|
570
674
|
);
|
|
571
675
|
}
|
|
572
676
|
}
|
|
677
|
+
// Splice shape: group → bare token (group-first precedence); role →
|
|
678
|
+
// explicit `role/<Name>` token (see asSplicePrincipal).
|
|
679
|
+
const splicePrincipal = asSplicePrincipal(principal);
|
|
573
680
|
|
|
574
681
|
const { gatePath } = gateAndEditPaths(kind, repoRelTarget);
|
|
575
682
|
// Ensure the branch workspace exists before locking.
|
|
@@ -579,7 +686,7 @@ export function createAccessRoutes(
|
|
|
579
686
|
|
|
580
687
|
const editPath = gatePath; // grant edits the same path it gates on
|
|
581
688
|
await withEditLock(workspaceId, branch, editPath, user, async () => {
|
|
582
|
-
await mutation.grant(workspaceId, kind, repoRelTarget, verb as Verb,
|
|
689
|
+
await mutation.grant(workspaceId, kind, repoRelTarget, verb as Verb, splicePrincipal);
|
|
583
690
|
});
|
|
584
691
|
|
|
585
692
|
// The write landed on disk under the lock; drop the cache so the
|
|
@@ -635,7 +742,19 @@ export function createAccessRoutes(
|
|
|
635
742
|
const branch = branchForWorkspaceId(workspaceId);
|
|
636
743
|
try {
|
|
637
744
|
const mode = (req.body as { mode?: unknown }).mode;
|
|
638
|
-
const { repoRelTarget, kind, principal } = parseMutationBody(req.body);
|
|
745
|
+
const { repoRelTarget, kind, principal: routePrincipal } = parseMutationBody(req.body);
|
|
746
|
+
// A group principal converts straight to the role-shaped bare token —
|
|
747
|
+
// no existence check (revoking a grant whose group has since vanished
|
|
748
|
+
// must keep working).
|
|
749
|
+
const principal = asSplicePrincipal(routePrincipal);
|
|
750
|
+
// GROUP revokes match the EXACT bare token only. The service's own
|
|
751
|
+
// shadow probe decides exact-vs-name for roles, but it cannot know a
|
|
752
|
+
// bare token was a GROUP once that group vanished from the active
|
|
753
|
+
// source — the name would read "unshadowed" and name-level matching
|
|
754
|
+
// would strip a same-named role's live `role/<Name>` grant. The route
|
|
755
|
+
// still knows the caller's kind, so it pins the matching here.
|
|
756
|
+
const revokeOpts =
|
|
757
|
+
routePrincipal.kind === 'group' ? ({ tokenMatch: 'exact' } as const) : undefined;
|
|
639
758
|
// Optional `verb` scopes ALL three paths to a single verb: a default revoke
|
|
640
759
|
// strips just that verb; remove-from-parent removes just that verb on the
|
|
641
760
|
// ancestor; deny-here denies just that verb at the target. Absent ⇒ the
|
|
@@ -691,7 +810,7 @@ export function createAccessRoutes(
|
|
|
691
810
|
);
|
|
692
811
|
}
|
|
693
812
|
// Scope the ancestor revoke to the same verb the user acted on (if any).
|
|
694
|
-
await mutation.revoke(workspaceId, 'folder', ancestorDir, principal, user.email, verb);
|
|
813
|
+
await mutation.revoke(workspaceId, 'folder', ancestorDir, principal, user.email, verb, revokeOpts);
|
|
695
814
|
});
|
|
696
815
|
|
|
697
816
|
accessControl.invalidate(workspaceId);
|
|
@@ -709,7 +828,7 @@ export function createAccessRoutes(
|
|
|
709
828
|
await assertCanMutate(workspaceId, branch, user.email, gatePath);
|
|
710
829
|
await withEditLock(workspaceId, branch, gatePath, user, async () => {
|
|
711
830
|
// Scope the deny to the same verb the user acted on (if any).
|
|
712
|
-
await mutation.denyHere(workspaceId, kind, repoRelTarget, principal, verb);
|
|
831
|
+
await mutation.denyHere(workspaceId, kind, repoRelTarget, principal, verb, revokeOpts);
|
|
713
832
|
});
|
|
714
833
|
accessControl.invalidate(workspaceId);
|
|
715
834
|
console.log(
|
|
@@ -727,7 +846,7 @@ export function createAccessRoutes(
|
|
|
727
846
|
const editPath = gatePath;
|
|
728
847
|
let changed = false;
|
|
729
848
|
await withEditLock(workspaceId, branch, editPath, user, async () => {
|
|
730
|
-
const r = await mutation.revoke(workspaceId, kind, repoRelTarget, principal, user.email, verb);
|
|
849
|
+
const r = await mutation.revoke(workspaceId, kind, repoRelTarget, principal, user.email, verb, revokeOpts);
|
|
731
850
|
changed = r.changed;
|
|
732
851
|
});
|
|
733
852
|
|
|
@@ -738,7 +857,7 @@ export function createAccessRoutes(
|
|
|
738
857
|
// any source left is an `ancestor`. Distinguish "inherited" (still named in
|
|
739
858
|
// an ancestor access.md — offer cascade/deny) from "no removable source
|
|
740
859
|
// here" (nothing named in any file we can edit — a genuine no-op). A
|
|
741
|
-
// principal who only RESOLVES via a
|
|
860
|
+
// principal who only RESOLVES via a group/role / everyone / admin-rescue has no
|
|
742
861
|
// source at all (it's not their own file entry) and so is correctly a 200
|
|
743
862
|
// no-op: there is nothing to remove here and no Remove was ever offered for
|
|
744
863
|
// them in the dialog. NEVER a silent unchanged-success when there IS a
|
|
@@ -796,7 +915,10 @@ export function createAccessRoutes(
|
|
|
796
915
|
});
|
|
797
916
|
|
|
798
917
|
// -------------------------------------------------------------------------
|
|
799
|
-
// Roles & Members (admin) —
|
|
918
|
+
// Roles & Members (admin) — MEMBERSHIP editing on the DEFAULT-branch
|
|
919
|
+
// roles.yaml. Roles are app-defined capabilities: there is deliberately NO
|
|
920
|
+
// create/rename/delete route — the admin surface cannot mint, rebrand, or
|
|
921
|
+
// retire a role (legacy people-set roles migrate out via convert-to-group).
|
|
800
922
|
// Routes are GLOBAL (no `:id`): the handler resolves the default-branch
|
|
801
923
|
// workspace internally, because that is the file admin status derives from.
|
|
802
924
|
// Mutating routes are admin-gated by `assertRolesAdmin` (the same admin-only
|
|
@@ -828,54 +950,66 @@ export function createAccessRoutes(
|
|
|
828
950
|
}
|
|
829
951
|
});
|
|
830
952
|
|
|
831
|
-
|
|
953
|
+
// NOTE: POST /access/roles (create), PATCH /access/roles/:canonical
|
|
954
|
+
// (rename), and DELETE /access/roles/:canonical are GONE — roles are
|
|
955
|
+
// app-defined capabilities, not user-editable objects.
|
|
956
|
+
|
|
957
|
+
router.post('/access/roles/:canonical/members', async (req, res) => {
|
|
832
958
|
const user = await requireUser(req, res);
|
|
833
959
|
if (!user) return;
|
|
834
960
|
try {
|
|
835
961
|
await assertRolesAdmin(user.email);
|
|
836
|
-
const
|
|
837
|
-
|
|
962
|
+
const canonical = canonicalRoleName(req.params.canonical);
|
|
963
|
+
const email = requireNonEmptyString((req.body ?? {}).email, 'email');
|
|
964
|
+
res.json({ roles: await rolesAdmin.addMember(user, canonical, email) });
|
|
838
965
|
} catch (err) {
|
|
839
966
|
const { status, body } = toHttpError(err);
|
|
840
967
|
res.status(status).json(body);
|
|
841
968
|
}
|
|
842
969
|
});
|
|
843
970
|
|
|
844
|
-
|
|
971
|
+
// Assign / unassign a GROUP to a role (capability-follows-membership). Any
|
|
972
|
+
// roster role accepts group references, Admin included — Admin's safety net
|
|
973
|
+
// is the parse-time invariant that it always keeps at least one DIRECT
|
|
974
|
+
// email member (a group-only Admin is rejected as hard as an adminless
|
|
975
|
+
// roles.yaml), so a broken or hostile directory can never leave the
|
|
976
|
+
// deployment adminless.
|
|
977
|
+
router.post('/access/roles/:canonical/groups', async (req, res) => {
|
|
845
978
|
const user = await requireUser(req, res);
|
|
846
979
|
if (!user) return;
|
|
847
980
|
try {
|
|
848
981
|
await assertRolesAdmin(user.email);
|
|
849
982
|
const canonical = canonicalRoleName(req.params.canonical);
|
|
850
|
-
|
|
983
|
+
const group = requireNonEmptyString((req.body ?? {}).group, 'group');
|
|
984
|
+
res.json({ roles: await rolesAdmin.assignGroup(user, canonical, group) });
|
|
851
985
|
} catch (err) {
|
|
852
986
|
const { status, body } = toHttpError(err);
|
|
853
987
|
res.status(status).json(body);
|
|
854
988
|
}
|
|
855
989
|
});
|
|
856
990
|
|
|
857
|
-
router.
|
|
991
|
+
router.delete('/access/roles/:canonical/groups/:group', async (req, res) => {
|
|
858
992
|
const user = await requireUser(req, res);
|
|
859
993
|
if (!user) return;
|
|
860
994
|
try {
|
|
861
995
|
await assertRolesAdmin(user.email);
|
|
862
996
|
const canonical = canonicalRoleName(req.params.canonical);
|
|
863
|
-
|
|
864
|
-
res.json({ roles: await rolesAdmin.renameRole(user, canonical, newDisplayName) });
|
|
997
|
+
res.json({ roles: await rolesAdmin.unassignGroup(user, canonical, String(req.params.group)) });
|
|
865
998
|
} catch (err) {
|
|
866
999
|
const { status, body } = toHttpError(err);
|
|
867
1000
|
res.status(status).json(body);
|
|
868
1001
|
}
|
|
869
1002
|
});
|
|
870
1003
|
|
|
871
|
-
|
|
1004
|
+
// Convert a legacy people-set role into a manual group (atomic two-file
|
|
1005
|
+
// move; grants keep working because the name is unchanged).
|
|
1006
|
+
router.post('/access/roles/:canonical/convert-to-group', async (req, res) => {
|
|
872
1007
|
const user = await requireUser(req, res);
|
|
873
1008
|
if (!user) return;
|
|
874
1009
|
try {
|
|
875
1010
|
await assertRolesAdmin(user.email);
|
|
876
1011
|
const canonical = canonicalRoleName(req.params.canonical);
|
|
877
|
-
|
|
878
|
-
res.json({ roles: await rolesAdmin.addMember(user, canonical, email) });
|
|
1012
|
+
res.json({ roles: await rolesAdmin.convertRoleToGroup(user, canonical) });
|
|
879
1013
|
} catch (err) {
|
|
880
1014
|
const { status, body } = toHttpError(err);
|
|
881
1015
|
res.status(status).json(body);
|