amalgm 0.1.159 → 0.1.160
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 +62 -0
- package/package.json +1 -1
- package/runtime/scripts/amalgm-mcp/browser/cookie-jar.js +360 -2
- package/runtime/scripts/amalgm-mcp/browser/rest.js +20 -0
- package/runtime/scripts/amalgm-mcp/server/routes/browser.js +10 -0
- package/runtime/scripts/amalgm-mcp/state/mutation-contracts.js +56 -0
- package/runtime/scripts/amalgm-mcp/state/mutations.js +69 -23
- package/runtime/scripts/amalgm-mcp/state/replicas.js +5 -5
- package/runtime/scripts/amalgm-mcp/state/shared-rest.js +5 -1
- package/runtime/scripts/amalgm-mcp/tests/browser-cookie-cloud.test.js +152 -0
- package/runtime/scripts/amalgm-mcp/tests/private-cookie-tunnel.test.js +108 -0
|
@@ -26,6 +26,7 @@ function createSharedRealtimeTunnel({
|
|
|
26
26
|
const mutationInflight = new Map();
|
|
27
27
|
const promotionInflight = new Map();
|
|
28
28
|
const attachmentInflight = new Map();
|
|
29
|
+
const privateEnsureInflight = new Map();
|
|
29
30
|
const joinInflight = new Map();
|
|
30
31
|
const inboundChains = new Map();
|
|
31
32
|
|
|
@@ -40,6 +41,31 @@ function createSharedRealtimeTunnel({
|
|
|
40
41
|
let attachmentRetryTimer = null;
|
|
41
42
|
let localFailures = 0;
|
|
42
43
|
|
|
44
|
+
async function ensurePrivateCookieResource() {
|
|
45
|
+
if (!enabled || privateEnsureInflight.size > 0) return;
|
|
46
|
+
try {
|
|
47
|
+
const bootstrap = await localStateJson('GET', '/browser/cookie-jar/cloud/bootstrap');
|
|
48
|
+
if (!bootstrap?.enabled || !bootstrap.resourceId) return;
|
|
49
|
+
const requestId = `private_${crypto.randomBytes(12).toString('hex')}`;
|
|
50
|
+
if (!sendLarge({
|
|
51
|
+
type: 'private.resource.ensure',
|
|
52
|
+
request_id: requestId,
|
|
53
|
+
resource_id: bootstrap.resourceId,
|
|
54
|
+
contract: bootstrap.contract,
|
|
55
|
+
schema_version: bootstrap.schemaVersion,
|
|
56
|
+
snapshot_b64: bootstrap.snapshotBase64,
|
|
57
|
+
snapshot_checksum: bootstrap.snapshotChecksum,
|
|
58
|
+
snapshot_bytes: bootstrap.snapshotBytes,
|
|
59
|
+
})) {
|
|
60
|
+
scheduleMutationDrain(RETRY_MS);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
privateEnsureInflight.set(requestId, bootstrap.resourceId);
|
|
64
|
+
} catch (error) {
|
|
65
|
+
warn(`private cookie bootstrap unavailable: ${error?.message || error}`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
43
69
|
function localStateJson(method, requestPath, body) {
|
|
44
70
|
const payload = body === undefined ? null : Buffer.from(JSON.stringify(body));
|
|
45
71
|
return new Promise((resolve, reject) => {
|
|
@@ -323,6 +349,7 @@ function createSharedRealtimeTunnel({
|
|
|
323
349
|
function startPump() {
|
|
324
350
|
if (!enabled) return;
|
|
325
351
|
startWakeStream();
|
|
352
|
+
void ensurePrivateCookieResource();
|
|
326
353
|
void joinReplicas();
|
|
327
354
|
scheduleAttachmentDrain();
|
|
328
355
|
schedulePromotionDrain();
|
|
@@ -375,6 +402,28 @@ function createSharedRealtimeTunnel({
|
|
|
375
402
|
});
|
|
376
403
|
}
|
|
377
404
|
|
|
405
|
+
function handlePrivateReady(frame) {
|
|
406
|
+
const resourceId = privateEnsureInflight.get(frame.request_id);
|
|
407
|
+
if (!resourceId || resourceId !== frame.resource_id) return;
|
|
408
|
+
void localStateJson('POST', '/browser/cookie-jar/cloud/install', {
|
|
409
|
+
resourceId: frame.resource_id,
|
|
410
|
+
authorityEpoch: frame.authority_epoch,
|
|
411
|
+
headVersion: frame.head_version,
|
|
412
|
+
snapshotVersion: frame.snapshot_version,
|
|
413
|
+
snapshotChecksum: frame.snapshot_checksum,
|
|
414
|
+
snapshotBase64: frame.snapshot_b64,
|
|
415
|
+
contract: frame.contract,
|
|
416
|
+
schemaVersion: frame.schema_version,
|
|
417
|
+
}).then(() => {
|
|
418
|
+
privateEnsureInflight.delete(frame.request_id);
|
|
419
|
+
void joinReplicas();
|
|
420
|
+
scheduleMutationDrain();
|
|
421
|
+
}).catch((error) => {
|
|
422
|
+
warn(`private cookie snapshot was not installed: ${error?.message || error}`);
|
|
423
|
+
setTimeout(() => enabled && handlePrivateReady(frame), RETRY_MS).unref?.();
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
|
|
378
427
|
function handleAck(frame) {
|
|
379
428
|
const identity = mutationInflight.get(frame.request_id) || {
|
|
380
429
|
resourceId: frame.resource_id,
|
|
@@ -396,6 +445,15 @@ function createSharedRealtimeTunnel({
|
|
|
396
445
|
}
|
|
397
446
|
|
|
398
447
|
function handleError(frame) {
|
|
448
|
+
const privateResource = privateEnsureInflight.get(frame.request_id);
|
|
449
|
+
if (privateResource) {
|
|
450
|
+
privateEnsureInflight.delete(frame.request_id);
|
|
451
|
+
warn(`private cookie bootstrap failed for ${privateResource}: ${frame.code || 'shared_error'}: ${frame.message || ''}`);
|
|
452
|
+
if (frame.retryable !== false) {
|
|
453
|
+
setTimeout(() => enabled && void ensurePrivateCookieResource(), RETRY_MS).unref?.();
|
|
454
|
+
}
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
399
457
|
const joinedResource = joinInflight.get(frame.request_id);
|
|
400
458
|
if (joinedResource) {
|
|
401
459
|
joinInflight.delete(frame.request_id);
|
|
@@ -505,6 +563,9 @@ function createSharedRealtimeTunnel({
|
|
|
505
563
|
schemaVersion: frame.schema_version,
|
|
506
564
|
}).catch(() => {});
|
|
507
565
|
return true;
|
|
566
|
+
case 'private.resource.ready':
|
|
567
|
+
handlePrivateReady(frame);
|
|
568
|
+
return true;
|
|
508
569
|
default:
|
|
509
570
|
return false;
|
|
510
571
|
}
|
|
@@ -524,6 +585,7 @@ function createSharedRealtimeTunnel({
|
|
|
524
585
|
mutationInflight.clear();
|
|
525
586
|
promotionInflight.clear();
|
|
526
587
|
attachmentInflight.clear();
|
|
588
|
+
privateEnsureInflight.clear();
|
|
527
589
|
joinInflight.clear();
|
|
528
590
|
inboundChains.clear();
|
|
529
591
|
if (wakeRequest) wakeRequest.destroy();
|
package/package.json
CHANGED
|
@@ -23,8 +23,12 @@ const {
|
|
|
23
23
|
COOKIE_SOURCE_BUNDLE_ID,
|
|
24
24
|
DEFAULT_COOKIE_SOURCE_PROFILE_ID,
|
|
25
25
|
} = require('./cookie-source');
|
|
26
|
+
const crypto = require('crypto');
|
|
26
27
|
|
|
27
28
|
const COOKIE_JAR_SCHEMA_VERSION = 1;
|
|
29
|
+
const COOKIE_CLOUD_CONTRACT = 'browser-cookie-jar@1';
|
|
30
|
+
const COOKIE_CLOUD_RESOURCE = 'browser-cookie-jar';
|
|
31
|
+
const COOKIE_CLOUD_PATH = 'amalgm://browser-cookie-jar/default';
|
|
28
32
|
const MAX_MUTATIONS_PER_MERGE = 10_000;
|
|
29
33
|
const MAX_TOMBSTONES = 4_096;
|
|
30
34
|
const DEFAULT_EXCLUDED_HOST_SUFFIXES = [
|
|
@@ -224,6 +228,16 @@ function publicSnapshot(jar) {
|
|
|
224
228
|
};
|
|
225
229
|
}
|
|
226
230
|
|
|
231
|
+
function cloudSnapshot(jar = loadInternalCookieJar()) {
|
|
232
|
+
return {
|
|
233
|
+
schemaVersion: COOKIE_JAR_SCHEMA_VERSION,
|
|
234
|
+
version: jar.version,
|
|
235
|
+
updatedAt: nowIso(),
|
|
236
|
+
records: [...jar.records.values()].sort((a, b) => a.key.localeCompare(b.key)),
|
|
237
|
+
tombstones: [...jar.tombstones.values()].sort((a, b) => a.key.localeCompare(b.key)),
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
227
241
|
function readCookieJar() {
|
|
228
242
|
return publicSnapshot(loadInternalCookieJar());
|
|
229
243
|
}
|
|
@@ -264,7 +278,7 @@ function deleteKeyFor(input) {
|
|
|
264
278
|
return cookieKey(input);
|
|
265
279
|
}
|
|
266
280
|
|
|
267
|
-
function
|
|
281
|
+
function mergeCookieJarMaterialized(input = {}) {
|
|
268
282
|
const sourceId = cleanSourceId(input.sourceId);
|
|
269
283
|
const extraHosts = Array.isArray(input.excludeHosts) ? input.excludeHosts : [];
|
|
270
284
|
const upserts = Array.isArray(input.upserts) ? input.upserts : [];
|
|
@@ -275,7 +289,7 @@ function mergeCookieJar(input = {}) {
|
|
|
275
289
|
|
|
276
290
|
let jar = loadInternalCookieJar();
|
|
277
291
|
const nextRevision = jar.version + 1;
|
|
278
|
-
const changedAt = nowIso();
|
|
292
|
+
const changedAt = String(input.changedAt || nowIso());
|
|
279
293
|
let changed = false;
|
|
280
294
|
|
|
281
295
|
for (const inputCookie of upserts) {
|
|
@@ -319,6 +333,344 @@ function mergeCookieJar(input = {}) {
|
|
|
319
333
|
return publicSnapshot(persistCookieJar(jar));
|
|
320
334
|
}
|
|
321
335
|
|
|
336
|
+
function privateCookieResourceId(forUserId) {
|
|
337
|
+
const clean = String(forUserId || '').trim();
|
|
338
|
+
if (!clean) return null;
|
|
339
|
+
const digest = crypto
|
|
340
|
+
.createHash('sha256')
|
|
341
|
+
.update(`private-user:${clean}:${COOKIE_CLOUD_CONTRACT}`)
|
|
342
|
+
.digest('base64url')
|
|
343
|
+
.slice(0, 32);
|
|
344
|
+
return `pvr_${digest}`;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function cookieCloudReplica(options = {}) {
|
|
348
|
+
const { machineId, userId } = require('../workspace/identity');
|
|
349
|
+
const ownerUserId = userId();
|
|
350
|
+
const resourceId = privateCookieResourceId(ownerUserId);
|
|
351
|
+
if (!resourceId) return null;
|
|
352
|
+
const database = options.database || require('../state/db').openLocalDb();
|
|
353
|
+
const { getReplicaByResourceId, registerReplica } = require('../state/replicas');
|
|
354
|
+
const existing = getReplicaByResourceId(resourceId, { database });
|
|
355
|
+
if (existing) return { ...existing, deviceId: machineId(), ownerUserId };
|
|
356
|
+
const registered = registerReplica({
|
|
357
|
+
resourceId,
|
|
358
|
+
localResource: COOKIE_CLOUD_RESOURCE,
|
|
359
|
+
localPath: COOKIE_CLOUD_PATH,
|
|
360
|
+
contract: COOKIE_CLOUD_CONTRACT,
|
|
361
|
+
schemaVersion: COOKIE_JAR_SCHEMA_VERSION,
|
|
362
|
+
authorityEpoch: 1,
|
|
363
|
+
appliedVersion: 0,
|
|
364
|
+
snapshotVersion: 0,
|
|
365
|
+
}, { database });
|
|
366
|
+
return { ...registered.replica, deviceId: machineId(), ownerUserId };
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function operationForUpsert(inputCookie, sourceId, extraHosts, changedAt) {
|
|
370
|
+
const cookie = normalizeJarCookie(inputCookie);
|
|
371
|
+
if (!validCookie(cookie, extraHosts)) return null;
|
|
372
|
+
if (isExpiredCookie(cookie)) {
|
|
373
|
+
return {
|
|
374
|
+
kind: 'cookie.delete',
|
|
375
|
+
key: cookieKey(cookie),
|
|
376
|
+
sourceId,
|
|
377
|
+
changedAt,
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
return {
|
|
381
|
+
kind: 'cookie.upsert',
|
|
382
|
+
key: cookieKey(cookie),
|
|
383
|
+
cookie,
|
|
384
|
+
sourceId,
|
|
385
|
+
changedAt,
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function operationForDelete(input, sourceId, extraHosts, changedAt) {
|
|
390
|
+
if (input && typeof input === 'object' && hostIsExcluded(input.domain, extraHosts)) return null;
|
|
391
|
+
const key = deleteKeyFor(input);
|
|
392
|
+
if (!key) return null;
|
|
393
|
+
return { kind: 'cookie.delete', key, sourceId, changedAt };
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function operationChangesJar(operation, snapshot = readCookieJar()) {
|
|
397
|
+
if (!operation) return false;
|
|
398
|
+
if (operation.kind === 'cookie.upsert') {
|
|
399
|
+
const current = (snapshot.cookies || []).find((cookie) => cookieKey(cookie) === operation.key);
|
|
400
|
+
return !current || cookieFingerprint(current) !== cookieFingerprint(operation.cookie);
|
|
401
|
+
}
|
|
402
|
+
return (snapshot.cookies || []).some((cookie) => cookieKey(cookie) === operation.key)
|
|
403
|
+
|| !(snapshot.tombstones || []).some((item) => item.key === operation.key);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function materializeCookieOperation(operation) {
|
|
407
|
+
if (operation.kind === 'cookie.upsert') {
|
|
408
|
+
return mergeCookieJarMaterialized({
|
|
409
|
+
sourceId: operation.sourceId,
|
|
410
|
+
upserts: [operation.cookie],
|
|
411
|
+
changedAt: operation.changedAt,
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
if (operation.kind === 'cookie.delete') {
|
|
415
|
+
return mergeCookieJarMaterialized({
|
|
416
|
+
sourceId: operation.sourceId,
|
|
417
|
+
deletes: [operation.key],
|
|
418
|
+
changedAt: operation.changedAt,
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
throw new Error(`Unsupported ${COOKIE_CLOUD_CONTRACT} operation ${JSON.stringify(operation.kind)}`);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function cookieEvent(operation, mutationId) {
|
|
425
|
+
return {
|
|
426
|
+
resource: COOKIE_CLOUD_RESOURCE,
|
|
427
|
+
op: operation.kind === 'cookie.delete' ? 'delete' : 'update',
|
|
428
|
+
id: crypto.createHash('sha256').update(operation.key).digest('hex').slice(0, 24),
|
|
429
|
+
value: { protected: true, kind: operation.kind },
|
|
430
|
+
source: `browser-cookie-jar:${operation.sourceId}`,
|
|
431
|
+
clientMutationId: mutationId,
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function commitCookieOperation(operation, replica = cookieCloudReplica(), options = {}) {
|
|
436
|
+
if (!replica) return materializeCookieOperation(operation);
|
|
437
|
+
const mutationId = options.mutationId || `mut_${crypto.randomBytes(18).toString('base64url')}`;
|
|
438
|
+
const database = options.database || require('../state/db').openLocalDb();
|
|
439
|
+
let snapshot;
|
|
440
|
+
require('../state/mutations').commitLocalMutation({
|
|
441
|
+
channelId: `resource:${replica.resourceId}`,
|
|
442
|
+
localResource: COOKIE_CLOUD_RESOURCE,
|
|
443
|
+
cloudResourceId: replica.resourceId,
|
|
444
|
+
mutationId,
|
|
445
|
+
deviceId: replica.deviceId,
|
|
446
|
+
authorityEpoch: replica.authorityEpoch,
|
|
447
|
+
baseCloudVersion: replica.appliedVersion,
|
|
448
|
+
contract: COOKIE_CLOUD_CONTRACT,
|
|
449
|
+
schemaVersion: COOKIE_JAR_SCHEMA_VERSION,
|
|
450
|
+
operationKind: operation.kind,
|
|
451
|
+
operation,
|
|
452
|
+
}, {
|
|
453
|
+
database,
|
|
454
|
+
materialize() {
|
|
455
|
+
snapshot = materializeCookieOperation(operation);
|
|
456
|
+
},
|
|
457
|
+
event: cookieEvent(operation, mutationId),
|
|
458
|
+
});
|
|
459
|
+
return snapshot || readCookieJar();
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function mergeCookieJar(input = {}) {
|
|
463
|
+
const replica = cookieCloudReplica();
|
|
464
|
+
if (!replica) return mergeCookieJarMaterialized(input);
|
|
465
|
+
const sourceId = cleanSourceId(input.sourceId);
|
|
466
|
+
const extraHosts = Array.isArray(input.excludeHosts) ? input.excludeHosts : [];
|
|
467
|
+
const upserts = Array.isArray(input.upserts) ? input.upserts : [];
|
|
468
|
+
const deletes = Array.isArray(input.deletes) ? input.deletes : [];
|
|
469
|
+
if (upserts.length + deletes.length > MAX_MUTATIONS_PER_MERGE) {
|
|
470
|
+
throw new Error(`Cookie mutation batch exceeds ${MAX_MUTATIONS_PER_MERGE}`);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
let snapshot = readCookieJar();
|
|
474
|
+
for (const inputCookie of upserts) {
|
|
475
|
+
const operation = operationForUpsert(inputCookie, sourceId, extraHosts, nowIso());
|
|
476
|
+
if (!operationChangesJar(operation, snapshot)) continue;
|
|
477
|
+
snapshot = commitCookieOperation(operation, replica);
|
|
478
|
+
}
|
|
479
|
+
for (const deletion of deletes) {
|
|
480
|
+
const operation = operationForDelete(deletion, sourceId, extraHosts, nowIso());
|
|
481
|
+
if (!operationChangesJar(operation, snapshot)) continue;
|
|
482
|
+
snapshot = commitCookieOperation(operation, replica);
|
|
483
|
+
}
|
|
484
|
+
return snapshot;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function normalizeCloudSnapshot(input) {
|
|
488
|
+
if (!input || input.schemaVersion !== COOKIE_JAR_SCHEMA_VERSION) {
|
|
489
|
+
throw new Error(`Unsupported ${COOKIE_CLOUD_CONTRACT} snapshot`);
|
|
490
|
+
}
|
|
491
|
+
const inputRecords = Array.isArray(input.records) ? input.records : [];
|
|
492
|
+
const inputTombstones = Array.isArray(input.tombstones) ? input.tombstones : [];
|
|
493
|
+
if (inputRecords.length + inputTombstones.length > MAX_MUTATIONS_PER_MERGE + MAX_TOMBSTONES) {
|
|
494
|
+
throw new Error('Cookie cloud snapshot is too large');
|
|
495
|
+
}
|
|
496
|
+
const records = new Map();
|
|
497
|
+
const tombstones = new Map();
|
|
498
|
+
for (const item of inputRecords) {
|
|
499
|
+
const cookie = normalizeJarCookie(item?.cookie || {});
|
|
500
|
+
if (!validCookie(cookie)) continue;
|
|
501
|
+
const key = cookieKey(cookie);
|
|
502
|
+
if (item?.key && item.key !== key) throw new Error('Cookie cloud snapshot key mismatch');
|
|
503
|
+
records.set(key, {
|
|
504
|
+
key,
|
|
505
|
+
cookie,
|
|
506
|
+
revision: Math.max(0, Number(item?.revision) || 0),
|
|
507
|
+
sourceId: cleanSourceId(item?.sourceId),
|
|
508
|
+
updatedAt: String(item?.updatedAt || input.updatedAt || nowIso()),
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
for (const item of inputTombstones) {
|
|
512
|
+
const key = String(item?.key || '');
|
|
513
|
+
if (!key) continue;
|
|
514
|
+
tombstones.set(key, {
|
|
515
|
+
key,
|
|
516
|
+
revision: Math.max(0, Number(item?.revision) || 0),
|
|
517
|
+
sourceId: cleanSourceId(item?.sourceId),
|
|
518
|
+
deletedAt: String(item?.deletedAt || input.updatedAt || nowIso()),
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
for (const [key, record] of records) {
|
|
522
|
+
const tombstone = tombstones.get(key);
|
|
523
|
+
if (!tombstone) continue;
|
|
524
|
+
if (tombstone.deletedAt >= record.updatedAt) records.delete(key);
|
|
525
|
+
else tombstones.delete(key);
|
|
526
|
+
}
|
|
527
|
+
return {
|
|
528
|
+
schemaVersion: COOKIE_JAR_SCHEMA_VERSION,
|
|
529
|
+
version: Math.max(0, Number(input.version) || 0),
|
|
530
|
+
records,
|
|
531
|
+
tombstones: pruneTombstones(tombstones),
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function persistCloudSnapshot(input) {
|
|
536
|
+
const normalized = normalizeCloudSnapshot(input);
|
|
537
|
+
return persistCookieJar({
|
|
538
|
+
exists: true,
|
|
539
|
+
version: normalized.version,
|
|
540
|
+
bundle: null,
|
|
541
|
+
records: normalized.records,
|
|
542
|
+
tombstones: normalized.tombstones,
|
|
543
|
+
migratedFromLegacy: false,
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function timestampForEntry(record, tombstone) {
|
|
548
|
+
return String(record?.updatedAt || tombstone?.deletedAt || '');
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function localBootstrapOperations(local, remote, pendingKeys) {
|
|
552
|
+
const operations = [];
|
|
553
|
+
const keys = new Set([...local.records.keys(), ...local.tombstones.keys()]);
|
|
554
|
+
for (const key of keys) {
|
|
555
|
+
if (pendingKeys.has(key)) continue;
|
|
556
|
+
const localRecord = local.records.get(key);
|
|
557
|
+
const localTombstone = local.tombstones.get(key);
|
|
558
|
+
const remoteRecord = remote.records.get(key);
|
|
559
|
+
const remoteTombstone = remote.tombstones.get(key);
|
|
560
|
+
const localTime = timestampForEntry(localRecord, localTombstone);
|
|
561
|
+
const remoteTime = timestampForEntry(remoteRecord, remoteTombstone);
|
|
562
|
+
if (!localTime || localTime <= remoteTime) continue;
|
|
563
|
+
if (localRecord) {
|
|
564
|
+
operations.push({
|
|
565
|
+
kind: 'cookie.upsert',
|
|
566
|
+
key,
|
|
567
|
+
cookie: localRecord.cookie,
|
|
568
|
+
sourceId: `${localRecord.sourceId}:bootstrap`,
|
|
569
|
+
changedAt: localRecord.updatedAt,
|
|
570
|
+
});
|
|
571
|
+
} else if (localTombstone) {
|
|
572
|
+
operations.push({
|
|
573
|
+
kind: 'cookie.delete',
|
|
574
|
+
key,
|
|
575
|
+
sourceId: `${localTombstone.sourceId}:bootstrap`,
|
|
576
|
+
changedAt: localTombstone.deletedAt,
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
return operations;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function getCookieCloudBootstrap(options = {}) {
|
|
584
|
+
const replica = cookieCloudReplica(options);
|
|
585
|
+
if (!replica) return { enabled: false, reason: 'runtime is not enrolled to a user' };
|
|
586
|
+
const bytes = Buffer.from(JSON.stringify(cloudSnapshot()), 'utf8');
|
|
587
|
+
return {
|
|
588
|
+
enabled: true,
|
|
589
|
+
resourceId: replica.resourceId,
|
|
590
|
+
contract: COOKIE_CLOUD_CONTRACT,
|
|
591
|
+
schemaVersion: COOKIE_JAR_SCHEMA_VERSION,
|
|
592
|
+
authorityEpoch: replica.authorityEpoch,
|
|
593
|
+
appliedVersion: replica.appliedVersion,
|
|
594
|
+
snapshotBase64: bytes.toString('base64'),
|
|
595
|
+
snapshotBytes: bytes.length,
|
|
596
|
+
snapshotChecksum: crypto.createHash('sha256').update(bytes).digest('hex'),
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function installCookieCloudBootstrap(input = {}, options = {}) {
|
|
601
|
+
const database = options.database || require('../state/db').openLocalDb();
|
|
602
|
+
const replica = cookieCloudReplica({ database });
|
|
603
|
+
if (!replica || input.resourceId !== replica.resourceId) {
|
|
604
|
+
throw new Error('Private cookie resource does not belong to this runtime user');
|
|
605
|
+
}
|
|
606
|
+
if (input.contract !== COOKIE_CLOUD_CONTRACT || Number(input.schemaVersion) !== COOKIE_JAR_SCHEMA_VERSION) {
|
|
607
|
+
throw new Error('Private cookie resource contract changed');
|
|
608
|
+
}
|
|
609
|
+
const bytes = Buffer.from(String(input.snapshotBase64 || ''), 'base64');
|
|
610
|
+
const checksum = crypto.createHash('sha256').update(bytes).digest('hex');
|
|
611
|
+
if (checksum !== input.snapshotChecksum) throw new Error('Private cookie snapshot checksum mismatch');
|
|
612
|
+
const local = loadInternalCookieJar();
|
|
613
|
+
const remote = normalizeCloudSnapshot(JSON.parse(bytes.toString('utf8')));
|
|
614
|
+
const pending = require('../state/mutations').listPendingCloudMutations(replica.resourceId, { database });
|
|
615
|
+
const pendingKeys = new Set(pending.map((mutation) => mutation.operation?.key).filter(Boolean));
|
|
616
|
+
const bootstrapOperations = localBootstrapOperations(local, remote, pendingKeys);
|
|
617
|
+
|
|
618
|
+
persistCloudSnapshot({
|
|
619
|
+
schemaVersion: COOKIE_JAR_SCHEMA_VERSION,
|
|
620
|
+
version: remote.version,
|
|
621
|
+
records: [...remote.records.values()],
|
|
622
|
+
tombstones: [...remote.tombstones.values()],
|
|
623
|
+
});
|
|
624
|
+
const { advanceReplicaSnapshot, refreshReplicaAuthority } = require('../state/replicas');
|
|
625
|
+
advanceReplicaSnapshot({
|
|
626
|
+
resourceId: replica.resourceId,
|
|
627
|
+
snapshotVersion: Number(input.snapshotVersion) || 0,
|
|
628
|
+
snapshotChecksum: checksum,
|
|
629
|
+
authorityEpoch: Number(input.authorityEpoch) || 1,
|
|
630
|
+
}, { database });
|
|
631
|
+
refreshReplicaAuthority({
|
|
632
|
+
resourceId: replica.resourceId,
|
|
633
|
+
authorityEpoch: Number(input.authorityEpoch) || 1,
|
|
634
|
+
headVersion: Number(input.headVersion) || 0,
|
|
635
|
+
contract: COOKIE_CLOUD_CONTRACT,
|
|
636
|
+
schemaVersion: COOKIE_JAR_SCHEMA_VERSION,
|
|
637
|
+
}, { database });
|
|
638
|
+
|
|
639
|
+
// Snapshot installation never discards local work. Already-journaled edits
|
|
640
|
+
// replay from their durable rows; pre-cloud legacy entries become ordinary
|
|
641
|
+
// new mutations through the same commit path.
|
|
642
|
+
for (const mutation of pending) materializeCookieOperation(mutation.operation);
|
|
643
|
+
let currentReplica = cookieCloudReplica({ database });
|
|
644
|
+
for (const operation of bootstrapOperations) {
|
|
645
|
+
commitCookieOperation(operation, currentReplica, { database });
|
|
646
|
+
currentReplica = cookieCloudReplica({ database });
|
|
647
|
+
}
|
|
648
|
+
return { replica: currentReplica, jar: readCookieJar(), bootstrappedMutations: bootstrapOperations.length };
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
function applyCloudCookieMutation(input = {}, options = {}) {
|
|
652
|
+
const envelope = input.envelope || {};
|
|
653
|
+
if (envelope.contract !== COOKIE_CLOUD_CONTRACT) {
|
|
654
|
+
throw new Error(`Cannot apply ${String(envelope.contract)} to the cookie jar`);
|
|
655
|
+
}
|
|
656
|
+
const database = options.database || require('../state/db').openLocalDb();
|
|
657
|
+
const mutationId = String(envelope.mutationId || '');
|
|
658
|
+
return require('../state/mutations').ingestCloudMutation({
|
|
659
|
+
localResource: COOKIE_CLOUD_RESOURCE,
|
|
660
|
+
envelope,
|
|
661
|
+
version: input.version,
|
|
662
|
+
committedAt: input.committedAt,
|
|
663
|
+
}, {
|
|
664
|
+
database,
|
|
665
|
+
materialize(mutation) {
|
|
666
|
+
materializeCookieOperation(mutation.operation);
|
|
667
|
+
},
|
|
668
|
+
event(mutation) {
|
|
669
|
+
return cookieEvent(mutation.operation, mutationId);
|
|
670
|
+
},
|
|
671
|
+
});
|
|
672
|
+
}
|
|
673
|
+
|
|
322
674
|
function diffCookieSnapshots(before = [], after = [], options = {}) {
|
|
323
675
|
const beforeMap = new Map(normalizeCookieList(before, options.excludeHosts).map((cookie) => [cookieKey(cookie), cookie]));
|
|
324
676
|
const afterMap = new Map(normalizeCookieList(after, options.excludeHosts).map((cookie) => [cookieKey(cookie), cookie]));
|
|
@@ -350,15 +702,21 @@ function cookieJarState(snapshot = readCookieJar()) {
|
|
|
350
702
|
}
|
|
351
703
|
|
|
352
704
|
module.exports = {
|
|
705
|
+
COOKIE_CLOUD_CONTRACT,
|
|
353
706
|
COOKIE_JAR_SCHEMA_VERSION,
|
|
354
707
|
DEFAULT_EXCLUDED_HOST_SUFFIXES,
|
|
708
|
+
applyCloudCookieMutation,
|
|
709
|
+
cloudSnapshot,
|
|
355
710
|
cookieFingerprint,
|
|
356
711
|
cookieJarState,
|
|
357
712
|
cookieKey,
|
|
358
713
|
cookiesMissingFromJar,
|
|
359
714
|
diffCookieSnapshots,
|
|
715
|
+
getCookieCloudBootstrap,
|
|
360
716
|
hostIsExcluded,
|
|
717
|
+
installCookieCloudBootstrap,
|
|
361
718
|
mergeCookieJar,
|
|
362
719
|
normalizeCookieList,
|
|
720
|
+
privateCookieResourceId,
|
|
363
721
|
readCookieJar,
|
|
364
722
|
};
|
|
@@ -13,6 +13,8 @@ const {
|
|
|
13
13
|
createBrowserLoginSession,
|
|
14
14
|
deleteBrowserAuthBundle,
|
|
15
15
|
getBrowserAuthBundle,
|
|
16
|
+
getCookieCloudBootstrap,
|
|
17
|
+
installCookieCloudBootstrap,
|
|
16
18
|
listBrowserLoginSessions,
|
|
17
19
|
listBrowserAuthBundles,
|
|
18
20
|
listBrowserProfiles,
|
|
@@ -121,6 +123,22 @@ function handleCookieJarMerge(body = {}, sendJson) {
|
|
|
121
123
|
sendJson(200, { jar });
|
|
122
124
|
}
|
|
123
125
|
|
|
126
|
+
function handleCookieCloudBootstrap(sendJson) {
|
|
127
|
+
try {
|
|
128
|
+
sendJson(200, getCookieCloudBootstrap());
|
|
129
|
+
} catch (error) {
|
|
130
|
+
sendJson(500, { error: error?.message || 'cookie cloud bootstrap failed' });
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function handleCookieCloudInstall(body = {}, sendJson) {
|
|
135
|
+
try {
|
|
136
|
+
sendJson(200, installCookieCloudBootstrap(body));
|
|
137
|
+
} catch (error) {
|
|
138
|
+
sendJson(Number(error?.statusCode) || 400, { error: error?.message || 'cookie cloud install failed' });
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
124
142
|
async function handleAuthBundleExport(body = {}, sendJson) {
|
|
125
143
|
const result = await exportAuthBundleFromProfile(body);
|
|
126
144
|
sendJson(200, { bundle: result.bundle });
|
|
@@ -333,6 +351,8 @@ module.exports = {
|
|
|
333
351
|
handleAuthBundlesList,
|
|
334
352
|
handleCookieJarGet,
|
|
335
353
|
handleCookieJarMerge,
|
|
354
|
+
handleCookieCloudBootstrap,
|
|
355
|
+
handleCookieCloudInstall,
|
|
336
356
|
handleLoginSessionCancel,
|
|
337
357
|
handleLoginSessionComplete,
|
|
338
358
|
handleLoginSessionCreate,
|
|
@@ -13,6 +13,16 @@ async function handleBrowserRoutes(ctx) {
|
|
|
13
13
|
browserRest.handleCookieJarMerge(await ctx.readJsonBody(), ctx.sendJson);
|
|
14
14
|
return true;
|
|
15
15
|
}
|
|
16
|
+
if (ctx.pathname === '/browser/cookie-jar/cloud/bootstrap' && ctx.method === 'GET') {
|
|
17
|
+
ctx.res.setHeader('Cache-Control', 'no-store');
|
|
18
|
+
browserRest.handleCookieCloudBootstrap(ctx.sendJson);
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
if (ctx.pathname === '/browser/cookie-jar/cloud/install' && ctx.method === 'POST') {
|
|
22
|
+
ctx.res.setHeader('Cache-Control', 'no-store');
|
|
23
|
+
browserRest.handleCookieCloudInstall(await ctx.readJsonBody(), ctx.sendJson);
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
16
26
|
if (ctx.pathname === '/browser/profiles' && ctx.method === 'GET') {
|
|
17
27
|
browserRest.handleProfilesList(ctx.sendJson);
|
|
18
28
|
return true;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Runtime-side persistence policy for mutation contracts.
|
|
5
|
+
*
|
|
6
|
+
* The mutation journal is the durable cloud outbox, so sensitive operations
|
|
7
|
+
* must be protected before they enter it. Transport callers explicitly decode
|
|
8
|
+
* the row while it is in memory; diagnostics only receive the redacted shape.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const { decryptJson, encryptJson } = require('../browser/auth-vault');
|
|
12
|
+
|
|
13
|
+
const COOKIE_CONTRACT = 'browser-cookie-jar@1';
|
|
14
|
+
const PROTECTED_KIND = 'amalgm.encrypted-json@1';
|
|
15
|
+
|
|
16
|
+
function isSensitiveContract(contract) {
|
|
17
|
+
return contract === COOKIE_CONTRACT;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function protectOperation(contract, operation) {
|
|
21
|
+
if (!isSensitiveContract(contract)) return operation;
|
|
22
|
+
return {
|
|
23
|
+
kind: PROTECTED_KIND,
|
|
24
|
+
contract,
|
|
25
|
+
envelope: encryptJson(operation).envelope,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function revealOperation(contract, stored) {
|
|
30
|
+
if (!isSensitiveContract(contract)) return stored;
|
|
31
|
+
if (
|
|
32
|
+
!stored
|
|
33
|
+
|| stored.kind !== PROTECTED_KIND
|
|
34
|
+
|| stored.contract !== contract
|
|
35
|
+
|| !stored.envelope
|
|
36
|
+
) {
|
|
37
|
+
throw new Error(`Mutation payload for ${contract} is not protected`);
|
|
38
|
+
}
|
|
39
|
+
return decryptJson(stored.envelope);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function diagnosticOperation(contract, operation) {
|
|
43
|
+
if (!isSensitiveContract(contract)) return operation;
|
|
44
|
+
return {
|
|
45
|
+
kind: operation && typeof operation === 'object' ? operation.kind || 'redacted' : 'redacted',
|
|
46
|
+
protected: true,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
module.exports = {
|
|
51
|
+
COOKIE_CONTRACT,
|
|
52
|
+
diagnosticOperation,
|
|
53
|
+
isSensitiveContract,
|
|
54
|
+
protectOperation,
|
|
55
|
+
revealOperation,
|
|
56
|
+
};
|
|
@@ -14,12 +14,17 @@
|
|
|
14
14
|
|
|
15
15
|
const { cachedStatement, openLocalDb } = require('./db');
|
|
16
16
|
const { insertStateEvent, publishStateEvent } = require('./events');
|
|
17
|
+
const {
|
|
18
|
+
diagnosticOperation,
|
|
19
|
+
protectOperation,
|
|
20
|
+
revealOperation,
|
|
21
|
+
} = require('./mutation-contracts');
|
|
17
22
|
const { trace } = require('./trace');
|
|
18
23
|
|
|
19
24
|
const MAX_MUTATION_BYTES = 4 * 1024 * 1024;
|
|
20
25
|
const DEFAULT_CLAIM_LIMIT = 100;
|
|
21
26
|
const DEFAULT_CLAIM_LEASE_MS = 30_000;
|
|
22
|
-
const
|
|
27
|
+
const CLOUD_RESOURCE_ID = /^(?:shr|pvr)_[A-Za-z0-9_-]{16,128}$/;
|
|
23
28
|
const MUTATION_ID = /^[A-Za-z0-9_-]{16,160}$/;
|
|
24
29
|
const OPERATION_KIND = /^[a-z][a-z0-9._:-]{0,63}$/;
|
|
25
30
|
const CONTRACT = /^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,127}$/;
|
|
@@ -96,24 +101,28 @@ function normalizeMutationInput(input) {
|
|
|
96
101
|
throw invalid(`operation exceeds the ${MAX_MUTATION_BYTES}-byte durable mutation limit`, 413);
|
|
97
102
|
}
|
|
98
103
|
|
|
99
|
-
|
|
104
|
+
// `shared_resource_id` is the installed SQLite compatibility column. The
|
|
105
|
+
// core treats it as a cloud resource id; `shr_` uses shared authority and
|
|
106
|
+
// `pvr_` uses same-user-private authority.
|
|
107
|
+
const cloudResourceInput = input.cloudResourceId ?? input.sharedResourceId;
|
|
108
|
+
const sharedResourceId = cloudResourceInput == null
|
|
100
109
|
? null
|
|
101
|
-
: cleanText(
|
|
102
|
-
if (sharedResourceId && !
|
|
103
|
-
throw invalid('
|
|
110
|
+
: cleanText(cloudResourceInput, 'cloudResourceId', 132);
|
|
111
|
+
if (sharedResourceId && !CLOUD_RESOURCE_ID.test(sharedResourceId)) {
|
|
112
|
+
throw invalid('cloudResourceId must be an opaque id beginning with shr_ or pvr_');
|
|
104
113
|
}
|
|
105
114
|
if (sharedResourceId && channelId !== `resource:${sharedResourceId}`) {
|
|
106
|
-
throw invalid('a
|
|
115
|
+
throw invalid('a cloud mutation channelId must be resource:<cloudResourceId>');
|
|
107
116
|
}
|
|
108
117
|
|
|
109
118
|
const authorityEpoch = nonNegativeInteger(input.authorityEpoch, 'authorityEpoch');
|
|
110
119
|
const baseCloudVersion = nonNegativeInteger(input.baseCloudVersion, 'baseCloudVersion');
|
|
111
120
|
const deviceId = input.deviceId == null ? null : cleanText(input.deviceId, 'deviceId', 256);
|
|
112
121
|
if (sharedResourceId && authorityEpoch < 1) {
|
|
113
|
-
throw invalid('
|
|
122
|
+
throw invalid('cloud mutations require authorityEpoch >= 1');
|
|
114
123
|
}
|
|
115
124
|
if (sharedResourceId && !deviceId) {
|
|
116
|
-
throw invalid('
|
|
125
|
+
throw invalid('cloud mutations require deviceId');
|
|
117
126
|
}
|
|
118
127
|
|
|
119
128
|
const localRevision = input.localRevision == null
|
|
@@ -123,6 +132,7 @@ function normalizeMutationInput(input) {
|
|
|
123
132
|
return {
|
|
124
133
|
channelId,
|
|
125
134
|
localResource,
|
|
135
|
+
cloudResourceId: sharedResourceId,
|
|
126
136
|
sharedResourceId,
|
|
127
137
|
mutationId,
|
|
128
138
|
deviceId,
|
|
@@ -137,12 +147,15 @@ function normalizeMutationInput(input) {
|
|
|
137
147
|
};
|
|
138
148
|
}
|
|
139
149
|
|
|
140
|
-
function normalizeJournalRow(row) {
|
|
150
|
+
function normalizeJournalRow(row, options = {}) {
|
|
141
151
|
if (!row) return null;
|
|
152
|
+
const storedOperation = safeJsonParse(row.operation_json);
|
|
153
|
+
const operation = revealOperation(row.contract, storedOperation);
|
|
142
154
|
return {
|
|
143
155
|
journalId: Number(row.journal_id),
|
|
144
156
|
channelId: row.channel_id,
|
|
145
157
|
localResource: row.local_resource,
|
|
158
|
+
cloudResourceId: row.shared_resource_id || null,
|
|
146
159
|
sharedResourceId: row.shared_resource_id || null,
|
|
147
160
|
mutationId: row.mutation_id,
|
|
148
161
|
deviceId: row.device_id || null,
|
|
@@ -154,7 +167,9 @@ function normalizeJournalRow(row) {
|
|
|
154
167
|
contract: row.contract,
|
|
155
168
|
schemaVersion: Number(row.schema_version),
|
|
156
169
|
operationKind: row.operation_kind,
|
|
157
|
-
operation:
|
|
170
|
+
operation: options.diagnostic === true
|
|
171
|
+
? diagnosticOperation(row.contract, operation)
|
|
172
|
+
: operation,
|
|
158
173
|
origin: row.mutation_origin,
|
|
159
174
|
state: row.mutation_state,
|
|
160
175
|
localRevision: row.local_revision == null ? null : Number(row.local_revision),
|
|
@@ -177,6 +192,10 @@ function getMutationRow(database, channelId, mutationId) {
|
|
|
177
192
|
}
|
|
178
193
|
|
|
179
194
|
function sameMutation(existing, mutation) {
|
|
195
|
+
const existingOperation = revealOperation(
|
|
196
|
+
existing.contract,
|
|
197
|
+
safeJsonParse(existing.operation_json),
|
|
198
|
+
);
|
|
180
199
|
return existing.local_resource === mutation.localResource
|
|
181
200
|
&& (existing.shared_resource_id || null) === mutation.sharedResourceId
|
|
182
201
|
&& (existing.device_id || null) === mutation.deviceId
|
|
@@ -185,11 +204,15 @@ function sameMutation(existing, mutation) {
|
|
|
185
204
|
&& existing.contract === mutation.contract
|
|
186
205
|
&& Number(existing.schema_version) === mutation.schemaVersion
|
|
187
206
|
&& existing.operation_kind === mutation.operationKind
|
|
188
|
-
&&
|
|
207
|
+
&& JSON.stringify(existingOperation) === mutation.operationJson
|
|
189
208
|
&& (existing.local_revision == null ? null : Number(existing.local_revision)) === mutation.localRevision;
|
|
190
209
|
}
|
|
191
210
|
|
|
192
211
|
function sameCloudEnvelope(existing, mutation) {
|
|
212
|
+
const existingOperation = revealOperation(
|
|
213
|
+
existing.contract,
|
|
214
|
+
safeJsonParse(existing.operation_json),
|
|
215
|
+
);
|
|
193
216
|
return existing.local_resource === mutation.localResource
|
|
194
217
|
&& (existing.shared_resource_id || null) === mutation.sharedResourceId
|
|
195
218
|
&& (existing.device_id || null) === mutation.deviceId
|
|
@@ -198,7 +221,7 @@ function sameCloudEnvelope(existing, mutation) {
|
|
|
198
221
|
&& existing.contract === mutation.contract
|
|
199
222
|
&& Number(existing.schema_version) === mutation.schemaVersion
|
|
200
223
|
&& existing.operation_kind === mutation.operationKind
|
|
201
|
-
&&
|
|
224
|
+
&& JSON.stringify(existingOperation) === mutation.operationJson;
|
|
202
225
|
}
|
|
203
226
|
|
|
204
227
|
function journalMutation(input, options = {}) {
|
|
@@ -248,7 +271,7 @@ function journalMutation(input, options = {}) {
|
|
|
248
271
|
mutation.contract,
|
|
249
272
|
mutation.schemaVersion,
|
|
250
273
|
mutation.operationKind,
|
|
251
|
-
mutation.
|
|
274
|
+
JSON.stringify(protectOperation(mutation.contract, mutation.operation)),
|
|
252
275
|
mutation.localRevision,
|
|
253
276
|
);
|
|
254
277
|
|
|
@@ -405,7 +428,7 @@ function journalCloudMutation(input, options = {}) {
|
|
|
405
428
|
throw invalid(`shared resource ${sharedResourceId} contract changed`, 409);
|
|
406
429
|
}
|
|
407
430
|
|
|
408
|
-
const existing =
|
|
431
|
+
const existing = findCloudMutationRow(database, sharedResourceId, mutation.mutationId);
|
|
409
432
|
if (existing) {
|
|
410
433
|
if (!sameCloudEnvelope(existing, mutation)) {
|
|
411
434
|
throw invalid(`mutationId ${mutation.mutationId} was reused with different content`, 409);
|
|
@@ -499,7 +522,7 @@ function journalCloudMutation(input, options = {}) {
|
|
|
499
522
|
mutation.contract,
|
|
500
523
|
mutation.schemaVersion,
|
|
501
524
|
mutation.operationKind,
|
|
502
|
-
mutation.
|
|
525
|
+
JSON.stringify(protectOperation(mutation.contract, mutation.operation)),
|
|
503
526
|
committedAt,
|
|
504
527
|
now,
|
|
505
528
|
now,
|
|
@@ -582,15 +605,17 @@ function commitLocalMutation(input, options = {}) {
|
|
|
582
605
|
|
|
583
606
|
function getMutation(channelId, mutationId, options = {}) {
|
|
584
607
|
const database = options.database || openLocalDb();
|
|
585
|
-
return normalizeJournalRow(getMutationRow(database, channelId, mutationId)
|
|
608
|
+
return normalizeJournalRow(getMutationRow(database, channelId, mutationId), {
|
|
609
|
+
diagnostic: options.revealOperation !== true,
|
|
610
|
+
});
|
|
586
611
|
}
|
|
587
612
|
|
|
588
613
|
function cloudEnvelope(mutation) {
|
|
589
|
-
if (!mutation.
|
|
614
|
+
if (!mutation.cloudResourceId || !mutation.deviceId) {
|
|
590
615
|
throw invalid(`mutation ${mutation.mutationId} is not cloud-deliverable`, 409);
|
|
591
616
|
}
|
|
592
617
|
return {
|
|
593
|
-
resourceId: mutation.
|
|
618
|
+
resourceId: mutation.cloudResourceId,
|
|
594
619
|
authorityEpoch: mutation.authorityEpoch,
|
|
595
620
|
mutationId: mutation.mutationId,
|
|
596
621
|
deviceId: mutation.deviceId,
|
|
@@ -652,7 +677,7 @@ function claimCloudMutations(options = {}) {
|
|
|
652
677
|
})();
|
|
653
678
|
}
|
|
654
679
|
|
|
655
|
-
function
|
|
680
|
+
function findCloudMutationRow(database, sharedResourceId, mutationId) {
|
|
656
681
|
return cachedStatement(database, `
|
|
657
682
|
SELECT * FROM mutation_journal
|
|
658
683
|
WHERE shared_resource_id = ? AND mutation_id = ?
|
|
@@ -671,7 +696,7 @@ function acknowledgeCloudMutation(sharedResourceIdInput, mutationIdInput, acknow
|
|
|
671
696
|
const database = options.database || openLocalDb();
|
|
672
697
|
|
|
673
698
|
return database.transaction(() => {
|
|
674
|
-
const row =
|
|
699
|
+
const row = findCloudMutationRow(database, sharedResourceId, mutationId);
|
|
675
700
|
if (!row) throw invalid(`unknown shared mutation ${mutationId}`, 404);
|
|
676
701
|
if (row.mutation_state === 'synced') {
|
|
677
702
|
if (Number(row.official_cloud_version) !== version) {
|
|
@@ -715,7 +740,7 @@ function failCloudMutation(sharedResourceIdInput, mutationIdInput, failure, opti
|
|
|
715
740
|
? (failure?.retryAt || new Date(Date.now() + 1000).toISOString())
|
|
716
741
|
: null;
|
|
717
742
|
const database = options.database || openLocalDb();
|
|
718
|
-
const row =
|
|
743
|
+
const row = findCloudMutationRow(database, sharedResourceId, mutationId);
|
|
719
744
|
if (!row) throw invalid(`unknown shared mutation ${mutationId}`, 404);
|
|
720
745
|
if (row.mutation_state === 'synced') return normalizeJournalRow(row);
|
|
721
746
|
|
|
@@ -750,7 +775,7 @@ function markMutationConflicted(sharedResourceIdInput, mutationIdInput, messageI
|
|
|
750
775
|
WHERE shared_resource_id = ? AND mutation_id = ? AND mutation_state != 'synced'
|
|
751
776
|
`).run(message, now, sharedResourceId, mutationId);
|
|
752
777
|
if (!result.changes) throw invalid(`unknown or synced shared mutation ${mutationId}`, 404);
|
|
753
|
-
return normalizeJournalRow(
|
|
778
|
+
return normalizeJournalRow(findCloudMutationRow(database, sharedResourceId, mutationId));
|
|
754
779
|
}
|
|
755
780
|
|
|
756
781
|
function listMutations(options = {}) {
|
|
@@ -768,7 +793,27 @@ function listMutations(options = {}) {
|
|
|
768
793
|
ORDER BY journal_id ASC
|
|
769
794
|
LIMIT ?
|
|
770
795
|
`).all(limit);
|
|
771
|
-
return rows.map(normalizeJournalRow);
|
|
796
|
+
return rows.map((row) => normalizeJournalRow(row, { diagnostic: true }));
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
/**
|
|
800
|
+
* Internal recovery view used by contract materializers after installing a
|
|
801
|
+
* newer snapshot. It reads the journal without claiming rows or changing
|
|
802
|
+
* their retry leases.
|
|
803
|
+
*/
|
|
804
|
+
function listPendingCloudMutations(resourceIdInput, options = {}) {
|
|
805
|
+
const resourceId = cleanText(resourceIdInput, 'resourceId', 132);
|
|
806
|
+
if (!CLOUD_RESOURCE_ID.test(resourceId)) {
|
|
807
|
+
throw invalid('resourceId must be an opaque cloud resource id');
|
|
808
|
+
}
|
|
809
|
+
const database = options.database || openLocalDb();
|
|
810
|
+
return cachedStatement(database, `
|
|
811
|
+
SELECT * FROM mutation_journal
|
|
812
|
+
WHERE shared_resource_id = ?
|
|
813
|
+
AND mutation_origin = 'local'
|
|
814
|
+
AND mutation_state != 'synced'
|
|
815
|
+
ORDER BY journal_id ASC
|
|
816
|
+
`).all(resourceId).map((row) => normalizeJournalRow(row));
|
|
772
817
|
}
|
|
773
818
|
|
|
774
819
|
function pruneMutationJournal(options = {}) {
|
|
@@ -814,6 +859,7 @@ module.exports = {
|
|
|
814
859
|
ingestCloudMutation,
|
|
815
860
|
journalCloudMutation,
|
|
816
861
|
journalMutation,
|
|
862
|
+
listPendingCloudMutations,
|
|
817
863
|
listMutations,
|
|
818
864
|
markMutationConflicted,
|
|
819
865
|
pruneMutationJournal,
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
const { cachedStatement, openLocalDb } = require('./db');
|
|
12
12
|
const { trace } = require('./trace');
|
|
13
13
|
|
|
14
|
-
const
|
|
14
|
+
const CLOUD_RESOURCE_ID = /^(?:shr|pvr)_[A-Za-z0-9_-]{16,128}$/;
|
|
15
15
|
const CONTRACT = /^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,127}$/;
|
|
16
16
|
|
|
17
17
|
function invalid(message, statusCode = 400) {
|
|
@@ -55,8 +55,8 @@ function normalizeReplica(row) {
|
|
|
55
55
|
|
|
56
56
|
function registerReplica(input, options = {}) {
|
|
57
57
|
const resourceId = requiredText(input?.resourceId, 'resourceId', 132);
|
|
58
|
-
if (!
|
|
59
|
-
throw invalid('resourceId must be an opaque id beginning with shr_');
|
|
58
|
+
if (!CLOUD_RESOURCE_ID.test(resourceId)) {
|
|
59
|
+
throw invalid('resourceId must be an opaque id beginning with shr_ or pvr_');
|
|
60
60
|
}
|
|
61
61
|
const localResource = requiredText(input?.localResource, 'localResource');
|
|
62
62
|
const localPath = requiredText(input?.localPath, 'localPath', 4096);
|
|
@@ -86,7 +86,7 @@ function registerReplica(input, options = {}) {
|
|
|
86
86
|
|| normalized.contract !== contract
|
|
87
87
|
|| normalized.schemaVersion !== schemaVersion
|
|
88
88
|
) {
|
|
89
|
-
throw invalid(`
|
|
89
|
+
throw invalid(`cloud resource ${resourceId} is already bound differently`, 409);
|
|
90
90
|
}
|
|
91
91
|
return { replica: normalized, duplicate: true };
|
|
92
92
|
}
|
|
@@ -123,7 +123,7 @@ function registerReplica(input, options = {}) {
|
|
|
123
123
|
);
|
|
124
124
|
} catch (error) {
|
|
125
125
|
if (String(error?.code || '').includes('CONSTRAINT')) {
|
|
126
|
-
throw invalid('that local
|
|
126
|
+
throw invalid('that local materialization is already bound to another cloud resource', 409);
|
|
127
127
|
}
|
|
128
128
|
throw error;
|
|
129
129
|
}
|
|
@@ -267,7 +267,11 @@ async function handleMutationFail(body, sendJson) {
|
|
|
267
267
|
|
|
268
268
|
async function handleMutationIngest(body, sendJson) {
|
|
269
269
|
try {
|
|
270
|
-
|
|
270
|
+
const contract = body?.envelope?.contract;
|
|
271
|
+
const result = contract === 'browser-cookie-jar@1'
|
|
272
|
+
? require('../browser/cookie-jar').applyCloudCookieMutation(body)
|
|
273
|
+
: require('./docs').applyCloudDocMutation(body);
|
|
274
|
+
sendJson(200, result);
|
|
271
275
|
} catch (error) {
|
|
272
276
|
sendHandlerError(error, sendJson);
|
|
273
277
|
}
|
|
@@ -0,0 +1,152 @@
|
|
|
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-cookie-cloud-'));
|
|
10
|
+
const userId = '11111111-1111-4111-8111-111111111111';
|
|
11
|
+
process.env.AMALGM_DIR = path.join(tempRoot, '.amalgm');
|
|
12
|
+
process.env.AMALGM_BROWSER_AUTH_KEY = 'e'.repeat(64);
|
|
13
|
+
fs.mkdirSync(process.env.AMALGM_DIR, { recursive: true });
|
|
14
|
+
fs.writeFileSync(path.join(process.env.AMALGM_DIR, 'computer.json'), JSON.stringify({
|
|
15
|
+
computer_id: 'machine-cookie-a',
|
|
16
|
+
user_id: userId,
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
const { closeLocalDb, openLocalDb } = require('../state/db');
|
|
20
|
+
const { listEventsAfter } = require('../state/events');
|
|
21
|
+
const {
|
|
22
|
+
acknowledgeCloudMutation,
|
|
23
|
+
claimCloudMutations,
|
|
24
|
+
getMutation,
|
|
25
|
+
} = require('../state/mutations');
|
|
26
|
+
const {
|
|
27
|
+
COOKIE_CLOUD_CONTRACT,
|
|
28
|
+
applyCloudCookieMutation,
|
|
29
|
+
getCookieCloudBootstrap,
|
|
30
|
+
mergeCookieJar,
|
|
31
|
+
privateCookieResourceId,
|
|
32
|
+
readCookieJar,
|
|
33
|
+
} = require('../browser/cookie-jar');
|
|
34
|
+
const { deleteBrowserAuthBundle } = require('../browser/auth-bundles');
|
|
35
|
+
const { COOKIE_SOURCE_BUNDLE_ID } = require('../browser/cookie-source');
|
|
36
|
+
|
|
37
|
+
function cookie(name, value) {
|
|
38
|
+
return {
|
|
39
|
+
name,
|
|
40
|
+
value,
|
|
41
|
+
domain: '.example.com',
|
|
42
|
+
path: '/',
|
|
43
|
+
expires: -1,
|
|
44
|
+
httpOnly: true,
|
|
45
|
+
secure: true,
|
|
46
|
+
sameSite: 'Lax',
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
test.beforeEach(() => {
|
|
51
|
+
const db = openLocalDb();
|
|
52
|
+
deleteBrowserAuthBundle(COOKIE_SOURCE_BUNDLE_ID);
|
|
53
|
+
db.prepare('DELETE FROM event_log').run();
|
|
54
|
+
db.prepare('DELETE FROM mutation_journal').run();
|
|
55
|
+
db.prepare('DELETE FROM shared_resource_replicas').run();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test.after(() => {
|
|
59
|
+
closeLocalDb();
|
|
60
|
+
fs.rmSync(tempRoot, { recursive: true, force: true });
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('signed-in cookie edits use the common encrypted journal/outbox', () => {
|
|
64
|
+
const secret = 'cloud-cookie-secret';
|
|
65
|
+
const result = mergeCookieJar({ sourceId: 'electron', upserts: [cookie('session', secret)] });
|
|
66
|
+
assert.equal(result.cookies[0].value, secret);
|
|
67
|
+
|
|
68
|
+
const db = openLocalDb();
|
|
69
|
+
const row = db.prepare('SELECT * FROM mutation_journal').get();
|
|
70
|
+
assert.ok(row.shared_resource_id.startsWith('pvr_'));
|
|
71
|
+
assert.equal(row.contract, COOKIE_CLOUD_CONTRACT);
|
|
72
|
+
assert.equal(row.operation_json.includes(secret), false, 'SQLite never stores plaintext cookie values');
|
|
73
|
+
|
|
74
|
+
const [claim] = claimCloudMutations({ database: db });
|
|
75
|
+
assert.equal(claim.envelope.operation.cookie.value, secret, 'the authenticated TLS transport gets the decoded operation');
|
|
76
|
+
assert.deepEqual(getMutation(row.channel_id, row.mutation_id, { database: db }).operation, {
|
|
77
|
+
kind: 'cookie.upsert',
|
|
78
|
+
protected: true,
|
|
79
|
+
});
|
|
80
|
+
assert.equal(JSON.stringify(listEventsAfter(0)).includes(secret), false, 'Local Live events are metadata-only');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('official cookie mutations materialize in order and deletions stay tombstoned', () => {
|
|
84
|
+
mergeCookieJar({ sourceId: 'machine-a', upserts: [cookie('local', 'local-secret')] });
|
|
85
|
+
const db = openLocalDb();
|
|
86
|
+
const [localClaim] = claimCloudMutations({ database: db });
|
|
87
|
+
acknowledgeCloudMutation(localClaim.envelope.resourceId, localClaim.envelope.mutationId, {
|
|
88
|
+
version: 1,
|
|
89
|
+
committedAt: '2026-07-20T20:00:00.000Z',
|
|
90
|
+
}, { database: db });
|
|
91
|
+
|
|
92
|
+
const remoteCookie = cookie('remote', 'remote-secret');
|
|
93
|
+
const key = require('../browser/cookie-jar').cookieKey(remoteCookie);
|
|
94
|
+
const common = {
|
|
95
|
+
resourceId: localClaim.envelope.resourceId,
|
|
96
|
+
authorityEpoch: 1,
|
|
97
|
+
deviceId: 'machine-cookie-b',
|
|
98
|
+
contract: COOKIE_CLOUD_CONTRACT,
|
|
99
|
+
schemaVersion: 1,
|
|
100
|
+
};
|
|
101
|
+
applyCloudCookieMutation({
|
|
102
|
+
version: 2,
|
|
103
|
+
committedAt: '2026-07-20T20:01:00.000Z',
|
|
104
|
+
envelope: {
|
|
105
|
+
...common,
|
|
106
|
+
mutationId: 'mut_cookie_remote_0001',
|
|
107
|
+
baseVersion: 1,
|
|
108
|
+
operationKind: 'cookie.upsert',
|
|
109
|
+
operation: {
|
|
110
|
+
kind: 'cookie.upsert',
|
|
111
|
+
key,
|
|
112
|
+
cookie: remoteCookie,
|
|
113
|
+
sourceId: 'machine-b',
|
|
114
|
+
changedAt: '2026-07-20T20:01:00.000Z',
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
}, { database: db });
|
|
118
|
+
assert.equal(readCookieJar().cookies.some((item) => item.value === 'remote-secret'), true);
|
|
119
|
+
|
|
120
|
+
applyCloudCookieMutation({
|
|
121
|
+
version: 3,
|
|
122
|
+
committedAt: '2026-07-20T20:02:00.000Z',
|
|
123
|
+
envelope: {
|
|
124
|
+
...common,
|
|
125
|
+
mutationId: 'mut_cookie_remote_0002',
|
|
126
|
+
baseVersion: 2,
|
|
127
|
+
operationKind: 'cookie.delete',
|
|
128
|
+
operation: {
|
|
129
|
+
kind: 'cookie.delete',
|
|
130
|
+
key,
|
|
131
|
+
sourceId: 'machine-b',
|
|
132
|
+
changedAt: '2026-07-20T20:02:00.000Z',
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
}, { database: db });
|
|
136
|
+
const deleted = readCookieJar();
|
|
137
|
+
assert.equal(deleted.cookies.some((item) => item.name === 'remote'), false);
|
|
138
|
+
assert.equal(deleted.tombstones.some((item) => item.key === key), true);
|
|
139
|
+
assert.equal(JSON.stringify(listEventsAfter(0)).includes('remote-secret'), false);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('the private resource is deterministic per user and bootstrap is automatic', () => {
|
|
143
|
+
mergeCookieJar({ sourceId: 'headless', upserts: [cookie('bootstrap', 'bootstrap-secret')] });
|
|
144
|
+
const bootstrap = getCookieCloudBootstrap();
|
|
145
|
+
assert.equal(bootstrap.enabled, true);
|
|
146
|
+
assert.equal(bootstrap.resourceId, privateCookieResourceId(userId));
|
|
147
|
+
assert.notEqual(
|
|
148
|
+
bootstrap.resourceId,
|
|
149
|
+
privateCookieResourceId('22222222-2222-4222-8222-222222222222'),
|
|
150
|
+
);
|
|
151
|
+
assert.equal(JSON.parse(Buffer.from(bootstrap.snapshotBase64, 'base64').toString('utf8')).schemaVersion, 1);
|
|
152
|
+
});
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const test = require('node:test');
|
|
4
|
+
const assert = require('node:assert/strict');
|
|
5
|
+
const crypto = require('crypto');
|
|
6
|
+
const http = require('http');
|
|
7
|
+
|
|
8
|
+
const { createSharedRealtimeTunnel } = require('../../../../packages/amalgm/lib/shared-realtime-tunnel');
|
|
9
|
+
|
|
10
|
+
const RESOURCE = 'pvr_private_cookie_tunnel_01';
|
|
11
|
+
const SNAPSHOT = Buffer.from(JSON.stringify({
|
|
12
|
+
schemaVersion: 1,
|
|
13
|
+
version: 0,
|
|
14
|
+
updatedAt: '2026-07-20T20:00:00.000Z',
|
|
15
|
+
records: [],
|
|
16
|
+
tombstones: [],
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
function sleep(ms) {
|
|
20
|
+
return new Promise((resolve) => { setTimeout(resolve, ms); });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function until(predicate, label) {
|
|
24
|
+
for (let i = 0; i < 100; i += 1) {
|
|
25
|
+
if (predicate()) return;
|
|
26
|
+
await sleep(10);
|
|
27
|
+
}
|
|
28
|
+
assert.fail(`timed out waiting for ${label}`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
test('the authenticated tunnel automatically ensures, installs, and joins the private cookie resource', async () => {
|
|
32
|
+
const calls = [];
|
|
33
|
+
const server = http.createServer((req, res) => {
|
|
34
|
+
let body = '';
|
|
35
|
+
req.on('data', (chunk) => { body += chunk; });
|
|
36
|
+
req.on('end', () => {
|
|
37
|
+
calls.push({ method: req.method, path: req.url, body: body ? JSON.parse(body) : null });
|
|
38
|
+
res.setHeader('content-type', 'application/json');
|
|
39
|
+
if (req.url === '/browser/cookie-jar/cloud/bootstrap') {
|
|
40
|
+
res.end(JSON.stringify({
|
|
41
|
+
enabled: true,
|
|
42
|
+
resourceId: RESOURCE,
|
|
43
|
+
contract: 'browser-cookie-jar@1',
|
|
44
|
+
schemaVersion: 1,
|
|
45
|
+
snapshotBase64: SNAPSHOT.toString('base64'),
|
|
46
|
+
snapshotBytes: SNAPSHOT.length,
|
|
47
|
+
snapshotChecksum: crypto.createHash('sha256').update(SNAPSHOT).digest('hex'),
|
|
48
|
+
}));
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
if (req.url === '/state/shared/replicas') {
|
|
52
|
+
res.end(JSON.stringify({ replicas: [{ resourceId: RESOURCE, appliedVersion: 0 }] }));
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (req.url?.startsWith('/state/stream')) {
|
|
56
|
+
res.writeHead(200, { 'content-type': 'text/event-stream' });
|
|
57
|
+
res.write('\n');
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
res.end(JSON.stringify({ mutations: [], promotions: [], attachments: [], ok: true }));
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
await new Promise((resolve) => { server.listen(0, '127.0.0.1', resolve); });
|
|
64
|
+
const port = server.address().port;
|
|
65
|
+
const sent = [];
|
|
66
|
+
const tunnel = createSharedRealtimeTunnel({
|
|
67
|
+
send(frame) { sent.push(frame); return true; },
|
|
68
|
+
sendLarge(frame) { sent.push(frame); return true; },
|
|
69
|
+
localPort: () => port,
|
|
70
|
+
localHeaders: (headers) => ({ ...headers }),
|
|
71
|
+
warn: () => {},
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
tunnel.enable(['shared.mutation.v1', 'private.cookie.v1']);
|
|
76
|
+
await until(() => sent.some((frame) => frame.type === 'private.resource.ensure'), 'private ensure frame');
|
|
77
|
+
const ensure = sent.find((frame) => frame.type === 'private.resource.ensure');
|
|
78
|
+
assert.equal(ensure.resource_id, RESOURCE);
|
|
79
|
+
assert.equal(ensure.contract, 'browser-cookie-jar@1');
|
|
80
|
+
|
|
81
|
+
tunnel.handleFrame({
|
|
82
|
+
type: 'private.resource.ready',
|
|
83
|
+
request_id: ensure.request_id,
|
|
84
|
+
resource_id: RESOURCE,
|
|
85
|
+
authority_epoch: 1,
|
|
86
|
+
head_version: 0,
|
|
87
|
+
snapshot_version: 0,
|
|
88
|
+
snapshot_checksum: crypto.createHash('sha256').update(SNAPSHOT).digest('hex'),
|
|
89
|
+
snapshot_b64: SNAPSHOT.toString('base64'),
|
|
90
|
+
contract: 'browser-cookie-jar@1',
|
|
91
|
+
schema_version: 1,
|
|
92
|
+
duplicate: false,
|
|
93
|
+
});
|
|
94
|
+
await until(
|
|
95
|
+
() => calls.some((call) => call.path === '/browser/cookie-jar/cloud/install'),
|
|
96
|
+
'local private snapshot install',
|
|
97
|
+
);
|
|
98
|
+
const install = calls.find((call) => call.path === '/browser/cookie-jar/cloud/install');
|
|
99
|
+
assert.equal(install.body.resourceId, RESOURCE);
|
|
100
|
+
await until(
|
|
101
|
+
() => sent.filter((frame) => frame.type === 'shared.resource.join' && frame.resource_id === RESOURCE).length >= 2,
|
|
102
|
+
'post-install private resource join',
|
|
103
|
+
);
|
|
104
|
+
} finally {
|
|
105
|
+
tunnel.stop();
|
|
106
|
+
server.close();
|
|
107
|
+
}
|
|
108
|
+
});
|