@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
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import {
|
|
2
|
+
EMAIL_REGEX,
|
|
3
|
+
GROUP_REF_PREFIX,
|
|
4
|
+
canonicalEmail,
|
|
5
|
+
canonicalRoleName,
|
|
6
|
+
RESERVED_ROLE_NAMES,
|
|
7
|
+
} from './access-control.service.js';
|
|
8
|
+
import { unsafeNameReason } from './group-files.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Locale-independent code-unit comparator. `localeCompare` would make the
|
|
12
|
+
* "deterministic render" depend on the process's ambient locale/ICU build —
|
|
13
|
+
* two deployments could then disagree on byte order and ping-pong no-op
|
|
14
|
+
* detection.
|
|
15
|
+
*/
|
|
16
|
+
function codeUnitCompare(a: string, b: string): number {
|
|
17
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Materializes an external directory's groups into `synced-groups.yaml` — the
|
|
22
|
+
* MACHINE-OWNED group file the access resolver reads in IdP mode.
|
|
23
|
+
*
|
|
24
|
+
* Why a file and not the DB: access resolution is file-based and evaluated at
|
|
25
|
+
* git refs (the merge/push gates depend on it), so group membership must live
|
|
26
|
+
* in the KB repo. Wholesale regeneration (never editing) is what makes the
|
|
27
|
+
* file conflict-free, and git history doubles as the membership audit log.
|
|
28
|
+
*
|
|
29
|
+
* The DIRECTORY ITSELF is not core's business: groups arrive through a
|
|
30
|
+
* {@link SyncedGroupsSource} an overlay provides (e.g. a SCIM mirror fed by
|
|
31
|
+
* Entra/Okta provisioning). Core owns the file format, the rendering rules,
|
|
32
|
+
* and the commit pipeline — the same module that parses the file on the read
|
|
33
|
+
* side (`group-files.ts`) governs what gets written into it.
|
|
34
|
+
*
|
|
35
|
+
* The renderer is a PURE function so its determinism is trivially testable;
|
|
36
|
+
* the writer wraps it with debounce (a provisioning cycle is a burst of many
|
|
37
|
+
* directory pushes — one commit per burst, not per mutation) and no-op
|
|
38
|
+
* detection (unchanged content never commits).
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
const FILE_HEADER = `# MACHINE-GENERATED from the identity provider's directory — do not edit.
|
|
42
|
+
# Membership is managed in the IdP; this file is regenerated wholesale on every
|
|
43
|
+
# provisioning push. Manual groups live in groups.yaml (ignored while this file
|
|
44
|
+
# exists — its presence IS what puts the deployment in IdP mode).
|
|
45
|
+
`;
|
|
46
|
+
|
|
47
|
+
/** A member of an externally-synced group, as the materializer needs it. */
|
|
48
|
+
export interface SyncedGroupMember {
|
|
49
|
+
/** Lowercased primary email — the identity join key; null when the IdP sent none. */
|
|
50
|
+
email: string | null;
|
|
51
|
+
/** Deactivated members stay mirrored upstream but are not materialized. */
|
|
52
|
+
active: boolean;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** A group from the external directory, as the materializer needs it. */
|
|
56
|
+
export interface SyncedGroupRecord {
|
|
57
|
+
/** The IdP's own identifier, if it sent one — only used to break sort ties. */
|
|
58
|
+
externalId: string | null;
|
|
59
|
+
displayName: string;
|
|
60
|
+
members: SyncedGroupMember[];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The seam an overlay implements to feed the materializer: "the current
|
|
65
|
+
* groups in the external directory." Core never learns HOW they got there
|
|
66
|
+
* (SCIM push, API poll, …).
|
|
67
|
+
*/
|
|
68
|
+
export interface SyncedGroupsSource {
|
|
69
|
+
listGroups(): Promise<SyncedGroupRecord[]>;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Name safety is the SHARED predicate in `group-files.ts` (`unsafeNameReason`)
|
|
73
|
+
// — the same rules the manual group editor asserts, including the reserved
|
|
74
|
+
// `role/` prefix. Here the name arrives from the IdP, so an unsafe group is
|
|
75
|
+
// SKIPPED with a warning instead of erroring (fail-closed: an unrepresentable
|
|
76
|
+
// group grants nothing).
|
|
77
|
+
|
|
78
|
+
export interface RenderedSyncedGroups {
|
|
79
|
+
text: string;
|
|
80
|
+
/** Groups/members that could not be materialized, human-readable. */
|
|
81
|
+
warnings: string[];
|
|
82
|
+
/** Groups actually emitted (post-skip). */
|
|
83
|
+
groupCount: number;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Deterministic render: same directory state → byte-identical file. Groups
|
|
88
|
+
* sort by canonical name (ties by externalId), member emails sort lexically.
|
|
89
|
+
* Excluded from materialization (each with a warning): groups with
|
|
90
|
+
* YAML-unsafe or reserved names, canonical-name duplicates (first wins), and
|
|
91
|
+
* members that are inactive or carry no email (email is the identity join
|
|
92
|
+
* key — a member without one cannot be granted anything).
|
|
93
|
+
*/
|
|
94
|
+
export function renderSyncedGroupsYaml(groups: SyncedGroupRecord[]): RenderedSyncedGroups {
|
|
95
|
+
const warnings: string[] = [];
|
|
96
|
+
const byCanonical = new Map<string, SyncedGroupRecord>();
|
|
97
|
+
|
|
98
|
+
const sorted = [...groups].sort((a, b) => {
|
|
99
|
+
const byName = codeUnitCompare(canonicalRoleName(a.displayName), canonicalRoleName(b.displayName));
|
|
100
|
+
if (byName !== 0) return byName;
|
|
101
|
+
const byExternalId = codeUnitCompare(a.externalId ?? '', b.externalId ?? '');
|
|
102
|
+
if (byExternalId !== 0) return byExternalId;
|
|
103
|
+
// Full-key duplicates: break the tie on membership, then on the RAW
|
|
104
|
+
// display name (same canonical can differ in case/spacing, and the winner
|
|
105
|
+
// of the first-wins dedup below decides the emitted name) — so the bytes
|
|
106
|
+
// never depend on source array order. The membership key is JSON — a
|
|
107
|
+
// plain delimiter join could collide (an email may legally contain the
|
|
108
|
+
// delimiter characters), and a collision between DIFFERENT member sets
|
|
109
|
+
// would push the tie-break back onto input order.
|
|
110
|
+
const memberKey = (members: SyncedGroupMember[]): string =>
|
|
111
|
+
JSON.stringify(
|
|
112
|
+
members.map((m) => `${JSON.stringify(m.email ?? '')}:${m.active}`).sort(codeUnitCompare),
|
|
113
|
+
);
|
|
114
|
+
const byMembers = codeUnitCompare(memberKey(a.members), memberKey(b.members));
|
|
115
|
+
if (byMembers !== 0) return byMembers;
|
|
116
|
+
return codeUnitCompare(a.displayName, b.displayName);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// Group names are UNTRUSTED IdP input and flow into warnings that land in
|
|
120
|
+
// logs — JSON.stringify them so a name carrying control characters (ANSI
|
|
121
|
+
// escapes, newlines) is rendered escaped instead of verbatim into the log
|
|
122
|
+
// stream (a newline-bearing name could otherwise forge whole log lines).
|
|
123
|
+
// JSON.stringify only escapes C0 controls (U+0000–U+001F) plus quote and
|
|
124
|
+
// backslash: C1 controls (U+007F–U+009F — including U+009B, the one-byte
|
|
125
|
+
// CSI that starts ANSI sequences on its own) and the JS line separators
|
|
126
|
+
// U+2028/U+2029 pass through raw, so escape those ourselves.
|
|
127
|
+
const printable = (name: string): string =>
|
|
128
|
+
JSON.stringify(name).replace(
|
|
129
|
+
/[\u007F-\u009F\u2028\u2029]/g,
|
|
130
|
+
(c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`,
|
|
131
|
+
);
|
|
132
|
+
for (const group of sorted) {
|
|
133
|
+
const reason = unsafeNameReason(group.displayName);
|
|
134
|
+
if (reason) {
|
|
135
|
+
warnings.push(`group ${printable(group.displayName)} skipped: ${reason} — rename it in the IdP`);
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
const canonical = canonicalRoleName(group.displayName);
|
|
139
|
+
if (RESERVED_ROLE_NAMES.has(canonical)) {
|
|
140
|
+
warnings.push(
|
|
141
|
+
`group ${printable(group.displayName)} skipped: '${canonical}' is a reserved name — rename it in the IdP`,
|
|
142
|
+
);
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
const existing = byCanonical.get(canonical);
|
|
146
|
+
if (existing) {
|
|
147
|
+
warnings.push(
|
|
148
|
+
`group ${printable(group.displayName)} skipped: name collides with ${printable(existing.displayName)} — rename one in the IdP`,
|
|
149
|
+
);
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
byCanonical.set(canonical, group);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const lines: string[] = [FILE_HEADER.trimEnd(), 'groups:'];
|
|
156
|
+
for (const [, group] of byCanonical) {
|
|
157
|
+
const emails = new Set<string>();
|
|
158
|
+
let skippedMembers = 0;
|
|
159
|
+
let malformedMembers = 0;
|
|
160
|
+
for (const member of group.members) {
|
|
161
|
+
if (!member.active || !member.email) {
|
|
162
|
+
skippedMembers++;
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
// The directory is UNTRUSTED input: a "email" carrying a newline or
|
|
166
|
+
// entry-grammar characters would corrupt the emitted YAML or inject
|
|
167
|
+
// memberships. Canonicalize and validate before it may become a line.
|
|
168
|
+
// A leading '#' passes the regex but the emitted `- #…` reads back as a
|
|
169
|
+
// comment (stripComment) — the member would silently vanish on the next
|
|
170
|
+
// resolver read, so refuse it here where it at least gets a warning.
|
|
171
|
+
// The reserved `group:` prefix passes EMAIL_REGEX (`group:lee@x.io`)
|
|
172
|
+
// but the read-side parser skips it as a group reference — refuse it
|
|
173
|
+
// here so the file never carries an entry its own parser warns on.
|
|
174
|
+
const email = canonicalEmail(member.email);
|
|
175
|
+
if (!EMAIL_REGEX.test(email) || email.startsWith('#') || email.startsWith(GROUP_REF_PREFIX)) {
|
|
176
|
+
malformedMembers++;
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
emails.add(email);
|
|
180
|
+
}
|
|
181
|
+
if (skippedMembers > 0) {
|
|
182
|
+
warnings.push(
|
|
183
|
+
`group ${printable(group.displayName)}: ${skippedMembers} member${skippedMembers === 1 ? '' : 's'} not materialized (inactive or no email)`,
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
if (malformedMembers > 0) {
|
|
187
|
+
warnings.push(
|
|
188
|
+
`group ${printable(group.displayName)}: ${malformedMembers} member${malformedMembers === 1 ? '' : 's'} not materialized (malformed email)`,
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
const name = group.displayName.trim();
|
|
192
|
+
if (emails.size === 0) {
|
|
193
|
+
lines.push(` ${name}: []`);
|
|
194
|
+
} else {
|
|
195
|
+
lines.push(` ${name}:`);
|
|
196
|
+
for (const email of [...emails].sort(codeUnitCompare)) lines.push(` - ${email}`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return { text: lines.join('\n') + '\n', warnings, groupCount: byCanonical.size };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export interface SyncedGroupsWriteResult {
|
|
204
|
+
/** False when the rendered content matched what is already committed. */
|
|
205
|
+
changed: boolean;
|
|
206
|
+
groupCount: number;
|
|
207
|
+
warnings: string[];
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export interface SyncedGroupsWriterDeps {
|
|
211
|
+
source: SyncedGroupsSource;
|
|
212
|
+
/** Current committed file content, or null when it does not exist yet. */
|
|
213
|
+
readCurrent: () => Promise<string | null>;
|
|
214
|
+
/** Write + commit the new content (the composition root owns the git plumbing). */
|
|
215
|
+
persist: (content: string) => Promise<void>;
|
|
216
|
+
/** Post-commit hook: cache invalidation + change events. */
|
|
217
|
+
onWritten?: (result: SyncedGroupsWriteResult) => void;
|
|
218
|
+
/** Trailing-edge debounce for provisioning bursts. */
|
|
219
|
+
debounceMs?: number;
|
|
220
|
+
log?: (message: string) => void;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const DEFAULT_DEBOUNCE_MS = 10_000;
|
|
224
|
+
|
|
225
|
+
export class SyncedGroupsWriter {
|
|
226
|
+
private timer: NodeJS.Timeout | null = null;
|
|
227
|
+
/** Serializes writes: a mutation landing mid-write queues one follow-up. */
|
|
228
|
+
private inflight: Promise<SyncedGroupsWriteResult> | null = null;
|
|
229
|
+
private rerunWanted = false;
|
|
230
|
+
|
|
231
|
+
constructor(private readonly deps: SyncedGroupsWriterDeps) {}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Note a directory mutation; (re)arm the debounce timer. Called on every
|
|
235
|
+
* successful ingress/degress, so a provisioning burst keeps pushing the
|
|
236
|
+
* timer and the write lands once, after the burst goes quiet.
|
|
237
|
+
*/
|
|
238
|
+
notifyMutation(): void {
|
|
239
|
+
if (this.timer) clearTimeout(this.timer);
|
|
240
|
+
this.timer = setTimeout(() => {
|
|
241
|
+
this.timer = null;
|
|
242
|
+
this.writeNow().catch((err) => {
|
|
243
|
+
this.deps.log?.(
|
|
244
|
+
`[directory-sync] deferred synced-groups write failed: ${err instanceof Error ? err.message : err}`,
|
|
245
|
+
);
|
|
246
|
+
});
|
|
247
|
+
}, this.deps.debounceMs ?? DEFAULT_DEBOUNCE_MS);
|
|
248
|
+
// Never hold the process open for a pending materialization.
|
|
249
|
+
this.timer.unref?.();
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Render + commit immediately, skipping the debounce. Today only the
|
|
253
|
+
debounce timer calls this; it is public so a future manual "sync now"
|
|
254
|
+
surface can reuse it without changing the writer. */
|
|
255
|
+
async writeNow(): Promise<SyncedGroupsWriteResult> {
|
|
256
|
+
if (this.inflight) {
|
|
257
|
+
// A write is running against a snapshot that may predate this call —
|
|
258
|
+
// ask for one follow-up run and share ITS eventual result semantics by
|
|
259
|
+
// awaiting the current one first (callers only need "a write covering
|
|
260
|
+
// my mutation happened"; the follow-up covers it).
|
|
261
|
+
this.rerunWanted = true;
|
|
262
|
+
await this.inflight.catch(() => undefined);
|
|
263
|
+
if (this.inflight) return this.inflight;
|
|
264
|
+
}
|
|
265
|
+
this.inflight = this.runOnce();
|
|
266
|
+
try {
|
|
267
|
+
let result = await this.inflight;
|
|
268
|
+
while (this.rerunWanted) {
|
|
269
|
+
this.rerunWanted = false;
|
|
270
|
+
this.inflight = this.runOnce();
|
|
271
|
+
result = await this.inflight;
|
|
272
|
+
}
|
|
273
|
+
return result;
|
|
274
|
+
} finally {
|
|
275
|
+
this.inflight = null;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
private async runOnce(): Promise<SyncedGroupsWriteResult> {
|
|
280
|
+
const groups = await this.deps.source.listGroups();
|
|
281
|
+
const rendered = renderSyncedGroupsYaml(groups);
|
|
282
|
+
for (const w of rendered.warnings) this.deps.log?.(`[directory-sync] ${w}`);
|
|
283
|
+
|
|
284
|
+
const current = await this.deps.readCurrent();
|
|
285
|
+
if (current === rendered.text) {
|
|
286
|
+
return { changed: false, groupCount: rendered.groupCount, warnings: rendered.warnings };
|
|
287
|
+
}
|
|
288
|
+
await this.deps.persist(rendered.text);
|
|
289
|
+
const result: SyncedGroupsWriteResult = {
|
|
290
|
+
changed: true,
|
|
291
|
+
groupCount: rendered.groupCount,
|
|
292
|
+
warnings: rendered.warnings,
|
|
293
|
+
};
|
|
294
|
+
this.deps.onWritten?.(result);
|
|
295
|
+
return result;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** Cancel any pending debounce (shutdown/tests). */
|
|
299
|
+
dispose(): void {
|
|
300
|
+
if (this.timer) clearTimeout(this.timer);
|
|
301
|
+
this.timer = null;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
@@ -211,6 +211,14 @@ export const fileLocks = pgTable('file_locks', {
|
|
|
211
211
|
path: text('path').notNull(),
|
|
212
212
|
holderUserId: uuid('holder_user_id').notNull().references(() => users.id),
|
|
213
213
|
holderName: text('holder_name').notNull(),
|
|
214
|
+
// How the lock was acquired. 'edit' (the default) is a normal write hold —
|
|
215
|
+
// the holder is editing and the release publishes their bytes.
|
|
216
|
+
// 'coordination' is a pure-mutex hold (see IWorkflowService.acquireLock)
|
|
217
|
+
// that grants NO write authority. Persisted on the row — not inferred at
|
|
218
|
+
// release time — so the write/checkpoint/commit-enqueue paths can refuse
|
|
219
|
+
// to treat a coordination hold as write possession for as long as the row
|
|
220
|
+
// lives, across process restarts included.
|
|
221
|
+
mode: text('mode').notNull().default('edit'),
|
|
214
222
|
acquiredAt: timestamp('acquired_at').defaultNow().notNull(),
|
|
215
223
|
lastHeartbeatAt: timestamp('last_heartbeat_at').defaultNow().notNull(),
|
|
216
224
|
expiresAt: timestamp('expires_at').notNull(),
|
|
@@ -220,6 +228,7 @@ export const fileLocks = pgTable('file_locks', {
|
|
|
220
228
|
// table scan once the lock count grows.
|
|
221
229
|
byExpiry: index('file_locks_by_expiry').on(t.expiresAt),
|
|
222
230
|
byHolder: index('file_locks_by_holder').on(t.holderUserId),
|
|
231
|
+
modeCheck: check('file_locks_mode', sql`${t.mode} IN ('edit', 'coordination')`),
|
|
223
232
|
}));
|
|
224
233
|
|
|
225
234
|
/**
|
|
@@ -4,6 +4,7 @@ import { createMcpAuthMiddleware } from '../mcp-auth.middleware.js';
|
|
|
4
4
|
import type { AuthService } from '../../auth/auth.service.js';
|
|
5
5
|
import type { IExternalApiKeyService } from '../../tool-auth/external-api-key.interface.js';
|
|
6
6
|
import type { BevelOAuthProvider } from '../oauth/bevel-oauth-provider.js';
|
|
7
|
+
import { InternalTokenService } from '../../tool-auth/internal-token.service.js';
|
|
7
8
|
|
|
8
9
|
const RESOURCE_METADATA_URL = 'https://bevel.example/.well-known/oauth-protected-resource/api/mcp';
|
|
9
10
|
|
|
@@ -64,6 +65,7 @@ function makeMw(
|
|
|
64
65
|
auth?: AuthService;
|
|
65
66
|
keys?: IExternalApiKeyService;
|
|
66
67
|
oauth?: BevelOAuthProvider;
|
|
68
|
+
internal?: InternalTokenService;
|
|
67
69
|
} = {},
|
|
68
70
|
) {
|
|
69
71
|
return createMcpAuthMiddleware(
|
|
@@ -71,6 +73,7 @@ function makeMw(
|
|
|
71
73
|
overrides.keys ?? makeExternalApiKeyService(),
|
|
72
74
|
overrides.oauth ?? makeOAuthProvider(),
|
|
73
75
|
RESOURCE_METADATA_URL,
|
|
76
|
+
overrides.internal ?? new InternalTokenService({ secret: 'test-secret-32-bytes-long-enough!!' }),
|
|
74
77
|
);
|
|
75
78
|
}
|
|
76
79
|
|
|
@@ -246,4 +249,117 @@ describe('createMcpAuthMiddleware', () => {
|
|
|
246
249
|
expect(status).toHaveBeenCalledWith(401);
|
|
247
250
|
expect(next).not.toHaveBeenCalled();
|
|
248
251
|
});
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* The internal-token branch: server-minted only (createSession's loopback
|
|
256
|
+
* bearer, the /mcp/local-token exchange). hexis-mcp's OAuth mode sends one
|
|
257
|
+
* here when it registers this endpoint as its remote manual — the exact hop
|
|
258
|
+
* that failed while this surface refused the shape.
|
|
259
|
+
*/
|
|
260
|
+
describe('internal tokens', () => {
|
|
261
|
+
const internal = new InternalTokenService({ secret: 'test-secret-32-bytes-long-enough!!' });
|
|
262
|
+
|
|
263
|
+
it('accepts a live internal token and resolves the user', async () => {
|
|
264
|
+
const token = internal.mint({ userId: 'user-7', externalProxy: true }, 60_000);
|
|
265
|
+
const auth = makeAuthService();
|
|
266
|
+
(auth.getUserById as ReturnType<typeof vi.fn>) = vi.fn(async () => ({
|
|
267
|
+
id: 'user-7',
|
|
268
|
+
email: 'seven@example.com',
|
|
269
|
+
}));
|
|
270
|
+
const mw = makeMw({ internal, auth });
|
|
271
|
+
const { req, res, next } = makeReqRes(`Bearer ${token}`);
|
|
272
|
+
await mw(req, res, next);
|
|
273
|
+
expect(next).toHaveBeenCalled();
|
|
274
|
+
expect(req.userId).toBe('user-7');
|
|
275
|
+
expect(req.userEmail).toBe('seven@example.com');
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it('401s an expired internal token', async () => {
|
|
279
|
+
const past = new InternalTokenService({
|
|
280
|
+
secret: 'test-secret-32-bytes-long-enough!!',
|
|
281
|
+
now: () => Date.now() - 120_000,
|
|
282
|
+
});
|
|
283
|
+
const token = past.mint({ userId: 'user-7', externalProxy: true }, 60_000);
|
|
284
|
+
const mw = makeMw({ internal });
|
|
285
|
+
const { req, res, next, status } = makeReqRes(`Bearer ${token}`);
|
|
286
|
+
await mw(req, res, next);
|
|
287
|
+
expect(next).not.toHaveBeenCalled();
|
|
288
|
+
expect(status).toHaveBeenCalledWith(401);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it('401s an internal token whose user no longer exists', async () => {
|
|
292
|
+
const token = internal.mint({ userId: 'ghost', externalProxy: true }, 60_000);
|
|
293
|
+
const auth = makeAuthService();
|
|
294
|
+
(auth.getUserById as ReturnType<typeof vi.fn>) = vi.fn(async () => null);
|
|
295
|
+
const mw = makeMw({ internal, auth });
|
|
296
|
+
const { req, res, next, status } = makeReqRes(`Bearer ${token}`);
|
|
297
|
+
await mw(req, res, next);
|
|
298
|
+
expect(next).not.toHaveBeenCalled();
|
|
299
|
+
expect(status).toHaveBeenCalledWith(401);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Only the externalProxy shape may be an MCP caller. A plain in-process
|
|
304
|
+
* internal token — the per-run credential the agent factory mints for its
|
|
305
|
+
* own code-mode client — is a loopback-surface credential, and admitting
|
|
306
|
+
* it here would let it open an MCP session (createSession would even mint
|
|
307
|
+
* it a fresh externalProxy bearer, upgrading it).
|
|
308
|
+
*/
|
|
309
|
+
it('401s a VALID internal token that lacks the externalProxy claim', async () => {
|
|
310
|
+
const token = internal.mint({ userId: 'user-7' }, 60_000);
|
|
311
|
+
const auth = makeAuthService();
|
|
312
|
+
(auth.getUserById as ReturnType<typeof vi.fn>) = vi.fn(async () => ({
|
|
313
|
+
id: 'user-7',
|
|
314
|
+
email: 'seven@example.com',
|
|
315
|
+
}));
|
|
316
|
+
const mw = makeMw({ internal, auth });
|
|
317
|
+
const { req, res, next, status } = makeReqRes(`Bearer ${token}`);
|
|
318
|
+
await mw(req, res, next);
|
|
319
|
+
expect(next).not.toHaveBeenCalled();
|
|
320
|
+
expect(status).toHaveBeenCalledWith(401);
|
|
321
|
+
// Rejected before any user lookup — the shape alone disqualifies it.
|
|
322
|
+
expect(auth.getUserById).not.toHaveBeenCalled();
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* `verify` answers null for the invalid cases it can see coming, but a
|
|
327
|
+
* malformed token of plausible shape can still THROW from inside it
|
|
328
|
+
* (e.g. `timingSafeEqual` on same-length strings whose byte lengths
|
|
329
|
+
* differ). That is the caller's bad token — a clean 401, never a 500 or
|
|
330
|
+
* an unhandled throw.
|
|
331
|
+
*/
|
|
332
|
+
it('401s — not 500s — when verify throws on a malformed token', async () => {
|
|
333
|
+
const throwing = {
|
|
334
|
+
looksLikeInternalToken: (t: string) => t.startsWith('bevel-int_'),
|
|
335
|
+
verify: () => {
|
|
336
|
+
throw new RangeError('Input buffers must have the same byte length');
|
|
337
|
+
},
|
|
338
|
+
} as unknown as InternalTokenService;
|
|
339
|
+
const mw = makeMw({ internal: throwing });
|
|
340
|
+
const { req, res, next, status } = makeReqRes('Bearer bevel-int_bödy.sïg');
|
|
341
|
+
await mw(req, res, next);
|
|
342
|
+
expect(next).not.toHaveBeenCalled();
|
|
343
|
+
expect(status).toHaveBeenCalledWith(401);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* The real-token spelling of the throw above: a signature of the SAME
|
|
348
|
+
* string length whose non-ASCII characters change its BYTE length makes
|
|
349
|
+
* `timingSafeEqual` throw inside the real `verify` — proof the guard is
|
|
350
|
+
* needed against the genuine service, not only a mock.
|
|
351
|
+
*/
|
|
352
|
+
it('401s a real token whose forged signature makes verify throw', async () => {
|
|
353
|
+
const token = internal.mint({ userId: 'user-7', externalProxy: true }, 60_000);
|
|
354
|
+
const dot = token.lastIndexOf('.');
|
|
355
|
+
const sig = token.slice(dot + 1);
|
|
356
|
+
// Same string LENGTH, different byte length: 'é' is two UTF-8 bytes.
|
|
357
|
+
const forged = `${token.slice(0, dot + 1)}é${sig.slice(1)}`;
|
|
358
|
+
const mw = makeMw({ internal });
|
|
359
|
+
const { req, res, next, status } = makeReqRes(`Bearer ${forged}`);
|
|
360
|
+
await mw(req, res, next);
|
|
361
|
+
expect(next).not.toHaveBeenCalled();
|
|
362
|
+
expect(status).toHaveBeenCalledWith(401);
|
|
363
|
+
});
|
|
364
|
+
});
|
|
249
365
|
});
|
|
@@ -82,7 +82,9 @@ async function connectClient(): Promise<Client> {
|
|
|
82
82
|
publicFrontendUrl: 'http://localhost:5173',
|
|
83
83
|
});
|
|
84
84
|
const stub = {} as never;
|
|
85
|
-
|
|
85
|
+
// local-token deps (internal tokens / OAuth provider / metadata URL) are only
|
|
86
|
+
// dereferenced by that route's handler — stubs suffice here.
|
|
87
|
+
app.use('/api', createMcpRoutes(mcpService, stub, fakeAuth, fakeAuth, stub, stub, stub, ''));
|
|
86
88
|
app.use('/api', createManualRoutes(registry, fakeAuth));
|
|
87
89
|
|
|
88
90
|
const transport = new StreamableHTTPClientTransport(new URL(`${baseUrl}/api/mcp`), {
|
|
@@ -41,7 +41,12 @@ async function mountWithRemove(
|
|
|
41
41
|
|
|
42
42
|
const app = express();
|
|
43
43
|
app.use(express.json());
|
|
44
|
-
|
|
44
|
+
// local-token deps (internal tokens / OAuth provider / metadata URL) are only
|
|
45
|
+
// dereferenced by that route's handler — stubs suffice here.
|
|
46
|
+
app.use(
|
|
47
|
+
'/api',
|
|
48
|
+
createMcpRoutes(mcpService, externalApiKeyService, fakeAuth, fakeAuth, stub, stub, stub, ''),
|
|
49
|
+
);
|
|
45
50
|
|
|
46
51
|
httpServer = await new Promise<HttpServer>((resolve) => {
|
|
47
52
|
const s = app.listen(0, () => resolve(s));
|