sealkeep 0.9.0 → 0.11.0
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/ARCHITECTURE.md +163 -14
- package/CHANGELOG.md +223 -1
- package/CONTROL_PLANE.md +2 -2
- package/LICENSE +1 -1
- package/README.md +108 -22
- package/THIRD_PARTY.md +2 -2
- package/THREAT_MODEL.md +23 -4
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/chunk-access.d.ts +26 -4
- package/dist/packages/sealkeep-crypto/src/chunk-access.js +219 -0
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/envelope.d.ts +11 -1
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/envelope.js +58 -21
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/format.d.ts +2 -2
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/index.d.ts +1 -0
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/index.js +1 -0
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/recipients.js +4 -4
- package/dist/packages/sealkeep-crypto/src/squeeze.d.ts +6 -0
- package/dist/packages/sealkeep-crypto/src/squeeze.js +39 -0
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/stream.d.ts +36 -3
- package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/stream.js +196 -37
- package/dist/site/index.html +1808 -1904
- package/dist/site/llms.txt +67 -0
- package/dist/site/trust/architecture-data-flow.html +53 -0
- package/dist/site/trust/audit-roadmap.html +37 -0
- package/dist/site/trust/deployment-responsibility.html +11 -0
- package/dist/site/trust/dpa-sample.html +30 -0
- package/dist/site/trust/release-provenance.html +21 -0
- package/dist/site/trust/subprocessors.html +15 -0
- package/dist/site/trust/threat-model.html +34 -0
- package/dist/site/trust/toms.html +41 -0
- package/dist/site/trust-document.css +32 -0
- package/dist/site/trust.html +73 -0
- package/dist/site/visual/assets/index-8Nxnkc7o.js +207 -0
- package/dist/site/visual/assets/index-oxLy1bpy.css +1 -0
- package/dist/site/visual/index.html +18 -0
- package/dist/site.zip +0 -0
- package/dist/src/activity.d.ts +9 -0
- package/dist/src/activity.js +90 -1
- package/dist/src/adapters.d.ts +175 -5
- package/dist/src/adapters.js +961 -73
- package/dist/src/agent-context.d.ts +135 -0
- package/dist/src/agent-context.js +1059 -0
- package/dist/src/archive-copies.d.ts +47 -0
- package/dist/src/archive-copies.js +179 -0
- package/dist/src/audit.d.ts +1 -1
- package/dist/src/audit.js +29 -4
- package/dist/src/autopilot.d.ts +52 -7
- package/dist/src/autopilot.js +143 -25
- package/dist/src/background-bandwidth.d.ts +46 -0
- package/dist/src/background-bandwidth.js +301 -0
- package/dist/src/background-cpu.d.ts +82 -0
- package/dist/src/background-cpu.js +212 -0
- package/dist/src/background-worker-error.d.ts +12 -0
- package/dist/src/background-worker-error.js +18 -0
- package/dist/src/branding.d.ts +24 -1
- package/dist/src/branding.js +31 -1
- package/dist/src/bridge.d.ts +233 -0
- package/dist/src/bridge.js +604 -0
- package/dist/src/byte-stream.d.ts +91 -0
- package/dist/src/byte-stream.js +385 -0
- package/dist/src/chunk-store.d.ts +41 -8
- package/dist/src/chunk-store.js +161 -65
- package/dist/src/cli.js +1698 -163
- package/dist/src/cloud.d.ts +841 -31
- package/dist/src/cloud.js +3196 -277
- package/dist/src/context-background.d.ts +37 -0
- package/dist/src/context-background.js +309 -0
- package/dist/src/context-drain-child.d.ts +1 -0
- package/dist/src/context-drain-child.js +98 -0
- package/dist/src/context-reader.d.ts +118 -0
- package/dist/src/context-reader.js +447 -0
- package/dist/src/control-plane/auth.d.ts +32 -4
- package/dist/src/control-plane/auth.js +85 -24
- package/dist/src/control-plane/server.js +19 -6
- package/dist/src/control-plane.d.ts +17 -1
- package/dist/src/control-plane.js +32 -6
- package/dist/src/crypto.d.ts +1 -1
- package/dist/src/crypto.js +5 -5
- package/dist/src/daemon-lease.d.ts +70 -0
- package/dist/src/daemon-lease.js +420 -0
- package/dist/src/daemon.d.ts +94 -1
- package/dist/src/daemon.js +1082 -105
- package/dist/src/darwin-service-policy.d.ts +41 -0
- package/dist/src/darwin-service-policy.js +60 -0
- package/dist/src/dashboard-cli.js +15 -15
- package/dist/src/device-authorization.d.ts +37 -0
- package/dist/src/device-authorization.js +199 -0
- package/dist/src/device-enrollment.d.ts +91 -0
- package/dist/src/device-enrollment.js +349 -0
- package/dist/src/disk.d.ts +17 -12
- package/dist/src/disk.js +43 -17
- package/dist/src/doctor.d.ts +35 -1
- package/dist/src/doctor.js +316 -41
- package/dist/src/durable-ticket-lock.d.ts +24 -0
- package/dist/src/durable-ticket-lock.js +232 -0
- package/dist/src/enroll.d.ts +1 -1
- package/dist/src/enroll.js +13 -7
- package/dist/src/env.d.ts +10 -1
- package/dist/src/env.js +11 -3
- package/dist/src/errors.d.ts +8 -8
- package/dist/src/errors.js +6 -6
- package/dist/src/flush.d.ts +12 -0
- package/dist/src/flush.js +37 -0
- package/dist/src/heartbeat.d.ts +86 -12
- package/dist/src/heartbeat.js +415 -29
- package/dist/src/index-background-watchdog.d.ts +1 -0
- package/dist/src/index-background-watchdog.js +94 -0
- package/dist/src/index-background-work.d.ts +21 -0
- package/dist/src/index-background-work.js +25 -0
- package/dist/src/index-background.d.ts +64 -0
- package/dist/src/index-background.js +394 -0
- package/dist/src/index-build-child.d.ts +1 -0
- package/dist/src/index-build-child.js +109 -0
- package/dist/src/index-manifest.d.ts +52 -0
- package/dist/src/index-manifest.js +444 -0
- package/dist/src/index-publication-proof.d.ts +84 -0
- package/dist/src/index-publication-proof.js +380 -0
- package/dist/src/index-publication-state.d.ts +149 -0
- package/dist/src/index-publication-state.js +696 -0
- package/dist/src/index-publication-verifier.d.ts +89 -0
- package/dist/src/index-publication-verifier.js +341 -0
- package/dist/src/index-publish.d.ts +62 -0
- package/dist/src/index-publish.js +540 -0
- package/dist/src/index-scratch-cleanup.d.ts +19 -0
- package/dist/src/index-scratch-cleanup.js +166 -0
- package/dist/src/index-segment-types.d.ts +132 -0
- package/dist/src/index-segment-types.js +21 -0
- package/dist/src/index-segments.d.ts +9 -0
- package/dist/src/index-segments.js +516 -0
- package/dist/src/index-store.d.ts +123 -0
- package/dist/src/index-store.js +495 -0
- package/dist/src/index-sync.d.ts +91 -15
- package/dist/src/index-sync.js +286 -53
- package/dist/src/index-upgrade-publication.d.ts +30 -0
- package/dist/src/index-upgrade-publication.js +179 -0
- package/dist/src/integration-manager.d.ts +32 -0
- package/dist/src/integration-manager.js +394 -0
- package/dist/src/leakscan.js +1 -1
- package/dist/src/local-api.d.ts +56 -15
- package/dist/src/local-api.js +4979 -541
- package/dist/src/machine-settings.d.ts +51 -0
- package/dist/src/machine-settings.js +166 -0
- package/dist/src/managed-chunks.d.ts +5 -2
- package/dist/src/managed-chunks.js +14 -14
- package/dist/src/mcp-install.d.ts +11 -9
- package/dist/src/mcp-install.js +73 -28
- package/dist/src/mcp-workspace.d.ts +18 -0
- package/dist/src/mcp-workspace.js +50 -0
- package/dist/src/mcp.js +294 -25
- package/dist/src/migrate.js +27 -21
- package/dist/src/notify.d.ts +1 -1
- package/dist/src/notify.js +5 -5
- package/dist/src/offload.d.ts +201 -14
- package/dist/src/offload.js +1848 -140
- package/dist/src/onboarding.d.ts +8 -1
- package/dist/src/onboarding.js +4 -4
- package/dist/src/packages.d.ts +2 -2
- package/dist/src/packages.js +10 -2
- package/dist/src/passkey.d.ts +0 -1
- package/dist/src/passkey.js +2 -7
- package/dist/src/password-lock.d.ts +2 -2
- package/dist/src/password-lock.js +6 -6
- package/dist/src/paths.d.ts +2 -0
- package/dist/src/paths.js +2 -0
- package/dist/src/presence.d.ts +86 -0
- package/dist/src/presence.js +240 -0
- package/dist/src/progress-deadline.d.ts +21 -0
- package/dist/src/progress-deadline.js +91 -0
- package/dist/src/project-repair.d.ts +55 -0
- package/dist/src/project-repair.js +131 -0
- package/dist/src/providers/gcs.d.ts +28 -7
- package/dist/src/providers/gcs.js +35 -24
- package/dist/src/providers/gdrive.d.ts +71 -8
- package/dist/src/providers/gdrive.js +223 -50
- package/dist/src/providers/index.d.ts +11 -3
- package/dist/src/providers/index.js +30 -10
- package/dist/src/providers/s3.d.ts +30 -8
- package/dist/src/providers/s3.js +41 -30
- package/dist/src/providers/safe-storage-fetch.d.ts +12 -0
- package/dist/src/providers/safe-storage-fetch.js +72 -0
- package/dist/src/queue.d.ts +136 -19
- package/dist/src/queue.js +862 -96
- package/dist/src/reclaim-transaction.d.ts +156 -0
- package/dist/src/reclaim-transaction.js +1027 -0
- package/dist/src/recovery-codes.d.ts +32 -0
- package/dist/src/recovery-codes.js +338 -0
- package/dist/src/recovery.js +12 -9
- package/dist/src/rehydrate.d.ts +25 -22
- package/dist/src/rehydrate.js +319 -23
- package/dist/src/restore.d.ts +57 -4
- package/dist/src/restore.js +272 -36
- package/dist/src/resume-stub.d.ts +92 -0
- package/dist/src/resume-stub.js +417 -0
- package/dist/src/retention.d.ts +98 -7
- package/dist/src/retention.js +1066 -62
- package/dist/src/rotate.js +3 -3
- package/dist/src/search.d.ts +566 -8
- package/dist/src/search.js +5897 -290
- package/dist/src/secrets.d.ts +51 -7
- package/dist/src/secrets.js +316 -24
- package/dist/src/service.d.ts +49 -11
- package/dist/src/service.js +776 -35
- package/dist/src/share.js +3 -3
- package/dist/src/shared-spaces.d.ts +98 -0
- package/dist/src/shared-spaces.js +214 -0
- package/dist/src/source-reader.d.ts +73 -0
- package/dist/src/source-reader.js +715 -0
- package/dist/src/spool.d.ts +1 -1
- package/dist/src/spool.js +1 -1
- package/dist/src/start-tui.js +2 -1
- package/dist/src/start.js +2 -2
- package/dist/src/storage-endpoint.d.ts +21 -0
- package/dist/src/storage-endpoint.js +122 -0
- package/dist/src/storage-setup.js +12 -12
- package/dist/src/storage-targets.d.ts +109 -6
- package/dist/src/storage-targets.js +975 -67
- package/dist/src/stream-to-cloud.d.ts +5 -1
- package/dist/src/stream-to-cloud.js +34 -14
- package/dist/src/sync-rules.d.ts +31 -6
- package/dist/src/sync-rules.js +153 -14
- package/dist/src/team-backfill-scheduling.d.ts +8 -0
- package/dist/src/team-backfill-scheduling.js +33 -0
- package/dist/src/team-backfill.d.ts +116 -0
- package/dist/src/team-backfill.js +1429 -0
- package/dist/src/team-index-cache.d.ts +16 -0
- package/dist/src/team-index-cache.js +152 -0
- package/dist/src/team-offboarding.d.ts +38 -0
- package/dist/src/team-offboarding.js +1043 -0
- package/dist/src/team-presence.d.ts +127 -0
- package/dist/src/team-presence.js +904 -0
- package/dist/src/team-publication-policy.d.ts +20 -0
- package/dist/src/team-publication-policy.js +140 -0
- package/dist/src/team-realtime.d.ts +68 -0
- package/dist/src/team-realtime.js +816 -0
- package/dist/src/team-source-facts-cache.d.ts +23 -0
- package/dist/src/team-source-facts-cache.js +255 -0
- package/dist/src/trash.d.ts +1 -1
- package/dist/src/trash.js +2 -2
- package/dist/src/tui.js +11 -12
- package/dist/src/types.d.ts +173 -7
- package/dist/src/types.js +20 -0
- package/dist/src/ui-server.d.ts +163 -35
- package/dist/src/ui-server.js +712 -72
- package/dist/src/ui.d.ts +1 -2
- package/dist/src/ui.js +1 -2
- package/dist/src/upload.d.ts +27 -0
- package/dist/src/upload.js +383 -43
- package/dist/src/vault.d.ts +226 -30
- package/dist/src/vault.js +1776 -192
- package/dist/src/watcher.d.ts +7 -1
- package/dist/src/watcher.js +198 -55
- package/dist/src/worker.d.ts +27 -3
- package/dist/src/worker.js +274 -55
- package/package.json +33 -12
- package/scripts/native-reboot-rehearsal.mjs +90 -0
- package/web/app.js +6032 -343
- package/web/bootstrap.js +17 -0
- package/web/index.html +255 -57
- package/web/rail.js +317 -40
- package/web/retention.html +2 -2
- package/web/rules-view.js +188 -16
- package/web/sessions-view.js +485 -62
- package/web/sessions.html +2 -2
- package/web/setup-api.js +152 -29
- package/web/setup-logic.js +68 -9
- package/web/setup.html +113 -44
- package/web/setup.js +604 -71
- package/web/style.css +513 -98
- package/dist/packages/vaultline-crypto/src/chunk-access.js +0 -93
- /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/aead.d.ts +0 -0
- /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/aead.js +0 -0
- /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/format.js +0 -0
- /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/recipients.d.ts +0 -0
- /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/sha256-stream.d.ts +0 -0
- /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/sha256-stream.js +0 -0
|
@@ -0,0 +1,816 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Push wake-ups for encrypted cross-account presence.
|
|
3
|
+
*
|
|
4
|
+
* REST remains the durable append log and cursor catch-up path. The daemon
|
|
5
|
+
* keeps one Socket.IO connection per machine, multiplexes every locally bound
|
|
6
|
+
* opaque team-space id over it, and writes received ciphertext rows to a
|
|
7
|
+
* bounded local inbox. Agent hooks read that inbox at tool boundaries, so a
|
|
8
|
+
* busy team does not turn "is anyone already touching this?" into continuous
|
|
9
|
+
* HTTP polling.
|
|
10
|
+
*
|
|
11
|
+
* No user setting is involved: the realtime origin/path are derived from the
|
|
12
|
+
* same hosted API URL the signed-in client already uses, and the bearer stays
|
|
13
|
+
* in the native keystore/in-memory Socket.IO handshake.
|
|
14
|
+
*/
|
|
15
|
+
import { link, mkdir, open, readFile, readdir, rename, unlink, writeFile } from "node:fs/promises";
|
|
16
|
+
import { dirname, join } from "node:path";
|
|
17
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
18
|
+
import { io } from "socket.io-client";
|
|
19
|
+
import { cloudAuthGeneration, cloudBackoffUntil, cloudToken, cloudUrl, listTeamSpaces, rememberCloudBackoff, } from "./cloud.js";
|
|
20
|
+
import { readConfig, revokeTeamSpaceBindings, updateTeamSpaceBindings } from "./vault.js";
|
|
21
|
+
const SPACE_RE = /^[A-Za-z0-9_-]{43}$/;
|
|
22
|
+
const SENDER_RE = /^[a-f0-9]{8}$/;
|
|
23
|
+
const CHANNEL_PREFIX = "sealkeep:team:";
|
|
24
|
+
const INBOX_LIMIT = 1_000;
|
|
25
|
+
const PUSH_QUEUE_LIMIT = 256;
|
|
26
|
+
const COMPACT_EVERY = 128;
|
|
27
|
+
const DEFAULT_REFRESH_MS = 5_000;
|
|
28
|
+
const TOKEN_RECHECK_MS = 5 * 60_000;
|
|
29
|
+
const STATUS_HEARTBEAT_MS = 30_000;
|
|
30
|
+
const HEALTH_MAX_AGE_MS = 90_000;
|
|
31
|
+
const SUBSCRIBE_TIMEOUT_MS = 10_000;
|
|
32
|
+
const inboxDir = (dataDir, spaceKey) => join(dataDir, "runtime", "team-presence-inbox", createHash("sha256").update(spaceKey).digest("hex"));
|
|
33
|
+
const rowName = (id) => `row-${String(id).padStart(16, "0")}.json`;
|
|
34
|
+
const ROW_FILE_RE = /^row-([0-9]{16})\.json$/;
|
|
35
|
+
const DROPPED_FILE_RE = /^dropped-([0-9]{16})\.marker$/;
|
|
36
|
+
const statusPath = (dataDir) => join(dataDir, "runtime", "team-realtime.json");
|
|
37
|
+
const writeQueues = new Map();
|
|
38
|
+
const compactCounters = new Map();
|
|
39
|
+
function queued(path, work) {
|
|
40
|
+
const prior = writeQueues.get(path) ?? Promise.resolve();
|
|
41
|
+
const next = prior.catch(() => undefined).then(work);
|
|
42
|
+
// The queue tail is bookkeeping, not a second observer of the operation.
|
|
43
|
+
// Keep it fulfilled even when `next` rejects; the caller owns that rejection.
|
|
44
|
+
// A rejecting `.finally()` tail caused an unhandled rejection during cleanup.
|
|
45
|
+
const tracked = next.then(() => undefined, () => undefined).finally(() => {
|
|
46
|
+
if (writeQueues.get(path) === tracked)
|
|
47
|
+
writeQueues.delete(path);
|
|
48
|
+
});
|
|
49
|
+
writeQueues.set(path, tracked);
|
|
50
|
+
return next;
|
|
51
|
+
}
|
|
52
|
+
async function atomicJson(path, value) {
|
|
53
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
54
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
55
|
+
await writeFile(temporary, JSON.stringify(value, null, 2) + "\n", { mode: 0o600 });
|
|
56
|
+
await rename(temporary, path);
|
|
57
|
+
}
|
|
58
|
+
function normalizedRow(value) {
|
|
59
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
60
|
+
return null;
|
|
61
|
+
const row = value;
|
|
62
|
+
const id = Number(row.id);
|
|
63
|
+
const sequence = Number(row.sequence);
|
|
64
|
+
const keyVersion = Number(row.key_version);
|
|
65
|
+
const sender = typeof row.sender === "string" ? row.sender : "";
|
|
66
|
+
const ciphertext = typeof row.ciphertext === "string" ? row.ciphertext : "";
|
|
67
|
+
if (!Number.isSafeInteger(id) || id <= 0)
|
|
68
|
+
return null;
|
|
69
|
+
if (!Number.isSafeInteger(sequence) || sequence < 0)
|
|
70
|
+
return null;
|
|
71
|
+
if (!Number.isSafeInteger(keyVersion) || keyVersion <= 0)
|
|
72
|
+
return null;
|
|
73
|
+
if (!SENDER_RE.test(sender) || !ciphertext || ciphertext.length > 4_096)
|
|
74
|
+
return null;
|
|
75
|
+
const createdAt = typeof row.created_at === "string" && row.created_at.length <= 80 ? row.created_at : undefined;
|
|
76
|
+
const spaceId = Number(row.space_id);
|
|
77
|
+
const accountId = Number(row.account_id);
|
|
78
|
+
return {
|
|
79
|
+
id, sender, sequence, key_version: keyVersion, ciphertext,
|
|
80
|
+
...(createdAt ? { created_at: createdAt } : {}),
|
|
81
|
+
...(Number.isSafeInteger(spaceId) ? { space_id: spaceId } : {}),
|
|
82
|
+
...(Number.isSafeInteger(accountId) && accountId > 0 ? { account_id: accountId } : {}),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
async function markRealtimeGap(dataDir, spaceKey, droppedThrough) {
|
|
86
|
+
if (!SPACE_RE.test(spaceKey) || !Number.isSafeInteger(droppedThrough) || droppedThrough <= 0)
|
|
87
|
+
return;
|
|
88
|
+
const dir = inboxDir(dataDir, spaceKey);
|
|
89
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
90
|
+
const marker = `dropped-${String(droppedThrough).padStart(16, "0")}.marker`;
|
|
91
|
+
try {
|
|
92
|
+
await (await open(join(dir, marker), "wx", 0o600)).close();
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
if (error.code !== "EEXIST")
|
|
96
|
+
throw error;
|
|
97
|
+
}
|
|
98
|
+
// Immutable, monotonically named markers avoid a cross-process lost-update
|
|
99
|
+
// race. Once the new high-water marker exists, lower ones are redundant.
|
|
100
|
+
const entries = await readdir(dir).catch(() => []);
|
|
101
|
+
await Promise.all(entries.flatMap((name) => {
|
|
102
|
+
const match = DROPPED_FILE_RE.exec(name);
|
|
103
|
+
return match && Number(match[1]) < droppedThrough ? [unlink(join(dir, name)).catch(() => undefined)] : [];
|
|
104
|
+
}));
|
|
105
|
+
}
|
|
106
|
+
async function compactInbox(dataDir, spaceKey) {
|
|
107
|
+
const dir = inboxDir(dataDir, spaceKey);
|
|
108
|
+
const entries = await readdir(dir).catch(() => []);
|
|
109
|
+
const rows = entries.flatMap((name) => {
|
|
110
|
+
const match = ROW_FILE_RE.exec(name);
|
|
111
|
+
return match ? [{ id: Number(match[1]), name }] : [];
|
|
112
|
+
}).filter((item) => Number.isSafeInteger(item.id)).sort((a, b) => a.id - b.id);
|
|
113
|
+
if (rows.length <= INBOX_LIMIT)
|
|
114
|
+
return;
|
|
115
|
+
const removing = rows.slice(0, rows.length - INBOX_LIMIT);
|
|
116
|
+
await markRealtimeGap(dataDir, spaceKey, removing[removing.length - 1].id);
|
|
117
|
+
await Promise.all(removing.map((item) => unlink(join(dir, item.name)).catch(() => undefined)));
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Cache one validated wire row in its own immutable file. This is O(event),
|
|
121
|
+
* not O(all spaces × inbox size), and atomic hard links make overlapping daemon handoffs
|
|
122
|
+
* converge on the first complete row instead of overwriting one another.
|
|
123
|
+
*/
|
|
124
|
+
export async function cacheRealtimeTeamPresence(dataDir, spaceKey, value) {
|
|
125
|
+
if (!SPACE_RE.test(spaceKey))
|
|
126
|
+
return false;
|
|
127
|
+
const row = normalizedRow(value);
|
|
128
|
+
if (!row)
|
|
129
|
+
return false;
|
|
130
|
+
const dir = inboxDir(dataDir, spaceKey);
|
|
131
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
132
|
+
const path = join(dir, rowName(row.id));
|
|
133
|
+
const temporary = join(dir, `.${rowName(row.id)}.${randomUUID()}.tmp`);
|
|
134
|
+
await writeFile(temporary, JSON.stringify(row) + "\n", { mode: 0o600 });
|
|
135
|
+
try {
|
|
136
|
+
await link(temporary, path);
|
|
137
|
+
}
|
|
138
|
+
catch (error) {
|
|
139
|
+
if (error.code === "EEXIST") {
|
|
140
|
+
await unlink(temporary).catch(() => undefined);
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
await unlink(temporary).catch(() => undefined);
|
|
144
|
+
throw error;
|
|
145
|
+
}
|
|
146
|
+
await unlink(temporary).catch(() => undefined);
|
|
147
|
+
const key = `${dataDir}\0${spaceKey}`;
|
|
148
|
+
const count = compactCounters.get(key);
|
|
149
|
+
if (count === undefined || count + 1 >= COMPACT_EVERY) {
|
|
150
|
+
compactCounters.set(key, 0);
|
|
151
|
+
await compactInbox(dataDir, spaceKey);
|
|
152
|
+
}
|
|
153
|
+
else
|
|
154
|
+
compactCounters.set(key, count + 1);
|
|
155
|
+
return true;
|
|
156
|
+
}
|
|
157
|
+
/** Read only local ciphertext rows newer than a durable cursor. */
|
|
158
|
+
export async function readRealtimeTeamPresence(dataDir, spaceKey, after) {
|
|
159
|
+
const cursorBefore = Number.isSafeInteger(after) ? Math.max(0, Math.trunc(after)) : 0;
|
|
160
|
+
if (!SPACE_RE.test(spaceKey))
|
|
161
|
+
return { rows: [], cursor: cursorBefore, gap: false };
|
|
162
|
+
const dir = inboxDir(dataDir, spaceKey);
|
|
163
|
+
const entries = await readdir(dir).catch(() => []);
|
|
164
|
+
const allRows = entries.flatMap((name) => {
|
|
165
|
+
const match = ROW_FILE_RE.exec(name);
|
|
166
|
+
return match ? [{ id: Number(match[1]), name }] : [];
|
|
167
|
+
}).filter((item) => Number.isSafeInteger(item.id)).sort((a, b) => a.id - b.id);
|
|
168
|
+
const markerHighWater = entries.reduce((max, name) => {
|
|
169
|
+
const match = DROPPED_FILE_RE.exec(name);
|
|
170
|
+
return match ? Math.max(max, Number(match[1])) : max;
|
|
171
|
+
}, 0);
|
|
172
|
+
const overflowHighWater = allRows.length > INBOX_LIMIT ? allRows[allRows.length - INBOX_LIMIT - 1].id : 0;
|
|
173
|
+
const retained = allRows.slice(-INBOX_LIMIT).filter((item) => item.id > cursorBefore);
|
|
174
|
+
const rows = (await Promise.all(retained.map(async (item) => {
|
|
175
|
+
try {
|
|
176
|
+
const row = normalizedRow(JSON.parse(await readFile(join(dir, item.name), "utf8")));
|
|
177
|
+
return row?.id === item.id ? row : null;
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
}))).filter((row) => row !== null).sort((a, b) => a.id - b.id);
|
|
183
|
+
return {
|
|
184
|
+
rows,
|
|
185
|
+
cursor: rows.reduce((max, row) => Math.max(max, row.id), cursorBefore),
|
|
186
|
+
gap: cursorBefore < Math.max(markerHighWater, overflowHighWater),
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
export function teamRealtimeChannel(routeKey) {
|
|
190
|
+
return `${CHANNEL_PREFIX}${routeKey}`;
|
|
191
|
+
}
|
|
192
|
+
export function teamRealtimeEndpoint(env = process.env) {
|
|
193
|
+
const api = new URL(cloudUrl(env));
|
|
194
|
+
const pathname = api.pathname.replace(/\/+$/, "");
|
|
195
|
+
const prefix = pathname.endsWith("/api") ? pathname.slice(0, -4) : "";
|
|
196
|
+
return { origin: api.origin, path: `${prefix}/realtime` || "/realtime" };
|
|
197
|
+
}
|
|
198
|
+
async function writeStatus(dataDir, status) {
|
|
199
|
+
await queued(statusPath(dataDir), () => atomicJson(statusPath(dataDir), status));
|
|
200
|
+
}
|
|
201
|
+
export async function readTeamRealtimeStatus(dataDir) {
|
|
202
|
+
try {
|
|
203
|
+
const value = JSON.parse(await readFile(statusPath(dataDir), "utf8"));
|
|
204
|
+
if (value.version !== 1 || typeof value.connected !== "boolean" || typeof value.updatedAt !== "string" || !Array.isArray(value.subscribedSpaces))
|
|
205
|
+
return null;
|
|
206
|
+
return {
|
|
207
|
+
version: 1,
|
|
208
|
+
connected: value.connected,
|
|
209
|
+
updatedAt: value.updatedAt,
|
|
210
|
+
subscribedSpaces: value.subscribedSpaces.filter((item) => typeof item === "string" && SPACE_RE.test(item)),
|
|
211
|
+
...(typeof value.connectionId === "string" ? { connectionId: value.connectionId.slice(0, 80) } : {}),
|
|
212
|
+
...(typeof value.lastError === "string" ? { lastError: value.lastError.slice(0, 240) } : {}),
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
export function teamRealtimeStatusHealthy(status, spaceKey, options = {}) {
|
|
220
|
+
if (!status?.connected || !status.subscribedSpaces.includes(spaceKey))
|
|
221
|
+
return false;
|
|
222
|
+
const age = (options.now ?? Date.now()) - Date.parse(status.updatedAt);
|
|
223
|
+
return Number.isFinite(age) && age >= 0 && age <= (options.maxAgeMs ?? HEALTH_MAX_AGE_MS);
|
|
224
|
+
}
|
|
225
|
+
export async function teamRealtimeHealthy(dataDir, spaceKey, options = {}) {
|
|
226
|
+
return teamRealtimeStatusHealthy(await readTeamRealtimeStatus(dataDir), spaceKey, options);
|
|
227
|
+
}
|
|
228
|
+
function errorLine(error) {
|
|
229
|
+
return (error instanceof Error ? error.message : String(error)).replace(/[\r\n]+/g, " ").slice(0, 240);
|
|
230
|
+
}
|
|
231
|
+
const objectOf = (value) => value && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
232
|
+
function numberOf(values) {
|
|
233
|
+
for (const value of values) {
|
|
234
|
+
const numeric = Number(value);
|
|
235
|
+
if (Number.isFinite(numeric) && numeric >= 0)
|
|
236
|
+
return numeric;
|
|
237
|
+
}
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
/** Normalize Socket.IO middleware errors and Engine.IO HTTP 429 responses. */
|
|
241
|
+
function realtimeRateLimit(error) {
|
|
242
|
+
const root = objectOf(error);
|
|
243
|
+
const data = objectOf(root?.data);
|
|
244
|
+
const context = objectOf(root?.context);
|
|
245
|
+
let body = null;
|
|
246
|
+
const responseText = typeof context?.responseText === "string" ? context.responseText : "";
|
|
247
|
+
if (responseText) {
|
|
248
|
+
try {
|
|
249
|
+
body = objectOf(JSON.parse(responseText));
|
|
250
|
+
}
|
|
251
|
+
catch { /* a proxy may return a plain-text 429 */ }
|
|
252
|
+
}
|
|
253
|
+
const bodyError = objectOf(body?.error);
|
|
254
|
+
const dataError = objectOf(data?.error);
|
|
255
|
+
const records = [root, data, dataError, context, body, bodyError].filter((item) => item !== null);
|
|
256
|
+
const status = numberOf(records.flatMap((item) => [item.status, item.statusCode, item.httpStatus]));
|
|
257
|
+
const words = records.flatMap((item) => [item.code, item.message, item.error])
|
|
258
|
+
.filter((item) => typeof item === "string").join(" ");
|
|
259
|
+
if (status !== 429 && !/(?:^|\D)429(?:\D|$)|rate[_ -]?limit|request allowance.*(?:exhausted|reached)|PROJECT_API_REQUEST_LIMIT_EXCEEDED/i.test(words))
|
|
260
|
+
return null;
|
|
261
|
+
const milliseconds = numberOf(records.flatMap((item) => [item.retryAfterMs, item.retry_after_ms]));
|
|
262
|
+
if (milliseconds !== null)
|
|
263
|
+
return { retryAfterMs: Math.max(1_000, milliseconds) };
|
|
264
|
+
const seconds = numberOf(records.flatMap((item) => [item.retryAfterSeconds, item.retry_after_seconds, item.retryAfter]));
|
|
265
|
+
if (seconds !== null)
|
|
266
|
+
return { retryAfterMs: Math.max(1_000, seconds * 1_000) };
|
|
267
|
+
const resetAt = records.flatMap((item) => [item.resetAt, item.reset_at]).find((item) => typeof item === "string");
|
|
268
|
+
if (typeof resetAt === "string" && Number.isFinite(Date.parse(resetAt))) {
|
|
269
|
+
return { retryAfterMs: Math.max(1_000, Date.parse(resetAt) - Date.now()) };
|
|
270
|
+
}
|
|
271
|
+
try {
|
|
272
|
+
const header = typeof context?.getResponseHeader === "function"
|
|
273
|
+
? String(context.getResponseHeader("retry-after") ?? "") : "";
|
|
274
|
+
const headerSeconds = Number(header);
|
|
275
|
+
if (Number.isFinite(headerSeconds) && headerSeconds >= 0)
|
|
276
|
+
return { retryAfterMs: Math.max(1_000, headerSeconds * 1_000) };
|
|
277
|
+
const headerAt = Date.parse(header);
|
|
278
|
+
if (Number.isFinite(headerAt))
|
|
279
|
+
return { retryAfterMs: Math.max(1_000, headerAt - Date.now()) };
|
|
280
|
+
}
|
|
281
|
+
catch { /* an Engine.IO context need not expose response headers */ }
|
|
282
|
+
return { retryAfterMs: 60_000 };
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Start the package-owned machine listener. It is deliberately fail-open:
|
|
286
|
+
* missing sign-in, no team spaces, network failures, and auth expiry leave the
|
|
287
|
+
* durable REST path intact and never stop the preservation daemon.
|
|
288
|
+
*/
|
|
289
|
+
export async function startTeamRealtime(dataDir, options = {}) {
|
|
290
|
+
const endpoint = teamRealtimeEndpoint();
|
|
291
|
+
// Public status and local inboxes are keyed by the stable logical space id.
|
|
292
|
+
// The values are rotatable, unguessable Socket.IO room ids.
|
|
293
|
+
const desired = new Map();
|
|
294
|
+
const subscribed = new Map();
|
|
295
|
+
const subscribing = new Map();
|
|
296
|
+
let socket = null;
|
|
297
|
+
let socketGeneration = 0;
|
|
298
|
+
let tokenInUse = null;
|
|
299
|
+
let lastTokenCheckAt = 0;
|
|
300
|
+
let nextTokenAttemptAt = 0;
|
|
301
|
+
let authGeneration = null;
|
|
302
|
+
let connectionId;
|
|
303
|
+
let closed = false;
|
|
304
|
+
let reconnectDelayMs = 500;
|
|
305
|
+
let reconnectTimer;
|
|
306
|
+
let refresh;
|
|
307
|
+
let refreshInFlight;
|
|
308
|
+
let closeInFlight;
|
|
309
|
+
let lastError;
|
|
310
|
+
let readyResolved = false;
|
|
311
|
+
let readyPersisting = false;
|
|
312
|
+
let resolveReady;
|
|
313
|
+
const ready = new Promise((resolve) => { resolveReady = resolve; });
|
|
314
|
+
const inboxWork = new Map();
|
|
315
|
+
const activeDrains = new Set();
|
|
316
|
+
let reconciliation = null;
|
|
317
|
+
const routeOf = (binding) => binding.realtimeKey && SPACE_RE.test(binding.realtimeKey) ? binding.realtimeKey : binding.spaceKey;
|
|
318
|
+
const sameRoutes = (left, right) => left.size === right.size && [...left].every(([spaceKey, routeKey]) => right.get(spaceKey) === routeKey);
|
|
319
|
+
/** Reload local bindings only; this is the cheap five-second discovery path. */
|
|
320
|
+
const reloadDesired = async () => {
|
|
321
|
+
const config = await readConfig(dataDir);
|
|
322
|
+
const next = new Map();
|
|
323
|
+
for (const binding of Object.values(config.teamSpaces ?? {})) {
|
|
324
|
+
if (!SPACE_RE.test(binding.spaceKey))
|
|
325
|
+
continue;
|
|
326
|
+
next.set(binding.spaceKey, routeOf(binding));
|
|
327
|
+
}
|
|
328
|
+
const changed = !sameRoutes(desired, next);
|
|
329
|
+
desired.clear();
|
|
330
|
+
for (const [spaceKey, routeKey] of next)
|
|
331
|
+
desired.set(spaceKey, routeKey);
|
|
332
|
+
return changed;
|
|
333
|
+
};
|
|
334
|
+
/** Best-effort removal of the revoked epoch key from this machine's keystore. */
|
|
335
|
+
const forgetRevokedFeed = async (spaceKey) => {
|
|
336
|
+
try {
|
|
337
|
+
const core = await import("./team-presence.js");
|
|
338
|
+
const forget = core.forgetTeamSpaceFeed;
|
|
339
|
+
if (typeof forget === "function") {
|
|
340
|
+
await forget(dataDir, spaceKey, options.preferred);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
catch (error) {
|
|
344
|
+
// The binding is already revoked. Keep that security boundary even if a
|
|
345
|
+
// platform keystore temporarily refuses deletion; surface it for repair.
|
|
346
|
+
lastError = `team feed revoke: ${errorLine(error)}`;
|
|
347
|
+
}
|
|
348
|
+
};
|
|
349
|
+
/**
|
|
350
|
+
* One authoritative REST reconciliation per connection generation or
|
|
351
|
+
* rotation notice. It is deliberately not called by the steady refresh
|
|
352
|
+
* timer: Socket.IO remains the wake-up path and REST remains bounded.
|
|
353
|
+
*/
|
|
354
|
+
const reconcileMemberships = () => {
|
|
355
|
+
if (reconciliation)
|
|
356
|
+
return reconciliation;
|
|
357
|
+
const work = (async () => {
|
|
358
|
+
const before = await readConfig(dataDir);
|
|
359
|
+
const localBySpace = new Map();
|
|
360
|
+
for (const binding of Object.values(before.teamSpaces ?? {})) {
|
|
361
|
+
if (SPACE_RE.test(binding.spaceKey) && !localBySpace.has(binding.spaceKey))
|
|
362
|
+
localBySpace.set(binding.spaceKey, binding);
|
|
363
|
+
}
|
|
364
|
+
if (!localBySpace.size) {
|
|
365
|
+
const routingChanged = await reloadDesired();
|
|
366
|
+
return { bindingChanged: false, routingChanged };
|
|
367
|
+
}
|
|
368
|
+
const hosted = await listTeamSpaces(dataDir, options.preferred);
|
|
369
|
+
const hostedBySpace = new Map(hosted.flatMap((raw) => {
|
|
370
|
+
const row = raw;
|
|
371
|
+
return SPACE_RE.test(row.space_key) ? [[row.space_key, row]] : [];
|
|
372
|
+
}));
|
|
373
|
+
let bindingChanged = false;
|
|
374
|
+
for (const [spaceKey, local] of localBySpace) {
|
|
375
|
+
const remote = hostedBySpace.get(spaceKey);
|
|
376
|
+
if (!remote) {
|
|
377
|
+
await revokeTeamSpaceBindings(dataDir, spaceKey);
|
|
378
|
+
await forgetRevokedFeed(spaceKey);
|
|
379
|
+
bindingChanged = true;
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
const patch = {};
|
|
383
|
+
const keyVersion = Number(remote.key_version);
|
|
384
|
+
if (Number.isSafeInteger(keyVersion) && keyVersion > 0)
|
|
385
|
+
patch.keyVersion = keyVersion;
|
|
386
|
+
if (remote.role === "owner" || remote.role === "member")
|
|
387
|
+
patch.role = remote.role;
|
|
388
|
+
if (typeof remote.realtime_key === "string" && SPACE_RE.test(remote.realtime_key))
|
|
389
|
+
patch.realtimeKey = remote.realtime_key;
|
|
390
|
+
if ((patch.keyVersion !== undefined && patch.keyVersion !== local.keyVersion)
|
|
391
|
+
|| (patch.role !== undefined && patch.role !== local.role)
|
|
392
|
+
|| (patch.realtimeKey !== undefined && patch.realtimeKey !== local.realtimeKey))
|
|
393
|
+
bindingChanged = true;
|
|
394
|
+
await updateTeamSpaceBindings(dataDir, spaceKey, patch);
|
|
395
|
+
}
|
|
396
|
+
const routingChanged = await reloadDesired();
|
|
397
|
+
return { bindingChanged, routingChanged };
|
|
398
|
+
})();
|
|
399
|
+
const tracked = work.finally(() => {
|
|
400
|
+
if (reconciliation === tracked)
|
|
401
|
+
reconciliation = null;
|
|
402
|
+
});
|
|
403
|
+
reconciliation = tracked;
|
|
404
|
+
return tracked;
|
|
405
|
+
};
|
|
406
|
+
const enqueuePresence = (spaceKey, value) => {
|
|
407
|
+
const row = normalizedRow(value);
|
|
408
|
+
if (!row)
|
|
409
|
+
return;
|
|
410
|
+
const work = inboxWork.get(spaceKey) ?? { rows: [], droppedThrough: 0, running: false };
|
|
411
|
+
inboxWork.set(spaceKey, work);
|
|
412
|
+
if (work.rows.length >= PUSH_QUEUE_LIMIT)
|
|
413
|
+
work.droppedThrough = Math.max(work.droppedThrough, row.id);
|
|
414
|
+
else
|
|
415
|
+
work.rows.push(row);
|
|
416
|
+
if (work.running)
|
|
417
|
+
return;
|
|
418
|
+
work.running = true;
|
|
419
|
+
const drain = (async () => {
|
|
420
|
+
for (;;) {
|
|
421
|
+
const next = work.rows.shift();
|
|
422
|
+
if (next) {
|
|
423
|
+
try {
|
|
424
|
+
await cacheRealtimeTeamPresence(dataDir, spaceKey, next);
|
|
425
|
+
}
|
|
426
|
+
catch (error) {
|
|
427
|
+
work.droppedThrough = Math.max(work.droppedThrough, next.id);
|
|
428
|
+
lastError = `presence inbox: ${errorLine(error)}`;
|
|
429
|
+
}
|
|
430
|
+
continue;
|
|
431
|
+
}
|
|
432
|
+
if (work.droppedThrough > 0) {
|
|
433
|
+
const droppedThrough = work.droppedThrough;
|
|
434
|
+
work.droppedThrough = 0;
|
|
435
|
+
try {
|
|
436
|
+
await markRealtimeGap(dataDir, spaceKey, droppedThrough);
|
|
437
|
+
}
|
|
438
|
+
catch (error) {
|
|
439
|
+
lastError = `presence gap marker: ${errorLine(error)}`;
|
|
440
|
+
}
|
|
441
|
+
continue;
|
|
442
|
+
}
|
|
443
|
+
break;
|
|
444
|
+
}
|
|
445
|
+
work.running = false;
|
|
446
|
+
})();
|
|
447
|
+
activeDrains.add(drain);
|
|
448
|
+
void drain.then(() => activeDrains.delete(drain), () => activeDrains.delete(drain));
|
|
449
|
+
};
|
|
450
|
+
const snapshot = () => ({
|
|
451
|
+
version: 1,
|
|
452
|
+
connected: socket?.connected === true,
|
|
453
|
+
updatedAt: new Date().toISOString(),
|
|
454
|
+
subscribedSpaces: [...subscribed.keys()].sort(),
|
|
455
|
+
...(connectionId ? { connectionId } : {}),
|
|
456
|
+
...(lastError ? { lastError } : {}),
|
|
457
|
+
});
|
|
458
|
+
const persistStatus = () => closed ? Promise.resolve() : writeStatus(dataDir, snapshot()).catch(() => undefined);
|
|
459
|
+
const scheduleReconnect = () => {
|
|
460
|
+
if (closed || reconnectTimer || Date.now() < nextTokenAttemptAt)
|
|
461
|
+
return;
|
|
462
|
+
const delay = reconnectDelayMs;
|
|
463
|
+
reconnectDelayMs = Math.min(15_000, reconnectDelayMs * 2);
|
|
464
|
+
nextTokenAttemptAt = Date.now() + delay;
|
|
465
|
+
reconnectTimer = setTimeout(() => {
|
|
466
|
+
reconnectTimer = undefined;
|
|
467
|
+
void refresh();
|
|
468
|
+
}, delay);
|
|
469
|
+
reconnectTimer.unref?.();
|
|
470
|
+
};
|
|
471
|
+
const settleReady = () => {
|
|
472
|
+
if (closed || readyResolved || readyPersisting || ![...desired.keys()].every((spaceKey) => subscribed.has(spaceKey)))
|
|
473
|
+
return;
|
|
474
|
+
// A separate hook process consumes the durable status file, not this
|
|
475
|
+
// closure. Do not announce readiness until the acknowledged subscription
|
|
476
|
+
// generation is visible there, or the very next tool boundary can read the
|
|
477
|
+
// prior disconnected generation and skip its required reconnect catch-up.
|
|
478
|
+
const generation = socketGeneration;
|
|
479
|
+
const expectedConnectionId = connectionId;
|
|
480
|
+
const expectedSpaces = [...desired.keys()].sort();
|
|
481
|
+
const expectedStatus = snapshot();
|
|
482
|
+
readyPersisting = true;
|
|
483
|
+
void writeStatus(dataDir, expectedStatus).then(() => readTeamRealtimeStatus(dataDir)).then((durable) => {
|
|
484
|
+
readyPersisting = false;
|
|
485
|
+
const sameSpaces = durable?.subscribedSpaces.length === expectedSpaces.length
|
|
486
|
+
&& expectedSpaces.every((spaceKey, index) => durable.subscribedSpaces[index] === spaceKey);
|
|
487
|
+
const socketMatches = expectedStatus.connected ? socket?.connected === true : socket?.connected !== true;
|
|
488
|
+
if (closed || readyResolved)
|
|
489
|
+
return;
|
|
490
|
+
if (generation !== socketGeneration || expectedConnectionId !== connectionId || !socketMatches
|
|
491
|
+
|| ![...desired.keys()].every((spaceKey) => subscribed.has(spaceKey))
|
|
492
|
+
|| durable?.connected !== expectedStatus.connected || durable.connectionId !== expectedConnectionId || !sameSpaces)
|
|
493
|
+
return;
|
|
494
|
+
readyResolved = true;
|
|
495
|
+
resolveReady();
|
|
496
|
+
}, () => { readyPersisting = false; });
|
|
497
|
+
};
|
|
498
|
+
const syncSubscriptions = () => {
|
|
499
|
+
if (!socket?.connected)
|
|
500
|
+
return;
|
|
501
|
+
const generation = socketGeneration;
|
|
502
|
+
const targetSocket = socket;
|
|
503
|
+
for (const [spaceKey, routeKey] of [...subscribed]) {
|
|
504
|
+
if (desired.get(spaceKey) === routeKey)
|
|
505
|
+
continue;
|
|
506
|
+
socket.emit("unsubscribe", { channel: teamRealtimeChannel(routeKey) });
|
|
507
|
+
subscribed.delete(spaceKey);
|
|
508
|
+
subscribing.delete(spaceKey);
|
|
509
|
+
}
|
|
510
|
+
for (const [spaceKey, routeKey] of desired) {
|
|
511
|
+
if (subscribed.get(spaceKey) === routeKey || subscribing.get(spaceKey) === routeKey)
|
|
512
|
+
continue;
|
|
513
|
+
subscribing.set(spaceKey, routeKey);
|
|
514
|
+
let settled = false;
|
|
515
|
+
const timeout = setTimeout(() => {
|
|
516
|
+
if (settled)
|
|
517
|
+
return;
|
|
518
|
+
settled = true;
|
|
519
|
+
if (generation !== socketGeneration || socket !== targetSocket)
|
|
520
|
+
return;
|
|
521
|
+
if (subscribing.get(spaceKey) === routeKey)
|
|
522
|
+
subscribing.delete(spaceKey);
|
|
523
|
+
lastError = `subscription timed out for ${spaceKey.slice(0, 8)}`;
|
|
524
|
+
void persistStatus();
|
|
525
|
+
}, SUBSCRIBE_TIMEOUT_MS);
|
|
526
|
+
timeout.unref?.();
|
|
527
|
+
socket.emit("subscribe", { channel: teamRealtimeChannel(routeKey) }, (ack) => {
|
|
528
|
+
if (settled)
|
|
529
|
+
return;
|
|
530
|
+
settled = true;
|
|
531
|
+
clearTimeout(timeout);
|
|
532
|
+
if (generation !== socketGeneration || socket !== targetSocket)
|
|
533
|
+
return;
|
|
534
|
+
if (subscribing.get(spaceKey) !== routeKey || desired.get(spaceKey) !== routeKey)
|
|
535
|
+
return;
|
|
536
|
+
subscribing.delete(spaceKey);
|
|
537
|
+
if (closed || !ack || typeof ack !== "object" || Array.isArray(ack))
|
|
538
|
+
return;
|
|
539
|
+
const response = ack;
|
|
540
|
+
if (response.success === true) {
|
|
541
|
+
subscribed.set(spaceKey, routeKey);
|
|
542
|
+
lastError = undefined;
|
|
543
|
+
settleReady();
|
|
544
|
+
}
|
|
545
|
+
else if (typeof response.error === "string") {
|
|
546
|
+
lastError = response.error.slice(0, 240);
|
|
547
|
+
}
|
|
548
|
+
void persistStatus();
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
settleReady();
|
|
552
|
+
void persistStatus();
|
|
553
|
+
};
|
|
554
|
+
const stopSocket = () => {
|
|
555
|
+
if (!socket)
|
|
556
|
+
return;
|
|
557
|
+
socketGeneration += 1;
|
|
558
|
+
socket.removeAllListeners();
|
|
559
|
+
socket.disconnect();
|
|
560
|
+
socket = null;
|
|
561
|
+
tokenInUse = null;
|
|
562
|
+
subscribed.clear();
|
|
563
|
+
subscribing.clear();
|
|
564
|
+
};
|
|
565
|
+
const reconcileAfterRotation = () => {
|
|
566
|
+
const generation = socketGeneration;
|
|
567
|
+
void reconcileMemberships().then(({ bindingChanged, routingChanged }) => {
|
|
568
|
+
if (closed || generation !== socketGeneration || socket?.connected !== true)
|
|
569
|
+
return;
|
|
570
|
+
if (bindingChanged || routingChanged)
|
|
571
|
+
connectionId = randomUUID();
|
|
572
|
+
if (!desired.size) {
|
|
573
|
+
stopSocket();
|
|
574
|
+
settleReady();
|
|
575
|
+
void persistStatus();
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
syncSubscriptions();
|
|
579
|
+
}, (error) => {
|
|
580
|
+
lastError = `team key rotation: ${errorLine(error)}`;
|
|
581
|
+
void persistStatus();
|
|
582
|
+
});
|
|
583
|
+
};
|
|
584
|
+
const connect = (token) => {
|
|
585
|
+
if (socket && tokenInUse === token) {
|
|
586
|
+
if (!socket.connected && !socket.active)
|
|
587
|
+
socket.connect();
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
stopSocket();
|
|
591
|
+
tokenInUse = token;
|
|
592
|
+
socket = io(endpoint.origin, {
|
|
593
|
+
path: endpoint.path,
|
|
594
|
+
auth: { token },
|
|
595
|
+
// Begin with the broadly proxy-safe Engine.IO polling handshake, then
|
|
596
|
+
// upgrade when the hosted edge supports WebSocket. Forcing WebSocket as
|
|
597
|
+
// the initial transport failed behind the real Spala/Cloudflare route
|
|
598
|
+
// even though its polling handshake and later upgrade are available.
|
|
599
|
+
transports: ["polling", "websocket"],
|
|
600
|
+
// Reconnects are owned by refresh(), which checks the shared durable 429
|
|
601
|
+
// receipt first. Letting Manager reconnect independently would create a
|
|
602
|
+
// second path capable of bypassing the server's Retry-After instruction.
|
|
603
|
+
reconnection: false,
|
|
604
|
+
timeout: 10_000,
|
|
605
|
+
});
|
|
606
|
+
socket.on("connect", () => {
|
|
607
|
+
socketGeneration += 1;
|
|
608
|
+
const generation = socketGeneration;
|
|
609
|
+
subscribed.clear();
|
|
610
|
+
subscribing.clear();
|
|
611
|
+
connectionId = randomUUID();
|
|
612
|
+
nextTokenAttemptAt = 0;
|
|
613
|
+
reconnectDelayMs = 500;
|
|
614
|
+
if (reconnectTimer)
|
|
615
|
+
clearTimeout(reconnectTimer);
|
|
616
|
+
reconnectTimer = undefined;
|
|
617
|
+
lastError = undefined;
|
|
618
|
+
// Do not join the route remembered before an offline rotation. First ask
|
|
619
|
+
// the membership-scoped REST endpoint for this account's current route.
|
|
620
|
+
void reconcileMemberships().then(() => {
|
|
621
|
+
if (closed || generation !== socketGeneration || socket?.connected !== true)
|
|
622
|
+
return;
|
|
623
|
+
if (!desired.size) {
|
|
624
|
+
stopSocket();
|
|
625
|
+
settleReady();
|
|
626
|
+
void persistStatus();
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
syncSubscriptions();
|
|
630
|
+
}, (error) => {
|
|
631
|
+
if (closed || generation !== socketGeneration || socket?.connected !== true)
|
|
632
|
+
return;
|
|
633
|
+
// A transient REST failure must not destroy a healthy live lane. Join
|
|
634
|
+
// the last durable route; a reconnect or key_rotated notice retries.
|
|
635
|
+
lastError = `team membership reconcile: ${errorLine(error)}`;
|
|
636
|
+
syncSubscriptions();
|
|
637
|
+
});
|
|
638
|
+
});
|
|
639
|
+
socket.on("disconnect", (reason) => {
|
|
640
|
+
socketGeneration += 1;
|
|
641
|
+
subscribed.clear();
|
|
642
|
+
subscribing.clear();
|
|
643
|
+
lastError = `disconnected: ${reason}`;
|
|
644
|
+
scheduleReconnect();
|
|
645
|
+
void persistStatus();
|
|
646
|
+
});
|
|
647
|
+
socket.on("connect_error", (error) => {
|
|
648
|
+
subscribed.clear();
|
|
649
|
+
subscribing.clear();
|
|
650
|
+
lastError = errorLine(error);
|
|
651
|
+
const limited = realtimeRateLimit(error);
|
|
652
|
+
if (limited) {
|
|
653
|
+
const until = Date.now() + limited.retryAfterMs;
|
|
654
|
+
stopSocket();
|
|
655
|
+
nextTokenAttemptAt = Math.max(nextTokenAttemptAt, until);
|
|
656
|
+
lastError = `rate limited until ${new Date(until).toISOString()}`;
|
|
657
|
+
const backoff = rememberCloudBackoff(dataDir, limited.retryAfterMs).then(() => persistStatus(), () => persistStatus());
|
|
658
|
+
activeDrains.add(backoff);
|
|
659
|
+
void backoff.then(() => activeDrains.delete(backoff));
|
|
660
|
+
}
|
|
661
|
+
else if (/auth|token|unauthor|forbid|jwt/i.test(lastError)) {
|
|
662
|
+
stopSocket();
|
|
663
|
+
nextTokenAttemptAt = Date.now() + TOKEN_RECHECK_MS;
|
|
664
|
+
}
|
|
665
|
+
else
|
|
666
|
+
scheduleReconnect();
|
|
667
|
+
void persistStatus();
|
|
668
|
+
});
|
|
669
|
+
// The native relay may send this server-authored control event directly,
|
|
670
|
+
// while proxy-compatible deployments wrap it in `broadcast` below. It
|
|
671
|
+
// carries no new route or key; authorized REST state remains the authority.
|
|
672
|
+
socket.on("key_rotated", (message) => {
|
|
673
|
+
const value = objectOf(message);
|
|
674
|
+
const spaceKey = typeof value?.space_key === "string" ? value.space_key : "";
|
|
675
|
+
if (!SPACE_RE.test(spaceKey) || !subscribed.has(spaceKey) || desired.get(spaceKey) !== subscribed.get(spaceKey))
|
|
676
|
+
return;
|
|
677
|
+
reconcileAfterRotation();
|
|
678
|
+
});
|
|
679
|
+
socket.on("broadcast", (message) => {
|
|
680
|
+
if (!message || typeof message !== "object" || Array.isArray(message))
|
|
681
|
+
return;
|
|
682
|
+
const envelope = message;
|
|
683
|
+
if (typeof envelope.channel !== "string" || !envelope.channel.startsWith(CHANNEL_PREFIX))
|
|
684
|
+
return;
|
|
685
|
+
const routeKey = envelope.channel.slice(CHANNEL_PREFIX.length);
|
|
686
|
+
const logicalSpaces = [...subscribed].flatMap(([spaceKey, subscribedRoute]) => subscribedRoute === routeKey && desired.get(spaceKey) === routeKey ? [spaceKey] : []);
|
|
687
|
+
if (!logicalSpaces.length)
|
|
688
|
+
return;
|
|
689
|
+
if (envelope.event === "presence") {
|
|
690
|
+
for (const spaceKey of logicalSpaces)
|
|
691
|
+
enqueuePresence(spaceKey, envelope.payload);
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
if (envelope.event !== "key_rotated")
|
|
695
|
+
return;
|
|
696
|
+
const payload = objectOf(envelope.payload);
|
|
697
|
+
const spaceKey = typeof payload?.space_key === "string" ? payload.space_key : "";
|
|
698
|
+
// The old subscribed route and the stable logical id must agree. Ignore
|
|
699
|
+
// every other payload field: the server-authored membership listing is
|
|
700
|
+
// the only authority for whether we remain a member and which room wins.
|
|
701
|
+
if (!SPACE_RE.test(spaceKey) || !logicalSpaces.includes(spaceKey))
|
|
702
|
+
return;
|
|
703
|
+
reconcileAfterRotation();
|
|
704
|
+
});
|
|
705
|
+
};
|
|
706
|
+
const refreshOnce = async () => {
|
|
707
|
+
if (closed)
|
|
708
|
+
return;
|
|
709
|
+
try {
|
|
710
|
+
const routingChanged = await reloadDesired();
|
|
711
|
+
if (closed)
|
|
712
|
+
return;
|
|
713
|
+
if (routingChanged && socket?.connected === true)
|
|
714
|
+
connectionId = randomUUID();
|
|
715
|
+
if (!desired.size) {
|
|
716
|
+
stopSocket();
|
|
717
|
+
lastError = undefined;
|
|
718
|
+
settleReady();
|
|
719
|
+
await persistStatus();
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
722
|
+
// A 429 learned by REST or a prior realtime handshake is a control-plane
|
|
723
|
+
// instruction, not a hint. Do not let Socket.IO's reconnect manager make
|
|
724
|
+
// background attempts during that durable cooldown. An already-open push
|
|
725
|
+
// socket is kept because it consumes no new handshake requests.
|
|
726
|
+
const backoffUntil = await cloudBackoffUntil(dataDir);
|
|
727
|
+
if (closed)
|
|
728
|
+
return;
|
|
729
|
+
if (socket?.connected !== true && backoffUntil > Date.now()) {
|
|
730
|
+
stopSocket();
|
|
731
|
+
nextTokenAttemptAt = Math.max(nextTokenAttemptAt, backoffUntil);
|
|
732
|
+
lastError = `rate limited until ${new Date(backoffUntil).toISOString()}`;
|
|
733
|
+
await persistStatus();
|
|
734
|
+
return;
|
|
735
|
+
}
|
|
736
|
+
// Space discovery is cheap JSON and runs every five seconds so enabling
|
|
737
|
+
// team mode needs no restart. Native token retrieval is deliberately not
|
|
738
|
+
// on that loop: probing Keychain/Secret Service hundreds of times per
|
|
739
|
+
// hour would turn an idle daemon into needless process churn.
|
|
740
|
+
const currentAuthGeneration = await cloudAuthGeneration(dataDir);
|
|
741
|
+
if (closed)
|
|
742
|
+
return;
|
|
743
|
+
if (currentAuthGeneration !== authGeneration)
|
|
744
|
+
nextTokenAttemptAt = 0;
|
|
745
|
+
if (socket && currentAuthGeneration === authGeneration && Date.now() - lastTokenCheckAt < TOKEN_RECHECK_MS) {
|
|
746
|
+
if (!socket.connected && !socket.active && Date.now() >= nextTokenAttemptAt)
|
|
747
|
+
socket.connect();
|
|
748
|
+
syncSubscriptions();
|
|
749
|
+
return;
|
|
750
|
+
}
|
|
751
|
+
if (!socket && Date.now() < nextTokenAttemptAt)
|
|
752
|
+
return;
|
|
753
|
+
const token = await cloudToken(dataDir, options.preferred);
|
|
754
|
+
if (closed)
|
|
755
|
+
return;
|
|
756
|
+
lastTokenCheckAt = Date.now();
|
|
757
|
+
nextTokenAttemptAt = 0;
|
|
758
|
+
authGeneration = currentAuthGeneration;
|
|
759
|
+
connect(token);
|
|
760
|
+
syncSubscriptions();
|
|
761
|
+
}
|
|
762
|
+
catch (error) {
|
|
763
|
+
// A missing/revoked token must not leave an authenticated socket alive.
|
|
764
|
+
// Transient loss degrades to REST and the next refresh retries cleanly.
|
|
765
|
+
stopSocket();
|
|
766
|
+
lastTokenCheckAt = Date.now();
|
|
767
|
+
nextTokenAttemptAt = Date.now() + TOKEN_RECHECK_MS;
|
|
768
|
+
authGeneration = await cloudAuthGeneration(dataDir).catch(() => null);
|
|
769
|
+
lastError = errorLine(error);
|
|
770
|
+
await persistStatus();
|
|
771
|
+
}
|
|
772
|
+
};
|
|
773
|
+
refresh = () => {
|
|
774
|
+
if (closed)
|
|
775
|
+
return Promise.resolve();
|
|
776
|
+
if (refreshInFlight)
|
|
777
|
+
return refreshInFlight;
|
|
778
|
+
const work = refreshOnce();
|
|
779
|
+
refreshInFlight = work;
|
|
780
|
+
void work.then(() => { if (refreshInFlight === work)
|
|
781
|
+
refreshInFlight = undefined; }, () => { if (refreshInFlight === work)
|
|
782
|
+
refreshInFlight = undefined; });
|
|
783
|
+
return work;
|
|
784
|
+
};
|
|
785
|
+
await refresh();
|
|
786
|
+
const refreshTimer = setInterval(() => { void refresh(); }, Math.max(250, options.refreshMs ?? DEFAULT_REFRESH_MS));
|
|
787
|
+
const heartbeatTimer = setInterval(() => { void persistStatus(); }, STATUS_HEARTBEAT_MS);
|
|
788
|
+
refreshTimer.unref?.();
|
|
789
|
+
heartbeatTimer.unref?.();
|
|
790
|
+
return {
|
|
791
|
+
ready,
|
|
792
|
+
refresh,
|
|
793
|
+
close: () => {
|
|
794
|
+
if (closeInFlight)
|
|
795
|
+
return closeInFlight;
|
|
796
|
+
closeInFlight = (async () => {
|
|
797
|
+
closed = true;
|
|
798
|
+
clearInterval(refreshTimer);
|
|
799
|
+
clearInterval(heartbeatTimer);
|
|
800
|
+
if (reconnectTimer)
|
|
801
|
+
clearTimeout(reconnectTimer);
|
|
802
|
+
reconnectTimer = undefined;
|
|
803
|
+
stopSocket();
|
|
804
|
+
await refreshInFlight;
|
|
805
|
+
await reconciliation?.catch(() => undefined);
|
|
806
|
+
await Promise.all([...activeDrains]);
|
|
807
|
+
lastError = undefined;
|
|
808
|
+
// Queue the final disconnected status after every owned producer has
|
|
809
|
+
// stopped. No refresh may recreate a socket or write after close.
|
|
810
|
+
await writeStatus(dataDir, snapshot()).catch(() => undefined);
|
|
811
|
+
})();
|
|
812
|
+
return closeInFlight;
|
|
813
|
+
},
|
|
814
|
+
status: snapshot,
|
|
815
|
+
};
|
|
816
|
+
}
|