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.
@@ -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
- async function joinReplicas() {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amalgm",
3
- "version": "0.1.164",
3
+ "version": "0.1.165",
4
4
  "description": "Amalgm local computer runtime: login, MCP, chat, events, previews, and tunnels.",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,
@@ -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 -> { session, user, blob, at, activeAt }) */
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.at < cutoff) {
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
- if (!peers || !peers.delete(session)) return { resource, session, peers: peers?.size ?? 0 };
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
- if (!peers.delete(session)) continue;
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; any other join failure must not.
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 sleep(50);
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 { getReplicaByResourceId } = require('../state/replicas');
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
- return /^(?:credentials?|secrets?)\.(?:json|ya?ml|toml)$/i.test(name);
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 || require('./access-store').currentUserRole(accessNodeId, { database });
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
  }