@bevel-software/platform-core-backend 0.10.0 → 0.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/core/create-core-server.d.ts.map +1 -1
- package/dist/core/create-core-server.js +12 -1
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/core/create-core-services.d.ts +22 -0
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js +34 -1
- package/dist/core/create-core-services.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/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 +165 -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/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 +3 -3
- package/src/core/create-core-server.ts +13 -0
- package/src/core/create-core-services.ts +68 -0
- 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/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 +174 -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
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
|
|
6
|
+
import type { AuthUser, IWorkflowService } from '@bevel-software/platform-shared';
|
|
7
|
+
import type { WorkspaceService } from '../../workspace/workspace.service.js';
|
|
8
|
+
import type { AccessControlService } from '../access-control.service.js';
|
|
9
|
+
import type { WorkflowEventBus } from '../../workflow/event-bus.js';
|
|
10
|
+
import { createSyncedGroupsCommitter } from '../synced-groups-committer.js';
|
|
11
|
+
import { PushNeedsAgentResolutionError, WorkflowValidationError } from '../../workflow/workflow.errors.js';
|
|
12
|
+
|
|
13
|
+
const KB = 'knowledge-base';
|
|
14
|
+
const BOT: AuthUser = { id: 'bot-1', email: 'directory-sync@bevel.local', name: 'Directory Sync Bot' };
|
|
15
|
+
|
|
16
|
+
describe('createSyncedGroupsCommitter.persist — post-commit push failures', () => {
|
|
17
|
+
let root: string;
|
|
18
|
+
|
|
19
|
+
beforeEach(async () => {
|
|
20
|
+
root = await fs.mkdtemp(path.join(os.tmpdir(), 'bevel-sg-committer-'));
|
|
21
|
+
await fs.mkdir(path.join(root, KB), { recursive: true });
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
afterEach(async () => {
|
|
25
|
+
await fs.rm(root, { recursive: true, force: true });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
function makeDeps(
|
|
29
|
+
workflowOverrides: Partial<
|
|
30
|
+
Record<'commitChanges' | 'releaseLock' | 'hasQueuedCommit' | 'hasUnpushedCommits', unknown>
|
|
31
|
+
> = {},
|
|
32
|
+
) {
|
|
33
|
+
const workspaceService = {
|
|
34
|
+
getOrCreateForBranch: vi.fn(async () => ({})),
|
|
35
|
+
getWorkspacePath: vi.fn(async () => root),
|
|
36
|
+
readFile: vi.fn(async (_id: string, wsRel: string) =>
|
|
37
|
+
fs.readFile(path.join(root, wsRel), 'utf-8'),
|
|
38
|
+
),
|
|
39
|
+
} as unknown as WorkspaceService;
|
|
40
|
+
const releaseLock = vi.fn(async () => undefined);
|
|
41
|
+
const releaseLockNoCommit = vi.fn(async () => undefined);
|
|
42
|
+
const releaseLockUntouched = vi.fn(async () => undefined);
|
|
43
|
+
const hasQueuedCommit = vi.fn(async () => false);
|
|
44
|
+
// Default: the branch still carries the landed-but-unpushed commit — the
|
|
45
|
+
// truthful answer everywhere except the worker-won-the-race scenario.
|
|
46
|
+
const hasUnpushedCommits = vi.fn(async () => true);
|
|
47
|
+
const workflowService = {
|
|
48
|
+
acquireLock: vi.fn(async () => ({ acquired: true, lock: {} })),
|
|
49
|
+
releaseLock,
|
|
50
|
+
releaseLockNoCommit,
|
|
51
|
+
releaseLockUntouched,
|
|
52
|
+
hasQueuedCommit,
|
|
53
|
+
hasUnpushedCommits,
|
|
54
|
+
commitChanges: vi.fn(async () => ({})),
|
|
55
|
+
...workflowOverrides,
|
|
56
|
+
} as unknown as IWorkflowService;
|
|
57
|
+
const accessControl = { invalidate: vi.fn() } as unknown as AccessControlService;
|
|
58
|
+
const eventBus = { emit: vi.fn() } as unknown as WorkflowEventBus;
|
|
59
|
+
const committer = createSyncedGroupsCommitter({
|
|
60
|
+
workspaceService,
|
|
61
|
+
workflowService,
|
|
62
|
+
accessControl,
|
|
63
|
+
eventBus,
|
|
64
|
+
kbDirName: KB,
|
|
65
|
+
bot: BOT,
|
|
66
|
+
defaultBranchOf: () => 'main',
|
|
67
|
+
});
|
|
68
|
+
return { committer, releaseLock, releaseLockNoCommit, hasQueuedCommit };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
it('resolves (edit is saved) when only the PUSH failed after a landed commit', async () => {
|
|
72
|
+
// The regression this pins: a push failure after a successful local commit
|
|
73
|
+
// used to reject persist — the next sync then read the committed bytes,
|
|
74
|
+
// saw a no-op, and the update stayed unpublished forever. A landed commit
|
|
75
|
+
// is a saved edit; the pending-commits ladder retries the share.
|
|
76
|
+
const { committer, releaseLock, releaseLockNoCommit } = makeDeps({
|
|
77
|
+
commitChanges: vi.fn(async () => {
|
|
78
|
+
throw new PushNeedsAgentResolutionError('main', '(batch)', 'non-fast-forward', 'rebase failed');
|
|
79
|
+
}),
|
|
80
|
+
});
|
|
81
|
+
await expect(committer.persist('groups:\n Team:\n - a@x.io\n')).resolves.toBeUndefined();
|
|
82
|
+
// The bytes landed on disk (write happened before the commit attempt)...
|
|
83
|
+
expect(await fs.readFile(path.join(root, KB, 'synced-groups.yaml'), 'utf-8')).toContain('a@x.io');
|
|
84
|
+
// ...and the lock released through the RETRY-ARMING path, never the discard.
|
|
85
|
+
expect(releaseLock).toHaveBeenCalled();
|
|
86
|
+
expect(releaseLockNoCommit).not.toHaveBeenCalled();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('verifies the QUEUE when the arm probe answers lock-not-held, and resolves on a live row', async () => {
|
|
90
|
+
// In production the writeFiles push-retry release has usually SUCCEEDED —
|
|
91
|
+
// enqueue first, then drop the row — so the committer's verification
|
|
92
|
+
// release finds no lock to release. But lock-not-held alone proves
|
|
93
|
+
// nothing: a release that died BEFORE its enqueue (expired/stolen lock)
|
|
94
|
+
// leaves the exact same answer. Only the queue itself can prove the
|
|
95
|
+
// vehicle — persist must consult it and resolve when a row exists.
|
|
96
|
+
const releaseLock = vi
|
|
97
|
+
.fn()
|
|
98
|
+
// writeFiles' own push-retry release: succeeded (row enqueued + dropped).
|
|
99
|
+
.mockResolvedValueOnce(undefined)
|
|
100
|
+
// The committer's arm probe: nothing left to release.
|
|
101
|
+
.mockRejectedValue(
|
|
102
|
+
new WorkflowValidationError('Cannot release lock: not held by you.', {
|
|
103
|
+
kind: 'lock-not-held',
|
|
104
|
+
}),
|
|
105
|
+
);
|
|
106
|
+
const hasQueuedCommit = vi.fn(async () => true);
|
|
107
|
+
const { committer } = makeDeps({
|
|
108
|
+
commitChanges: vi.fn(async () => {
|
|
109
|
+
throw new PushNeedsAgentResolutionError('main', '(batch)', 'non-fast-forward', 'rebase failed');
|
|
110
|
+
}),
|
|
111
|
+
releaseLock,
|
|
112
|
+
hasQueuedCommit,
|
|
113
|
+
});
|
|
114
|
+
await expect(committer.persist('groups:\n Team:\n - a@x.io\n')).resolves.toBeUndefined();
|
|
115
|
+
expect(releaseLock).toHaveBeenCalledTimes(2);
|
|
116
|
+
expect(hasQueuedCommit).toHaveBeenCalledWith('main', 'main', `${KB}/synced-groups.yaml`);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('REJECTS on lock-not-held with NO live queue row (release died before its enqueue)', async () => {
|
|
120
|
+
// The inference hole this pins: the ORIGINAL push-retry release hit an
|
|
121
|
+
// expired/stolen lock — releaseLock throws lock-not-held BEFORE its
|
|
122
|
+
// enqueue, writeFiles swallows it — and the committer's probe then gets
|
|
123
|
+
// the same lock-not-held. The old "lock gone ⇒ armed" inference reported
|
|
124
|
+
// success while NO pending row existed and the landed commit stayed
|
|
125
|
+
// unpublished forever. With the queue consulted directly, an empty queue
|
|
126
|
+
// must surface the original push failure.
|
|
127
|
+
const releaseLock = vi.fn().mockRejectedValue(
|
|
128
|
+
new WorkflowValidationError('Cannot release lock: not held by you.', {
|
|
129
|
+
kind: 'lock-not-held',
|
|
130
|
+
}),
|
|
131
|
+
);
|
|
132
|
+
const hasQueuedCommit = vi.fn(async () => false);
|
|
133
|
+
const { committer } = makeDeps({
|
|
134
|
+
commitChanges: vi.fn(async () => {
|
|
135
|
+
throw new PushNeedsAgentResolutionError('main', '(batch)', 'non-fast-forward', 'rebase failed');
|
|
136
|
+
}),
|
|
137
|
+
releaseLock,
|
|
138
|
+
hasQueuedCommit,
|
|
139
|
+
});
|
|
140
|
+
await expect(committer.persist('groups:\n Team:\n - a@x.io\n')).rejects.toBeInstanceOf(
|
|
141
|
+
PushNeedsAgentResolutionError,
|
|
142
|
+
);
|
|
143
|
+
expect(hasQueuedCommit).toHaveBeenCalled();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('resolves when the worker DRAINED the row between release and probe (nothing left unpushed)', async () => {
|
|
147
|
+
// The race the unpushed check closes: the pending-commits worker grabbed
|
|
148
|
+
// the freshly enqueued row and pushed it in the window between writeFiles'
|
|
149
|
+
// release and the committer's probe. The queue answers "no live row" —
|
|
150
|
+
// truthfully — but rethrowing would report a directory-sync failure AFTER
|
|
151
|
+
// successful publication. A branch with nothing left unpushed proves the
|
|
152
|
+
// worker won; persist must resolve.
|
|
153
|
+
const releaseLock = vi
|
|
154
|
+
.fn()
|
|
155
|
+
.mockResolvedValueOnce(undefined) // writeFiles' release: enqueued + dropped
|
|
156
|
+
.mockRejectedValue(
|
|
157
|
+
new WorkflowValidationError('Cannot release lock: not held by you.', {
|
|
158
|
+
kind: 'lock-not-held',
|
|
159
|
+
}),
|
|
160
|
+
);
|
|
161
|
+
const { committer } = makeDeps({
|
|
162
|
+
commitChanges: vi.fn(async () => {
|
|
163
|
+
throw new PushNeedsAgentResolutionError('main', '(batch)', 'non-fast-forward', 'rebase failed');
|
|
164
|
+
}),
|
|
165
|
+
releaseLock,
|
|
166
|
+
hasQueuedCommit: vi.fn(async () => false),
|
|
167
|
+
hasUnpushedCommits: vi.fn(async () => false),
|
|
168
|
+
});
|
|
169
|
+
await expect(committer.persist('groups:\n Team:\n - a@x.io\n')).resolves.toBeUndefined();
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('REJECTS when the queue itself cannot be read (armed must be PROVEN, never assumed)', async () => {
|
|
173
|
+
const releaseLock = vi.fn().mockRejectedValue(
|
|
174
|
+
new WorkflowValidationError('Cannot release lock: not held by you.', {
|
|
175
|
+
kind: 'lock-not-held',
|
|
176
|
+
}),
|
|
177
|
+
);
|
|
178
|
+
const hasQueuedCommit = vi.fn(async () => {
|
|
179
|
+
throw new Error('db down');
|
|
180
|
+
});
|
|
181
|
+
const { committer } = makeDeps({
|
|
182
|
+
commitChanges: vi.fn(async () => {
|
|
183
|
+
throw new PushNeedsAgentResolutionError('main', '(batch)', 'non-fast-forward', 'rebase failed');
|
|
184
|
+
}),
|
|
185
|
+
releaseLock,
|
|
186
|
+
hasQueuedCommit,
|
|
187
|
+
});
|
|
188
|
+
await expect(committer.persist('groups:\n Team:\n - a@x.io\n')).rejects.toBeInstanceOf(
|
|
189
|
+
PushNeedsAgentResolutionError,
|
|
190
|
+
);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('re-arms the retry itself when the push-retry release could not enqueue its row', async () => {
|
|
194
|
+
// writeFiles' release swallows an enqueue failure (warn + continue), so
|
|
195
|
+
// without the committer's own arm the landed commit would have NO retry
|
|
196
|
+
// vehicle: the next sync reads the committed bytes, sees a no-op, and the
|
|
197
|
+
// update stays unpublished forever. The committer must retry the release
|
|
198
|
+
// (which enqueues the pending-commit row) before reporting success.
|
|
199
|
+
const releaseLock = vi
|
|
200
|
+
.fn()
|
|
201
|
+
.mockRejectedValueOnce(new Error('pending_commits insert failed')) // writeFiles' release
|
|
202
|
+
.mockResolvedValueOnce(undefined); // the committer's re-arm succeeds
|
|
203
|
+
const { committer } = makeDeps({
|
|
204
|
+
commitChanges: vi.fn(async () => {
|
|
205
|
+
throw new PushNeedsAgentResolutionError('main', '(batch)', 'non-fast-forward', 'rebase failed');
|
|
206
|
+
}),
|
|
207
|
+
releaseLock,
|
|
208
|
+
});
|
|
209
|
+
await expect(committer.persist('groups:\n Team:\n - a@x.io\n')).resolves.toBeUndefined();
|
|
210
|
+
expect(releaseLock).toHaveBeenCalledTimes(2);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('REJECTS when no retry vehicle can be proven (both the release and the re-arm fail)', async () => {
|
|
214
|
+
// The one shape that may not report success: the commit landed, the push
|
|
215
|
+
// needs help, and no pending-commit row could be enqueued. Resolving here
|
|
216
|
+
// would tell the writer the update is published-or-retried when nothing
|
|
217
|
+
// will ever retry it — the failure signal must survive to the caller.
|
|
218
|
+
const releaseLock = vi.fn().mockRejectedValue(new Error('pending_commits insert failed'));
|
|
219
|
+
const { committer } = makeDeps({
|
|
220
|
+
commitChanges: vi.fn(async () => {
|
|
221
|
+
throw new PushNeedsAgentResolutionError('main', '(batch)', 'non-fast-forward', 'rebase failed');
|
|
222
|
+
}),
|
|
223
|
+
releaseLock,
|
|
224
|
+
});
|
|
225
|
+
await expect(committer.persist('groups:\n Team:\n - a@x.io\n')).rejects.toBeInstanceOf(
|
|
226
|
+
PushNeedsAgentResolutionError,
|
|
227
|
+
);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it('still rejects on a genuine pre-commit failure (nothing landed)', async () => {
|
|
231
|
+
const { committer } = makeDeps({
|
|
232
|
+
commitChanges: vi.fn(async () => {
|
|
233
|
+
throw new Error('commit exploded');
|
|
234
|
+
}),
|
|
235
|
+
});
|
|
236
|
+
await expect(committer.persist('groups: {}\n')).rejects.toThrow('commit exploded');
|
|
237
|
+
});
|
|
238
|
+
});
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { parseGroupsFile } from '../group-files.js';
|
|
3
|
+
import {
|
|
4
|
+
SyncedGroupsWriter,
|
|
5
|
+
renderSyncedGroupsYaml,
|
|
6
|
+
type SyncedGroupMember,
|
|
7
|
+
type SyncedGroupRecord,
|
|
8
|
+
} from '../synced-groups-writer.js';
|
|
9
|
+
|
|
10
|
+
function member(overrides: Partial<SyncedGroupMember> = {}): SyncedGroupMember {
|
|
11
|
+
return {
|
|
12
|
+
email: 'ada@x.io',
|
|
13
|
+
active: true,
|
|
14
|
+
...overrides,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function group(
|
|
19
|
+
displayName: string,
|
|
20
|
+
members: SyncedGroupMember[],
|
|
21
|
+
externalId = `ext-${displayName}`,
|
|
22
|
+
): SyncedGroupRecord {
|
|
23
|
+
return {
|
|
24
|
+
externalId,
|
|
25
|
+
displayName,
|
|
26
|
+
members,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe('renderSyncedGroupsYaml', () => {
|
|
31
|
+
it('renders deterministically: sorted groups, sorted emails, stable bytes', () => {
|
|
32
|
+
const groups = [
|
|
33
|
+
group('Sales', [member({ email: 'zoe@x.io' }), member({ email: 'bo@x.io' })]),
|
|
34
|
+
group('Engineering', [member()]),
|
|
35
|
+
];
|
|
36
|
+
const a = renderSyncedGroupsYaml(groups);
|
|
37
|
+
const b = renderSyncedGroupsYaml([...groups].reverse());
|
|
38
|
+
expect(a.text).toBe(b.text);
|
|
39
|
+
expect(a.groupCount).toBe(2);
|
|
40
|
+
// Engineering sorts before Sales; emails sort within a group.
|
|
41
|
+
const engineeringAt = a.text.indexOf('Engineering:');
|
|
42
|
+
const salesAt = a.text.indexOf('Sales:');
|
|
43
|
+
expect(engineeringAt).toBeGreaterThan(-1);
|
|
44
|
+
expect(engineeringAt).toBeLessThan(salesAt);
|
|
45
|
+
expect(a.text.indexOf('bo@x.io')).toBeLessThan(a.text.indexOf('zoe@x.io'));
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('round-trips through the resolver-side parser', () => {
|
|
49
|
+
const rendered = renderSyncedGroupsYaml([
|
|
50
|
+
group('Engineering', [member(), member({ email: 'bo@x.io' })]),
|
|
51
|
+
group('Empty Team', []),
|
|
52
|
+
]);
|
|
53
|
+
const parsed = parseGroupsFile(rendered.text, 'synced-groups.yaml');
|
|
54
|
+
expect(parsed.ok).toBe(true);
|
|
55
|
+
if (parsed.ok) {
|
|
56
|
+
expect(parsed.warnings).toEqual([]);
|
|
57
|
+
expect([...parsed.groups.keys()].sort()).toEqual(['empty team', 'engineering']);
|
|
58
|
+
expect([...parsed.groups.get('engineering')!.emails].sort()).toEqual(['ada@x.io', 'bo@x.io']);
|
|
59
|
+
expect(parsed.groups.get('empty team')!.emails.size).toBe(0);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('excludes inactive and email-less members, with a warning', () => {
|
|
64
|
+
const rendered = renderSyncedGroupsYaml([
|
|
65
|
+
group('Engineering', [
|
|
66
|
+
member(),
|
|
67
|
+
member({ email: 'gone@x.io', active: false }),
|
|
68
|
+
member({ email: null }),
|
|
69
|
+
]),
|
|
70
|
+
]);
|
|
71
|
+
expect(rendered.text).toContain('ada@x.io');
|
|
72
|
+
expect(rendered.text).not.toContain('gone@x.io');
|
|
73
|
+
expect(rendered.warnings.join(' ')).toContain('2 members not materialized');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('refuses malformed emails from the directory (YAML corruption / injection)', () => {
|
|
77
|
+
const rendered = renderSyncedGroupsYaml([
|
|
78
|
+
group('Engineering', [
|
|
79
|
+
member(),
|
|
80
|
+
// A newline-bearing "email" would smuggle extra member lines into the file.
|
|
81
|
+
member({ email: 'evil@x.io\n - attacker@evil.io' }),
|
|
82
|
+
member({ email: 'not an email' }),
|
|
83
|
+
// Passes the shape regex but would read back as a YAML comment.
|
|
84
|
+
member({ email: '#tag@x.io' }),
|
|
85
|
+
member({ email: ' ADA@X.IO ' }), // canonicalized, deduped with ada@x.io
|
|
86
|
+
]),
|
|
87
|
+
]);
|
|
88
|
+
expect(rendered.text).toContain('ada@x.io');
|
|
89
|
+
expect(rendered.text).not.toContain('attacker@evil.io');
|
|
90
|
+
expect(rendered.text).not.toContain('not an email');
|
|
91
|
+
expect(rendered.text).not.toContain('#tag');
|
|
92
|
+
expect(rendered.text.match(/ada@x\.io/g)).toHaveLength(1);
|
|
93
|
+
expect(rendered.warnings.join(' ')).toContain('malformed email');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("refuses the reserved 'group:' prefix as a member email (the read side skips it as a ref token)", () => {
|
|
97
|
+
const rendered = renderSyncedGroupsYaml([
|
|
98
|
+
group('Engineering', [member(), member({ email: 'group:lee@x.io' })]),
|
|
99
|
+
]);
|
|
100
|
+
expect(rendered.text).toContain('ada@x.io');
|
|
101
|
+
expect(rendered.text).not.toContain('group:lee@x.io');
|
|
102
|
+
expect(rendered.warnings.join(' ')).toContain('malformed email');
|
|
103
|
+
// The emitted file round-trips warning-free through the parser.
|
|
104
|
+
const parsed = parseGroupsFile(rendered.text, 'synced-groups.yaml');
|
|
105
|
+
expect(parsed.ok && parsed.warnings).toEqual([]);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('renders IdP names ESCAPED into warnings — control characters never reach the log verbatim', () => {
|
|
109
|
+
const rendered = renderSyncedGroupsYaml([
|
|
110
|
+
group('Evil\u001b[31mTeam\nFAKE LOG LINE', [member()]),
|
|
111
|
+
]);
|
|
112
|
+
expect(rendered.groupCount).toBe(0);
|
|
113
|
+
expect(rendered.warnings).toHaveLength(1);
|
|
114
|
+
// The raw control chars must not appear; their JSON escapes must.
|
|
115
|
+
expect(rendered.warnings[0]).not.toContain('\n');
|
|
116
|
+
expect(rendered.warnings[0]).not.toContain('\u001b');
|
|
117
|
+
expect(rendered.warnings[0]).toContain('\\n');
|
|
118
|
+
expect(rendered.warnings[0]).toContain('\\u001b');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('also escapes C1 controls and U+2028/U+2029 — the ranges JSON.stringify leaves raw', () => {
|
|
122
|
+
// JSON.stringify only escapes C0 (U+0000–U+001F): the one-byte CSI
|
|
123
|
+
// U+009B starts an ANSI sequence on its own, DEL (U+007F) is a control
|
|
124
|
+
// too, and U+2028/U+2029 are line breaks to JS consumers — all four
|
|
125
|
+
// would otherwise reach the log stream verbatim.
|
|
126
|
+
const raw = { csi: '\u009B', del: '\u007F', ls: '\u2028', ps: '\u2029' };
|
|
127
|
+
// C1/2028/2029 do NOT make a name unsafe (only C0 does), so the group
|
|
128
|
+
// is emitted — the name reaches the log through a MEMBER warning.
|
|
129
|
+
const rendered = renderSyncedGroupsYaml([
|
|
130
|
+
group(`Csi${raw.csi}31mTeam${raw.del}${raw.ls}${raw.ps}FAKE`, [member({ active: false })]),
|
|
131
|
+
]);
|
|
132
|
+
expect(rendered.groupCount).toBe(1);
|
|
133
|
+
expect(rendered.warnings).toHaveLength(1);
|
|
134
|
+
const warning = rendered.warnings[0];
|
|
135
|
+
for (const c of Object.values(raw)) {
|
|
136
|
+
expect(warning).not.toContain(c);
|
|
137
|
+
}
|
|
138
|
+
expect(warning).toContain('\\u009b');
|
|
139
|
+
expect(warning).toContain('\\u007f');
|
|
140
|
+
expect(warning).toContain('\\u2028');
|
|
141
|
+
expect(warning).toContain('\\u2029');
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('full-key duplicate tie-break is collision-proof (JSON member key, not a delimiter join)', () => {
|
|
145
|
+
// Crafted so the OLD delimiter-joined keys collide: one malformed "email"
|
|
146
|
+
// containing the join delimiters serializes identically to two real
|
|
147
|
+
// members. A collision pushed the tie onto input order — the two renders
|
|
148
|
+
// below would then disagree on which duplicate wins first-wins dedup.
|
|
149
|
+
const a = group('Team', [member({ email: 'a@x.io:true,b@x.io' })], 'ext-1');
|
|
150
|
+
const b = group('Team', [member({ email: 'a@x.io' }), member({ email: 'b@x.io' })], 'ext-1');
|
|
151
|
+
const forward = renderSyncedGroupsYaml([a, b]);
|
|
152
|
+
const reversed = renderSyncedGroupsYaml([b, a]);
|
|
153
|
+
expect(forward.text).toBe(reversed.text);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('sorts by code units, not locale (byte-stable across deployments)', () => {
|
|
157
|
+
// localeCompare in most locales collates 'é' before 'z'; code units put
|
|
158
|
+
// 'z' (0x7a) before 'é' (0xe9). Pin the code-unit order so the rendered
|
|
159
|
+
// bytes never depend on the process's ambient locale/ICU build.
|
|
160
|
+
const rendered = renderSyncedGroupsYaml([
|
|
161
|
+
group('équipe', [member()]),
|
|
162
|
+
group('z team', [member()]),
|
|
163
|
+
]);
|
|
164
|
+
const z = rendered.text.indexOf('z team:');
|
|
165
|
+
const e = rendered.text.indexOf('équipe:');
|
|
166
|
+
expect(z).toBeGreaterThan(-1);
|
|
167
|
+
expect(z).toBeLessThan(e);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('skips YAML-unsafe, reserved, and name-colliding groups (fail-closed)', () => {
|
|
171
|
+
const rendered = renderSyncedGroupsYaml([
|
|
172
|
+
group('Ops: West', [member()]),
|
|
173
|
+
group('everyone', [member()]),
|
|
174
|
+
group('Engineering', [member()], 'ext-a'),
|
|
175
|
+
group('engineering', [member({ email: 'bo@x.io' })], 'ext-b'),
|
|
176
|
+
]);
|
|
177
|
+
expect(rendered.groupCount).toBe(1);
|
|
178
|
+
expect(rendered.text).not.toContain('Ops: West');
|
|
179
|
+
expect(rendered.text).not.toContain('everyone');
|
|
180
|
+
const joined = rendered.warnings.join('\n');
|
|
181
|
+
expect(joined).toContain('"Ops: West" skipped');
|
|
182
|
+
expect(joined).toContain('reserved');
|
|
183
|
+
expect(joined).toContain('collides');
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
describe('SyncedGroupsWriter', () => {
|
|
188
|
+
const GROUPS = [group('Engineering', [member()])];
|
|
189
|
+
|
|
190
|
+
function makeWriter(overrides: { current?: string | null; debounceMs?: number } = {}) {
|
|
191
|
+
const persisted: string[] = [];
|
|
192
|
+
const written: unknown[] = [];
|
|
193
|
+
let current = overrides.current ?? null;
|
|
194
|
+
const writer = new SyncedGroupsWriter({
|
|
195
|
+
source: { listGroups: async () => GROUPS },
|
|
196
|
+
readCurrent: async () => current,
|
|
197
|
+
persist: async (content) => {
|
|
198
|
+
persisted.push(content);
|
|
199
|
+
current = content;
|
|
200
|
+
},
|
|
201
|
+
onWritten: (result) => written.push(result),
|
|
202
|
+
debounceMs: overrides.debounceMs ?? 50,
|
|
203
|
+
});
|
|
204
|
+
return { writer, persisted, written };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
beforeEach(() => vi.useFakeTimers());
|
|
208
|
+
afterEach(() => vi.useRealTimers());
|
|
209
|
+
|
|
210
|
+
it('writeNow persists changed content and fires onWritten', async () => {
|
|
211
|
+
const { writer, persisted, written } = makeWriter();
|
|
212
|
+
const result = await writer.writeNow();
|
|
213
|
+
expect(result.changed).toBe(true);
|
|
214
|
+
expect(result.groupCount).toBe(1);
|
|
215
|
+
expect(persisted).toHaveLength(1);
|
|
216
|
+
expect(persisted[0]).toContain('Engineering:');
|
|
217
|
+
expect(written).toHaveLength(1);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('is a no-op (no commit, no events) when the content is unchanged', async () => {
|
|
221
|
+
const { writer, persisted, written } = makeWriter({
|
|
222
|
+
current: renderSyncedGroupsYaml(GROUPS).text,
|
|
223
|
+
});
|
|
224
|
+
const result = await writer.writeNow();
|
|
225
|
+
expect(result.changed).toBe(false);
|
|
226
|
+
expect(persisted).toHaveLength(0);
|
|
227
|
+
expect(written).toHaveLength(0);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it('debounces a burst of mutations into one write', async () => {
|
|
231
|
+
const { writer, persisted } = makeWriter({ debounceMs: 50 });
|
|
232
|
+
writer.notifyMutation();
|
|
233
|
+
await vi.advanceTimersByTimeAsync(30);
|
|
234
|
+
writer.notifyMutation(); // burst continues → timer re-arms
|
|
235
|
+
await vi.advanceTimersByTimeAsync(30);
|
|
236
|
+
expect(persisted).toHaveLength(0); // still inside the burst window
|
|
237
|
+
writer.notifyMutation();
|
|
238
|
+
await vi.advanceTimersByTimeAsync(60); // burst goes quiet → fire once
|
|
239
|
+
expect(persisted).toHaveLength(1);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it('dispose cancels a pending debounce', async () => {
|
|
243
|
+
const { writer, persisted } = makeWriter({ debounceMs: 50 });
|
|
244
|
+
writer.notifyMutation();
|
|
245
|
+
writer.dispose();
|
|
246
|
+
await vi.advanceTimersByTimeAsync(200);
|
|
247
|
+
expect(persisted).toHaveLength(0);
|
|
248
|
+
});
|
|
249
|
+
});
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Access-control service interface. The implementation reads `roles.yaml
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* Access-control service interface. The implementation reads `roles.yaml`,
|
|
3
|
+
* the active group file (`synced-groups.yaml` / `groups.yaml`), and the
|
|
4
|
+
* `access.md` tree from the workspace's `knowledge-base` clone and resolves
|
|
5
|
+
* the access verbs (read / write / download / owner) against them.
|
|
5
6
|
*
|
|
6
7
|
* All `relativePath` arguments are repo-relative POSIX paths inside the KB
|
|
7
8
|
* repo (e.g. `Knowledge/Sales/Foo.md`, `roles.yaml`) — NOT workspace-relative
|
|
@@ -20,8 +21,8 @@
|
|
|
20
21
|
*
|
|
21
22
|
* Only a principal NAMED in a file (a direct user grant `Name <email>`, or a
|
|
22
23
|
* group/role token) produces sources. A USER who merely RESOLVES to access via a
|
|
23
|
-
*
|
|
24
|
-
* per-target file entry and produces NO source (the
|
|
24
|
+
* group or role they belong to, the built-in `everyone`, or admin-rescue is NOT a
|
|
25
|
+
* per-target file entry and produces NO source (the group/role itself shows as its own
|
|
25
26
|
* row instead).
|
|
26
27
|
*/
|
|
27
28
|
export type GrantSource =
|
|
@@ -50,6 +51,24 @@ export type GrantPrincipal =
|
|
|
50
51
|
| { kind: 'user'; email: string }
|
|
51
52
|
| { kind: 'role'; role: string };
|
|
52
53
|
|
|
54
|
+
/**
|
|
55
|
+
* One collective principal in a resolved eligible list, with WHAT it is: a
|
|
56
|
+
* ROLE (an app-defined capability) or a GROUP (a grant audience from the
|
|
57
|
+
* active group source). The resolver knows the kind from the merged principal
|
|
58
|
+
* index — a `role/<canonical>` alias hit is always the role; a bare token is
|
|
59
|
+
* whatever owns it under group-first precedence — and the share dialog needs
|
|
60
|
+
* it to badge each grantee row honestly ("Role" vs "Group") and to round-trip
|
|
61
|
+
* the row's principal with the right kind.
|
|
62
|
+
*
|
|
63
|
+
* Additive: `principals` rides NEXT TO the legacy `roles: string[]` (the same
|
|
64
|
+
* names, kind erased), which many name-only consumers (banners, owner
|
|
65
|
+
* contact lines, PR-routing messages) still read. It is optional in the
|
|
66
|
+
* interface so existing test doubles stay valid; the real service always
|
|
67
|
+
* returns it, and payload consumers fall back to `roles` (all treated as
|
|
68
|
+
* roles) when absent.
|
|
69
|
+
*/
|
|
70
|
+
export type ResolvedPrincipal = { name: string; kind: 'role' | 'group' };
|
|
71
|
+
|
|
53
72
|
/**
|
|
54
73
|
* Per-verb sources of a principal's access on a target. Only verbs the principal
|
|
55
74
|
* actually holds (via a named file entry) appear; each maps to the closest-first
|
|
@@ -155,7 +174,11 @@ export interface IAccessControl {
|
|
|
155
174
|
eligibleOwners(
|
|
156
175
|
workspaceId: string,
|
|
157
176
|
relativePath: string,
|
|
158
|
-
): Promise<{
|
|
177
|
+
): Promise<{
|
|
178
|
+
principals?: ResolvedPrincipal[];
|
|
179
|
+
roles: string[];
|
|
180
|
+
users: { name: string; email: string }[];
|
|
181
|
+
}>;
|
|
159
182
|
|
|
160
183
|
/**
|
|
161
184
|
* The set of principals (roles + direct users) with `write` on this path.
|
|
@@ -167,7 +190,11 @@ export interface IAccessControl {
|
|
|
167
190
|
eligibleWriters(
|
|
168
191
|
workspaceId: string,
|
|
169
192
|
relativePath: string,
|
|
170
|
-
): Promise<{
|
|
193
|
+
): Promise<{
|
|
194
|
+
principals?: ResolvedPrincipal[];
|
|
195
|
+
roles: string[];
|
|
196
|
+
users: { name: string; email: string }[];
|
|
197
|
+
}>;
|
|
171
198
|
|
|
172
199
|
/**
|
|
173
200
|
* Answers the file viewer's "who can see this?" affordance. `restricted` is
|
|
@@ -181,7 +208,12 @@ export interface IAccessControl {
|
|
|
181
208
|
eligibleReaders(
|
|
182
209
|
workspaceId: string,
|
|
183
210
|
relativePath: string,
|
|
184
|
-
): Promise<{
|
|
211
|
+
): Promise<{
|
|
212
|
+
restricted: boolean;
|
|
213
|
+
principals?: ResolvedPrincipal[];
|
|
214
|
+
roles: string[];
|
|
215
|
+
users: { name: string; email: string }[];
|
|
216
|
+
}>;
|
|
185
217
|
|
|
186
218
|
/**
|
|
187
219
|
* The set of principals (roles + direct users) with `download` on this path.
|
|
@@ -194,7 +226,11 @@ export interface IAccessControl {
|
|
|
194
226
|
eligibleDownloaders(
|
|
195
227
|
workspaceId: string,
|
|
196
228
|
relativePath: string,
|
|
197
|
-
): Promise<{
|
|
229
|
+
): Promise<{
|
|
230
|
+
principals?: ResolvedPrincipal[];
|
|
231
|
+
roles: string[];
|
|
232
|
+
users: { name: string; email: string }[];
|
|
233
|
+
}>;
|
|
198
234
|
|
|
199
235
|
/**
|
|
200
236
|
* Finite expanded email set for configured users who could approve this path
|
|
@@ -237,12 +273,22 @@ export interface IAccessControl {
|
|
|
237
273
|
*
|
|
238
274
|
* Returns only the verbs the principal effectively holds; a verb with no
|
|
239
275
|
* access is omitted. A principal with no access anywhere yields an empty map.
|
|
276
|
+
*
|
|
277
|
+
* `opts.tokenMatch: 'exact'` pins a ROLE-shaped principal to its literal
|
|
278
|
+
* token spelling: only the exact token (bare, or `role/<name>`) counts as
|
|
279
|
+
* the principal's own entry, regardless of group shadowing. The mutation
|
|
280
|
+
* layer uses this so a check runs against the SAME identity a pinned
|
|
281
|
+
* exact-token splice edited — e.g. verifying a GROUP deny whose group has
|
|
282
|
+
* vanished, where the default (alias-tolerant when unshadowed) matching
|
|
283
|
+
* would misattribute a same-named role's surviving `role/<name>` grant to
|
|
284
|
+
* the group. Omitted (or `'name'`) → the shadowing-derived default.
|
|
240
285
|
*/
|
|
241
286
|
grantSources(
|
|
242
287
|
workspaceId: string,
|
|
243
288
|
kind: AccessTargetKind,
|
|
244
289
|
relativePath: string,
|
|
245
290
|
principal: GrantPrincipal,
|
|
291
|
+
opts?: { tokenMatch?: 'exact' | 'name' },
|
|
246
292
|
): Promise<GrantSources>;
|
|
247
293
|
|
|
248
294
|
/**
|
|
@@ -264,34 +310,22 @@ export interface IAccessControl {
|
|
|
264
310
|
*/
|
|
265
311
|
validateRolesYaml(text: string): { ok: true } | { ok: false; errors: string[] };
|
|
266
312
|
|
|
267
|
-
/**
|
|
268
|
-
* Find every folder-`access.md` reference to a role, by canonical name, in the
|
|
269
|
-
* cached model. ADVISORY ONLY — it powers the delete-confirmation "N rules
|
|
270
|
-
* will be ignored" warning, and it may UNDERCOUNT: `collectAccessFiles` only
|
|
271
|
-
* walks files literally named `access.md`, so a role granted in a node's OWN
|
|
272
|
-
* frontmatter is a real grant this scan misses. That is acceptable for a
|
|
273
|
-
* non-fatal warning. It must NOT be used to gate the rename rewrite — that
|
|
274
|
-
* needs the sound git-grep scan in `roles-admin.service.ts`, which also covers
|
|
275
|
-
* node frontmatter. An undercount there would silently orphan access.
|
|
276
|
-
*/
|
|
277
|
-
referencesToRole(
|
|
278
|
-
workspaceId: string,
|
|
279
|
-
canonicalRole: string,
|
|
280
|
-
): Promise<{ path: string; verb: string }[]>;
|
|
281
|
-
|
|
282
313
|
/**
|
|
283
314
|
* Enumerate the grantable principals known to the KB, for the share-dialog
|
|
284
|
-
* autocomplete. `
|
|
285
|
-
* `roles.yaml` role display names
|
|
286
|
-
*
|
|
287
|
-
*
|
|
288
|
-
*
|
|
289
|
-
*
|
|
290
|
-
*
|
|
315
|
+
* autocomplete. `roles` are the built-in `everyone` role plus the declared
|
|
316
|
+
* `roles.yaml` role display names — ROLE principals only, never groups
|
|
317
|
+
* (`everyone` is surfaced so the UI can grant public read; the grant route
|
|
318
|
+
* gates it to the `read` verb only). `groups` are the ACTIVE group source's
|
|
319
|
+
* display names as merged into the resolver's cached model — served from
|
|
320
|
+
* that cache precisely so suggest/grant don't re-read the files per call.
|
|
321
|
+
* `people` are every email named in `roles.yaml` (name defaults to the
|
|
322
|
+
* local part) unioned with every `Name <email>` grant in any `access.md`
|
|
323
|
+
* (named). The login-only `users` table is unioned in by the caller — this
|
|
324
|
+
* method covers the KB-canonical people the users table misses.
|
|
291
325
|
*/
|
|
292
326
|
kbPrincipals(
|
|
293
327
|
workspaceId: string,
|
|
294
|
-
): Promise<{
|
|
328
|
+
): Promise<{ roles: string[]; groups: string[]; people: { name: string; email: string }[] }>;
|
|
295
329
|
|
|
296
330
|
/**
|
|
297
331
|
* Reverse-lookup an email by its SHA-256 hash (per `hashEmail` semantics)
|