amalgm 0.1.164 → 0.1.165
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 +82 -2
- package/package.json +1 -1
- package/runtime/scripts/amalgm-mcp/server/routes/state.js +8 -0
- package/runtime/scripts/amalgm-mcp/state/db.js +10 -0
- package/runtime/scripts/amalgm-mcp/state/mutations.js +6 -0
- package/runtime/scripts/amalgm-mcp/state/presence.js +98 -5
- package/runtime/scripts/amalgm-mcp/state/replicas.js +26 -1
- package/runtime/scripts/amalgm-mcp/state/rest.js +5 -0
- package/runtime/scripts/amalgm-mcp/state/shared-rest.js +35 -0
- package/runtime/scripts/amalgm-mcp/tests/shared-presence-tunnel.test.js +104 -0
- package/runtime/scripts/amalgm-mcp/tests/shared-tunnel-rehydration.test.js +26 -2
- package/runtime/scripts/amalgm-mcp/tests/state-presence.test.js +58 -0
- package/runtime/scripts/amalgm-mcp/tests/state-shared-replica.test.js +39 -1
- package/runtime/scripts/amalgm-mcp/tests/workspace-inclusion.test.js +35 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-tree-store.test.js +52 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree/inclusion.js +9 -1
- package/runtime/scripts/amalgm-mcp/workspace/tree-store.js +10 -1
|
@@ -12,9 +12,11 @@ const crypto = require('crypto');
|
|
|
12
12
|
const http = require('http');
|
|
13
13
|
|
|
14
14
|
const CAPABILITY = 'shared.mutation.v1';
|
|
15
|
+
const PRESENCE_CAPABILITY = 'shared.presence.v1';
|
|
15
16
|
const CLAIM_LIMIT = 16;
|
|
16
17
|
const CLAIM_LEASE_MS = 30_000;
|
|
17
18
|
const RETRY_MS = 1_000;
|
|
19
|
+
const REAUTHORIZE_MS = 30_000;
|
|
18
20
|
|
|
19
21
|
function createSharedRealtimeTunnel({
|
|
20
22
|
send,
|
|
@@ -31,6 +33,7 @@ function createSharedRealtimeTunnel({
|
|
|
31
33
|
const inboundChains = new Map();
|
|
32
34
|
|
|
33
35
|
let enabled = false;
|
|
36
|
+
let presenceEnabled = false;
|
|
34
37
|
let mutationDrain = null;
|
|
35
38
|
let promotionDrain = null;
|
|
36
39
|
let attachmentDrain = null;
|
|
@@ -39,6 +42,7 @@ function createSharedRealtimeTunnel({
|
|
|
39
42
|
let mutationRetryTimer = null;
|
|
40
43
|
let promotionRetryTimer = null;
|
|
41
44
|
let attachmentRetryTimer = null;
|
|
45
|
+
let reauthorizeTimer = null;
|
|
42
46
|
let localFailures = 0;
|
|
43
47
|
|
|
44
48
|
async function ensurePrivateCookieResource() {
|
|
@@ -261,11 +265,23 @@ function createSharedRealtimeTunnel({
|
|
|
261
265
|
await attachmentDrain;
|
|
262
266
|
}
|
|
263
267
|
|
|
264
|
-
|
|
268
|
+
function scheduleReauthorize() {
|
|
269
|
+
if (!enabled || reauthorizeTimer) return;
|
|
270
|
+
reauthorizeTimer = setTimeout(() => {
|
|
271
|
+
reauthorizeTimer = null;
|
|
272
|
+
void joinReplicas({ revokedOnly: true });
|
|
273
|
+
}, REAUTHORIZE_MS);
|
|
274
|
+
reauthorizeTimer.unref?.();
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async function joinReplicas(options = {}) {
|
|
265
278
|
try {
|
|
266
279
|
const result = await localStateJson('GET', '/state/shared/replicas');
|
|
280
|
+
let revoked = false;
|
|
267
281
|
for (const replica of Array.isArray(result?.replicas) ? result.replicas : []) {
|
|
268
282
|
if (!replica?.resourceId) continue;
|
|
283
|
+
if (replica.accessStatus === 'revoked') revoked = true;
|
|
284
|
+
if (options.revokedOnly && replica.accessStatus !== 'revoked') continue;
|
|
269
285
|
const requestId = `join_${crypto.randomBytes(12).toString('hex')}`;
|
|
270
286
|
const queued = send({
|
|
271
287
|
type: 'shared.resource.join',
|
|
@@ -275,9 +291,25 @@ function createSharedRealtimeTunnel({
|
|
|
275
291
|
});
|
|
276
292
|
if (queued) joinInflight.set(requestId, replica.resourceId);
|
|
277
293
|
}
|
|
294
|
+
if (revoked) scheduleReauthorize();
|
|
278
295
|
} catch {
|
|
279
296
|
// The local service can still be starting; wake reconnect retries.
|
|
297
|
+
if (options.revokedOnly) scheduleReauthorize();
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function handleRevoked(resourceId, reason) {
|
|
302
|
+
if (!resourceId) return;
|
|
303
|
+
for (const [requestId, identity] of mutationInflight) {
|
|
304
|
+
if (identity.resourceId === resourceId) mutationInflight.delete(requestId);
|
|
280
305
|
}
|
|
306
|
+
void localStateJson('POST', '/state/shared/replicas/revoked', {
|
|
307
|
+
resourceId,
|
|
308
|
+
reason,
|
|
309
|
+
}).catch((error) => {
|
|
310
|
+
warn(`shared revocation was not persisted for ${resourceId}: ${error?.message || error}`);
|
|
311
|
+
});
|
|
312
|
+
scheduleReauthorize();
|
|
281
313
|
}
|
|
282
314
|
|
|
283
315
|
function parseWakeChunk(state, chunk) {
|
|
@@ -303,6 +335,22 @@ function createSharedRealtimeTunnel({
|
|
|
303
335
|
} catch {
|
|
304
336
|
// The stream is only a wake signal; durable queues remain truth.
|
|
305
337
|
}
|
|
338
|
+
} else if (eventType === 'presence-publish' && presenceEnabled && data.length > 0) {
|
|
339
|
+
try {
|
|
340
|
+
const presence = JSON.parse(data.join('\n'));
|
|
341
|
+
if (presence?.cloudResourceId && presence?.session) {
|
|
342
|
+
send({
|
|
343
|
+
type: 'shared.presence.publish',
|
|
344
|
+
resource_id: presence.cloudResourceId,
|
|
345
|
+
session: presence.session,
|
|
346
|
+
...(presence.leave
|
|
347
|
+
? { leave: true }
|
|
348
|
+
: { blob: presence.blob ?? null, active: presence.active === true }),
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
} catch {
|
|
352
|
+
// Presence is replaceable; the next position or heartbeat repairs it.
|
|
353
|
+
}
|
|
306
354
|
}
|
|
307
355
|
boundary = state.buffer.indexOf('\n\n');
|
|
308
356
|
}
|
|
@@ -461,6 +509,13 @@ function createSharedRealtimeTunnel({
|
|
|
461
509
|
const joinedResource = joinInflight.get(frame.request_id);
|
|
462
510
|
if (joinedResource) {
|
|
463
511
|
joinInflight.delete(frame.request_id);
|
|
512
|
+
if (frame.status === 403 || frame.status === 404) {
|
|
513
|
+
handleRevoked(
|
|
514
|
+
joinedResource,
|
|
515
|
+
frame.status === 404 ? 'resource-unavailable' : 'access-removed',
|
|
516
|
+
);
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
464
519
|
if (
|
|
465
520
|
frame.code === 'shared_tail_pruned'
|
|
466
521
|
|| frame.code === 'shared_tail_too_large'
|
|
@@ -514,6 +569,14 @@ function createSharedRealtimeTunnel({
|
|
|
514
569
|
mutationId: frame.mutation_id,
|
|
515
570
|
};
|
|
516
571
|
if (!identity.resourceId || !identity.mutationId) return;
|
|
572
|
+
if (frame.status === 403 || frame.status === 404) {
|
|
573
|
+
mutationInflight.delete(frame.request_id);
|
|
574
|
+
handleRevoked(
|
|
575
|
+
identity.resourceId,
|
|
576
|
+
frame.status === 404 ? 'resource-unavailable' : 'access-removed',
|
|
577
|
+
);
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
517
580
|
void localStateJson('POST', '/state/mutations/fail', {
|
|
518
581
|
resourceId: identity.resourceId,
|
|
519
582
|
mutationId: identity.mutationId,
|
|
@@ -562,6 +625,18 @@ function createSharedRealtimeTunnel({
|
|
|
562
625
|
warn(`shared tree resync request failed for ${frame.resource_id}: ${error?.message || error}`);
|
|
563
626
|
});
|
|
564
627
|
return true;
|
|
628
|
+
case 'shared.resource.revoked':
|
|
629
|
+
handleRevoked(frame.resource_id, frame.reason);
|
|
630
|
+
return true;
|
|
631
|
+
case 'shared.presence':
|
|
632
|
+
if (!presenceEnabled) return false;
|
|
633
|
+
void localStateJson('POST', '/state/shared/presence/apply', {
|
|
634
|
+
resourceId: frame.resource_id,
|
|
635
|
+
peers: frame.peers,
|
|
636
|
+
}).catch((error) => {
|
|
637
|
+
warn(`shared presence was not applied for ${frame.resource_id}: ${error?.message || error}`);
|
|
638
|
+
});
|
|
639
|
+
return true;
|
|
565
640
|
case 'shared.mutation.committed':
|
|
566
641
|
handleCommitted(frame);
|
|
567
642
|
return true;
|
|
@@ -577,6 +652,7 @@ function createSharedRealtimeTunnel({
|
|
|
577
652
|
contract: frame.contract,
|
|
578
653
|
schemaVersion: frame.schema_version,
|
|
579
654
|
}).then(async (replica) => {
|
|
655
|
+
scheduleMutationDrain();
|
|
580
656
|
if (frame.contract === 'workspace-tree@1' && replica.authorityChanged) {
|
|
581
657
|
await localStateJson('POST', '/state/shared/replicas/rehydrate', {
|
|
582
658
|
resourceId: frame.resource_id,
|
|
@@ -606,11 +682,13 @@ function createSharedRealtimeTunnel({
|
|
|
606
682
|
return;
|
|
607
683
|
}
|
|
608
684
|
enabled = true;
|
|
685
|
+
presenceEnabled = capabilities.includes(PRESENCE_CAPABILITY);
|
|
609
686
|
startPump();
|
|
610
687
|
}
|
|
611
688
|
|
|
612
689
|
function stop() {
|
|
613
690
|
enabled = false;
|
|
691
|
+
presenceEnabled = false;
|
|
614
692
|
mutationInflight.clear();
|
|
615
693
|
promotionInflight.clear();
|
|
616
694
|
attachmentInflight.clear();
|
|
@@ -623,13 +701,15 @@ function createSharedRealtimeTunnel({
|
|
|
623
701
|
clearTimeout(mutationRetryTimer);
|
|
624
702
|
clearTimeout(promotionRetryTimer);
|
|
625
703
|
clearTimeout(attachmentRetryTimer);
|
|
704
|
+
clearTimeout(reauthorizeTimer);
|
|
626
705
|
wakeRetryTimer = null;
|
|
627
706
|
mutationRetryTimer = null;
|
|
628
707
|
promotionRetryTimer = null;
|
|
629
708
|
attachmentRetryTimer = null;
|
|
709
|
+
reauthorizeTimer = null;
|
|
630
710
|
}
|
|
631
711
|
|
|
632
712
|
return { enable, handleFrame, stop };
|
|
633
713
|
}
|
|
634
714
|
|
|
635
|
-
module.exports = { CAPABILITY, createSharedRealtimeTunnel };
|
|
715
|
+
module.exports = { CAPABILITY, PRESENCE_CAPABILITY, createSharedRealtimeTunnel };
|
package/package.json
CHANGED
|
@@ -99,6 +99,14 @@ async function handleStateRoutes(ctx) {
|
|
|
99
99
|
await sharedRest.handleReplicaAuthority(await ctx.readJsonBody(), ctx.sendJson);
|
|
100
100
|
return true;
|
|
101
101
|
}
|
|
102
|
+
if (ctx.pathname === '/state/shared/replicas/revoked') {
|
|
103
|
+
await sharedRest.handleReplicaRevoked(await ctx.readJsonBody(), ctx.sendJson);
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
if (ctx.pathname === '/state/shared/presence/apply') {
|
|
107
|
+
await sharedRest.handlePresenceApply(await ctx.readJsonBody(), ctx.sendJson);
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
102
110
|
if (ctx.pathname === '/state/shared/replicas/rehydrate') {
|
|
103
111
|
await sharedRest.handleReplicaRehydrate(await ctx.readJsonBody(), ctx.sendJson);
|
|
104
112
|
return true;
|
|
@@ -150,6 +150,9 @@ function migrate(database = openLocalDb()) {
|
|
|
150
150
|
applied_version INTEGER NOT NULL DEFAULT 0 CHECK (applied_version >= 0),
|
|
151
151
|
snapshot_version INTEGER NOT NULL DEFAULT 0 CHECK (snapshot_version >= 0),
|
|
152
152
|
snapshot_checksum TEXT,
|
|
153
|
+
access_status TEXT NOT NULL DEFAULT 'active'
|
|
154
|
+
CHECK (access_status IN ('active', 'revoked')),
|
|
155
|
+
access_reason TEXT,
|
|
153
156
|
sync_status TEXT NOT NULL DEFAULT 'synced'
|
|
154
157
|
CHECK (sync_status IN (
|
|
155
158
|
'saving-local',
|
|
@@ -700,6 +703,13 @@ function migrate(database = openLocalDb()) {
|
|
|
700
703
|
// Wave-3 canaries created shared_resource_promotions before the fence
|
|
701
704
|
// cursor existed.
|
|
702
705
|
ensureColumn(database, 'shared_resource_promotions', 'journal_cursor', 'INTEGER NOT NULL DEFAULT 0');
|
|
706
|
+
ensureColumn(
|
|
707
|
+
database,
|
|
708
|
+
'shared_resource_replicas',
|
|
709
|
+
'access_status',
|
|
710
|
+
"TEXT NOT NULL DEFAULT 'active' CHECK (access_status IN ('active', 'revoked'))",
|
|
711
|
+
);
|
|
712
|
+
ensureColumn(database, 'shared_resource_replicas', 'access_reason', 'TEXT');
|
|
703
713
|
require('./migrations').runLegacyMigrations(database);
|
|
704
714
|
}
|
|
705
715
|
|
|
@@ -643,6 +643,12 @@ function claimCloudMutations(options = {}) {
|
|
|
643
643
|
WHERE shared_resource_id IS NOT NULL
|
|
644
644
|
AND mutation_origin = 'local'
|
|
645
645
|
AND materialized_at IS NOT NULL
|
|
646
|
+
AND NOT EXISTS (
|
|
647
|
+
SELECT 1
|
|
648
|
+
FROM shared_resource_replicas replica
|
|
649
|
+
WHERE replica.resource_id = mutation_journal.shared_resource_id
|
|
650
|
+
AND replica.access_status = 'revoked'
|
|
651
|
+
)
|
|
646
652
|
AND (
|
|
647
653
|
mutation_state = 'saved-local'
|
|
648
654
|
OR (
|
|
@@ -35,7 +35,7 @@ const SWEEP_INTERVAL_MS = 10_000;
|
|
|
35
35
|
/** Positions are small facts; anything bigger is data smuggling on the lane. */
|
|
36
36
|
const MAX_BLOB_JSON_BYTES = 8 * 1024;
|
|
37
37
|
|
|
38
|
-
/** resource -> (session ->
|
|
38
|
+
/** resource -> (session -> public peer fields + local/cloud source metadata) */
|
|
39
39
|
const channels = new Map();
|
|
40
40
|
let sweepTimer = null;
|
|
41
41
|
|
|
@@ -60,7 +60,13 @@ function stampedUser() {
|
|
|
60
60
|
|
|
61
61
|
function peersOf(resource) {
|
|
62
62
|
const peers = channels.get(resource);
|
|
63
|
-
return peers ? Array.from(peers.values())
|
|
63
|
+
return peers ? Array.from(peers.values(), (peer) => ({
|
|
64
|
+
session: peer.session,
|
|
65
|
+
user: peer.user,
|
|
66
|
+
blob: peer.blob,
|
|
67
|
+
at: peer.at,
|
|
68
|
+
activeAt: peer.activeAt,
|
|
69
|
+
})) : [];
|
|
64
70
|
}
|
|
65
71
|
|
|
66
72
|
function broadcast(resource) {
|
|
@@ -74,8 +80,14 @@ function ensureSweeper() {
|
|
|
74
80
|
for (const [resource, peers] of channels) {
|
|
75
81
|
let changed = false;
|
|
76
82
|
for (const [session, entry] of peers) {
|
|
77
|
-
if (entry.
|
|
83
|
+
if (entry.freshAt < cutoff) {
|
|
78
84
|
peers.delete(session);
|
|
85
|
+
if (entry.source === 'local') publishLocal({
|
|
86
|
+
resource,
|
|
87
|
+
cloudResourceId: entry.cloudResourceId,
|
|
88
|
+
session,
|
|
89
|
+
leave: true,
|
|
90
|
+
});
|
|
79
91
|
changed = true;
|
|
80
92
|
}
|
|
81
93
|
}
|
|
@@ -91,6 +103,20 @@ function ensureSweeper() {
|
|
|
91
103
|
if (typeof sweepTimer.unref === 'function') sweepTimer.unref();
|
|
92
104
|
}
|
|
93
105
|
|
|
106
|
+
function cloudResourceIdFor(resource) {
|
|
107
|
+
try {
|
|
108
|
+
const replica = require('./replicas').getReplicaByLocalResource(resource);
|
|
109
|
+
return replica?.accessStatus === 'revoked' ? null : (replica?.resourceId || null);
|
|
110
|
+
} catch {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function publishLocal(change) {
|
|
116
|
+
if (!change.cloudResourceId) return;
|
|
117
|
+
emitter.emit('presence-publish', change);
|
|
118
|
+
}
|
|
119
|
+
|
|
94
120
|
/**
|
|
95
121
|
* Publish one binding's presence on a resource. `blob` is the surface's
|
|
96
122
|
* opaque position state (may be null — present without a position);
|
|
@@ -114,15 +140,26 @@ function updatePresence(input) {
|
|
|
114
140
|
channels.set(resource, peers);
|
|
115
141
|
}
|
|
116
142
|
const previous = peers.get(session);
|
|
143
|
+
const cloudResourceId = cloudResourceIdFor(resource);
|
|
117
144
|
peers.set(session, {
|
|
118
145
|
session,
|
|
119
146
|
user: stampedUser(),
|
|
120
147
|
blob,
|
|
121
148
|
at: now,
|
|
122
149
|
activeAt: input?.active ? now : (previous?.activeAt ?? now),
|
|
150
|
+
freshAt: now,
|
|
151
|
+
source: 'local',
|
|
152
|
+
cloudResourceId,
|
|
123
153
|
});
|
|
124
154
|
ensureSweeper();
|
|
125
155
|
broadcast(resource);
|
|
156
|
+
publishLocal({
|
|
157
|
+
resource,
|
|
158
|
+
cloudResourceId,
|
|
159
|
+
session,
|
|
160
|
+
blob,
|
|
161
|
+
active: input?.active === true,
|
|
162
|
+
});
|
|
126
163
|
return { resource, session, peers: peers.size };
|
|
127
164
|
}
|
|
128
165
|
|
|
@@ -131,9 +168,18 @@ function leavePresence(input) {
|
|
|
131
168
|
const resource = requireString(input?.resource, 'resource');
|
|
132
169
|
const session = requireString(input?.session, 'session');
|
|
133
170
|
const peers = channels.get(resource);
|
|
134
|
-
|
|
171
|
+
const previous = peers?.get(session);
|
|
172
|
+
if (!peers || previous?.source !== 'local' || !peers.delete(session)) {
|
|
173
|
+
return { resource, session, peers: peers?.size ?? 0 };
|
|
174
|
+
}
|
|
135
175
|
if (peers.size === 0) channels.delete(resource);
|
|
136
176
|
broadcast(resource);
|
|
177
|
+
publishLocal({
|
|
178
|
+
resource,
|
|
179
|
+
cloudResourceId: previous.cloudResourceId,
|
|
180
|
+
session,
|
|
181
|
+
leave: true,
|
|
182
|
+
});
|
|
137
183
|
return { resource, session, peers: peers?.size ?? 0 };
|
|
138
184
|
}
|
|
139
185
|
|
|
@@ -145,12 +191,52 @@ function leavePresence(input) {
|
|
|
145
191
|
function evictSession(session) {
|
|
146
192
|
if (typeof session !== 'string' || !session) return;
|
|
147
193
|
for (const [resource, peers] of Array.from(channels)) {
|
|
148
|
-
|
|
194
|
+
const previous = peers.get(session);
|
|
195
|
+
if (previous?.source !== 'local' || !peers.delete(session)) continue;
|
|
149
196
|
if (peers.size === 0) channels.delete(resource);
|
|
150
197
|
broadcast(resource);
|
|
198
|
+
publishLocal({
|
|
199
|
+
resource,
|
|
200
|
+
cloudResourceId: previous.cloudResourceId,
|
|
201
|
+
session,
|
|
202
|
+
leave: true,
|
|
203
|
+
});
|
|
151
204
|
}
|
|
152
205
|
}
|
|
153
206
|
|
|
207
|
+
/** Replace cloud peers for one local replica while preserving local bindings. */
|
|
208
|
+
function replaceCloudPresence(input) {
|
|
209
|
+
const resource = requireString(input?.resource, 'resource');
|
|
210
|
+
const incoming = Array.isArray(input?.peers) ? input.peers : [];
|
|
211
|
+
let peers = channels.get(resource);
|
|
212
|
+
if (!peers) {
|
|
213
|
+
peers = new Map();
|
|
214
|
+
channels.set(resource, peers);
|
|
215
|
+
}
|
|
216
|
+
for (const [session, entry] of Array.from(peers)) {
|
|
217
|
+
if (entry.source === 'cloud') peers.delete(session);
|
|
218
|
+
}
|
|
219
|
+
const now = Date.now();
|
|
220
|
+
for (const peer of incoming) {
|
|
221
|
+
if (!peer || typeof peer.session !== 'string' || typeof peer.user !== 'string') continue;
|
|
222
|
+
if (peers.get(peer.session)?.source === 'local') continue;
|
|
223
|
+
peers.set(peer.session, {
|
|
224
|
+
session: peer.session,
|
|
225
|
+
user: peer.user,
|
|
226
|
+
blob: peer.blob ?? null,
|
|
227
|
+
at: Number(peer.at) || now,
|
|
228
|
+
activeAt: Number(peer.activeAt) || now,
|
|
229
|
+
freshAt: now,
|
|
230
|
+
source: 'cloud',
|
|
231
|
+
cloudResourceId: null,
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
if (peers.size === 0) channels.delete(resource);
|
|
235
|
+
else ensureSweeper();
|
|
236
|
+
broadcast(resource);
|
|
237
|
+
return { resource, peers: peers.size };
|
|
238
|
+
}
|
|
239
|
+
|
|
154
240
|
/**
|
|
155
241
|
* Current state as broadcast frames — sent to a stream on connect so a late
|
|
156
242
|
* joiner sees existing peers immediately instead of waiting out their next
|
|
@@ -165,11 +251,18 @@ function subscribePresence(callback) {
|
|
|
165
251
|
return () => emitter.off('presence', callback);
|
|
166
252
|
}
|
|
167
253
|
|
|
254
|
+
function subscribePresencePublishes(callback) {
|
|
255
|
+
emitter.on('presence-publish', callback);
|
|
256
|
+
return () => emitter.off('presence-publish', callback);
|
|
257
|
+
}
|
|
258
|
+
|
|
168
259
|
module.exports = {
|
|
169
260
|
PRESENCE_TTL_MS,
|
|
170
261
|
evictSession,
|
|
171
262
|
leavePresence,
|
|
263
|
+
replaceCloudPresence,
|
|
172
264
|
snapshotFrames,
|
|
173
265
|
subscribePresence,
|
|
266
|
+
subscribePresencePublishes,
|
|
174
267
|
updatePresence,
|
|
175
268
|
};
|
|
@@ -13,6 +13,7 @@ const { trace } = require('./trace');
|
|
|
13
13
|
const { isCloudResourceId } = require('./cloud-resource-id');
|
|
14
14
|
|
|
15
15
|
const CONTRACT = /^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,127}$/;
|
|
16
|
+
const ACCESS_REASONS = new Set(['access-removed', 'resource-unavailable']);
|
|
16
17
|
|
|
17
18
|
function invalid(message, statusCode = 400) {
|
|
18
19
|
return Object.assign(new Error(message), { statusCode });
|
|
@@ -46,6 +47,8 @@ function normalizeReplica(row) {
|
|
|
46
47
|
appliedVersion: Number(row.applied_version),
|
|
47
48
|
snapshotVersion: Number(row.snapshot_version),
|
|
48
49
|
snapshotChecksum: row.snapshot_checksum || null,
|
|
50
|
+
accessStatus: row.access_status,
|
|
51
|
+
accessReason: row.access_reason || null,
|
|
49
52
|
syncStatus: row.sync_status,
|
|
50
53
|
lastError: row.last_error || null,
|
|
51
54
|
createdAt: row.created_at,
|
|
@@ -179,10 +182,13 @@ function refreshReplicaAuthority(input, options = {}) {
|
|
|
179
182
|
if (authorityEpoch < Number(row.authority_epoch)) {
|
|
180
183
|
return { ...normalizeReplica(row), authorityChanged: false };
|
|
181
184
|
}
|
|
182
|
-
const authorityChanged = authorityEpoch > Number(row.authority_epoch)
|
|
185
|
+
const authorityChanged = authorityEpoch > Number(row.authority_epoch)
|
|
186
|
+
|| row.access_status !== 'active';
|
|
183
187
|
cachedStatement(database, `
|
|
184
188
|
UPDATE shared_resource_replicas
|
|
185
189
|
SET authority_epoch = ?,
|
|
190
|
+
access_status = 'active',
|
|
191
|
+
access_reason = NULL,
|
|
186
192
|
sync_status = CASE WHEN applied_version < ? THEN 'syncing' ELSE sync_status END,
|
|
187
193
|
last_error = CASE
|
|
188
194
|
WHEN applied_version < ? THEN 'official tail recovery required'
|
|
@@ -194,6 +200,24 @@ function refreshReplicaAuthority(input, options = {}) {
|
|
|
194
200
|
return { ...getReplicaByResourceId(resourceId, { database }), authorityChanged };
|
|
195
201
|
}
|
|
196
202
|
|
|
203
|
+
function markReplicaRevoked(input, options = {}) {
|
|
204
|
+
const resourceId = requiredText(input?.resourceId, 'resourceId', 132);
|
|
205
|
+
const reason = requiredText(input?.reason, 'reason', 64);
|
|
206
|
+
if (!ACCESS_REASONS.has(reason)) throw invalid('reason must describe an access revocation');
|
|
207
|
+
const database = options.database || openLocalDb();
|
|
208
|
+
const now = new Date().toISOString();
|
|
209
|
+
const result = cachedStatement(database, `
|
|
210
|
+
UPDATE shared_resource_replicas
|
|
211
|
+
SET access_status = 'revoked',
|
|
212
|
+
access_reason = ?,
|
|
213
|
+
updated_at = ?
|
|
214
|
+
WHERE resource_id = ?
|
|
215
|
+
`).run(reason, now, resourceId);
|
|
216
|
+
if (!result.changes) throw invalid(`unknown shared replica ${resourceId}`, 404);
|
|
217
|
+
trace('replica-revoked', { resourceId, reason });
|
|
218
|
+
return getReplicaByResourceId(resourceId, { database });
|
|
219
|
+
}
|
|
220
|
+
|
|
197
221
|
/**
|
|
198
222
|
* A freshly attached official snapshot subsumes every version at or before
|
|
199
223
|
* it, so the replica's cursors jump forward to the snapshot — never backward:
|
|
@@ -241,6 +265,7 @@ module.exports = {
|
|
|
241
265
|
getReplicaByLocalResource,
|
|
242
266
|
getReplicaByResourceId,
|
|
243
267
|
listReplicas,
|
|
268
|
+
markReplicaRevoked,
|
|
244
269
|
refreshReplicaAuthority,
|
|
245
270
|
registerReplica,
|
|
246
271
|
};
|
|
@@ -219,6 +219,7 @@ function handleStream(req, res, query) {
|
|
|
219
219
|
let heartbeat = null;
|
|
220
220
|
let unsubscribe = null;
|
|
221
221
|
let unsubscribePresence = null;
|
|
222
|
+
let unsubscribePresencePublishes = null;
|
|
222
223
|
let tornDown = false;
|
|
223
224
|
const teardown = () => {
|
|
224
225
|
if (tornDown) return;
|
|
@@ -226,6 +227,7 @@ function handleStream(req, res, query) {
|
|
|
226
227
|
if (heartbeat) clearInterval(heartbeat);
|
|
227
228
|
if (unsubscribe) unsubscribe();
|
|
228
229
|
if (unsubscribePresence) unsubscribePresence();
|
|
230
|
+
if (unsubscribePresencePublishes) unsubscribePresencePublishes();
|
|
229
231
|
// The connection was the presence: its death removes this session's
|
|
230
232
|
// entries everywhere, and the broadcast tells every other stream.
|
|
231
233
|
if (presenceSession) presence.evictSession(presenceSession);
|
|
@@ -304,6 +306,9 @@ function handleStream(req, res, query) {
|
|
|
304
306
|
unsubscribePresence = presence.subscribePresence((frame) => {
|
|
305
307
|
writeSafe(() => sendSseControl(res, 'presence', frame));
|
|
306
308
|
});
|
|
309
|
+
unsubscribePresencePublishes = presence.subscribePresencePublishes((frame) => {
|
|
310
|
+
writeSafe(() => sendSseControl(res, 'presence-publish', frame));
|
|
311
|
+
});
|
|
307
312
|
|
|
308
313
|
// A named event (not an SSE comment) so EventSource clients can observe it
|
|
309
314
|
// and detect a silently dead stream, e.g. tunnel frame drops. It doubles as
|
|
@@ -63,6 +63,39 @@ async function handleReplicaAuthority(body, sendJson) {
|
|
|
63
63
|
}
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
async function handleReplicaRevoked(body, sendJson) {
|
|
67
|
+
try {
|
|
68
|
+
const replica = require('./replicas').markReplicaRevoked({
|
|
69
|
+
resourceId: body?.resourceId,
|
|
70
|
+
reason: body?.reason,
|
|
71
|
+
});
|
|
72
|
+
require('./presence').replaceCloudPresence({
|
|
73
|
+
resource: replica.localResource,
|
|
74
|
+
peers: [],
|
|
75
|
+
});
|
|
76
|
+
sendJson(200, replica);
|
|
77
|
+
} catch (error) {
|
|
78
|
+
sendHandlerError(error, sendJson);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function handlePresenceApply(body, sendJson) {
|
|
83
|
+
try {
|
|
84
|
+
const replica = require('./replicas').getReplicaByResourceId(body?.resourceId);
|
|
85
|
+
if (!replica || replica.accessStatus === 'revoked') {
|
|
86
|
+
const error = new Error(`unknown active shared replica ${body?.resourceId || ''}`);
|
|
87
|
+
error.statusCode = 404;
|
|
88
|
+
throw error;
|
|
89
|
+
}
|
|
90
|
+
sendJson(200, require('./presence').replaceCloudPresence({
|
|
91
|
+
resource: replica.localResource,
|
|
92
|
+
peers: body?.peers,
|
|
93
|
+
}));
|
|
94
|
+
} catch (error) {
|
|
95
|
+
sendHandlerError(error, sendJson);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
66
99
|
async function handleReplicaRehydrate(body, sendJson) {
|
|
67
100
|
try {
|
|
68
101
|
sendJson(202, require('./attachments').requestRehydration({ resourceId: body?.resourceId }));
|
|
@@ -291,6 +324,7 @@ module.exports = {
|
|
|
291
324
|
handleMutationClaim,
|
|
292
325
|
handleMutationFail,
|
|
293
326
|
handleMutationIngest,
|
|
327
|
+
handlePresenceApply,
|
|
294
328
|
handlePromotionAck,
|
|
295
329
|
handlePromotionClaim,
|
|
296
330
|
handlePromotionFail,
|
|
@@ -300,5 +334,6 @@ module.exports = {
|
|
|
300
334
|
handleReplicaList,
|
|
301
335
|
handleReplicaRegister,
|
|
302
336
|
handleReplicaRehydrate,
|
|
337
|
+
handleReplicaRevoked,
|
|
303
338
|
handleWorkspaceContentDiscover,
|
|
304
339
|
};
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const test = require('node:test');
|
|
4
|
+
const assert = require('node:assert/strict');
|
|
5
|
+
const http = require('http');
|
|
6
|
+
|
|
7
|
+
const {
|
|
8
|
+
CAPABILITY,
|
|
9
|
+
PRESENCE_CAPABILITY,
|
|
10
|
+
createSharedRealtimeTunnel,
|
|
11
|
+
} = require('../../../../packages/amalgm/lib/shared-realtime-tunnel');
|
|
12
|
+
|
|
13
|
+
const RESOURCE = '77777777-7777-7777-8777-777777777777';
|
|
14
|
+
|
|
15
|
+
async function until(predicate, label) {
|
|
16
|
+
for (let attempt = 0; attempt < 100; attempt += 1) {
|
|
17
|
+
if (predicate()) return;
|
|
18
|
+
await new Promise((resolve) => { setTimeout(resolve, 10); });
|
|
19
|
+
}
|
|
20
|
+
assert.fail(`timed out waiting for ${label}`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
test('the runtime tunnel bridges presence in both directions without a durable queue', async () => {
|
|
24
|
+
const calls = [];
|
|
25
|
+
const server = http.createServer((req, res) => {
|
|
26
|
+
let body = '';
|
|
27
|
+
req.on('data', (chunk) => { body += chunk; });
|
|
28
|
+
req.on('end', () => {
|
|
29
|
+
const parsed = body ? JSON.parse(body) : null;
|
|
30
|
+
calls.push({ method: req.method, path: req.url, body: parsed });
|
|
31
|
+
if (req.url.startsWith('/state/stream')) {
|
|
32
|
+
res.writeHead(200, { 'content-type': 'text/event-stream' });
|
|
33
|
+
res.write('event: presence-publish\n');
|
|
34
|
+
res.write(`data: ${JSON.stringify({
|
|
35
|
+
cloudResourceId: RESOURCE,
|
|
36
|
+
session: 'ps_runtime',
|
|
37
|
+
blob: { kind: 'text-caret@1', anchor: 3, head: 5 },
|
|
38
|
+
active: true,
|
|
39
|
+
})}\n\n`);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
res.setHeader('content-type', 'application/json');
|
|
43
|
+
if (req.url === '/state/shared/replicas') {
|
|
44
|
+
res.end(JSON.stringify({ replicas: [] }));
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
res.end(JSON.stringify({ mutations: [], promotions: [], attachments: [] }));
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
await new Promise((resolve) => { server.listen(0, '127.0.0.1', resolve); });
|
|
51
|
+
const sent = [];
|
|
52
|
+
const tunnel = createSharedRealtimeTunnel({
|
|
53
|
+
send: (frame) => { sent.push(frame); return true; },
|
|
54
|
+
sendLarge: (frame) => { sent.push(frame); return true; },
|
|
55
|
+
localPort: () => server.address().port,
|
|
56
|
+
localHeaders: (headers) => ({ ...headers }),
|
|
57
|
+
warn: () => {},
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
tunnel.enable([CAPABILITY, PRESENCE_CAPABILITY]);
|
|
62
|
+
await until(
|
|
63
|
+
() => sent.some((frame) => frame.type === 'shared.presence.publish'),
|
|
64
|
+
'outbound presence',
|
|
65
|
+
);
|
|
66
|
+
assert.deepEqual(sent.find((frame) => frame.type === 'shared.presence.publish'), {
|
|
67
|
+
type: 'shared.presence.publish',
|
|
68
|
+
resource_id: RESOURCE,
|
|
69
|
+
session: 'ps_runtime',
|
|
70
|
+
blob: { kind: 'text-caret@1', anchor: 3, head: 5 },
|
|
71
|
+
active: true,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
tunnel.handleFrame({
|
|
75
|
+
type: 'shared.presence',
|
|
76
|
+
resource_id: RESOURCE,
|
|
77
|
+
peers: [{
|
|
78
|
+
session: 'ps_direct',
|
|
79
|
+
user: 'direct-user',
|
|
80
|
+
blob: { anchor: 8 },
|
|
81
|
+
at: 20,
|
|
82
|
+
activeAt: 20,
|
|
83
|
+
}],
|
|
84
|
+
});
|
|
85
|
+
await until(
|
|
86
|
+
() => calls.some((call) => call.path === '/state/shared/presence/apply'),
|
|
87
|
+
'inbound presence apply',
|
|
88
|
+
);
|
|
89
|
+
const apply = calls.find((call) => call.path === '/state/shared/presence/apply');
|
|
90
|
+
assert.deepEqual(apply.body, {
|
|
91
|
+
resourceId: RESOURCE,
|
|
92
|
+
peers: [{
|
|
93
|
+
session: 'ps_direct',
|
|
94
|
+
user: 'direct-user',
|
|
95
|
+
blob: { anchor: 8 },
|
|
96
|
+
at: 20,
|
|
97
|
+
activeAt: 20,
|
|
98
|
+
}],
|
|
99
|
+
});
|
|
100
|
+
} finally {
|
|
101
|
+
tunnel.stop();
|
|
102
|
+
server.close();
|
|
103
|
+
}
|
|
104
|
+
});
|
|
@@ -22,7 +22,8 @@ async function until(predicate, label) {
|
|
|
22
22
|
|
|
23
23
|
// The tunnel client against a real (local, in-test) state service: a join
|
|
24
24
|
// answered with shared_tail_pruned or shared_tail_too_large must turn into a
|
|
25
|
-
// rehydration request plus an attachment drain
|
|
25
|
+
// rehydration request plus an attachment drain. Permission failures are a
|
|
26
|
+
// separate authority transition and must persist as revocation instead.
|
|
26
27
|
test('a pruned-tail join answer triggers rehydration through the local state service', async () => {
|
|
27
28
|
const calls = [];
|
|
28
29
|
const server = http.createServer((req, res) => {
|
|
@@ -106,8 +107,31 @@ test('a pruned-tail join answer triggers rehydration through the local state ser
|
|
|
106
107
|
status: 403,
|
|
107
108
|
retryable: false,
|
|
108
109
|
});
|
|
109
|
-
await
|
|
110
|
+
await until(
|
|
111
|
+
() => calls.some((call) => call.path === '/state/shared/replicas/revoked'),
|
|
112
|
+
'the durable revocation state',
|
|
113
|
+
);
|
|
110
114
|
assert.equal(rehydrations(), before, 'a permission failure is not a rehydration signal');
|
|
115
|
+
const revoked = calls.find((call) => call.path === '/state/shared/replicas/revoked');
|
|
116
|
+
assert.deepEqual(revoked.body, { resourceId: RESOURCE, reason: 'access-removed' });
|
|
117
|
+
|
|
118
|
+
const revokedCalls = () => calls.filter(
|
|
119
|
+
(call) => call.path === '/state/shared/replicas/revoked',
|
|
120
|
+
).length;
|
|
121
|
+
const beforeExplicitFrame = revokedCalls();
|
|
122
|
+
tunnel.handleFrame({
|
|
123
|
+
type: 'shared.resource.revoked',
|
|
124
|
+
resource_id: RESOURCE,
|
|
125
|
+
reason: 'resource-unavailable',
|
|
126
|
+
});
|
|
127
|
+
await until(
|
|
128
|
+
() => revokedCalls() === beforeExplicitFrame + 1,
|
|
129
|
+
'the explicit revocation frame',
|
|
130
|
+
);
|
|
131
|
+
assert.deepEqual(
|
|
132
|
+
calls.filter((call) => call.path === '/state/shared/replicas/revoked').at(-1).body,
|
|
133
|
+
{ resourceId: RESOURCE, reason: 'resource-unavailable' },
|
|
134
|
+
);
|
|
111
135
|
} finally {
|
|
112
136
|
tunnel.stop();
|
|
113
137
|
server.close();
|
|
@@ -14,6 +14,8 @@ const { closeLocalDb } = require('../state/db');
|
|
|
14
14
|
const { listEventsAfter } = require('../state/events');
|
|
15
15
|
const presence = require('../state/presence');
|
|
16
16
|
const rest = require('../state/rest');
|
|
17
|
+
const sharedRest = require('../state/shared-rest');
|
|
18
|
+
const { registerReplica } = require('../state/replicas');
|
|
17
19
|
|
|
18
20
|
test.after(() => {
|
|
19
21
|
closeLocalDb();
|
|
@@ -128,3 +130,59 @@ test('presence frames are absent from replay surfaces', () => {
|
|
|
128
130
|
req.emit('close');
|
|
129
131
|
presence.leavePresence({ resource: 'doc:t6', session: 'ps_r' });
|
|
130
132
|
});
|
|
133
|
+
|
|
134
|
+
test('shared presence forwards local facts, merges cloud peers, and clears them on revocation', async () => {
|
|
135
|
+
const cloudResourceId = '77777777-7777-7777-8777-777777777777';
|
|
136
|
+
const localResource = 'doc:shared-presence';
|
|
137
|
+
registerReplica({
|
|
138
|
+
resourceId: cloudResourceId,
|
|
139
|
+
localResource,
|
|
140
|
+
localPath: path.join(tempRoot, 'shared-presence.md'),
|
|
141
|
+
contract: 'text-yjs@1',
|
|
142
|
+
schemaVersion: 1,
|
|
143
|
+
authorityEpoch: 1,
|
|
144
|
+
});
|
|
145
|
+
const frames = [];
|
|
146
|
+
const publishes = [];
|
|
147
|
+
const unsubscribeFrames = presence.subscribePresence((frame) => frames.push(frame));
|
|
148
|
+
const unsubscribePublishes = presence.subscribePresencePublishes((frame) => publishes.push(frame));
|
|
149
|
+
|
|
150
|
+
presence.updatePresence({
|
|
151
|
+
resource: localResource,
|
|
152
|
+
session: 'ps_local',
|
|
153
|
+
blob: { kind: 'text-caret@1', anchor: 2, head: 2 },
|
|
154
|
+
active: true,
|
|
155
|
+
});
|
|
156
|
+
assert.deepEqual(publishes, [{
|
|
157
|
+
resource: localResource,
|
|
158
|
+
cloudResourceId,
|
|
159
|
+
session: 'ps_local',
|
|
160
|
+
blob: { kind: 'text-caret@1', anchor: 2, head: 2 },
|
|
161
|
+
active: true,
|
|
162
|
+
}]);
|
|
163
|
+
|
|
164
|
+
presence.replaceCloudPresence({
|
|
165
|
+
resource: localResource,
|
|
166
|
+
peers: [
|
|
167
|
+
{ session: 'ps_local', user: 'official-local', blob: null, at: 10, activeAt: 10 },
|
|
168
|
+
{ session: 'ps_remote', user: 'remote-user', blob: { anchor: 9 }, at: 11, activeAt: 11 },
|
|
169
|
+
],
|
|
170
|
+
});
|
|
171
|
+
assert.equal(publishes.length, 1, 'an inbound cloud roster never echoes back upstream');
|
|
172
|
+
assert.deepEqual(frames.at(-1).peers.map((peer) => peer.session).sort(), ['ps_local', 'ps_remote']);
|
|
173
|
+
|
|
174
|
+
await sharedRest.handleReplicaRevoked(
|
|
175
|
+
{ resourceId: cloudResourceId, reason: 'access-removed' },
|
|
176
|
+
(status) => assert.equal(status, 200),
|
|
177
|
+
);
|
|
178
|
+
assert.deepEqual(
|
|
179
|
+
frames.at(-1).peers.map((peer) => peer.session),
|
|
180
|
+
['ps_local'],
|
|
181
|
+
'revocation removes imported peers without disrupting local bindings',
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
presence.leavePresence({ resource: localResource, session: 'ps_local' });
|
|
185
|
+
assert.equal(publishes.at(-1).leave, true);
|
|
186
|
+
unsubscribeFrames();
|
|
187
|
+
unsubscribePublishes();
|
|
188
|
+
});
|
|
@@ -27,7 +27,11 @@ const {
|
|
|
27
27
|
claimPromotions,
|
|
28
28
|
enqueuePromotion,
|
|
29
29
|
} = require('../state/promotions');
|
|
30
|
-
const {
|
|
30
|
+
const {
|
|
31
|
+
getReplicaByResourceId,
|
|
32
|
+
markReplicaRevoked,
|
|
33
|
+
refreshReplicaAuthority,
|
|
34
|
+
} = require('../state/replicas');
|
|
31
35
|
const {
|
|
32
36
|
acknowledgeAttachment,
|
|
33
37
|
claimAttachments,
|
|
@@ -529,3 +533,37 @@ test('a promotion-born replica (which never attached) gets an attachment row for
|
|
|
529
533
|
assert.equal(requested.attachment.resourceId, resourceId);
|
|
530
534
|
assert.equal(requested.attachment.localPath, target);
|
|
531
535
|
});
|
|
536
|
+
|
|
537
|
+
test('revoked authority pauses the outbox and a successful rejoin resumes it', () => {
|
|
538
|
+
const opened = docs.openDoc(target);
|
|
539
|
+
const client = clientFromState(opened.state);
|
|
540
|
+
const update = captureEdit(client, (text) => text.insert(text.length, 'revocation-safe\n'));
|
|
541
|
+
const mutationId = 'mut_revocation_resume_1';
|
|
542
|
+
docs.applyDocMutations(target, [{ mutationId, update }]);
|
|
543
|
+
|
|
544
|
+
const syncStatus = getReplicaByResourceId(resourceId).syncStatus;
|
|
545
|
+
const revoked = markReplicaRevoked({ resourceId, reason: 'access-removed' });
|
|
546
|
+
assert.equal(revoked.accessStatus, 'revoked');
|
|
547
|
+
assert.equal(revoked.syncStatus, syncStatus, 'authority does not rewrite sync progress');
|
|
548
|
+
assert.equal(
|
|
549
|
+
claimCloudMutations().some((claim) => claim.envelope.mutationId === mutationId),
|
|
550
|
+
false,
|
|
551
|
+
'revoked resources do not leave the local outbox',
|
|
552
|
+
);
|
|
553
|
+
|
|
554
|
+
const before = getReplicaByResourceId(resourceId);
|
|
555
|
+
const refreshed = refreshReplicaAuthority({
|
|
556
|
+
resourceId,
|
|
557
|
+
authorityEpoch: before.authorityEpoch + 1,
|
|
558
|
+
headVersion: before.appliedVersion,
|
|
559
|
+
contract: before.contract,
|
|
560
|
+
schemaVersion: before.schemaVersion,
|
|
561
|
+
});
|
|
562
|
+
assert.equal(refreshed.accessStatus, 'active');
|
|
563
|
+
assert.equal(refreshed.accessReason, null);
|
|
564
|
+
assert.equal(
|
|
565
|
+
claimCloudMutations().some((claim) => claim.envelope.mutationId === mutationId),
|
|
566
|
+
true,
|
|
567
|
+
'the same durable edit resumes after authority is restored',
|
|
568
|
+
);
|
|
569
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const test = require('node:test');
|
|
4
|
+
const assert = require('node:assert/strict');
|
|
5
|
+
|
|
6
|
+
const { shouldEnroll, shouldSync } = require('../workspace/tree/inclusion');
|
|
7
|
+
|
|
8
|
+
test('workspace identity excludes credential material at every depth', () => {
|
|
9
|
+
const privatePaths = [
|
|
10
|
+
'server.pem',
|
|
11
|
+
'keys/deploy.key',
|
|
12
|
+
'keys/id_ed25519',
|
|
13
|
+
'mobile/signing.p12',
|
|
14
|
+
'infra/terraform.tfstate',
|
|
15
|
+
'config/service-account-prod.json',
|
|
16
|
+
'nested/.aws/credentials',
|
|
17
|
+
'nested/.kube/config',
|
|
18
|
+
'.npmrc',
|
|
19
|
+
];
|
|
20
|
+
for (const relativePath of privatePaths) {
|
|
21
|
+
assert.equal(shouldEnroll(relativePath), false, relativePath);
|
|
22
|
+
assert.equal(shouldSync(relativePath), false, relativePath);
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('the credential fence does not exclude public templates or keys', () => {
|
|
27
|
+
for (const relativePath of [
|
|
28
|
+
'.env.example',
|
|
29
|
+
'keys/id_ed25519.pub',
|
|
30
|
+
'certificates/server.crt',
|
|
31
|
+
'docs/service-account.md',
|
|
32
|
+
]) {
|
|
33
|
+
assert.equal(shouldEnroll(relativePath), true, relativePath);
|
|
34
|
+
}
|
|
35
|
+
});
|
|
@@ -177,6 +177,58 @@ test('direct shell creates and renames enter the same UUID tree journal', () =>
|
|
|
177
177
|
assert.deepEqual(rows.map((row) => row.operation_kind), ['tree.create', 'tree.rename']);
|
|
178
178
|
});
|
|
179
179
|
|
|
180
|
+
test('moving a node requires write access at both source and destination', () => {
|
|
181
|
+
const root = path.join(process.env.AMALGM_WORKSPACES_DIR, 'move-authority');
|
|
182
|
+
const sourcePath = path.join(root, 'source');
|
|
183
|
+
const destinationPath = path.join(root, 'destination');
|
|
184
|
+
const filePath = path.join(sourcePath, 'note.md');
|
|
185
|
+
fs.mkdirSync(sourcePath, { recursive: true });
|
|
186
|
+
fs.mkdirSync(destinationPath, { recursive: true });
|
|
187
|
+
fs.writeFileSync(filePath, 'identity survives the move\n');
|
|
188
|
+
const workspace = upsertWorkspace({ path: root, name: 'Move authority', source: 'computer' });
|
|
189
|
+
enrollWorkspace(workspace.id);
|
|
190
|
+
|
|
191
|
+
const database = openLocalDb();
|
|
192
|
+
const source = nodeForPath(sourcePath).node;
|
|
193
|
+
const destination = nodeForPath(destinationPath).node;
|
|
194
|
+
const file = nodeForPath(filePath).node;
|
|
195
|
+
const memberId = 'member-move-authority';
|
|
196
|
+
const grant = database.prepare(`
|
|
197
|
+
INSERT INTO workspace_node_permissions(workspace_id, node_id, user_id, role)
|
|
198
|
+
VALUES (?, ?, ?, ?)
|
|
199
|
+
`);
|
|
200
|
+
grant.run(workspace.id, workspace.id, memberId, 'viewer');
|
|
201
|
+
grant.run(workspace.id, source.id, memberId, 'editor');
|
|
202
|
+
grant.run(workspace.id, destination.id, memberId, 'no_access');
|
|
203
|
+
|
|
204
|
+
const identity = require('../workspace/identity');
|
|
205
|
+
const currentUserId = identity.userId;
|
|
206
|
+
identity.userId = () => memberId;
|
|
207
|
+
try {
|
|
208
|
+
assert.throws(() => commitTreeOperation({
|
|
209
|
+
kind: 'tree.move',
|
|
210
|
+
workspaceId: workspace.id,
|
|
211
|
+
nodeId: file.id,
|
|
212
|
+
parentId: destination.id,
|
|
213
|
+
}), /tree destination is forbidden/);
|
|
214
|
+
assert.equal(nodeForPath(filePath).node.id, file.id);
|
|
215
|
+
|
|
216
|
+
database.prepare(`
|
|
217
|
+
UPDATE workspace_node_permissions SET role = 'editor'
|
|
218
|
+
WHERE node_id = ? AND user_id = ?
|
|
219
|
+
`).run(destination.id, memberId);
|
|
220
|
+
commitTreeOperation({
|
|
221
|
+
kind: 'tree.move',
|
|
222
|
+
workspaceId: workspace.id,
|
|
223
|
+
nodeId: file.id,
|
|
224
|
+
parentId: destination.id,
|
|
225
|
+
});
|
|
226
|
+
assert.equal(nodeForPath(path.join(destinationPath, 'note.md')).node.id, file.id);
|
|
227
|
+
} finally {
|
|
228
|
+
identity.userId = currentUserId;
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
|
|
180
232
|
test('recursive create enrolls missing parents without overwriting existing disk entries', () => {
|
|
181
233
|
const root = path.join(process.env.AMALGM_WORKSPACES_DIR, 'recursive');
|
|
182
234
|
fs.mkdirSync(root);
|
|
@@ -4,10 +4,14 @@ const path = require('path');
|
|
|
4
4
|
|
|
5
5
|
const EXCLUDED_DIRECTORIES = new Set([
|
|
6
6
|
'.amalgm',
|
|
7
|
+
'.aws',
|
|
7
8
|
'.cache',
|
|
9
|
+
'.docker',
|
|
8
10
|
'.git',
|
|
11
|
+
'.gnupg',
|
|
9
12
|
'.gradle',
|
|
10
13
|
'.hg',
|
|
14
|
+
'.kube',
|
|
11
15
|
'.mypy_cache',
|
|
12
16
|
'.next',
|
|
13
17
|
'.nyc_output',
|
|
@@ -41,7 +45,11 @@ function partsFor(relativePath) {
|
|
|
41
45
|
|
|
42
46
|
function isPrivateFile(name) {
|
|
43
47
|
if (/^\.env(?:\..+)?$/i.test(name) && !/\.example$/i.test(name)) return true;
|
|
44
|
-
|
|
48
|
+
if (/^(?:credentials?|secrets?)\.(?:json|ya?ml|toml|ini|conf)$/i.test(name)) return true;
|
|
49
|
+
if (/^id_(?:dsa|ecdsa|ed25519|rsa)$/i.test(name)) return true;
|
|
50
|
+
if (/^(?:\.git-credentials|\.netrc|\.npmrc|\.pypirc|auth\.json|kubeconfig)$/i.test(name)) return true;
|
|
51
|
+
if (/^(?:service[-_]?account|firebase[-_]?adminsdk).*\.json$/i.test(name)) return true;
|
|
52
|
+
return /\.(?:key|keystore|kdbx|p12|pfx|pem|ppk|tfstate(?:\.backup)?)$/i.test(name);
|
|
45
53
|
}
|
|
46
54
|
|
|
47
55
|
// Machine-local metadata, dependencies, generated caches, and secrets never
|
|
@@ -275,8 +275,9 @@ function requirePathCapability(pathInput, capability, options = {}) {
|
|
|
275
275
|
function commitTreeOperation(input, options = {}) {
|
|
276
276
|
let operation = normalizeTreeOperation(input);
|
|
277
277
|
const database = options.database || openLocalDb();
|
|
278
|
+
const accessStore = require('./access-store');
|
|
278
279
|
const accessNodeId = operation.kind === 'tree.create' ? operation.parentId : operation.nodeId;
|
|
279
|
-
const role = options.role ||
|
|
280
|
+
const role = options.role || accessStore.currentUserRole(accessNodeId, { database });
|
|
280
281
|
if (!can(role, capabilityForOperation(operation.kind))) throw invalid('tree operation is forbidden', 403);
|
|
281
282
|
const workspace = workspaceRow(operation.workspaceId, database);
|
|
282
283
|
if (!workspace) throw invalid('workspace not found', 404);
|
|
@@ -285,6 +286,14 @@ function commitTreeOperation(input, options = {}) {
|
|
|
285
286
|
.map(rowToNode)
|
|
286
287
|
.map((node) => [node.id, node]),
|
|
287
288
|
);
|
|
289
|
+
if (operation.kind === 'tree.move' || operation.kind === 'tree.restore') {
|
|
290
|
+
const destinationId = operation.parentId || before.get(operation.nodeId)?.parentId;
|
|
291
|
+
const destinationRole = options.role
|
|
292
|
+
|| accessStore.currentUserRole(destinationId, { database });
|
|
293
|
+
if (!can(destinationRole, 'create')) {
|
|
294
|
+
throw invalid('tree destination is forbidden', 403);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
288
297
|
if (operation.kind === 'tree.move' && !operation.previousParentId) {
|
|
289
298
|
operation = { ...operation, previousParentId: before.get(operation.nodeId)?.parentId || null };
|
|
290
299
|
}
|