amalgm 0.1.161 → 0.1.162
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/lib/shared-realtime-tunnel.js +30 -1
- package/package.json +1 -1
- package/runtime/scripts/amalgm-mcp/fs/rest.js +26 -7
- package/runtime/scripts/amalgm-mcp/index.js +3 -0
- package/runtime/scripts/amalgm-mcp/server/routes/state.js +4 -0
- package/runtime/scripts/amalgm-mcp/server/routes/workspace.js +32 -0
- package/runtime/scripts/amalgm-mcp/state/attachments.js +7 -5
- package/runtime/scripts/amalgm-mcp/state/cloud-resource-id.js +16 -0
- package/runtime/scripts/amalgm-mcp/state/db.js +74 -0
- package/runtime/scripts/amalgm-mcp/state/docs.js +122 -4
- package/runtime/scripts/amalgm-mcp/state/migrations.js +62 -0
- package/runtime/scripts/amalgm-mcp/state/mutations.js +4 -4
- package/runtime/scripts/amalgm-mcp/state/promotions.js +2 -2
- package/runtime/scripts/amalgm-mcp/state/replicas.js +6 -5
- package/runtime/scripts/amalgm-mcp/state/shared-rest.js +27 -22
- package/runtime/scripts/amalgm-mcp/tests/state-promotion-ownership.test.js +5 -9
- package/runtime/scripts/amalgm-mcp/tests/workspace-access-store.test.js +120 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint.test.js +137 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-reconcile.test.js +264 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-tree-cloud.test.js +155 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-tree-core.test.js +114 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-tree-store.test.js +227 -0
- package/runtime/scripts/amalgm-mcp/workspace/access-store.js +182 -0
- package/runtime/scripts/amalgm-mcp/workspace/binding.js +62 -0
- package/runtime/scripts/amalgm-mcp/workspace/checkpoint.js +237 -0
- package/runtime/scripts/amalgm-mcp/workspace/git.js +63 -12
- package/runtime/scripts/amalgm-mcp/workspace/ledger.js +96 -0
- package/runtime/scripts/amalgm-mcp/workspace/merge.js +290 -0
- package/runtime/scripts/amalgm-mcp/workspace/reconcile.js +347 -0
- package/runtime/scripts/amalgm-mcp/workspace/rest.js +182 -2
- package/runtime/scripts/amalgm-mcp/workspace/store.js +59 -12
- package/runtime/scripts/amalgm-mcp/workspace/transition.js +141 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree/access.js +62 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree/identity.js +53 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree/inclusion.js +68 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree/local-state.js +174 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree/operations.js +93 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree/reducer.js +138 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree/snapshot.js +82 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree-cloud.js +391 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree-promotion.js +94 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree-store.js +666 -0
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const test = require('node:test');
|
|
4
|
+
const assert = require('node:assert/strict');
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const os = require('os');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
|
|
9
|
+
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'amalgm-workspace-tree-test-'));
|
|
10
|
+
process.env.AMALGM_DIR = path.join(tempRoot, '.amalgm');
|
|
11
|
+
process.env.AMALGM_WORKSPACES_DIR = path.join(tempRoot, 'workspaces');
|
|
12
|
+
process.env.AMALGM_COMPUTER_ID = 'computer-tree-test';
|
|
13
|
+
fs.mkdirSync(process.env.AMALGM_WORKSPACES_DIR, { recursive: true });
|
|
14
|
+
|
|
15
|
+
const { closeLocalDb, openLocalDb } = require('../state/db');
|
|
16
|
+
const docs = require('../state/docs');
|
|
17
|
+
const { handleProjectRegister } = require('../workspace/rest');
|
|
18
|
+
const { upsertWorkspace } = require('../workspace/store');
|
|
19
|
+
const {
|
|
20
|
+
commitPathCreate,
|
|
21
|
+
commitPathRename,
|
|
22
|
+
commitTreeOperation,
|
|
23
|
+
enrollWorkspace,
|
|
24
|
+
enrollRegisteredWorkspaces,
|
|
25
|
+
listWorkspaceNodes,
|
|
26
|
+
nodeForPath,
|
|
27
|
+
reconcileWorkspaceTree,
|
|
28
|
+
} = require('../workspace/tree-store');
|
|
29
|
+
const { isUuidV7 } = require('../workspace/tree/identity');
|
|
30
|
+
|
|
31
|
+
test.after(() => {
|
|
32
|
+
docs.closeAllDocs();
|
|
33
|
+
closeLocalDb();
|
|
34
|
+
fs.rmSync(tempRoot, { recursive: true, force: true });
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('enrollment gives every node a UUID and open docs keep one resource through cascaded rename', () => {
|
|
38
|
+
const root = path.join(process.env.AMALGM_WORKSPACES_DIR, 'project');
|
|
39
|
+
const folder = path.join(root, 'src');
|
|
40
|
+
const file = path.join(folder, 'index.js');
|
|
41
|
+
fs.mkdirSync(folder, { recursive: true });
|
|
42
|
+
fs.writeFileSync(file, 'export const value = 1;\n');
|
|
43
|
+
fs.mkdirSync(path.join(root, '.git'), { recursive: true });
|
|
44
|
+
fs.writeFileSync(path.join(root, '.git', 'HEAD'), 'ref: refs/heads/main\n');
|
|
45
|
+
|
|
46
|
+
const workspace = upsertWorkspace({ path: root, name: 'Project', source: 'computer' });
|
|
47
|
+
assert.equal(isUuidV7(workspace.id), true);
|
|
48
|
+
const enrolled = enrollWorkspace(workspace.id);
|
|
49
|
+
assert.equal(enrolled.nodes.length, 3);
|
|
50
|
+
assert.equal(enrolled.nodes.every((node) => isUuidV7(node.id)), true);
|
|
51
|
+
assert.equal(enrolled.nodes.some((node) => node.name === '.git'), false);
|
|
52
|
+
|
|
53
|
+
const beforeNode = nodeForPath(file).node;
|
|
54
|
+
const opened = docs.openDoc(file);
|
|
55
|
+
assert.equal(opened.resource, `doc:${beforeNode.id}`);
|
|
56
|
+
const renamedFile = path.join(folder, 'main.js');
|
|
57
|
+
commitPathRename(file, renamedFile);
|
|
58
|
+
const afterFile = docs.openDoc(renamedFile);
|
|
59
|
+
assert.equal(afterFile.resource, opened.resource);
|
|
60
|
+
assert.equal(afterFile.nodeId, beforeNode.id);
|
|
61
|
+
assert.equal(docs.getDocText(renamedFile), 'export const value = 1;\n');
|
|
62
|
+
|
|
63
|
+
const renamedFolder = path.join(root, 'source');
|
|
64
|
+
commitPathRename(folder, renamedFolder);
|
|
65
|
+
const cascadedPath = path.join(renamedFolder, 'main.js');
|
|
66
|
+
assert.equal(fs.existsSync(cascadedPath), true);
|
|
67
|
+
assert.equal(docs.openDoc(cascadedPath).resource, opened.resource);
|
|
68
|
+
assert.equal(nodeForPath(cascadedPath).node.id, beforeNode.id);
|
|
69
|
+
|
|
70
|
+
const state = openLocalDb().prepare('SELECT path, node_id FROM doc_states WHERE node_id = ?').get(beforeNode.id);
|
|
71
|
+
assert.equal(state.path, cascadedPath);
|
|
72
|
+
assert.equal(state.node_id, beforeNode.id);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('first-update enrollment assigns UUIDs to existing files and ignores nested noise', async () => {
|
|
76
|
+
const root = path.join(process.env.AMALGM_WORKSPACES_DIR, 'existing-before-update');
|
|
77
|
+
const repo = path.join(root, 'packages', 'repo');
|
|
78
|
+
const source = path.join(repo, 'src', 'index.js');
|
|
79
|
+
const externalRepo = path.join(tempRoot, 'symlink-target-repo');
|
|
80
|
+
fs.mkdirSync(path.dirname(source), { recursive: true });
|
|
81
|
+
fs.writeFileSync(source, 'export const ready = true;\n');
|
|
82
|
+
fs.writeFileSync(path.join(repo, '.env.example'), 'SAFE_EXAMPLE=1\n');
|
|
83
|
+
fs.writeFileSync(path.join(repo, '.env'), 'SECRET=1\n');
|
|
84
|
+
fs.mkdirSync(path.join(repo, '.git', 'objects'), { recursive: true });
|
|
85
|
+
fs.writeFileSync(path.join(repo, '.git', 'HEAD'), 'ref: refs/heads/main\n');
|
|
86
|
+
fs.mkdirSync(path.join(repo, 'node_modules', 'dependency'), { recursive: true });
|
|
87
|
+
fs.writeFileSync(path.join(repo, 'node_modules', 'dependency', 'index.js'), 'generated\n');
|
|
88
|
+
fs.mkdirSync(path.join(repo, '.next', 'cache'), { recursive: true });
|
|
89
|
+
fs.writeFileSync(path.join(repo, '.next', 'cache', 'data'), 'generated\n');
|
|
90
|
+
fs.mkdirSync(path.join(repo, '.amalgm', 'reports'), { recursive: true });
|
|
91
|
+
fs.writeFileSync(path.join(repo, '.amalgm', 'reports', 'run.json'), '{}\n');
|
|
92
|
+
fs.mkdirSync(path.join(repo, '__pycache__'), { recursive: true });
|
|
93
|
+
fs.writeFileSync(path.join(repo, '__pycache__', 'module.pyc'), 'generated\n');
|
|
94
|
+
fs.writeFileSync(path.join(repo, '.DS_Store'), 'generated\n');
|
|
95
|
+
fs.mkdirSync(externalRepo, { recursive: true });
|
|
96
|
+
fs.writeFileSync(path.join(externalRepo, 'README.md'), '# Linked repo\n');
|
|
97
|
+
const linkedRepo = path.join(repo, 'linked-repo');
|
|
98
|
+
fs.symlinkSync(externalRepo, linkedRepo, 'dir');
|
|
99
|
+
|
|
100
|
+
const workspace = upsertWorkspace({ path: root, name: 'Existing before update', source: 'computer' });
|
|
101
|
+
const database = openLocalDb();
|
|
102
|
+
assert.equal(
|
|
103
|
+
database.prepare('SELECT count(*) AS count FROM workspace_nodes WHERE workspace_id = ?').get(workspace.id).count,
|
|
104
|
+
1,
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
enrollRegisteredWorkspaces({ database });
|
|
108
|
+
const nodes = listWorkspaceNodes(workspace.id, { database });
|
|
109
|
+
assert.equal(nodes.every((node) => isUuidV7(node.id)), true);
|
|
110
|
+
assert.ok(nodes.some((node) => node.relativePath === 'packages/repo/src/index.js'));
|
|
111
|
+
assert.ok(nodes.some((node) => node.relativePath === 'packages/repo/.env.example'));
|
|
112
|
+
assert.equal(nodes.some((node) => node.relativePath.includes('/.git/')), false);
|
|
113
|
+
assert.equal(nodes.some((node) => node.relativePath.includes('/node_modules/')), false);
|
|
114
|
+
assert.equal(nodes.some((node) => node.relativePath.includes('/.next/')), false);
|
|
115
|
+
assert.equal(nodes.some((node) => node.relativePath.includes('/.amalgm/')), false);
|
|
116
|
+
assert.equal(nodes.some((node) => node.relativePath.includes('/__pycache__/')), false);
|
|
117
|
+
assert.equal(nodes.some((node) => node.relativePath.endsWith('/.env')), false);
|
|
118
|
+
assert.equal(nodes.some((node) => node.relativePath.endsWith('/.DS_Store')), false);
|
|
119
|
+
|
|
120
|
+
const linkNode = nodes.find((node) => node.relativePath === 'packages/repo/linked-repo');
|
|
121
|
+
assert.equal(linkNode.kind, 'symlink');
|
|
122
|
+
assert.equal(linkNode.symlinkTarget, externalRepo);
|
|
123
|
+
assert.equal(nodes.some((node) => node.relativePath.startsWith('packages/repo/linked-repo/')), false);
|
|
124
|
+
|
|
125
|
+
let registration;
|
|
126
|
+
await handleProjectRegister({ path: linkedRepo, name: 'Linked repo' }, (status, body) => {
|
|
127
|
+
registration = { status, body };
|
|
128
|
+
});
|
|
129
|
+
assert.equal(registration.status, 200);
|
|
130
|
+
assert.equal(registration.body.workspace.path, fs.realpathSync(externalRepo));
|
|
131
|
+
const linkedNodes = listWorkspaceNodes(registration.body.workspace.id, { database });
|
|
132
|
+
assert.equal(linkedNodes.every((node) => isUuidV7(node.id)), true);
|
|
133
|
+
assert.ok(linkedNodes.some((node) => node.relativePath === 'README.md'));
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('trash is editor-class while permanent removal is owner-only', () => {
|
|
137
|
+
const workspace = openLocalDb().prepare('SELECT * FROM workspaces LIMIT 1').get();
|
|
138
|
+
const folder = listWorkspaceNodes(workspace.id).find((node) => node.name === 'source');
|
|
139
|
+
const trashed = commitTreeOperation({
|
|
140
|
+
kind: 'tree.trash', workspaceId: workspace.id, nodeId: folder.id,
|
|
141
|
+
}, { role: 'editor' });
|
|
142
|
+
assert.equal(trashed.operation.kind, 'tree.trash');
|
|
143
|
+
assert.equal(listWorkspaceNodes(workspace.id).some((node) => node.id === folder.id), false);
|
|
144
|
+
|
|
145
|
+
assert.throws(() => commitTreeOperation({
|
|
146
|
+
kind: 'tree.purge', workspaceId: workspace.id, nodeId: folder.id,
|
|
147
|
+
}, { role: 'editor' }), /forbidden/);
|
|
148
|
+
commitTreeOperation({
|
|
149
|
+
kind: 'tree.purge', workspaceId: workspace.id, nodeId: folder.id,
|
|
150
|
+
}, { role: 'owner' });
|
|
151
|
+
const purged = listWorkspaceNodes(workspace.id, { includeTrashed: true, includePurged: true })
|
|
152
|
+
.find((node) => node.id === folder.id);
|
|
153
|
+
assert.ok(purged.purgedAt);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test('direct shell creates and renames enter the same UUID tree journal', () => {
|
|
157
|
+
const root = path.join(process.env.AMALGM_WORKSPACES_DIR, 'observed');
|
|
158
|
+
fs.mkdirSync(root);
|
|
159
|
+
const workspace = upsertWorkspace({ path: root, name: 'Observed', source: 'computer' });
|
|
160
|
+
enrollWorkspace(workspace.id);
|
|
161
|
+
const firstPath = path.join(root, 'from-shell.txt');
|
|
162
|
+
fs.writeFileSync(firstPath, 'hello\n');
|
|
163
|
+
const created = reconcileWorkspaceTree(workspace.id);
|
|
164
|
+
assert.equal(created.operations.length, 1);
|
|
165
|
+
assert.equal(created.operations[0].kind, 'tree.create');
|
|
166
|
+
const nodeId = nodeForPath(firstPath).node.id;
|
|
167
|
+
|
|
168
|
+
const secondPath = path.join(root, 'renamed-by-agent.txt');
|
|
169
|
+
fs.renameSync(firstPath, secondPath);
|
|
170
|
+
const renamed = reconcileWorkspaceTree(workspace.id);
|
|
171
|
+
assert.equal(renamed.operations.length, 1);
|
|
172
|
+
assert.equal(renamed.operations[0].kind, 'tree.rename');
|
|
173
|
+
assert.equal(nodeForPath(secondPath).node.id, nodeId);
|
|
174
|
+
const rows = openLocalDb().prepare(`
|
|
175
|
+
SELECT operation_kind FROM mutation_journal WHERE local_resource = ? ORDER BY journal_id
|
|
176
|
+
`).all(`workspace-tree:${workspace.id}`);
|
|
177
|
+
assert.deepEqual(rows.map((row) => row.operation_kind), ['tree.create', 'tree.rename']);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test('recursive create enrolls missing parents without overwriting existing disk entries', () => {
|
|
181
|
+
const root = path.join(process.env.AMALGM_WORKSPACES_DIR, 'recursive');
|
|
182
|
+
fs.mkdirSync(root);
|
|
183
|
+
const workspace = upsertWorkspace({ path: root, name: 'Recursive', source: 'computer' });
|
|
184
|
+
enrollWorkspace(workspace.id);
|
|
185
|
+
|
|
186
|
+
const target = path.join(root, 'one', 'two', 'note.md');
|
|
187
|
+
commitPathCreate(target, 'file');
|
|
188
|
+
assert.equal(fs.readFileSync(target, 'utf8'), '');
|
|
189
|
+
assert.ok(nodeForPath(path.join(root, 'one'))?.node.id);
|
|
190
|
+
assert.ok(nodeForPath(path.join(root, 'one', 'two'))?.node.id);
|
|
191
|
+
assert.ok(nodeForPath(target)?.node.id);
|
|
192
|
+
|
|
193
|
+
const existing = path.join(root, 'existing.txt');
|
|
194
|
+
fs.writeFileSync(existing, 'keep me\n');
|
|
195
|
+
commitPathCreate(existing, 'file');
|
|
196
|
+
assert.equal(fs.readFileSync(existing, 'utf8'), 'keep me\n');
|
|
197
|
+
assert.ok(nodeForPath(existing)?.node.id);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test('parent restore leaves an independently trashed child in its own trash slot', () => {
|
|
201
|
+
const root = path.join(process.env.AMALGM_WORKSPACES_DIR, 'trash-cascades');
|
|
202
|
+
const folderPath = path.join(root, 'folder');
|
|
203
|
+
const childPath = path.join(folderPath, 'child.txt');
|
|
204
|
+
fs.mkdirSync(folderPath, { recursive: true });
|
|
205
|
+
fs.writeFileSync(childPath, 'child bytes\n');
|
|
206
|
+
const workspace = upsertWorkspace({ path: root, name: 'Trash cascades', source: 'computer' });
|
|
207
|
+
enrollWorkspace(workspace.id);
|
|
208
|
+
const folderId = nodeForPath(folderPath).node.id;
|
|
209
|
+
const childId = nodeForPath(childPath).node.id;
|
|
210
|
+
const childTrashPath = path.join(process.env.AMALGM_DIR, 'trash', 'workspaces', workspace.id, childId);
|
|
211
|
+
const folderTrashPath = path.join(process.env.AMALGM_DIR, 'trash', 'workspaces', workspace.id, folderId);
|
|
212
|
+
|
|
213
|
+
commitTreeOperation({ kind: 'tree.trash', workspaceId: workspace.id, nodeId: childId });
|
|
214
|
+
assert.equal(fs.readFileSync(childTrashPath, 'utf8'), 'child bytes\n');
|
|
215
|
+
commitTreeOperation({ kind: 'tree.trash', workspaceId: workspace.id, nodeId: folderId });
|
|
216
|
+
commitTreeOperation({ kind: 'tree.restore', workspaceId: workspace.id, nodeId: folderId });
|
|
217
|
+
assert.equal(fs.existsSync(folderPath), true);
|
|
218
|
+
assert.equal(fs.existsSync(childPath), false);
|
|
219
|
+
assert.equal(fs.existsSync(childTrashPath), true);
|
|
220
|
+
const child = listWorkspaceNodes(workspace.id, { includeTrashed: true }).find((node) => node.id === childId);
|
|
221
|
+
assert.ok(child.trashedAt);
|
|
222
|
+
|
|
223
|
+
commitTreeOperation({ kind: 'tree.trash', workspaceId: workspace.id, nodeId: folderId });
|
|
224
|
+
commitTreeOperation({ kind: 'tree.purge', workspaceId: workspace.id, nodeId: folderId });
|
|
225
|
+
assert.equal(fs.existsSync(folderTrashPath), false);
|
|
226
|
+
assert.equal(fs.existsSync(childTrashPath), false);
|
|
227
|
+
});
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { openLocalDb } = require('../state/db');
|
|
4
|
+
const {
|
|
5
|
+
can,
|
|
6
|
+
capabilitiesForRole,
|
|
7
|
+
isRole,
|
|
8
|
+
resolveEffectiveRole,
|
|
9
|
+
} = require('./tree/access');
|
|
10
|
+
|
|
11
|
+
function invalid(message, statusCode = 400) {
|
|
12
|
+
return Object.assign(new Error(message), { statusCode });
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function cleanUserId(value, field = 'userId') {
|
|
16
|
+
const userId = typeof value === 'string' ? value.trim() : '';
|
|
17
|
+
if (!userId || userId.length > 256) throw invalid(`${field} is required`);
|
|
18
|
+
return userId;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function nodeRow(database, nodeId) {
|
|
22
|
+
return database.prepare('SELECT * FROM workspace_nodes WHERE id = ?').get(nodeId) || null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function workspaceEpoch(workspaceId, options = {}) {
|
|
26
|
+
const database = options.database || openLocalDb();
|
|
27
|
+
return Number(database.prepare(`
|
|
28
|
+
SELECT authority_epoch FROM workspace_access_state WHERE workspace_id = ?
|
|
29
|
+
`).get(workspaceId)?.authority_epoch || 0);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function bumpEpoch(database, workspaceId) {
|
|
33
|
+
database.prepare(`
|
|
34
|
+
INSERT INTO workspace_access_state(workspace_id, authority_epoch, updated_at)
|
|
35
|
+
VALUES (?, 2, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
36
|
+
ON CONFLICT(workspace_id) DO UPDATE SET
|
|
37
|
+
authority_epoch = workspace_access_state.authority_epoch + 1,
|
|
38
|
+
updated_at = excluded.updated_at
|
|
39
|
+
`).run(workspaceId);
|
|
40
|
+
return workspaceEpoch(workspaceId, { database });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function explicitPermissions(workspaceId, userId, database) {
|
|
44
|
+
return database.prepare(`
|
|
45
|
+
SELECT node_id, role
|
|
46
|
+
FROM workspace_node_permissions
|
|
47
|
+
WHERE workspace_id = ? AND user_id = ? AND revoked_at IS NULL
|
|
48
|
+
`).all(workspaceId, userId);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function resolveAccess(nodeId, userIdInput, options = {}) {
|
|
52
|
+
const database = options.database || openLocalDb();
|
|
53
|
+
const userId = cleanUserId(userIdInput);
|
|
54
|
+
const target = nodeRow(database, nodeId);
|
|
55
|
+
if (!target || target.purged_at || target.missing_at) return null;
|
|
56
|
+
const parents = new Map(
|
|
57
|
+
database.prepare('SELECT id, parent_id FROM workspace_nodes WHERE workspace_id = ?')
|
|
58
|
+
.all(target.workspace_id)
|
|
59
|
+
.map((row) => [row.id, row.parent_id || null]),
|
|
60
|
+
);
|
|
61
|
+
const roles = new Map(
|
|
62
|
+
explicitPermissions(target.workspace_id, userId, database)
|
|
63
|
+
.map((row) => [row.node_id, row.role]),
|
|
64
|
+
);
|
|
65
|
+
const role = resolveEffectiveRole(nodeId, parents, roles);
|
|
66
|
+
return {
|
|
67
|
+
workspaceId: target.workspace_id,
|
|
68
|
+
nodeId,
|
|
69
|
+
userId,
|
|
70
|
+
role,
|
|
71
|
+
capabilities: capabilitiesForRole(role),
|
|
72
|
+
authorityEpoch: workspaceEpoch(target.workspace_id, { database }),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function requireOwner(database, actorUserId, node) {
|
|
77
|
+
const access = resolveAccess(node.id, actorUserId, { database });
|
|
78
|
+
if (!access || !can(access.role, 'manage_access')) throw invalid('only an owner can manage access', 403);
|
|
79
|
+
return access;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function currentUserRole(nodeId, options = {}) {
|
|
83
|
+
const actorUserId = require('./identity').userId();
|
|
84
|
+
if (!actorUserId) return 'owner';
|
|
85
|
+
return resolveAccess(nodeId, actorUserId, options)?.role || 'no_access';
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function setNodePermission(input, options = {}) {
|
|
89
|
+
const database = options.database || openLocalDb();
|
|
90
|
+
const node = nodeRow(database, input?.nodeId);
|
|
91
|
+
if (!node || node.purged_at) throw invalid('node not found', 404);
|
|
92
|
+
const actorUserId = cleanUserId(input?.actorUserId, 'actorUserId');
|
|
93
|
+
const userId = cleanUserId(input?.userId);
|
|
94
|
+
const role = input?.role;
|
|
95
|
+
if (!isRole(role)) throw invalid('role must be owner, editor, viewer, or no_access');
|
|
96
|
+
requireOwner(database, actorUserId, node);
|
|
97
|
+
if (role === 'owner' && node.id !== node.workspace_id) {
|
|
98
|
+
throw invalid('owner may only be set at the workspace root', 409);
|
|
99
|
+
}
|
|
100
|
+
if (userId === actorUserId && role !== 'owner') {
|
|
101
|
+
throw invalid('an owner cannot lower their own root access', 409);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
let authorityEpoch;
|
|
105
|
+
let permissionVersion;
|
|
106
|
+
database.transaction(() => {
|
|
107
|
+
database.prepare(`
|
|
108
|
+
INSERT INTO workspace_node_permissions (
|
|
109
|
+
workspace_id, node_id, user_id, role, permission_version, revoked_at
|
|
110
|
+
) VALUES (?, ?, ?, ?, 1, NULL)
|
|
111
|
+
ON CONFLICT(node_id, user_id) DO UPDATE SET
|
|
112
|
+
role = excluded.role,
|
|
113
|
+
permission_version = workspace_node_permissions.permission_version + 1,
|
|
114
|
+
revoked_at = NULL,
|
|
115
|
+
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
|
116
|
+
`).run(node.workspace_id, node.id, userId, role);
|
|
117
|
+
permissionVersion = Number(database.prepare(`
|
|
118
|
+
SELECT permission_version FROM workspace_node_permissions
|
|
119
|
+
WHERE node_id = ? AND user_id = ?
|
|
120
|
+
`).get(node.id, userId).permission_version);
|
|
121
|
+
authorityEpoch = bumpEpoch(database, node.workspace_id);
|
|
122
|
+
})();
|
|
123
|
+
return { workspaceId: node.workspace_id, nodeId: node.id, userId, role, permissionVersion, authorityEpoch };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function clearNodePermission(input, options = {}) {
|
|
127
|
+
const database = options.database || openLocalDb();
|
|
128
|
+
const node = nodeRow(database, input?.nodeId);
|
|
129
|
+
if (!node) throw invalid('node not found', 404);
|
|
130
|
+
const actorUserId = cleanUserId(input?.actorUserId, 'actorUserId');
|
|
131
|
+
const userId = cleanUserId(input?.userId);
|
|
132
|
+
requireOwner(database, actorUserId, node);
|
|
133
|
+
|
|
134
|
+
let authorityEpoch;
|
|
135
|
+
const changed = database.transaction(() => {
|
|
136
|
+
const result = database.prepare(`
|
|
137
|
+
UPDATE workspace_node_permissions
|
|
138
|
+
SET revoked_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),
|
|
139
|
+
permission_version = permission_version + 1,
|
|
140
|
+
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
|
141
|
+
WHERE node_id = ? AND user_id = ? AND revoked_at IS NULL AND role <> 'owner'
|
|
142
|
+
`).run(node.id, userId);
|
|
143
|
+
if (result.changes) authorityEpoch = bumpEpoch(database, node.workspace_id);
|
|
144
|
+
return result.changes > 0;
|
|
145
|
+
})();
|
|
146
|
+
return { workspaceId: node.workspace_id, nodeId: node.id, userId, changed, authorityEpoch: authorityEpoch || workspaceEpoch(node.workspace_id, { database }) };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Remove every active grant for one user and bump the workspace epoch once. */
|
|
150
|
+
function revokeWorkspaceUser(input, options = {}) {
|
|
151
|
+
const database = options.database || openLocalDb();
|
|
152
|
+
const workspaceId = input?.workspaceId;
|
|
153
|
+
const root = nodeRow(database, workspaceId);
|
|
154
|
+
if (!root || root.id !== root.workspace_id) throw invalid('workspace not found', 404);
|
|
155
|
+
const actorUserId = cleanUserId(input?.actorUserId, 'actorUserId');
|
|
156
|
+
const userId = cleanUserId(input?.userId);
|
|
157
|
+
requireOwner(database, actorUserId, root);
|
|
158
|
+
if (userId === actorUserId) throw invalid('the workspace owner cannot be revoked', 409);
|
|
159
|
+
|
|
160
|
+
let authorityEpoch;
|
|
161
|
+
const revoked = database.transaction(() => {
|
|
162
|
+
const result = database.prepare(`
|
|
163
|
+
UPDATE workspace_node_permissions
|
|
164
|
+
SET revoked_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),
|
|
165
|
+
permission_version = permission_version + 1,
|
|
166
|
+
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
|
167
|
+
WHERE workspace_id = ? AND user_id = ? AND revoked_at IS NULL AND role <> 'owner'
|
|
168
|
+
`).run(workspaceId, userId);
|
|
169
|
+
if (result.changes) authorityEpoch = bumpEpoch(database, workspaceId);
|
|
170
|
+
return result.changes;
|
|
171
|
+
})();
|
|
172
|
+
return { workspaceId, userId, revoked, authorityEpoch: authorityEpoch || workspaceEpoch(workspaceId, { database }) };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
module.exports = {
|
|
176
|
+
clearNodePermission,
|
|
177
|
+
currentUserRole,
|
|
178
|
+
resolveAccess,
|
|
179
|
+
revokeWorkspaceUser,
|
|
180
|
+
setNodePermission,
|
|
181
|
+
workspaceEpoch,
|
|
182
|
+
};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* One step of binding a local file to a derived-id shared resource,
|
|
5
|
+
* modeled on the machine wall's proven attach/promote cycle. Callers run
|
|
6
|
+
* it from their own periodic reconcile until it reports bound — each call
|
|
7
|
+
* advances the durable attachment request by at most one move, so a
|
|
8
|
+
* binding survives restarts and offline stretches for free.
|
|
9
|
+
*
|
|
10
|
+
* Owners pass `create: true`: when the cloud has never heard of the id,
|
|
11
|
+
* the empty local file is promoted into existence (exactly one create
|
|
12
|
+
* wins a race; the loser's next attach finds the resource existing).
|
|
13
|
+
* Readers of a peer's resource keep `create` off and simply retry the
|
|
14
|
+
* attach until the owner's create lands.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const fs = require('fs');
|
|
18
|
+
const path = require('path');
|
|
19
|
+
|
|
20
|
+
const ATTACH_REJECTED = /shared_resource_read_forbidden/;
|
|
21
|
+
|
|
22
|
+
/** Adapt a shared-rest handler (which speaks sendJson) into a promise. */
|
|
23
|
+
function callLocal(handler, body) {
|
|
24
|
+
return new Promise((resolve, reject) => {
|
|
25
|
+
handler(body, (status, payload) => {
|
|
26
|
+
if (status >= 400) reject(new Error(payload?.error || `local shared route failed with ${status}`));
|
|
27
|
+
else resolve(payload);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function ensureDocBound({ resourceId, filePath, create = false }) {
|
|
33
|
+
if (require('../state/replicas').getReplicaByResourceId(resourceId)) {
|
|
34
|
+
return { bound: true, state: 'bound', lastError: null };
|
|
35
|
+
}
|
|
36
|
+
const shared = require('../state/shared-rest');
|
|
37
|
+
const attachments = require('../state/attachments');
|
|
38
|
+
|
|
39
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
40
|
+
if (!fs.existsSync(filePath)) fs.writeFileSync(filePath, '');
|
|
41
|
+
|
|
42
|
+
const request = attachments.getAttachmentByResource(resourceId);
|
|
43
|
+
if (!request) {
|
|
44
|
+
await callLocal(shared.handleAttachmentStart, { resourceId, path: filePath });
|
|
45
|
+
return { bound: false, state: 'attaching', lastError: null };
|
|
46
|
+
}
|
|
47
|
+
if (request.state === 'rejected') {
|
|
48
|
+
if (
|
|
49
|
+
create
|
|
50
|
+
&& ATTACH_REJECTED.test(request.lastError || '')
|
|
51
|
+
&& !require('../state/promotions').getPromotionByResource(resourceId)
|
|
52
|
+
) {
|
|
53
|
+
await callLocal(shared.handlePromotionStart, { resourceId, path: filePath });
|
|
54
|
+
return { bound: false, state: 'creating', lastError: request.lastError };
|
|
55
|
+
}
|
|
56
|
+
attachments.retryAttachment(resourceId);
|
|
57
|
+
return { bound: false, state: 'retrying', lastError: request.lastError };
|
|
58
|
+
}
|
|
59
|
+
return { bound: false, state: request.state, lastError: request.lastError || null };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
module.exports = { ensureDocBound };
|