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
package/dist/src/vault.js
CHANGED
|
@@ -1,42 +1,413 @@
|
|
|
1
|
-
import { mkdir, readFile, writeFile, rename, access, rm, stat } from "node:fs/promises";
|
|
2
|
-
import { constants, createReadStream, createWriteStream, existsSync } from "node:fs";
|
|
3
|
-
import { basename, dirname, join, resolve } from "node:path";
|
|
1
|
+
import { mkdir, open, readFile, writeFile, rename, access, rm, stat } from "node:fs/promises";
|
|
2
|
+
import { constants, createReadStream, createWriteStream, existsSync, lstatSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
3
|
+
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
|
4
4
|
import { createHash, randomUUID } from "node:crypto";
|
|
5
5
|
import { createGzip, createGunzip, gunzipSync } from "node:zlib";
|
|
6
6
|
import { pipeline } from "node:stream/promises";
|
|
7
|
+
import { setImmediate as yieldToEventLoop } from "node:timers/promises";
|
|
8
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
7
9
|
import { decryptLegacyArchive, equalHex, isLegacyPhraseCheck, phraseCheck, sha256, upgradeLegacyPhraseCheck } from "./crypto.js";
|
|
8
10
|
import { canonicalPhrase, generateRecoveryPhrase } from "./mnemonic.js";
|
|
9
|
-
import { decryptArchive as openEnvelope, hashFilePrefixes, hashFileRange, keyRecipientId, openArchiveToFile, sealArchiveToFile, unwrapArchiveKey, x25519PublicKeyFromRaw, x25519PrivateKeyFromRaw, zeroize } from "../packages/
|
|
10
|
-
import { fail,
|
|
11
|
+
import { decryptArchive as openEnvelope, hashFilePrefixes, hashFileRange, keyRecipientId, openArchiveToFile, sealArchiveToFile, unwrapArchiveKey, x25519PublicKeyFromRaw, x25519PrivateKeyFromRaw, rawPublicKey, zeroize } from "../packages/sealkeep-crypto/src/index.js";
|
|
12
|
+
import { fail, SealkeepError } from "./errors.js";
|
|
11
13
|
import { recordAudit } from "./audit.js";
|
|
12
|
-
import { isV2 } from "./types.js";
|
|
14
|
+
import { isV2, teamCustodyRecordIsValid } from "./types.js";
|
|
13
15
|
import { envVar } from "./env.js";
|
|
14
|
-
|
|
16
|
+
import { hasCompleteVerifiedCopyChain, hasVerifiedCopy } from "./archive-copies.js";
|
|
17
|
+
import { acquireDurableTicketLock } from "./durable-ticket-lock.js";
|
|
18
|
+
// Agent hook stdout/stderr is a protocol boundary. A damaged historical
|
|
19
|
+
// sidecar must remain visible in Doctor/Health, but the low-level warning that
|
|
20
|
+
// `listArchives` emits for an interactive process must not make an otherwise
|
|
21
|
+
// successful SessionStart look noisy or malformed. AsyncLocalStorage keeps the
|
|
22
|
+
// exception scoped to the hook operation even when search imports and awaits
|
|
23
|
+
// several layers below it; unrelated work in the same process still warns.
|
|
24
|
+
const archiveWarningSilence = new AsyncLocalStorage();
|
|
25
|
+
const warnedArchiveDamage = new Map();
|
|
26
|
+
export function withoutArchiveRecordWarnings(operation) {
|
|
27
|
+
return archiveWarningSilence.run(true, operation);
|
|
28
|
+
}
|
|
29
|
+
async function syncRegularFile(path) {
|
|
30
|
+
// Write access, not read: Windows refuses FlushFileBuffers on a read-only
|
|
31
|
+
// handle with EPERM, so every record write failed there. POSIX is happy
|
|
32
|
+
// either way. A file that cannot be opened for writing (a restored archive
|
|
33
|
+
// made read-only) still gets its flush attempt through the read handle.
|
|
34
|
+
const handle = await open(path, "r+").catch(async (error) => {
|
|
35
|
+
if (error.code === "EACCES" || error.code === "EPERM" || error.code === "EROFS")
|
|
36
|
+
return open(path, "r");
|
|
37
|
+
throw error;
|
|
38
|
+
});
|
|
39
|
+
try {
|
|
40
|
+
const info = await handle.stat();
|
|
41
|
+
if (!info.isFile())
|
|
42
|
+
throw new Error(`Archive record temporary path is not a regular file: ${path}`);
|
|
43
|
+
await handle.sync();
|
|
44
|
+
}
|
|
45
|
+
finally {
|
|
46
|
+
await handle.close();
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
async function syncRecordDirectory(path) {
|
|
50
|
+
let handle;
|
|
51
|
+
try {
|
|
52
|
+
handle = await open(path, "r");
|
|
53
|
+
await handle.sync();
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
// Windows does not support opening a directory for fsync. The temporary
|
|
57
|
+
// file itself is still synced before rename, which is the strongest
|
|
58
|
+
// durability primitive Node exposes on that platform.
|
|
59
|
+
if (process.platform !== "win32")
|
|
60
|
+
throw error;
|
|
61
|
+
}
|
|
62
|
+
finally {
|
|
63
|
+
await handle?.close();
|
|
64
|
+
}
|
|
65
|
+
}
|
|
15
66
|
/**
|
|
16
67
|
* Writes one archive record atomically.
|
|
17
68
|
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
69
|
+
* The complete temporary file is fsynced before rename, then the archive
|
|
70
|
+
* directory is fsynced after rename. Atomic visibility alone is not enough for
|
|
71
|
+
* source reclaim: the purge journal must never become more durable than the
|
|
72
|
+
* record saying where the recoverable archive lives.
|
|
22
73
|
*/
|
|
23
|
-
export async function writeRecord(root, record) {
|
|
74
|
+
export async function writeRecord(root, record, operations = {}) {
|
|
24
75
|
const path = join(root, `${record.id}.json`);
|
|
25
76
|
const temp = `${path}.${randomUUID()}.tmp`;
|
|
26
|
-
|
|
27
|
-
|
|
77
|
+
const syncFile = operations.syncFile ?? syncRegularFile;
|
|
78
|
+
const renameRecord = operations.rename ?? rename;
|
|
79
|
+
const syncDirectory = operations.syncDirectory ?? syncRecordDirectory;
|
|
80
|
+
try {
|
|
81
|
+
await writeFile(temp, JSON.stringify(record, null, 2) + "\n", { mode: 0o600, flag: "wx" });
|
|
82
|
+
await syncFile(temp);
|
|
83
|
+
await renameRecord(temp, path);
|
|
84
|
+
await syncDirectory(root);
|
|
85
|
+
}
|
|
86
|
+
finally {
|
|
87
|
+
// Before rename this clears an unpublished temporary; after rename it is a
|
|
88
|
+
// no-op. Never remove the target to disguise an fsync error.
|
|
89
|
+
await rm(temp, { force: true }).catch(() => undefined);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
const ARCHIVE_RECORD_LOCK_WAIT_MS = 30_000;
|
|
93
|
+
/**
|
|
94
|
+
* Serialize a read/modify/write of one archive record across the daemon, CLI,
|
|
95
|
+
* dashboard and hooks. Atomic rename prevents torn JSON, but by itself it
|
|
96
|
+
* still lets two valid stale documents overwrite one another (for example an
|
|
97
|
+
* upload adding a remote copy while retention marks the source reclaimed).
|
|
98
|
+
*/
|
|
99
|
+
export async function mutateArchiveRecord(root, archiveId, change, options = {}) {
|
|
100
|
+
const lockDir = join(root, ".record-locks");
|
|
101
|
+
const lockKey = createHash("sha256").update(archiveId).digest("hex");
|
|
102
|
+
const legacyLockPath = join(lockDir, `${lockKey}.lock`);
|
|
103
|
+
const ticketQueuePath = join(lockDir, `${lockKey}.claims`);
|
|
104
|
+
await mkdir(lockDir, { recursive: true, mode: 0o700 });
|
|
105
|
+
// Never compare-then-unlink a stale legacy lock: another process could
|
|
106
|
+
// publish a new owner in that gap. Old-format leftovers require deliberate
|
|
107
|
+
// singleton maintenance; current processes use immutable FIFO tickets.
|
|
108
|
+
if (await stat(legacyLockPath).then(() => true, (error) => {
|
|
109
|
+
if (error.code === "ENOENT")
|
|
110
|
+
return false;
|
|
111
|
+
throw error;
|
|
112
|
+
})) {
|
|
113
|
+
return fail("internal", `Archive ${archiveId} has a legacy update lock. Stop Sealkeep services and repair that lock before retrying; no record was changed.`);
|
|
114
|
+
}
|
|
115
|
+
const recordLock = await acquireDurableTicketLock(ticketQueuePath, `archive record ${archiveId}`, {
|
|
116
|
+
timeoutMs: ARCHIVE_RECORD_LOCK_WAIT_MS,
|
|
117
|
+
pollMs: 25,
|
|
118
|
+
}).catch((error) => {
|
|
119
|
+
if (error.code === "DURABLE_LOCK_TIMEOUT") {
|
|
120
|
+
return fail("internal", `Timed out waiting for another Sealkeep process to finish updating archive ${archiveId}`);
|
|
121
|
+
}
|
|
122
|
+
throw error;
|
|
123
|
+
});
|
|
124
|
+
try {
|
|
125
|
+
const path = join(root, `${archiveId}.json`);
|
|
126
|
+
let current;
|
|
127
|
+
try {
|
|
128
|
+
current = JSON.parse(await readFile(path, "utf8"));
|
|
129
|
+
}
|
|
130
|
+
catch (error) {
|
|
131
|
+
// Streamed seals do not have a record until the provider has verified
|
|
132
|
+
// the bytes. Let their first verified write use this same lock rather
|
|
133
|
+
// than falling back to a check-then-create race. Never treat malformed
|
|
134
|
+
// existing JSON as absent: `initial` is only safe for a true ENOENT.
|
|
135
|
+
if (error.code === "ENOENT" && options.initial)
|
|
136
|
+
current = options.initial;
|
|
137
|
+
else
|
|
138
|
+
return fail("archive_not_found", `Archive not found: ${archiveId}`, { archiveId });
|
|
139
|
+
}
|
|
140
|
+
if (current.id !== archiveId)
|
|
141
|
+
fail("invalid_argument", "An initial archive record must use the requested id", { archiveId, currentId: current.id });
|
|
142
|
+
const next = await change(current);
|
|
143
|
+
if (next.id !== archiveId)
|
|
144
|
+
fail("invalid_argument", "An archive record update cannot change its id", { archiveId, nextId: next.id });
|
|
145
|
+
await writeRecord(root, next);
|
|
146
|
+
return next;
|
|
147
|
+
}
|
|
148
|
+
finally {
|
|
149
|
+
await recordLock.release();
|
|
150
|
+
}
|
|
28
151
|
}
|
|
29
152
|
export const ADAPTER_VERSION = "1";
|
|
30
153
|
export const CONFIG_VERSION = 1;
|
|
154
|
+
const MAX_INTEGRATION_CONFIG_BYTES = 4 * 1024 * 1024;
|
|
155
|
+
function ownedByCurrentUid(info) {
|
|
156
|
+
return typeof process.getuid !== "function" || info.uid === process.getuid();
|
|
157
|
+
}
|
|
158
|
+
function smallTextFile(path) {
|
|
159
|
+
try {
|
|
160
|
+
// These files are treated as durable statements made by this user's own
|
|
161
|
+
// setup. Following another user's file (including through a symlink) would
|
|
162
|
+
// turn a readable system/shared config into authority to choose our vault.
|
|
163
|
+
if (!ownedByCurrentUid(lstatSync(path)))
|
|
164
|
+
return null;
|
|
165
|
+
const info = statSync(path);
|
|
166
|
+
if (!ownedByCurrentUid(info) || !info.isFile() || info.size > MAX_INTEGRATION_CONFIG_BYTES)
|
|
167
|
+
return null;
|
|
168
|
+
return readFileSync(path, "utf8");
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
function normalizedAbsoluteDataDir(value, home) {
|
|
175
|
+
const expanded = value === "~" ? home : value.startsWith("~/") || value.startsWith("~\\") ? join(home, value.slice(2)) : value;
|
|
176
|
+
if (!expanded || expanded.includes("\0") || !isAbsolute(expanded))
|
|
177
|
+
return null;
|
|
178
|
+
return resolve(expanded);
|
|
179
|
+
}
|
|
180
|
+
function jsonString(value) {
|
|
181
|
+
try {
|
|
182
|
+
const parsed = JSON.parse(value);
|
|
183
|
+
return typeof parsed === "string" ? parsed : null;
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
function codexMcpDataDirs(text) {
|
|
190
|
+
const values = [];
|
|
191
|
+
const headers = [...text.matchAll(/^\s*\[([^\]\r\n]+)\]\s*$/gm)];
|
|
192
|
+
for (let index = 0; index < headers.length; index += 1) {
|
|
193
|
+
const match = headers[index];
|
|
194
|
+
if (!/^mcp_servers\.(?:sealkeep|vaultline)\.env$/i.test(match[1]))
|
|
195
|
+
continue;
|
|
196
|
+
const start = match.index + match[0].length;
|
|
197
|
+
const end = headers[index + 1]?.index ?? text.length;
|
|
198
|
+
const body = text.slice(start, end);
|
|
199
|
+
const assignment = body.match(/^\s*SEALKEEP_DATA_DIR\s*=\s*("(?:\\.|[^"\\])*"|'[^']*')\s*(?:#.*)?$/mi)
|
|
200
|
+
?? body.match(/^\s*VAULTLINE_DATA_DIR\s*=\s*("(?:\\.|[^"\\])*"|'[^']*')\s*(?:#.*)?$/mi);
|
|
201
|
+
if (!assignment)
|
|
202
|
+
continue;
|
|
203
|
+
const literal = assignment[1];
|
|
204
|
+
const value = literal.startsWith('"') ? jsonString(literal) : literal.slice(1, -1);
|
|
205
|
+
if (value)
|
|
206
|
+
values.push(value);
|
|
207
|
+
}
|
|
208
|
+
return values;
|
|
209
|
+
}
|
|
210
|
+
function shellDataDirs(command) {
|
|
211
|
+
// Sealkeep writes a single-quoted argument. Double-quoted and unquoted forms
|
|
212
|
+
// are accepted for older owned units, but only beside Sealkeep's own command
|
|
213
|
+
// and `--data-dir` marker.
|
|
214
|
+
if (!/(?:^|[\s/])(?:sealkeep|vaultline)(?:[\s'"/]|$)/i.test(command))
|
|
215
|
+
return [];
|
|
216
|
+
const values = [];
|
|
217
|
+
const argument = /(?:^|\s)["']?--data-dir["']?(?:=|\s+)(?:'((?:[^']|'"'"')*)'|"((?:\\.|[^"\\])*)"|([^\s<]+))/gi;
|
|
218
|
+
for (const match of command.matchAll(argument)) {
|
|
219
|
+
if (match[1] !== undefined)
|
|
220
|
+
values.push(match[1].replaceAll(`'"'"'`, "'"));
|
|
221
|
+
else if (match[2] !== undefined)
|
|
222
|
+
values.push(jsonString(`"${match[2]}"`) ?? match[2]);
|
|
223
|
+
else if (match[3] !== undefined)
|
|
224
|
+
values.push(match[3]);
|
|
225
|
+
}
|
|
226
|
+
return values;
|
|
227
|
+
}
|
|
228
|
+
function parsedJson(text) {
|
|
229
|
+
try {
|
|
230
|
+
return JSON.parse(text);
|
|
231
|
+
}
|
|
232
|
+
catch {
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
function jsonMcpDataDirs(text) {
|
|
237
|
+
const document = parsedJson(text);
|
|
238
|
+
if (document === null)
|
|
239
|
+
return [];
|
|
240
|
+
const found = [];
|
|
241
|
+
const visit = (value) => {
|
|
242
|
+
if (Array.isArray(value)) {
|
|
243
|
+
for (const item of value)
|
|
244
|
+
visit(item);
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
if (!value || typeof value !== "object")
|
|
248
|
+
return;
|
|
249
|
+
const record = value;
|
|
250
|
+
const servers = record.mcpServers;
|
|
251
|
+
if (servers && typeof servers === "object" && !Array.isArray(servers)) {
|
|
252
|
+
for (const name of ["sealkeep", "vaultline"]) {
|
|
253
|
+
const server = servers[name];
|
|
254
|
+
if (!server || typeof server !== "object" || Array.isArray(server))
|
|
255
|
+
continue;
|
|
256
|
+
const env = server.env;
|
|
257
|
+
const dataDir = env && typeof env === "object" && !Array.isArray(env)
|
|
258
|
+
? env.SEALKEEP_DATA_DIR ?? env.VAULTLINE_DATA_DIR
|
|
259
|
+
: undefined;
|
|
260
|
+
if (typeof dataDir === "string")
|
|
261
|
+
found.push(dataDir);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
for (const child of Object.values(record))
|
|
265
|
+
visit(child);
|
|
266
|
+
};
|
|
267
|
+
visit(document);
|
|
268
|
+
return found;
|
|
269
|
+
}
|
|
270
|
+
function jsonHookDataDirs(text) {
|
|
271
|
+
const document = parsedJson(text);
|
|
272
|
+
if (!document || typeof document !== "object" || Array.isArray(document))
|
|
273
|
+
return [];
|
|
274
|
+
const hooks = document.hooks;
|
|
275
|
+
if (!hooks || typeof hooks !== "object")
|
|
276
|
+
return [];
|
|
277
|
+
const found = [];
|
|
278
|
+
const visit = (value) => {
|
|
279
|
+
if (Array.isArray(value)) {
|
|
280
|
+
for (const item of value)
|
|
281
|
+
visit(item);
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
if (!value || typeof value !== "object")
|
|
285
|
+
return;
|
|
286
|
+
const record = value;
|
|
287
|
+
if (typeof record.command === "string")
|
|
288
|
+
found.push(...shellDataDirs(record.command));
|
|
289
|
+
for (const [name, child] of Object.entries(record))
|
|
290
|
+
if (name !== "command")
|
|
291
|
+
visit(child);
|
|
292
|
+
};
|
|
293
|
+
visit(hooks);
|
|
294
|
+
return found;
|
|
295
|
+
}
|
|
296
|
+
function recognizableVaultConfig(text) {
|
|
297
|
+
let document;
|
|
298
|
+
try {
|
|
299
|
+
document = JSON.parse(text);
|
|
300
|
+
}
|
|
301
|
+
catch {
|
|
302
|
+
return false;
|
|
303
|
+
}
|
|
304
|
+
if (!document || typeof document !== "object" || Array.isArray(document))
|
|
305
|
+
return false;
|
|
306
|
+
const config = document;
|
|
307
|
+
const recovery = config.recovery;
|
|
308
|
+
const storage = config.storage;
|
|
309
|
+
return Number.isSafeInteger(config.version) && Number(config.version) > 0
|
|
310
|
+
&& typeof config.vaultId === "string" && config.vaultId.trim().length > 0
|
|
311
|
+
&& !!recovery && typeof recovery === "object" && !Array.isArray(recovery)
|
|
312
|
+
&& typeof recovery.phraseCheck === "string"
|
|
313
|
+
&& recovery.phraseCheck.length > 0
|
|
314
|
+
&& !!storage && typeof storage === "object" && !Array.isArray(storage)
|
|
315
|
+
&& typeof storage.provider === "string"
|
|
316
|
+
&& typeof storage.root === "string";
|
|
317
|
+
}
|
|
318
|
+
function xmlText(value) {
|
|
319
|
+
return value.replaceAll(""", '"').replaceAll("'", "'").replaceAll("<", "<").replaceAll(">", ">").replaceAll("&", "&");
|
|
320
|
+
}
|
|
321
|
+
function serviceDataDirs(text) {
|
|
322
|
+
const decoded = xmlText(text);
|
|
323
|
+
const values = shellDataDirs(decoded);
|
|
324
|
+
const plist = /<string>--data-dir<\/string>\s*<string>([^<]+)<\/string>/gi;
|
|
325
|
+
for (const match of decoded.matchAll(plist))
|
|
326
|
+
if (match[1])
|
|
327
|
+
values.push(match[1]);
|
|
328
|
+
return values;
|
|
329
|
+
}
|
|
330
|
+
function ownedDataDirReferences(home, xdgConfigHome) {
|
|
331
|
+
// XDG_CONFIG_HOME is authoritative only for a normal process-wide lookup.
|
|
332
|
+
// An explicit `home` supplied by a caller/test must remain a complete
|
|
333
|
+
// boundary and never inherit a service unit from an unrelated real account.
|
|
334
|
+
const systemdConfigHome = xdgConfigHome && isAbsolute(xdgConfigHome) ? resolve(xdgConfigHome) : join(home, ".config");
|
|
335
|
+
const files = [
|
|
336
|
+
{ path: join(home, ".codex", "config.toml"), source: "Codex MCP", extract: codexMcpDataDirs },
|
|
337
|
+
{ path: join(home, ".codex", "hooks.json"), source: "Codex hooks", extract: jsonHookDataDirs },
|
|
338
|
+
{ path: join(home, ".claude", "settings.json"), source: "Claude hooks", extract: jsonHookDataDirs },
|
|
339
|
+
{ path: join(home, ".claude.json"), source: "Claude MCP", extract: jsonMcpDataDirs },
|
|
340
|
+
{ path: join(home, "Library", "LaunchAgents", "ai.sealkeep.agent.plist"), source: "Sealkeep background service", extract: serviceDataDirs },
|
|
341
|
+
{ path: join(home, "Library", "LaunchAgents", "ai.vaultline.agent.plist"), source: "legacy background service", extract: serviceDataDirs },
|
|
342
|
+
{ path: join(systemdConfigHome, "systemd", "user", "sealkeep.service"), source: "Sealkeep background service", extract: serviceDataDirs },
|
|
343
|
+
{ path: join(systemdConfigHome, "systemd", "user", "vaultline.service"), source: "legacy background service", extract: serviceDataDirs },
|
|
344
|
+
];
|
|
345
|
+
const references = [];
|
|
346
|
+
for (const file of files) {
|
|
347
|
+
const text = smallTextFile(file.path);
|
|
348
|
+
if (text === null)
|
|
349
|
+
continue;
|
|
350
|
+
for (const value of file.extract(text)) {
|
|
351
|
+
const dataDir = normalizedAbsoluteDataDir(value, home);
|
|
352
|
+
if (dataDir)
|
|
353
|
+
references.push({ dataDir, source: file.source });
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
return references;
|
|
357
|
+
}
|
|
358
|
+
function initializedVaultIdentity(path) {
|
|
359
|
+
const config = smallTextFile(join(path, "config.json"));
|
|
360
|
+
if (config === null || !recognizableVaultConfig(config))
|
|
361
|
+
return null;
|
|
362
|
+
try {
|
|
363
|
+
return realpathSync(path);
|
|
364
|
+
}
|
|
365
|
+
catch {
|
|
366
|
+
return null;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
function existingOwnedDirectory(path) {
|
|
370
|
+
try {
|
|
371
|
+
const info = statSync(path);
|
|
372
|
+
return ownedByCurrentUid(lstatSync(path)) && ownedByCurrentUid(info) && info.isDirectory();
|
|
373
|
+
}
|
|
374
|
+
catch {
|
|
375
|
+
return false;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
31
378
|
/**
|
|
32
|
-
* ~/.sealkeep for new vaults; an existing ~/.
|
|
33
|
-
*
|
|
379
|
+
* ~/.sealkeep for new vaults; an existing ~/.vaultline keeps working forever.
|
|
380
|
+
* A unique initialized vault named by owned integrations is the same vault a
|
|
381
|
+
* bare CLI/UI/MCP process must use. Multiple initialized candidates are never
|
|
382
|
+
* guessed between: explicit `--data-dir` is then required.
|
|
34
383
|
*/
|
|
35
|
-
export const defaultDataDir = () => {
|
|
36
|
-
const
|
|
37
|
-
const legacy = join(
|
|
38
|
-
const current = join(
|
|
39
|
-
|
|
384
|
+
export const defaultDataDir = (home, environment = process.env) => {
|
|
385
|
+
const resolvedHome = resolve(home ?? environment.HOME ?? environment.USERPROFILE ?? ".");
|
|
386
|
+
const legacy = resolve(join(resolvedHome, ".vaultline"));
|
|
387
|
+
const current = resolve(join(resolvedHome, ".sealkeep"));
|
|
388
|
+
const byPath = new Map();
|
|
389
|
+
const add = (dataDir, source) => {
|
|
390
|
+
const identity = initializedVaultIdentity(dataDir);
|
|
391
|
+
if (identity === null)
|
|
392
|
+
return;
|
|
393
|
+
const candidate = byPath.get(identity) ?? { dataDir, sources: new Set() };
|
|
394
|
+
candidate.sources.add(source);
|
|
395
|
+
byPath.set(identity, candidate);
|
|
396
|
+
};
|
|
397
|
+
add(current, "~/.sealkeep");
|
|
398
|
+
add(legacy, "~/.vaultline");
|
|
399
|
+
for (const reference of ownedDataDirReferences(resolvedHome, home === undefined ? environment.XDG_CONFIG_HOME : undefined))
|
|
400
|
+
add(reference.dataDir, reference.source);
|
|
401
|
+
if (byPath.size === 1)
|
|
402
|
+
return byPath.values().next().value.dataDir;
|
|
403
|
+
if (byPath.size > 1) {
|
|
404
|
+
const candidates = [...byPath.values()].map(({ dataDir, sources }) => ({ dataDir, sources: [...sources].sort() }));
|
|
405
|
+
fail("invalid_argument", `Sealkeep found more than one initialized vault on this machine and will not guess which is current: ${candidates.map((candidate) => candidate.dataDir).join(", ")}. Re-run with --data-dir <path>.`, { candidates });
|
|
406
|
+
}
|
|
407
|
+
// Preserve the historical empty-directory fallback too: an unfinished old
|
|
408
|
+
// install should continue in ~/.vaultline, while a genuinely fresh machine
|
|
409
|
+
// starts in ~/.sealkeep.
|
|
410
|
+
if (!existsSync(current) && existingOwnedDirectory(legacy))
|
|
40
411
|
return legacy;
|
|
41
412
|
return current;
|
|
42
413
|
};
|
|
@@ -49,12 +420,82 @@ async function exists(path) { try {
|
|
|
49
420
|
catch {
|
|
50
421
|
return false;
|
|
51
422
|
} }
|
|
52
|
-
async function writeJson(path, value) { await mkdir(dirname(path), { recursive: true }); await writeFile(path, JSON.stringify(value, null, 2) + "\n", { mode: 0o600 }); }
|
|
53
423
|
/**
|
|
54
|
-
*
|
|
55
|
-
*
|
|
424
|
+
* Config updates that move a team binding and its sharing mode must land as
|
|
425
|
+
* one file replacement. A crash may leave the old config or the new config,
|
|
426
|
+
* never a half-written JSON document or a team binding without its mode.
|
|
56
427
|
*/
|
|
57
|
-
|
|
428
|
+
async function writeJsonAtomic(path, value) {
|
|
429
|
+
await mkdir(dirname(path), { recursive: true });
|
|
430
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
431
|
+
try {
|
|
432
|
+
await writeFile(temporary, JSON.stringify(value, null, 2) + "\n", { mode: 0o600 });
|
|
433
|
+
await rename(temporary, path);
|
|
434
|
+
}
|
|
435
|
+
catch (error) {
|
|
436
|
+
await rm(temporary, { force: true }).catch(() => undefined);
|
|
437
|
+
throw error;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
const configMutationQueues = new Map();
|
|
441
|
+
const CONFIG_LOCK_STALE_MS = 10 * 60_000;
|
|
442
|
+
const CONFIG_LOCK_WAIT_MS = 30_000;
|
|
443
|
+
function processIsAlive(pid) {
|
|
444
|
+
if (!Number.isSafeInteger(pid) || pid <= 0)
|
|
445
|
+
return false;
|
|
446
|
+
try {
|
|
447
|
+
process.kill(pid, 0);
|
|
448
|
+
return true;
|
|
449
|
+
}
|
|
450
|
+
catch (error) {
|
|
451
|
+
return error.code === "EPERM";
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
/**
|
|
455
|
+
* Serialize config read/modify/write cycles across the daemon, CLI, local API,
|
|
456
|
+
* and agent-hook processes. Atomic rename prevents torn JSON; this lock also
|
|
457
|
+
* prevents a perfectly valid but stale whole document from winning later and
|
|
458
|
+
* resurrecting a revoked team binding.
|
|
459
|
+
*/
|
|
460
|
+
async function withConfigLock(dataDir, work) {
|
|
461
|
+
const lockDir = join(dataDir, "locks");
|
|
462
|
+
const lockPath = join(lockDir, "config.lock");
|
|
463
|
+
await mkdir(lockDir, { recursive: true, mode: 0o700 });
|
|
464
|
+
const started = Date.now();
|
|
465
|
+
for (;;) {
|
|
466
|
+
try {
|
|
467
|
+
const { open } = await import("node:fs/promises");
|
|
468
|
+
const handle = await open(lockPath, "wx", 0o600);
|
|
469
|
+
await handle.writeFile(`${process.pid} ${new Date().toISOString()}\n`);
|
|
470
|
+
await handle.close();
|
|
471
|
+
break;
|
|
472
|
+
}
|
|
473
|
+
catch (error) {
|
|
474
|
+
if (error.code !== "EEXIST")
|
|
475
|
+
throw error;
|
|
476
|
+
const [held, owner] = await Promise.all([
|
|
477
|
+
stat(lockPath).catch(() => null),
|
|
478
|
+
readFile(lockPath, "utf8").catch(() => ""),
|
|
479
|
+
]);
|
|
480
|
+
const pid = Number(owner.trim().split(/\s+/, 1)[0]);
|
|
481
|
+
if (held && Date.now() - held.mtimeMs > CONFIG_LOCK_STALE_MS && !processIsAlive(pid)) {
|
|
482
|
+
await rm(lockPath, { force: true });
|
|
483
|
+
continue;
|
|
484
|
+
}
|
|
485
|
+
if (Date.now() - started >= CONFIG_LOCK_WAIT_MS) {
|
|
486
|
+
return fail("internal", "Timed out waiting for another Sealkeep process to finish updating the vault configuration");
|
|
487
|
+
}
|
|
488
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
try {
|
|
492
|
+
return await work();
|
|
493
|
+
}
|
|
494
|
+
finally {
|
|
495
|
+
await rm(lockPath, { force: true }).catch(() => undefined);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
async function readConfigFile(dataDir) {
|
|
58
499
|
let raw;
|
|
59
500
|
try {
|
|
60
501
|
raw = await readFile(configPath(dataDir), "utf8");
|
|
@@ -65,6 +506,38 @@ export async function readConfig(dataDir) {
|
|
|
65
506
|
const config = JSON.parse(raw);
|
|
66
507
|
if (config.version !== CONFIG_VERSION)
|
|
67
508
|
fail("config_unsupported_version", `Vault config version ${String(config.version)} is not supported by this build (expected ${CONFIG_VERSION})`, { found: config.version, expected: CONFIG_VERSION });
|
|
509
|
+
return config;
|
|
510
|
+
}
|
|
511
|
+
function withCurrentPhraseCheck(config) {
|
|
512
|
+
if (!isLegacyPhraseCheck(config.recovery.phraseCheck))
|
|
513
|
+
return config;
|
|
514
|
+
return { ...config, recovery: { ...config.recovery, phraseCheck: upgradeLegacyPhraseCheck(config.recovery.phraseCheck) } };
|
|
515
|
+
}
|
|
516
|
+
/** Serialize every config mutation around a cross-process atomic replace. */
|
|
517
|
+
async function mutateConfigAtomically(dataDir, change) {
|
|
518
|
+
const path = configPath(dataDir);
|
|
519
|
+
const prior = configMutationQueues.get(path) ?? Promise.resolve();
|
|
520
|
+
const operation = prior.catch(() => undefined).then(() => withConfigLock(dataDir, async () => {
|
|
521
|
+
const stored = await readConfigFile(dataDir);
|
|
522
|
+
const current = withCurrentPhraseCheck(stored);
|
|
523
|
+
const next = change(current);
|
|
524
|
+
if (next !== stored)
|
|
525
|
+
await writeJsonAtomic(path, next);
|
|
526
|
+
return next;
|
|
527
|
+
}));
|
|
528
|
+
const tracked = operation.then(() => undefined, () => undefined).finally(() => {
|
|
529
|
+
if (configMutationQueues.get(path) === tracked)
|
|
530
|
+
configMutationQueues.delete(path);
|
|
531
|
+
});
|
|
532
|
+
configMutationQueues.set(path, tracked);
|
|
533
|
+
return operation;
|
|
534
|
+
}
|
|
535
|
+
/**
|
|
536
|
+
* Reads and version-gates the vault config. A config written by a newer build is
|
|
537
|
+
* refused rather than silently reinterpreted; migrations attach here when v2 lands.
|
|
538
|
+
*/
|
|
539
|
+
export async function readConfig(dataDir) {
|
|
540
|
+
const config = await readConfigFile(dataDir);
|
|
68
541
|
// Defect #49: builds before this one stored `recovery.phraseCheck` as a
|
|
69
542
|
// bare, unsalted SHA-256 -- a cheap offline oracle. upgradeLegacyPhraseCheck
|
|
70
543
|
// re-derives the cost-matched value from that stored digest alone, with no
|
|
@@ -72,34 +545,121 @@ export async function readConfig(dataDir) {
|
|
|
72
545
|
// first time anything reads its config -- nobody has to run a migration or
|
|
73
546
|
// even unlock the vault first for this to happen.
|
|
74
547
|
if (isLegacyPhraseCheck(config.recovery.phraseCheck)) {
|
|
75
|
-
|
|
548
|
+
const upgraded = withCurrentPhraseCheck(config);
|
|
76
549
|
// Best effort: an unwritable dataDir still gets the upgraded value for
|
|
77
550
|
// this call (below), and simply repeats the upgrade on its next read.
|
|
78
|
-
|
|
551
|
+
return withConfigLock(dataDir, async () => {
|
|
552
|
+
const latest = await readConfigFile(dataDir);
|
|
553
|
+
const settled = withCurrentPhraseCheck(latest);
|
|
554
|
+
if (settled !== latest)
|
|
555
|
+
await writeJsonAtomic(configPath(dataDir), settled);
|
|
556
|
+
return settled;
|
|
557
|
+
}).catch(() => upgraded);
|
|
79
558
|
}
|
|
80
559
|
return config;
|
|
81
560
|
}
|
|
82
561
|
export async function vaultStatus(dataDir) {
|
|
83
562
|
const config = await readConfig(dataDir);
|
|
84
563
|
const archives = await listArchives(dataDir);
|
|
564
|
+
const byId = new Map(archives.map((archive) => [archive.id, archive]));
|
|
565
|
+
// A delta is a storage link, not another agent session. Its `source.bytes`
|
|
566
|
+
// describes the whole transcript so the next append can prove its prefix;
|
|
567
|
+
// summing that value for every link would count the base again at every
|
|
568
|
+
// snapshot (100 + 120 + 130 instead of 130). Only trust a delta as a link
|
|
569
|
+
// when its direct base is present and has the promised length. A damaged or
|
|
570
|
+
// orphaned pointer remains visible as a standalone record rather than making
|
|
571
|
+
// history silently disappear from Overview.
|
|
572
|
+
const logicalDelta = (archive) => {
|
|
573
|
+
const delta = deltaOf(archive);
|
|
574
|
+
if (!delta || delta.baseBytes >= archive.source.bytes)
|
|
575
|
+
return null;
|
|
576
|
+
return byId.get(delta.baseArchiveId)?.source.bytes === delta.baseBytes ? delta : null;
|
|
577
|
+
};
|
|
578
|
+
const logicalArchives = archives.filter((archive) => !logicalDelta(archive));
|
|
579
|
+
// `archiveCount` remains the logical sealed-object count for compatibility:
|
|
580
|
+
// two whole snapshots of one transcript are still two archive roots. The UI
|
|
581
|
+
// folds every snapshot/delta carrying the same exact source path into one
|
|
582
|
+
// session row, so expose that user-facing count separately instead of
|
|
583
|
+
// labelling archive roots as sessions.
|
|
584
|
+
const sessionCount = new Set(archives.map((archive) => archive.source.path || archive.id)).size;
|
|
585
|
+
const archivedBytes = archives.reduce((total, archive) => {
|
|
586
|
+
const delta = logicalDelta(archive);
|
|
587
|
+
return total + (delta ? archive.source.bytes - delta.baseBytes : archive.source.bytes);
|
|
588
|
+
}, 0);
|
|
589
|
+
// Physical encrypted/compressed bytes are a different fact from the amount
|
|
590
|
+
// of logical source history represented above. Keeping both prevents every
|
|
591
|
+
// dashboard consumer from calling (for example) 130 GB of source history
|
|
592
|
+
// "130 GB sealed" when the actual archive objects occupy about 10 GB.
|
|
593
|
+
const sealedBytes = archives.reduce((total, archive) => total + ("storedBytes" in archive.cipher ? archive.cipher.storedBytes : archive.source.bytes), 0);
|
|
594
|
+
// "Off this machine" is a logical-session count too. A delta-backed session
|
|
595
|
+
// is restorable away from this Mac only when every object in its chain has a
|
|
596
|
+
// verified copy; counting each verified link would otherwise display e.g.
|
|
597
|
+
// "3 of 1" for one three-link session.
|
|
598
|
+
const rootOf = (archive) => {
|
|
599
|
+
let cursor = archive;
|
|
600
|
+
const seen = new Set();
|
|
601
|
+
for (;;) {
|
|
602
|
+
const delta = logicalDelta(cursor);
|
|
603
|
+
if (!delta || seen.has(cursor.id))
|
|
604
|
+
return cursor.id;
|
|
605
|
+
seen.add(cursor.id);
|
|
606
|
+
cursor = byId.get(delta.baseArchiveId);
|
|
607
|
+
}
|
|
608
|
+
};
|
|
609
|
+
const groups = new Map();
|
|
610
|
+
for (const archive of archives) {
|
|
611
|
+
const root = rootOf(archive);
|
|
612
|
+
const chain = groups.get(root);
|
|
613
|
+
if (chain)
|
|
614
|
+
chain.push(archive);
|
|
615
|
+
else
|
|
616
|
+
groups.set(root, [archive]);
|
|
617
|
+
}
|
|
618
|
+
const cloudArchiveCount = [...groups.values()].filter((chain) => {
|
|
619
|
+
if (!chain.every(isV2))
|
|
620
|
+
return false;
|
|
621
|
+
return hasCompleteVerifiedCopyChain(chain);
|
|
622
|
+
}).length;
|
|
623
|
+
// A chain with individually verified links is still pending until one
|
|
624
|
+
// destination contains every link. Otherwise split copies (base on Drive,
|
|
625
|
+
// delta on Cloud) made the Queue read empty while no remote restore existed.
|
|
626
|
+
// Count every physical link in such a chain conservatively: the uploader may
|
|
627
|
+
// choose any one eligible common destination, so the status must not claim
|
|
628
|
+
// less work than the chain can require.
|
|
629
|
+
const incompleteChains = [...groups.values()].filter((chain) => !chain.every(isV2) || !hasCompleteVerifiedCopyChain(chain));
|
|
630
|
+
const incompleteIds = new Set(incompleteChains.flatMap((chain) => chain.map((archive) => archive.id)));
|
|
631
|
+
// V1 cannot be uploaded by the current provider path, but hiding it here
|
|
632
|
+
// made an old local-only archive look durable. Keep it visible as blocked
|
|
633
|
+
// pending work; callers can present the required migration separately.
|
|
634
|
+
const pendingUploads = archives.filter((archive) => !isV2(archive) || incompleteIds.has(archive.id) || !hasVerifiedCopy(archive));
|
|
635
|
+
const blockedUploadCount = archives.filter((archive) => !isV2(archive)).length;
|
|
85
636
|
// Resolved to "sealed" rather than passed through raw, so no consumer ever
|
|
86
637
|
// has to know that absence and sealed are the same thing.
|
|
87
|
-
return {
|
|
638
|
+
return {
|
|
639
|
+
vaultId: config.vaultId,
|
|
640
|
+
provider: config.storage.provider,
|
|
641
|
+
storageMode: config.storageMode ?? "sealed",
|
|
642
|
+
remoteStorage: config.remoteStorage,
|
|
643
|
+
archiveCount: logicalArchives.length,
|
|
644
|
+
sessionCount,
|
|
645
|
+
archivedBytes,
|
|
646
|
+
sealedBytes,
|
|
647
|
+
cloudArchiveCount,
|
|
648
|
+
incompleteChainCount: incompleteChains.filter((chain) => chain.every(isV2)).length,
|
|
649
|
+
blockedUploadCount,
|
|
650
|
+
pendingUploadCount: pendingUploads.length,
|
|
651
|
+
pendingUploadBytes: pendingUploads.reduce((total, archive) => total + ("storedBytes" in archive.cipher ? archive.cipher.storedBytes : archive.source.bytes), 0)
|
|
652
|
+
};
|
|
88
653
|
}
|
|
89
654
|
export async function configureRemoteStorage(dataDir, remoteStorage) {
|
|
90
655
|
if (!remoteStorage.bucket.trim() || !remoteStorage.prefix.trim())
|
|
91
656
|
fail("invalid_argument", "--bucket and --prefix are required");
|
|
92
|
-
const config = await readConfig(dataDir);
|
|
93
657
|
const normalized = { ...remoteStorage, bucket: remoteStorage.bucket.trim(), prefix: remoteStorage.prefix.replace(/^\/+|\/+$/g, "") };
|
|
94
658
|
if (!normalized.prefix)
|
|
95
659
|
fail("invalid_argument", "--prefix cannot be empty");
|
|
96
|
-
|
|
97
|
-
await writeJson(configPath(dataDir), next);
|
|
98
|
-
return next;
|
|
660
|
+
return mutateConfigAtomically(dataDir, (config) => ({ ...config, remoteStorage: normalized }));
|
|
99
661
|
}
|
|
100
662
|
export async function initialize(dataDir, providedPhrase, opts = {}) {
|
|
101
|
-
if (await exists(configPath(dataDir)))
|
|
102
|
-
fail("vault_already_initialized", `Vault already initialized at ${dataDir}`, { dataDir });
|
|
103
663
|
const phrase = canonicalPhrase(providedPhrase ?? generateRecoveryPhrase());
|
|
104
664
|
// Defect #49: `??` only falls back to a generated phrase on null/undefined,
|
|
105
665
|
// so `setup --recovery-phrase ""` sailed through with an empty phrase and no
|
|
@@ -118,7 +678,11 @@ export async function initialize(dataDir, providedPhrase, opts = {}) {
|
|
|
118
678
|
...(opts.storageMode === "plain" ? { storageMode: "plain" } : {}),
|
|
119
679
|
recovery: { phraseCheck: phraseCheck(phrase) }
|
|
120
680
|
};
|
|
121
|
-
await
|
|
681
|
+
await withConfigLock(dataDir, async () => {
|
|
682
|
+
if (await exists(configPath(dataDir)))
|
|
683
|
+
fail("vault_already_initialized", `Vault already initialized at ${dataDir}`, { dataDir });
|
|
684
|
+
await writeJsonAtomic(configPath(dataDir), config);
|
|
685
|
+
});
|
|
122
686
|
await mkdir(config.storage.root, { recursive: true });
|
|
123
687
|
return { config, phrase };
|
|
124
688
|
}
|
|
@@ -154,13 +718,13 @@ function assertArchivable(absolute, size) {
|
|
|
154
718
|
* 3 MB reports the 3 MB archive. Nothing is returned when the file was
|
|
155
719
|
* rewritten, truncated, or has never been archived.
|
|
156
720
|
*/
|
|
157
|
-
export async function provenAppendOffset(archives, sourcePath, currentBytes) {
|
|
721
|
+
export async function provenAppendOffset(archives, sourcePath, currentBytes, onActivity, signal, onYield) {
|
|
158
722
|
const candidates = archives
|
|
159
723
|
.filter((record) => record.source.path === sourcePath && record.source.bytes > 0 && record.source.bytes <= currentBytes)
|
|
160
724
|
.sort((a, b) => b.source.bytes - a.source.bytes);
|
|
161
725
|
if (candidates.length === 0)
|
|
162
726
|
return null;
|
|
163
|
-
const digests = await hashFilePrefixes(sourcePath, candidates.map((record) => record.source.bytes));
|
|
727
|
+
const digests = await hashFilePrefixes(sourcePath, candidates.map((record) => record.source.bytes), onActivity, signal, onYield);
|
|
164
728
|
for (const record of candidates) {
|
|
165
729
|
const prefix = digests.get(record.source.bytes);
|
|
166
730
|
if (prefix && equalHex(prefix, record.source.sha256))
|
|
@@ -168,6 +732,9 @@ export async function provenAppendOffset(archives, sourcePath, currentBytes) {
|
|
|
168
732
|
}
|
|
169
733
|
return null;
|
|
170
734
|
}
|
|
735
|
+
/** Bound replay work for an agent that appends forever. The next snapshot
|
|
736
|
+
* after this many physical links is sealed whole and becomes a new base. */
|
|
737
|
+
export const MAX_DELTA_CHAIN_LINKS = 16;
|
|
171
738
|
/**
|
|
172
739
|
* The delta pointer on a record, when the record is a delta archive.
|
|
173
740
|
*
|
|
@@ -194,26 +761,39 @@ export function deltaOf(record) {
|
|
|
194
761
|
* is removed for the same reason sealArchiveToFile removes its destination — a
|
|
195
762
|
* crash must never leave a .partial that could be mistaken for an archive.
|
|
196
763
|
*/
|
|
197
|
-
async function writePlainArchive(sourcePath, staged, onProgress) {
|
|
764
|
+
async function writePlainArchive(sourcePath, staged, snapshotBytes, onProgress, signal) {
|
|
198
765
|
const originalHash = createHash("sha256");
|
|
199
766
|
const storedHash = createHash("sha256");
|
|
200
767
|
let originalBytes = 0;
|
|
201
768
|
let storedBytes = 0;
|
|
202
769
|
try {
|
|
203
|
-
await pipeline(
|
|
770
|
+
await pipeline(
|
|
771
|
+
// A transcript may still be receiving turns while the background worker
|
|
772
|
+
// archives it. Read exactly the size captured before the source lock was
|
|
773
|
+
// entered, just like the sealed path does. `end` is inclusive; for an
|
|
774
|
+
// empty snapshot we let the stream open/read at most byte zero and drop
|
|
775
|
+
// it below, preserving both source-error reporting and a real empty gzip.
|
|
776
|
+
createReadStream(sourcePath, { start: 0, end: Math.max(0, snapshotBytes - 1), signal }), async function* (chunks) {
|
|
777
|
+
let remaining = snapshotBytes;
|
|
204
778
|
for await (const piece of chunks) {
|
|
205
|
-
|
|
206
|
-
|
|
779
|
+
signal?.throwIfAborted();
|
|
780
|
+
if (remaining <= 0)
|
|
781
|
+
continue;
|
|
782
|
+
const slice = piece.subarray(0, remaining);
|
|
783
|
+
originalHash.update(slice);
|
|
784
|
+
originalBytes += slice.length;
|
|
785
|
+
remaining -= slice.length;
|
|
207
786
|
onProgress?.(originalBytes);
|
|
208
|
-
yield
|
|
787
|
+
yield slice;
|
|
209
788
|
}
|
|
210
789
|
}, createGzip({ level: 6 }), async function* (chunks) {
|
|
211
790
|
for await (const piece of chunks) {
|
|
791
|
+
signal?.throwIfAborted();
|
|
212
792
|
storedHash.update(piece);
|
|
213
793
|
storedBytes += piece.length;
|
|
214
794
|
yield piece;
|
|
215
795
|
}
|
|
216
|
-
}, createWriteStream(staged, { mode: 0o600 }));
|
|
796
|
+
}, createWriteStream(staged, { mode: 0o600, signal }), { signal });
|
|
217
797
|
}
|
|
218
798
|
catch (error) {
|
|
219
799
|
await rm(staged, { force: true });
|
|
@@ -235,70 +815,290 @@ async function writePlainArchive(sourcePath, staged, onProgress) {
|
|
|
235
815
|
* queue worker turns it on, because re-sealing a growing session whole on
|
|
236
816
|
* every snapshot is exactly how 4 GB of transcript became 40 GB of storage.
|
|
237
817
|
*/
|
|
818
|
+
/**
|
|
819
|
+
* One seal of a given source at a time, across processes.
|
|
820
|
+
*
|
|
821
|
+
* Deduplication is a check followed by an act: look for an archive of these
|
|
822
|
+
* exact bytes, and seal one if there is none. Two callers arriving together
|
|
823
|
+
* both looked, both found nothing, and both sealed — two archives of one file,
|
|
824
|
+
* two records, twice the bytes. Not hypothetical: the daemon and a hand-run
|
|
825
|
+
* `sealkeep archive` reach for the same session constantly, and the queue runs
|
|
826
|
+
* seals in parallel by design.
|
|
827
|
+
*
|
|
828
|
+
* A lock file rather than an in-process mutex, because the racing callers are
|
|
829
|
+
* usually different processes. A dead owner's lock is reclaimed immediately;
|
|
830
|
+
* a malformed legacy lock has no PID to prove dead, so it is reclaimed only
|
|
831
|
+
* after the staleness window. Age alone never overrides a live owner: sealing a
|
|
832
|
+
* multi-gigabyte transcript can legitimately take longer than either window.
|
|
833
|
+
*/
|
|
834
|
+
const SEAL_LOCK_STALE_MS = 30 * 60_000;
|
|
835
|
+
function cancellationError(signal) {
|
|
836
|
+
if (signal.reason instanceof Error)
|
|
837
|
+
return signal.reason;
|
|
838
|
+
const error = new Error("The seal was cancelled");
|
|
839
|
+
error.name = "AbortError";
|
|
840
|
+
return error;
|
|
841
|
+
}
|
|
842
|
+
async function cancellablePause(milliseconds, signal) {
|
|
843
|
+
if (!signal) {
|
|
844
|
+
await new Promise((resolvePause) => setTimeout(resolvePause, milliseconds));
|
|
845
|
+
return;
|
|
846
|
+
}
|
|
847
|
+
if (signal.aborted)
|
|
848
|
+
throw cancellationError(signal);
|
|
849
|
+
await new Promise((resolvePause, reject) => {
|
|
850
|
+
const timer = setTimeout(done, milliseconds);
|
|
851
|
+
const cancel = () => { clearTimeout(timer); cleanup(); reject(cancellationError(signal)); };
|
|
852
|
+
function cleanup() { signal.removeEventListener("abort", cancel); }
|
|
853
|
+
function done() { cleanup(); resolvePause(); }
|
|
854
|
+
signal.addEventListener("abort", cancel, { once: true });
|
|
855
|
+
});
|
|
856
|
+
}
|
|
857
|
+
async function withSourceLock(dataDir, absolute, work, signal) {
|
|
858
|
+
const { mkdir: makeDir, rm: remove, stat: statFile, open } = await import("node:fs/promises");
|
|
859
|
+
const dir = join(dataDir, "locks");
|
|
860
|
+
await makeDir(dir, { recursive: true, mode: 0o700 });
|
|
861
|
+
const lock = join(dir, `${createHash("sha256").update(absolute).digest("hex").slice(0, 32)}.lock`);
|
|
862
|
+
const ownership = `${process.pid} ${randomUUID()} ${new Date().toISOString()}\n`;
|
|
863
|
+
const abandoned = (held, owner) => {
|
|
864
|
+
const pid = Number(owner.trim().split(/\s+/, 1)[0]);
|
|
865
|
+
const hasPid = Number.isSafeInteger(pid) && pid > 0;
|
|
866
|
+
return (hasPid && !processIsAlive(pid))
|
|
867
|
+
|| (!hasPid && held !== null && Date.now() - Number(held.mtimeMs) > SEAL_LOCK_STALE_MS);
|
|
868
|
+
};
|
|
869
|
+
/**
|
|
870
|
+
* Only one waiter may remove an abandoned source lock at a time. Without this
|
|
871
|
+
* small recovery guard, two processes can both observe the same dead PID;
|
|
872
|
+
* the slower one's `rm` can then delete the faster one's newly acquired live
|
|
873
|
+
* lock and both proceed to seal. The guard is held through the second
|
|
874
|
+
* read/check/remove, never through the seal itself.
|
|
875
|
+
*/
|
|
876
|
+
const recoverAbandonedLock = async () => {
|
|
877
|
+
const recoveryLock = `${lock}.recovery`;
|
|
878
|
+
const recoveryOwnership = `${process.pid} ${randomUUID()} ${new Date().toISOString()}\n`;
|
|
879
|
+
for (;;) {
|
|
880
|
+
signal?.throwIfAborted();
|
|
881
|
+
try {
|
|
882
|
+
const handle = await open(recoveryLock, "wx", 0o600);
|
|
883
|
+
try {
|
|
884
|
+
await handle.writeFile(recoveryOwnership);
|
|
885
|
+
}
|
|
886
|
+
catch (error) {
|
|
887
|
+
await remove(recoveryLock, { force: true }).catch(() => undefined);
|
|
888
|
+
throw error;
|
|
889
|
+
}
|
|
890
|
+
finally {
|
|
891
|
+
await handle.close().catch(() => undefined);
|
|
892
|
+
}
|
|
893
|
+
break;
|
|
894
|
+
}
|
|
895
|
+
catch (error) {
|
|
896
|
+
if (error.code !== "EEXIST")
|
|
897
|
+
throw error;
|
|
898
|
+
const [held, owner] = await Promise.all([
|
|
899
|
+
statFile(recoveryLock).catch(() => null),
|
|
900
|
+
readFile(recoveryLock, "utf8").catch(() => ""),
|
|
901
|
+
]);
|
|
902
|
+
if (abandoned(held, owner)) {
|
|
903
|
+
// A token check prevents a delayed dead-guard observer from deleting
|
|
904
|
+
// a successor guard that appeared at the same path meanwhile.
|
|
905
|
+
const current = await readFile(recoveryLock, "utf8").catch(() => null);
|
|
906
|
+
if (current === owner)
|
|
907
|
+
await remove(recoveryLock, { force: true }).catch(() => undefined);
|
|
908
|
+
continue;
|
|
909
|
+
}
|
|
910
|
+
await cancellablePause(100, signal);
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
try {
|
|
914
|
+
const [held, owner] = await Promise.all([
|
|
915
|
+
statFile(lock).catch(() => null),
|
|
916
|
+
readFile(lock, "utf8").catch(() => ""),
|
|
917
|
+
]);
|
|
918
|
+
if (abandoned(held, owner))
|
|
919
|
+
await remove(lock, { force: true });
|
|
920
|
+
}
|
|
921
|
+
finally {
|
|
922
|
+
const current = await readFile(recoveryLock, "utf8").catch(() => null);
|
|
923
|
+
if (current === recoveryOwnership)
|
|
924
|
+
await remove(recoveryLock, { force: true }).catch(() => undefined);
|
|
925
|
+
}
|
|
926
|
+
};
|
|
927
|
+
for (;;) {
|
|
928
|
+
signal?.throwIfAborted();
|
|
929
|
+
try {
|
|
930
|
+
const handle = await open(lock, "wx", 0o600);
|
|
931
|
+
try {
|
|
932
|
+
await handle.writeFile(ownership);
|
|
933
|
+
}
|
|
934
|
+
catch (error) {
|
|
935
|
+
await remove(lock, { force: true }).catch(() => undefined);
|
|
936
|
+
throw error;
|
|
937
|
+
}
|
|
938
|
+
finally {
|
|
939
|
+
await handle.close().catch(() => undefined);
|
|
940
|
+
}
|
|
941
|
+
break;
|
|
942
|
+
}
|
|
943
|
+
catch (error) {
|
|
944
|
+
if (error.code !== "EEXIST")
|
|
945
|
+
throw error;
|
|
946
|
+
const [held, owner] = await Promise.all([
|
|
947
|
+
statFile(lock).catch(() => null),
|
|
948
|
+
readFile(lock, "utf8").catch(() => ""),
|
|
949
|
+
]);
|
|
950
|
+
if (abandoned(held, owner)) {
|
|
951
|
+
await recoverAbandonedLock();
|
|
952
|
+
continue;
|
|
953
|
+
}
|
|
954
|
+
await cancellablePause(100, signal);
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
try {
|
|
958
|
+
return await work();
|
|
959
|
+
}
|
|
960
|
+
finally {
|
|
961
|
+
// Never unlink a successor's lock. This matters after stale-lock recovery:
|
|
962
|
+
// an unusually delayed former owner can still reach its `finally` after a
|
|
963
|
+
// new process has acquired the same path.
|
|
964
|
+
const current = await readFile(lock, "utf8").catch(() => null);
|
|
965
|
+
if (current === ownership)
|
|
966
|
+
await remove(lock, { force: true }).catch(() => undefined);
|
|
967
|
+
}
|
|
968
|
+
}
|
|
238
969
|
export async function archiveFile(dataDir, sourcePath, rawPhrase, agent = "custom", hooks = {}) {
|
|
970
|
+
hooks.signal?.throwIfAborted();
|
|
239
971
|
const phrase = canonicalPhrase(rawPhrase);
|
|
240
972
|
const config = await readConfig(dataDir);
|
|
973
|
+
const projectScope = teamSpaceOf(config, hooks.project, hooks.projectKey)?.spaceKey;
|
|
241
974
|
if (!equalHex(config.recovery.phraseCheck, phraseCheck(phrase)))
|
|
242
975
|
fail("recovery_phrase_mismatch", "Recovery phrase does not match this vault");
|
|
243
|
-
// The
|
|
244
|
-
//
|
|
245
|
-
|
|
246
|
-
const chunkBytes = hooks.chunkBytes ?? (() => {
|
|
247
|
-
const override = Number(envVar("CHUNK_BYTES"));
|
|
248
|
-
return Number.isInteger(override) && override > 0 ? override : undefined;
|
|
249
|
-
})();
|
|
976
|
+
// The typed seam lets deterministic tests exercise boundaries without a
|
|
977
|
+
// hidden environment variable changing production archive layout.
|
|
978
|
+
const chunkBytes = hooks.chunkBytes;
|
|
250
979
|
const absolute = resolve(sourcePath);
|
|
251
980
|
const source = await stat(absolute).catch(() => null);
|
|
252
981
|
if (!source?.isFile())
|
|
253
982
|
return fail("source_unreadable", `Transcript is not readable: ${absolute}`, { sourcePath: absolute });
|
|
254
983
|
assertArchivable(absolute, source.size);
|
|
255
|
-
//
|
|
256
|
-
//
|
|
257
|
-
//
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
984
|
+
// Everything from here — the "have I already got this?" question and the seal
|
|
985
|
+
// that answers it — happens under one lock per source, so two callers cannot
|
|
986
|
+
// both decide the answer is no.
|
|
987
|
+
return withSourceLock(dataDir, absolute, async () => {
|
|
988
|
+
hooks.signal?.throwIfAborted();
|
|
989
|
+
// Hashing the source to answer "have I already archived exactly this?" is a
|
|
990
|
+
// whole extra read of a file that may be gigabytes, so it is only worth doing
|
|
991
|
+
// when some archive of this path already claims the same length. Anything of
|
|
992
|
+
// a different length has certainly changed.
|
|
993
|
+
const archives = await listArchives(dataDir);
|
|
994
|
+
const sameLength = archives.filter((item) => item.source.path === absolute && item.source.bytes === source.size);
|
|
995
|
+
if (sameLength.length > 0) {
|
|
996
|
+
// The source may be a live transcript. Hash the size captured above,
|
|
997
|
+
// which is also the range the seal records; an unbounded read can chase an
|
|
998
|
+
// appending agent indefinitely and can never equal a same-length archive.
|
|
999
|
+
const { sha256: digest } = await hashFileRange(absolute, 0, source.size, hooks.onActivity, hooks.signal, hooks.onYield);
|
|
1000
|
+
const existing = sameLength.find((item) => equalHex(item.source.sha256, digest));
|
|
1001
|
+
if (existing)
|
|
1002
|
+
return { ...existing, deduplicated: true };
|
|
1003
|
+
}
|
|
1004
|
+
// What this snapshot adds to the newest one that is still a prefix of the
|
|
1005
|
+
// file. Recorded, not acted on: the archive below is a complete, standalone
|
|
1006
|
+
// archive of the whole transcript. See the note on `supersedes` in types.ts.
|
|
1007
|
+
const base = await provenAppendOffset(archives, absolute, source.size, hooks.onActivity, hooks.signal, hooks.onYield);
|
|
1008
|
+
// The delta path: the caller asked for it, the prefix is proven, and there
|
|
1009
|
+
// is genuinely a tail to seal. Plain vaults are excluded — a bare gzip has
|
|
1010
|
+
// no ranged layout, and "portable, readable memory" should stay whole files.
|
|
1011
|
+
// (base.bytes === size never reaches here: the dedupe above already returned
|
|
1012
|
+
// that archive.)
|
|
1013
|
+
if (hooks.delta === true && config.storageMode !== "plain" && base && base.bytes < source.size) {
|
|
1014
|
+
// `provenAppendOffset` deliberately keeps its public result small, but the
|
|
1015
|
+
// delta writer also needs the hash it just proved. Re-checking that hash
|
|
1016
|
+
// after the tail is sealed closes the window where an in-place rewrite of
|
|
1017
|
+
// the prefix could otherwise publish a chain that can never restore.
|
|
1018
|
+
const baseRecord = archives.find((record) => record.id === base.archiveId);
|
|
1019
|
+
if (!baseRecord)
|
|
1020
|
+
return fail("internal", `The proven base archive disappeared before ${absolute} could be sealed`, { archiveId: base.archiveId, sourcePath: absolute });
|
|
1021
|
+
// Replaying an unbounded append chain on every incremental index pass is
|
|
1022
|
+
// quadratic over a long-running session. Once the chain reaches the fixed
|
|
1023
|
+
// limit, fall through to the ordinary full-snapshot writer. That snapshot
|
|
1024
|
+
// is independently restorable, and later appends start a short new chain.
|
|
1025
|
+
if (resolveDeltaChain(archives, baseRecord).length < MAX_DELTA_CHAIN_LINKS) {
|
|
1026
|
+
return sealDeltaArchive(dataDir, config, absolute, agent, phrase, source.size, { ...base, sha256: baseRecord.source.sha256 }, hooks);
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
const id = randomUUID();
|
|
1030
|
+
const objectPath = join(config.storage.root, `${id}.skarchive`);
|
|
1031
|
+
const staged = `${objectPath}.${randomUUID()}.partial`;
|
|
1032
|
+
if (config.storageMode === "plain") {
|
|
1033
|
+
const plain = await writePlainArchive(absolute, staged, source.size, hooks.onProgress, hooks.signal).catch((error) => {
|
|
1034
|
+
const code = error.code;
|
|
1035
|
+
if (code && ["ENOENT", "EACCES", "EPERM", "EISDIR", "EBUSY"].includes(code))
|
|
1036
|
+
return fail("source_unreadable", `Transcript is not readable: ${absolute}`, { sourcePath: absolute });
|
|
1037
|
+
throw error;
|
|
1038
|
+
});
|
|
1039
|
+
// The record keeps the sealed shape minus the envelope, because everything
|
|
1040
|
+
// downstream — dedupe, verify, offload, push — reads `cipher` as "the
|
|
1041
|
+
// stored object's summary" and never as proof of encryption. In particular
|
|
1042
|
+
// `ciphertextSha256` keeps its name: it has always meant "hash of the
|
|
1043
|
+
// bytes on disk", which here is the gzip, and renaming it would fork every
|
|
1044
|
+
// consumer into two field names for one meaning. No envelope is written
|
|
1045
|
+
// because inventing one would claim key wrapping that never happened.
|
|
1046
|
+
const record = {
|
|
1047
|
+
version: 2, id, createdAt: new Date().toISOString(),
|
|
1048
|
+
// Stored, not re-derived: a project was knowable only by parsing the source
|
|
1049
|
+
// path, which works for an agent's own directories and nowhere else. Extending
|
|
1050
|
+
// a project's membership needs the answer the seal already had.
|
|
1051
|
+
source: { path: absolute, agent, bytes: plain.originalBytes, sha256: plain.originalSha256, ...(hooks.project ? { project: hooks.project } : {}), ...(hooks.projectKey ? { projectKey: hooks.projectKey } : {}), ...(projectScope ? { projectScope } : {}) },
|
|
1052
|
+
cipher: { algorithm: "none", ciphertextSha256: plain.storedSha256, storedBytes: plain.storedBytes, chunks: 0 },
|
|
1053
|
+
objectPath, ...(base ? { supersedes: base } : {})
|
|
1054
|
+
};
|
|
1055
|
+
try {
|
|
1056
|
+
await rename(staged, objectPath);
|
|
1057
|
+
}
|
|
1058
|
+
catch (error) {
|
|
1059
|
+
await rm(staged, { force: true });
|
|
1060
|
+
throw error;
|
|
1061
|
+
}
|
|
1062
|
+
await writeRecord(config.storage.root, record);
|
|
1063
|
+
await recordAudit(dataDir, "archive.create", "allowed", { archiveId: id, agent, bytes: plain.originalBytes, recipients: 0, supersedes: base?.archiveId ?? null });
|
|
1064
|
+
return { ...record, deduplicated: false };
|
|
1065
|
+
}
|
|
1066
|
+
// Compression happens before the seal because ciphertext is incompressible
|
|
1067
|
+
// by construction. How much it buys was assumed here to be about 2x; measured
|
|
1068
|
+
// on a real 1.6 GB Codex rollout it is 1.5x, because these files carry a lot
|
|
1069
|
+
// of base64 and tool output that has already been compressed once. The
|
|
1070
|
+
// difference describes expected storage, not safe admission: the disk guard
|
|
1071
|
+
// uses the codec's incompressible upper bound before these bytes are seen.
|
|
1072
|
+
const tokenCollector = await inlineIndexWanted(dataDir, hooks, source.size)
|
|
1073
|
+
? (await import("./search.js")).createSealTokenCollector(source.size)
|
|
1074
|
+
: null;
|
|
1075
|
+
const sealed = await sealArchiveToFile({ path: absolute, end: source.size }, staged, {
|
|
1076
|
+
...(tokenCollector ? { onPlaintext: tokenCollector.onPlaintext } : {}),
|
|
1077
|
+
// gzip-chunk: compression lives inside each chunk, so sealing is a single
|
|
1078
|
+
// pass with no staged body — the ×2 disk window is gone — and every chunk
|
|
1079
|
+
// can later be fetched and opened alone. Archives sealed under the legacy
|
|
1080
|
+
// whole-body layout stay readable forever; this changes what is WRITTEN.
|
|
1081
|
+
// Members of this project are wrapped in; members of other projects are not.
|
|
1082
|
+
recipients: mergeRecipients(configuredRecipients(config, phrase, { project: hooks.project ?? undefined, projectKey: hooks.projectKey ?? undefined }), hooks.teamRecipients ?? []), archiveId: id, compression: hooks.compression ?? "gzip-chunk",
|
|
1083
|
+
adapter: { agent, version: ADAPTER_VERSION }, scratchDir: config.storage.root,
|
|
1084
|
+
...(chunkBytes ? { chunkBytes } : {}),
|
|
1085
|
+
...(hooks.onProgress ? { onProgress: hooks.onProgress } : {}),
|
|
1086
|
+
...(hooks.onYield ? { onYield: hooks.onYield } : {}),
|
|
1087
|
+
...(hooks.signal ? { signal: hooks.signal } : {}),
|
|
1088
|
+
}).catch((error) => {
|
|
284
1089
|
const code = error.code;
|
|
285
1090
|
if (code && ["ENOENT", "EACCES", "EPERM", "EISDIR", "EBUSY"].includes(code))
|
|
286
1091
|
return fail("source_unreadable", `Transcript is not readable: ${absolute}`, { sourcePath: absolute });
|
|
287
1092
|
throw error;
|
|
288
1093
|
});
|
|
289
|
-
// The record keeps the sealed shape minus the envelope, because everything
|
|
290
|
-
// downstream — dedupe, verify, offload, push — reads `cipher` as "the
|
|
291
|
-
// stored object's summary" and never as proof of encryption. In particular
|
|
292
|
-
// `ciphertextSha256` keeps its name: it has always meant "hash of the
|
|
293
|
-
// bytes on disk", which here is the gzip, and renaming it would fork every
|
|
294
|
-
// consumer into two field names for one meaning. No envelope is written
|
|
295
|
-
// because inventing one would claim key wrapping that never happened.
|
|
296
1094
|
const record = {
|
|
297
|
-
version: 2, id, createdAt:
|
|
298
|
-
source: { path: absolute, agent, bytes:
|
|
299
|
-
cipher: { algorithm:
|
|
300
|
-
objectPath, ...(base ? { supersedes: base } : {})
|
|
1095
|
+
version: 2, id, createdAt: sealed.envelope.manifest.createdAt,
|
|
1096
|
+
source: { path: absolute, agent, bytes: sealed.originalBytes, sha256: sealed.originalSha256, ...(hooks.project ? { project: hooks.project } : {}), ...(hooks.projectKey ? { projectKey: hooks.projectKey } : {}), ...(projectScope ? { projectScope } : {}) },
|
|
1097
|
+
cipher: { algorithm: sealed.envelope.suite, ciphertextSha256: sealed.ciphertextSha256, storedBytes: sealed.storedBytes, chunks: sealed.envelope.chunks.length },
|
|
1098
|
+
envelope: sealed.envelope, objectPath, ...(base ? { supersedes: base } : {})
|
|
301
1099
|
};
|
|
1100
|
+
// The object only becomes an archive once it is whole: the manifest hashes are
|
|
1101
|
+
// proved by the last chunk, so a crash mid-seal leaves a .partial nobody reads.
|
|
302
1102
|
try {
|
|
303
1103
|
await rename(staged, objectPath);
|
|
304
1104
|
}
|
|
@@ -306,50 +1106,57 @@ export async function archiveFile(dataDir, sourcePath, rawPhrase, agent = "custo
|
|
|
306
1106
|
await rm(staged, { force: true });
|
|
307
1107
|
throw error;
|
|
308
1108
|
}
|
|
309
|
-
await
|
|
310
|
-
await recordAudit(dataDir, "archive.create", "allowed", { archiveId: id, agent, bytes:
|
|
1109
|
+
await writeRecord(config.storage.root, record);
|
|
1110
|
+
await recordAudit(dataDir, "archive.create", "allowed", { archiveId: id, agent, bytes: sealed.originalBytes, recipients: sealed.envelope.wrappedKeys.length, supersedes: base?.archiveId ?? null });
|
|
1111
|
+
if (tokenCollector)
|
|
1112
|
+
await indexSealInline(dataDir, phrase, record, tokenCollector.finish(sealed.envelope.chunks.length), hooks.signal);
|
|
311
1113
|
return { ...record, deduplicated: false };
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
1114
|
+
}, hooks.signal);
|
|
1115
|
+
}
|
|
1116
|
+
/** Above this a seal is a whale: the catch-up indexes it last, deliberately, and the seal itself must not carry that cost. */
|
|
1117
|
+
const INLINE_INDEX_MAX_BYTES = 256 * 1024 * 1024;
|
|
1118
|
+
/** A blob index is merged whole, in this process, on every inline seal: fine while it is small, an out-of-memory crash inside the daemon once it is not. */
|
|
1119
|
+
const INLINE_INDEX_MAX_BLOB_ARCHIVES = 2000;
|
|
1120
|
+
async function inlineIndexWanted(dataDir, hooks, sourceBytes) {
|
|
1121
|
+
if (!hooks.indexInline || (hooks.compression ?? "gzip-chunk") === "zstd" || sourceBytes > INLINE_INDEX_MAX_BYTES)
|
|
1122
|
+
return false;
|
|
1123
|
+
const { segmentsEnabled } = await import("./index-store.js");
|
|
1124
|
+
// Segments append one small sealed file per seal, whatever the vault's size,
|
|
1125
|
+
// under the manifest lock — safe beside a running catch-up.
|
|
1126
|
+
if (await segmentsEnabled(dataDir))
|
|
1127
|
+
return true;
|
|
1128
|
+
// The blob has one writer at a time, and while the catch-up is building it
|
|
1129
|
+
// is that writer: a merge from here would race its checkpoint and one of
|
|
1130
|
+
// the two would be lost (the test "cannot bypass the migration guard").
|
|
1131
|
+
const lane = await readFile(join(dataDir, "daemon-state.json"), "utf8")
|
|
1132
|
+
.then((raw) => JSON.parse(raw))
|
|
1133
|
+
.catch(() => null);
|
|
1134
|
+
if (lane?.indexing?.state === "building")
|
|
1135
|
+
return false;
|
|
1136
|
+
// Past this the whole-index merge belongs to the catch-up lane (or to a
|
|
1137
|
+
// migration: `sealkeep index migrate` gives a big vault seal-time indexing).
|
|
1138
|
+
const coverage = await readFile(join(dataDir, "index", "coverage.json"), "utf8")
|
|
1139
|
+
.then((raw) => JSON.parse(raw))
|
|
1140
|
+
.catch(() => null);
|
|
1141
|
+
return (coverage?.indexed?.length ?? 0) <= INLINE_INDEX_MAX_BLOB_ARCHIVES;
|
|
1142
|
+
}
|
|
1143
|
+
/**
|
|
1144
|
+
* The seal is durable before this runs and stays durable if this fails: an
|
|
1145
|
+
* index entry that cannot be written is named (warning + audit) and the next
|
|
1146
|
+
* catch-up pass indexes the archive from its sealed bytes — the contract the
|
|
1147
|
+
* cloud seal path keeps in chunk-store.ts.
|
|
1148
|
+
*/
|
|
1149
|
+
async function indexSealInline(dataDir, phrase, record, tokensByChunk, signal) {
|
|
343
1150
|
try {
|
|
344
|
-
await
|
|
1151
|
+
const { indexArchiveTokens } = await import("./search.js");
|
|
1152
|
+
await indexArchiveTokens(dataDir, phrase, record, tokensByChunk, { ...(signal ? { signal } : {}) });
|
|
345
1153
|
}
|
|
346
1154
|
catch (error) {
|
|
347
|
-
|
|
348
|
-
|
|
1155
|
+
const code = error?.code;
|
|
1156
|
+
const named = typeof code === "string" ? code : error instanceof Error ? error.message.split("\n")[0] : String(error);
|
|
1157
|
+
process.emitWarning(`index merge deferred for ${record.id.slice(0, 8)}: ${named} — the next index pass indexes it from the sealed bytes`, "SealkeepIndexDeferred");
|
|
1158
|
+
await recordAudit(dataDir, "index.merge", "denied", { archiveId: record.id, reason: named, preserved: false }).catch(() => undefined);
|
|
349
1159
|
}
|
|
350
|
-
await writeJson(join(config.storage.root, `${id}.json`), record);
|
|
351
|
-
await recordAudit(dataDir, "archive.create", "allowed", { archiveId: id, agent, bytes: sealed.originalBytes, recipients: sealed.envelope.wrappedKeys.length, supersedes: base?.archiveId ?? null });
|
|
352
|
-
return { ...record, deduplicated: false };
|
|
353
1160
|
}
|
|
354
1161
|
/**
|
|
355
1162
|
* Seals only what an appended transcript added since its proven base archive.
|
|
@@ -370,33 +1177,97 @@ export async function archiveFile(dataDir, sourcePath, rawPhrase, agent = "custo
|
|
|
370
1177
|
* *needs* it — so writing `supersedes` here would offer the base for pruning
|
|
371
1178
|
* and turn a storage saving into data loss.
|
|
372
1179
|
*/
|
|
1180
|
+
async function hashDeltaSnapshot(sourcePath, size, prefixBytes, onActivity, signal, onYield) {
|
|
1181
|
+
const whole = createHash("sha256");
|
|
1182
|
+
const prefix = createHash("sha256");
|
|
1183
|
+
const tail = createHash("sha256");
|
|
1184
|
+
let bytes = 0;
|
|
1185
|
+
let nextYield = 16 * 1024 * 1024;
|
|
1186
|
+
// One bounded pass is important here. Three independent reads could each
|
|
1187
|
+
// describe a different instant of a live transcript; hashing all three
|
|
1188
|
+
// views from the same chunks proves that `whole` is exactly base + sealed
|
|
1189
|
+
// tail whenever both component hashes match.
|
|
1190
|
+
for await (const chunk of createReadStream(sourcePath, { start: 0, end: size - 1, signal })) {
|
|
1191
|
+
signal?.throwIfAborted();
|
|
1192
|
+
const piece = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
1193
|
+
whole.update(piece);
|
|
1194
|
+
const prefixRemaining = Math.max(0, prefixBytes - bytes);
|
|
1195
|
+
const split = Math.min(prefixRemaining, piece.length);
|
|
1196
|
+
if (split > 0)
|
|
1197
|
+
prefix.update(piece.subarray(0, split));
|
|
1198
|
+
if (split < piece.length)
|
|
1199
|
+
tail.update(piece.subarray(split));
|
|
1200
|
+
bytes += piece.length;
|
|
1201
|
+
onActivity?.(bytes);
|
|
1202
|
+
if (bytes >= nextYield) {
|
|
1203
|
+
nextYield = bytes + 16 * 1024 * 1024;
|
|
1204
|
+
if (onYield)
|
|
1205
|
+
await onYield();
|
|
1206
|
+
else
|
|
1207
|
+
await yieldToEventLoop();
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
return {
|
|
1211
|
+
bytes,
|
|
1212
|
+
wholeSha256: whole.digest("hex"),
|
|
1213
|
+
prefixSha256: prefix.digest("hex"),
|
|
1214
|
+
tailSha256: tail.digest("hex")
|
|
1215
|
+
};
|
|
1216
|
+
}
|
|
373
1217
|
async function sealDeltaArchive(dataDir, config, absolute, agent, phrase, size, base, hooks) {
|
|
374
1218
|
const id = randomUUID();
|
|
375
|
-
const objectPath = join(config.storage.root, `${id}.
|
|
1219
|
+
const objectPath = join(config.storage.root, `${id}.skarchive`);
|
|
376
1220
|
const staged = `${objectPath}.${randomUUID()}.partial`;
|
|
1221
|
+
const projectScope = teamSpaceOf(config, hooks.project, hooks.projectKey)?.spaceKey;
|
|
1222
|
+
// Same inline-indexing gate a full seal uses, sized to what THIS seal
|
|
1223
|
+
// actually covers: the delta's own appended range, not the whole
|
|
1224
|
+
// transcript. That range is exactly what the delta's own chunk numbers
|
|
1225
|
+
// describe, which is what lets a later query ranged-read this object
|
|
1226
|
+
// directly instead of decrypting the whole chain for one quote.
|
|
1227
|
+
const appendedBytes = size - base.bytes;
|
|
1228
|
+
const tokenCollector = await inlineIndexWanted(dataDir, hooks, appendedBytes)
|
|
1229
|
+
? (await import("./search.js")).createSealTokenCollector(appendedBytes)
|
|
1230
|
+
: null;
|
|
377
1231
|
const sealed = await sealArchiveToFile({ path: absolute, start: base.bytes, end: size }, staged, {
|
|
378
|
-
recipients: configuredRecipients(config, phrase, { project: hooks.project ?? undefined }), archiveId: id, compression: "gzip-chunk",
|
|
1232
|
+
recipients: mergeRecipients(configuredRecipients(config, phrase, { project: hooks.project ?? undefined, projectKey: hooks.projectKey ?? undefined }), hooks.teamRecipients ?? []), archiveId: id, compression: "gzip-chunk",
|
|
379
1233
|
adapter: { agent, version: ADAPTER_VERSION }, scratchDir: config.storage.root,
|
|
380
|
-
...(
|
|
1234
|
+
...(tokenCollector ? { onPlaintext: tokenCollector.onPlaintext } : {}),
|
|
1235
|
+
...(hooks.onProgress ? { onProgress: hooks.onProgress } : {}),
|
|
1236
|
+
...(hooks.onYield ? { onYield: hooks.onYield } : {}),
|
|
1237
|
+
...(hooks.signal ? { signal: hooks.signal } : {}),
|
|
381
1238
|
}).catch((error) => {
|
|
382
1239
|
const code = error.code;
|
|
383
1240
|
if (code && ["ENOENT", "EACCES", "EPERM", "EISDIR", "EBUSY"].includes(code))
|
|
384
1241
|
return fail("source_unreadable", `Transcript is not readable: ${absolute}`, { sourcePath: absolute });
|
|
385
1242
|
throw error;
|
|
386
1243
|
});
|
|
387
|
-
//
|
|
388
|
-
//
|
|
389
|
-
//
|
|
390
|
-
//
|
|
391
|
-
//
|
|
392
|
-
|
|
393
|
-
|
|
1244
|
+
// Validate the source snapshot after sealing and before publishing. Length
|
|
1245
|
+
// alone is not proof: an agent (or editor) can rewrite already-proved bytes
|
|
1246
|
+
// in place without changing the file size. The prefix must still be the base
|
|
1247
|
+
// archive and the tail must still be the bytes in the staged delta. The
|
|
1248
|
+
// queued retry will re-prove the source and choose a whole archive if either
|
|
1249
|
+
// component changed.
|
|
1250
|
+
let snapshot;
|
|
1251
|
+
try {
|
|
1252
|
+
snapshot = await hashDeltaSnapshot(absolute, size, base.bytes, hooks.onActivity, hooks.signal, hooks.onYield);
|
|
1253
|
+
}
|
|
1254
|
+
catch (error) {
|
|
1255
|
+
await rm(staged, { force: true });
|
|
1256
|
+
const code = error.code;
|
|
1257
|
+
if (code && ["ENOENT", "EACCES", "EPERM", "EISDIR", "EBUSY"].includes(code))
|
|
1258
|
+
return fail("source_unreadable", `Transcript is not readable: ${absolute}`, { sourcePath: absolute });
|
|
1259
|
+
throw error;
|
|
1260
|
+
}
|
|
1261
|
+
if (sealed.originalBytes !== size - base.bytes ||
|
|
1262
|
+
snapshot.bytes !== size ||
|
|
1263
|
+
!equalHex(snapshot.prefixSha256, base.sha256) ||
|
|
1264
|
+
!equalHex(snapshot.tailSha256, sealed.originalSha256)) {
|
|
394
1265
|
await rm(staged, { force: true });
|
|
395
1266
|
fail("source_unreadable", `${absolute} changed while its append was being sealed; it will re-archive whole on the next pass`, { sourcePath: absolute, bytes: size });
|
|
396
1267
|
}
|
|
397
1268
|
const record = {
|
|
398
1269
|
version: 2, id, createdAt: sealed.envelope.manifest.createdAt,
|
|
399
|
-
source: { path: absolute, agent, bytes: size, sha256:
|
|
1270
|
+
source: { path: absolute, agent, bytes: size, sha256: snapshot.wholeSha256, ...(hooks.project ? { project: hooks.project } : {}), ...(hooks.projectKey ? { projectKey: hooks.projectKey } : {}), ...(projectScope ? { projectScope } : {}) },
|
|
400
1271
|
cipher: { algorithm: sealed.envelope.suite, ciphertextSha256: sealed.ciphertextSha256, storedBytes: sealed.storedBytes, chunks: sealed.envelope.chunks.length },
|
|
401
1272
|
envelope: sealed.envelope, objectPath,
|
|
402
1273
|
delta: { baseArchiveId: base.archiveId, baseBytes: base.bytes }
|
|
@@ -408,8 +1279,12 @@ async function sealDeltaArchive(dataDir, config, absolute, agent, phrase, size,
|
|
|
408
1279
|
await rm(staged, { force: true });
|
|
409
1280
|
throw error;
|
|
410
1281
|
}
|
|
411
|
-
await
|
|
1282
|
+
await writeRecord(config.storage.root, record);
|
|
412
1283
|
await recordAudit(dataDir, "archive.create", "allowed", { archiveId: id, agent, bytes: size, appendedBytes: sealed.originalBytes, recipients: sealed.envelope.wrappedKeys.length, supersedes: null, deltaBase: base.archiveId });
|
|
1284
|
+
// The seal is durable either way; indexing it is advisory (see
|
|
1285
|
+
// indexSealInline) and, unlike a full seal, covers only the appended range.
|
|
1286
|
+
if (tokenCollector)
|
|
1287
|
+
await indexSealInline(dataDir, phrase, record, tokenCollector.finish(sealed.envelope.chunks.length), hooks.signal);
|
|
413
1288
|
return { ...record, deduplicated: false };
|
|
414
1289
|
}
|
|
415
1290
|
/**
|
|
@@ -431,21 +1306,653 @@ export function configuredRecipients(config, rawPhrase, scope) {
|
|
|
431
1306
|
const asked = typeof scope === "string" ? { group: scope } : (scope ?? {});
|
|
432
1307
|
const keys = (config.recipients ?? [])
|
|
433
1308
|
.filter((recipient) => (asked.group ? recipient.group === asked.group : true))
|
|
434
|
-
.filter((recipient) =>
|
|
1309
|
+
.filter((recipient) => {
|
|
1310
|
+
if (!recipient.project && !recipient.projectKey)
|
|
1311
|
+
return true;
|
|
1312
|
+
// Once a stable key is known, a display label is never sufficient proof
|
|
1313
|
+
// of scope. This is the fail-closed boundary that keeps org-a/api from
|
|
1314
|
+
// receiving org-b/api's memory.
|
|
1315
|
+
if (asked.projectKey)
|
|
1316
|
+
return recipient.projectKey === asked.projectKey;
|
|
1317
|
+
if (recipient.projectKey)
|
|
1318
|
+
return false;
|
|
1319
|
+
return recipient.project === asked.project;
|
|
1320
|
+
})
|
|
435
1321
|
.map((recipient) => ({ kind: "x25519", publicKey: x25519PublicKeyFromRaw(Buffer.from(recipient.publicKey, "base64")) }));
|
|
436
1322
|
return [{ kind: "phrase", phrase }, ...keys];
|
|
437
1323
|
}
|
|
1324
|
+
/** Accepted managed team members that should open future project archives. */
|
|
1325
|
+
export async function teamRecipientsForProject(dataDir, project, projectKey) {
|
|
1326
|
+
if (!project && !projectKey)
|
|
1327
|
+
return [];
|
|
1328
|
+
const config = await readConfig(dataDir);
|
|
1329
|
+
const binding = teamSpaceOf(config, project, projectKey);
|
|
1330
|
+
if (!binding)
|
|
1331
|
+
return [];
|
|
1332
|
+
try {
|
|
1333
|
+
const { listTeamMembers } = await import("./cloud.js");
|
|
1334
|
+
return (await listTeamMembers(dataDir, binding.spaceKey)).filter((member) => typeof member.public_key === "string").map((member) => ({ kind: "x25519", publicKey: x25519PublicKeyFromRaw(Buffer.from(member.public_key, "base64")) }));
|
|
1335
|
+
}
|
|
1336
|
+
catch {
|
|
1337
|
+
// A transient control-plane outage must not prevent local preservation; the
|
|
1338
|
+
// normal recovery phrase recipient still seals the archive safely.
|
|
1339
|
+
return [];
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
function mergeRecipients(base, extra) {
|
|
1343
|
+
const identity = (item) => item.kind === "phrase"
|
|
1344
|
+
? `phrase:${item.phrase.normalize("NFKD")}`
|
|
1345
|
+
: `x25519:${keyRecipientId(Buffer.isBuffer(item.publicKey) ? item.publicKey : rawPublicKey(item.publicKey))}`;
|
|
1346
|
+
const seen = new Set(base.map(identity));
|
|
1347
|
+
return [...base, ...extra.filter((item) => {
|
|
1348
|
+
const key = identity(item);
|
|
1349
|
+
if (seen.has(key))
|
|
1350
|
+
return false;
|
|
1351
|
+
seen.add(key);
|
|
1352
|
+
return true;
|
|
1353
|
+
})];
|
|
1354
|
+
}
|
|
1355
|
+
/**
|
|
1356
|
+
* Every archive this vault knows about.
|
|
1357
|
+
*
|
|
1358
|
+
* One unreadable record used to take the whole list with it: `JSON.parse`
|
|
1359
|
+
* threw inside a `Promise.all`, so a single truncated file — a crash mid-write,
|
|
1360
|
+
* a disk that filled between `open` and `close` — made `status`, `search`,
|
|
1361
|
+
* `retention` and every other command that lists archives fail outright. A
|
|
1362
|
+
* vault of two hundred good records became a vault of none, over one bad byte.
|
|
1363
|
+
*
|
|
1364
|
+
* A record that will not parse is skipped and NAMED, not swallowed: it may be
|
|
1365
|
+
* the only trace of an archive, and quietly dropping it would turn a loud
|
|
1366
|
+
* problem into a missing one. The archives that do parse still answer.
|
|
1367
|
+
*/
|
|
438
1368
|
export async function listArchives(dataDir) {
|
|
439
1369
|
const config = await readConfig(dataDir);
|
|
1370
|
+
const scan = await currentArchiveLedgerScan(config.storage.root);
|
|
1371
|
+
reportUnreadableArchiveRecords(config.storage.root, scan.unreadable);
|
|
1372
|
+
// Each caller owns the collection it receives even when several concurrent
|
|
1373
|
+
// dashboard requests shared the underlying filesystem scan.
|
|
1374
|
+
return scan.records.slice();
|
|
1375
|
+
}
|
|
1376
|
+
// The dashboard loads several archive-derived endpoints together. A per-call
|
|
1377
|
+
// batch bound alone still multiplied 32 opens by every endpoint. Share both an
|
|
1378
|
+
// in-flight scan and a completed, generation-validated scan. Archive record
|
|
1379
|
+
// writers publish with atomic rename, which changes the root generation. A
|
|
1380
|
+
// malformed file is the exception: a person may repair it in place, so its own
|
|
1381
|
+
// stat fingerprint is revalidated before the completed scan can be reused.
|
|
1382
|
+
const inFlightArchiveLedgerScans = new Map();
|
|
1383
|
+
const completedArchiveLedgerScans = new Map();
|
|
1384
|
+
const MAX_COMPLETED_ARCHIVE_LEDGER_SCANS = 32;
|
|
1385
|
+
async function archiveDirectoryGeneration(storageRoot) {
|
|
1386
|
+
try {
|
|
1387
|
+
const value = await stat(storageRoot, { bigint: true });
|
|
1388
|
+
return [value.dev, value.ino, value.size, value.mtimeNs, value.ctimeNs].join(":");
|
|
1389
|
+
}
|
|
1390
|
+
catch {
|
|
1391
|
+
return null;
|
|
1392
|
+
}
|
|
1393
|
+
}
|
|
1394
|
+
async function archiveSidecarFingerprint(path) {
|
|
1395
|
+
try {
|
|
1396
|
+
const value = await stat(path, { bigint: true });
|
|
1397
|
+
return [value.dev, value.ino, value.size, value.mtimeNs, value.ctimeNs].join(":");
|
|
1398
|
+
}
|
|
1399
|
+
catch {
|
|
1400
|
+
return null;
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
async function completedArchiveLedgerScanIsCurrent(storageRoot, scan) {
|
|
1404
|
+
if (!scan.cacheable || !scan.directoryGeneration
|
|
1405
|
+
|| await archiveDirectoryGeneration(storageRoot) !== scan.directoryGeneration)
|
|
1406
|
+
return false;
|
|
1407
|
+
for (const [file, fingerprint] of scan.damaged) {
|
|
1408
|
+
if (await archiveSidecarFingerprint(join(storageRoot, file)) !== fingerprint)
|
|
1409
|
+
return false;
|
|
1410
|
+
}
|
|
1411
|
+
return true;
|
|
1412
|
+
}
|
|
1413
|
+
function rememberCompletedArchiveLedgerScan(storageRoot, scan) {
|
|
1414
|
+
completedArchiveLedgerScans.delete(storageRoot);
|
|
1415
|
+
completedArchiveLedgerScans.set(storageRoot, scan);
|
|
1416
|
+
if (completedArchiveLedgerScans.size <= MAX_COMPLETED_ARCHIVE_LEDGER_SCANS)
|
|
1417
|
+
return;
|
|
1418
|
+
const oldest = completedArchiveLedgerScans.keys().next().value;
|
|
1419
|
+
if (oldest !== undefined)
|
|
1420
|
+
completedArchiveLedgerScans.delete(oldest);
|
|
1421
|
+
}
|
|
1422
|
+
async function currentArchiveLedgerScan(storageRoot) {
|
|
1423
|
+
const key = resolve(storageRoot);
|
|
1424
|
+
const cached = completedArchiveLedgerScans.get(key);
|
|
1425
|
+
if (cached) {
|
|
1426
|
+
if (await completedArchiveLedgerScanIsCurrent(storageRoot, cached)) {
|
|
1427
|
+
// Refresh insertion order so the small process-wide map behaves as an LRU.
|
|
1428
|
+
completedArchiveLedgerScans.delete(key);
|
|
1429
|
+
completedArchiveLedgerScans.set(key, cached);
|
|
1430
|
+
return cached;
|
|
1431
|
+
}
|
|
1432
|
+
if (completedArchiveLedgerScans.get(key) === cached)
|
|
1433
|
+
completedArchiveLedgerScans.delete(key);
|
|
1434
|
+
}
|
|
1435
|
+
// Another caller may have started the replacement scan while this caller
|
|
1436
|
+
// awaited generation/fingerprint validation above.
|
|
1437
|
+
const existing = inFlightArchiveLedgerScans.get(key);
|
|
1438
|
+
if (existing)
|
|
1439
|
+
return existing;
|
|
1440
|
+
let tracked;
|
|
1441
|
+
tracked = scanArchiveLedger(storageRoot).then((scan) => {
|
|
1442
|
+
if (scan.cacheable)
|
|
1443
|
+
rememberCompletedArchiveLedgerScan(key, scan);
|
|
1444
|
+
else
|
|
1445
|
+
completedArchiveLedgerScans.delete(key);
|
|
1446
|
+
return scan;
|
|
1447
|
+
}).finally(() => {
|
|
1448
|
+
if (inFlightArchiveLedgerScans.get(key) === tracked)
|
|
1449
|
+
inFlightArchiveLedgerScans.delete(key);
|
|
1450
|
+
});
|
|
1451
|
+
inFlightArchiveLedgerScans.set(key, tracked);
|
|
1452
|
+
return tracked;
|
|
1453
|
+
}
|
|
1454
|
+
function objectValue(value) {
|
|
1455
|
+
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
1456
|
+
? value
|
|
1457
|
+
: null;
|
|
1458
|
+
}
|
|
1459
|
+
function requiredString(value) {
|
|
1460
|
+
return typeof value === "string" && value.length > 0;
|
|
1461
|
+
}
|
|
1462
|
+
function nonNegativeNumber(value) {
|
|
1463
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0;
|
|
1464
|
+
}
|
|
1465
|
+
function archiveSourceIsValid(value) {
|
|
1466
|
+
const source = objectValue(value);
|
|
1467
|
+
return source !== null
|
|
1468
|
+
&& requiredString(source.path)
|
|
1469
|
+
&& requiredString(source.agent)
|
|
1470
|
+
&& nonNegativeNumber(source.bytes)
|
|
1471
|
+
&& typeof source.sha256 === "string";
|
|
1472
|
+
}
|
|
1473
|
+
function legacyCipherIsValid(value) {
|
|
1474
|
+
const cipher = objectValue(value);
|
|
1475
|
+
return cipher !== null
|
|
1476
|
+
&& cipher.algorithm === "aes-256-gcm"
|
|
1477
|
+
&& requiredString(cipher.nonce)
|
|
1478
|
+
&& requiredString(cipher.authTag)
|
|
1479
|
+
&& typeof cipher.ciphertextSha256 === "string"
|
|
1480
|
+
&& cipher.compression === "none";
|
|
1481
|
+
}
|
|
1482
|
+
function legacyWrappedKeyIsValid(value) {
|
|
1483
|
+
const wrapped = objectValue(value);
|
|
1484
|
+
return wrapped !== null
|
|
1485
|
+
&& wrapped.algorithm === "scrypt-aes-256-gcm"
|
|
1486
|
+
&& requiredString(wrapped.salt)
|
|
1487
|
+
&& requiredString(wrapped.nonce)
|
|
1488
|
+
&& requiredString(wrapped.authTag)
|
|
1489
|
+
&& requiredString(wrapped.ciphertext);
|
|
1490
|
+
}
|
|
1491
|
+
function currentCipherIsValid(value) {
|
|
1492
|
+
const cipher = objectValue(value);
|
|
1493
|
+
return cipher !== null
|
|
1494
|
+
&& (cipher.algorithm === "none" || cipher.algorithm === "aes-256-gcm" || cipher.algorithm === "chacha20-poly1305")
|
|
1495
|
+
&& typeof cipher.ciphertextSha256 === "string"
|
|
1496
|
+
&& nonNegativeNumber(cipher.storedBytes)
|
|
1497
|
+
&& nonNegativeNumber(cipher.chunks)
|
|
1498
|
+
&& Number.isInteger(cipher.chunks);
|
|
1499
|
+
}
|
|
1500
|
+
function currentEnvelopeIsValid(value, archiveId, suite) {
|
|
1501
|
+
const envelope = objectValue(value);
|
|
1502
|
+
const manifest = objectValue(envelope?.manifest);
|
|
1503
|
+
return envelope !== null
|
|
1504
|
+
&& envelope.version === 2
|
|
1505
|
+
&& envelope.archiveId === archiveId
|
|
1506
|
+
&& envelope.suite === suite
|
|
1507
|
+
&& nonNegativeNumber(envelope.chunkBytes)
|
|
1508
|
+
&& Array.isArray(envelope.chunks)
|
|
1509
|
+
&& manifest !== null
|
|
1510
|
+
&& typeof manifest.plaintextSha256 === "string"
|
|
1511
|
+
&& nonNegativeNumber(manifest.plaintextBytes)
|
|
1512
|
+
&& requiredString(manifest.createdAt)
|
|
1513
|
+
&& Array.isArray(envelope.wrappedKeys);
|
|
1514
|
+
}
|
|
1515
|
+
function archiveRecordIsValid(value) {
|
|
1516
|
+
const record = objectValue(value);
|
|
1517
|
+
if (record === null
|
|
1518
|
+
|| !requiredString(record.id)
|
|
1519
|
+
|| !requiredString(record.createdAt)
|
|
1520
|
+
|| !archiveSourceIsValid(record.source)
|
|
1521
|
+
|| !requiredString(record.objectPath))
|
|
1522
|
+
return false;
|
|
1523
|
+
if (record.version === 1) {
|
|
1524
|
+
return record.teamCustody === undefined && legacyCipherIsValid(record.cipher) && legacyWrappedKeyIsValid(record.wrappedKey);
|
|
1525
|
+
}
|
|
1526
|
+
if (record.version !== 2 || !currentCipherIsValid(record.cipher))
|
|
1527
|
+
return false;
|
|
1528
|
+
let sealedIdentity = record.id;
|
|
1529
|
+
if (record.teamCustody !== undefined) {
|
|
1530
|
+
if (!teamCustodyRecordIsValid(record))
|
|
1531
|
+
return false;
|
|
1532
|
+
sealedIdentity = record.teamCustody.envelopeArchiveId;
|
|
1533
|
+
}
|
|
1534
|
+
// Plain archives deliberately have no encryption envelope. Every encrypted
|
|
1535
|
+
// V2 archive must carry the envelope facts needed to interpret its cipher.
|
|
1536
|
+
return record.cipher.algorithm === "none"
|
|
1537
|
+
? record.envelope === undefined
|
|
1538
|
+
: currentEnvelopeIsValid(record.envelope, sealedIdentity, record.cipher.algorithm);
|
|
1539
|
+
}
|
|
1540
|
+
function parseArchiveSidecar(raw) {
|
|
1541
|
+
try {
|
|
1542
|
+
const record = JSON.parse(raw);
|
|
1543
|
+
return archiveRecordIsValid(record) ? record : null;
|
|
1544
|
+
}
|
|
1545
|
+
catch {
|
|
1546
|
+
return null;
|
|
1547
|
+
}
|
|
1548
|
+
}
|
|
1549
|
+
async function readArchiveSidecar(storageRoot, file) {
|
|
1550
|
+
const path = join(storageRoot, file);
|
|
1551
|
+
let raw;
|
|
1552
|
+
try {
|
|
1553
|
+
raw = await readFile(path, "utf8");
|
|
1554
|
+
}
|
|
1555
|
+
catch {
|
|
1556
|
+
return { kind: "transient", file };
|
|
1557
|
+
}
|
|
1558
|
+
const record = parseArchiveSidecar(raw);
|
|
1559
|
+
if (record)
|
|
1560
|
+
return { kind: "record", file, record };
|
|
1561
|
+
// A malformed sidecar is safe to remember only after proving the exact file
|
|
1562
|
+
// stayed unchanged across a second read. This closes the race where an
|
|
1563
|
+
// in-place repair lands between the failed parse and the fingerprint stat.
|
|
1564
|
+
const before = await archiveSidecarFingerprint(path);
|
|
1565
|
+
if (!before)
|
|
1566
|
+
return { kind: "transient", file };
|
|
1567
|
+
let confirmedRaw;
|
|
1568
|
+
try {
|
|
1569
|
+
confirmedRaw = await readFile(path, "utf8");
|
|
1570
|
+
}
|
|
1571
|
+
catch {
|
|
1572
|
+
return { kind: "transient", file };
|
|
1573
|
+
}
|
|
1574
|
+
const after = await archiveSidecarFingerprint(path);
|
|
1575
|
+
if (!after || before !== after)
|
|
1576
|
+
return { kind: "transient", file };
|
|
1577
|
+
const repaired = parseArchiveSidecar(confirmedRaw);
|
|
1578
|
+
return repaired
|
|
1579
|
+
? { kind: "record", file, record: repaired }
|
|
1580
|
+
: { kind: "damaged", file, fingerprint: after };
|
|
1581
|
+
}
|
|
1582
|
+
async function scanArchiveLedger(storageRoot) {
|
|
440
1583
|
const { readdir } = await import("node:fs/promises");
|
|
441
|
-
const
|
|
442
|
-
const
|
|
443
|
-
|
|
1584
|
+
const before = await archiveDirectoryGeneration(storageRoot);
|
|
1585
|
+
const files = (await readdir(storageRoot)).filter((file) => file.endsWith(".json"));
|
|
1586
|
+
const records = [];
|
|
1587
|
+
const unreadable = [];
|
|
1588
|
+
const damaged = new Map();
|
|
1589
|
+
let transientFailure = before === null;
|
|
1590
|
+
// A real long-lived vault has thousands of sidecars, and the dashboard asks
|
|
1591
|
+
// several archive-derived questions at once. Opening every file in one
|
|
1592
|
+
// Promise.all submitted thousands of filesystem requests per caller, then
|
|
1593
|
+
// ran all of their JSON.parse continuations in one dense microtask wave. The
|
|
1594
|
+
// bytes were small, but the shared local API event loop stopped answering
|
|
1595
|
+
// lightweight health/UI requests while that wave drained. Bound both the fd
|
|
1596
|
+
// fan-out and the synchronous parse burst, and explicitly return to the event
|
|
1597
|
+
// loop between batches. The completed cache above is guarded by the root's
|
|
1598
|
+
// durable generation, so a later request cannot miss a record another
|
|
1599
|
+
// Sealkeep process atomically replaced.
|
|
1600
|
+
const batchSize = 32;
|
|
1601
|
+
for (let offset = 0; offset < files.length; offset += batchSize) {
|
|
1602
|
+
const batch = await Promise.all(files.slice(offset, offset + batchSize)
|
|
1603
|
+
.map((file) => readArchiveSidecar(storageRoot, file)));
|
|
1604
|
+
for (const result of batch) {
|
|
1605
|
+
if (result.kind === "record")
|
|
1606
|
+
records.push(result.record);
|
|
1607
|
+
else {
|
|
1608
|
+
unreadable.push(result.file);
|
|
1609
|
+
if (result.kind === "damaged")
|
|
1610
|
+
damaged.set(result.file, result.fingerprint);
|
|
1611
|
+
else
|
|
1612
|
+
transientFailure = true;
|
|
1613
|
+
}
|
|
1614
|
+
}
|
|
1615
|
+
if (offset + batchSize < files.length)
|
|
1616
|
+
await yieldToEventLoop();
|
|
1617
|
+
}
|
|
1618
|
+
const after = await archiveDirectoryGeneration(storageRoot);
|
|
1619
|
+
const stable = before !== null && before === after;
|
|
1620
|
+
return {
|
|
1621
|
+
records: records.sort((a, b) => b.createdAt.localeCompare(a.createdAt)),
|
|
1622
|
+
unreadable,
|
|
1623
|
+
directoryGeneration: stable ? after : null,
|
|
1624
|
+
damaged,
|
|
1625
|
+
cacheable: stable && !transientFailure,
|
|
1626
|
+
};
|
|
1627
|
+
}
|
|
1628
|
+
function reportUnreadableArchiveRecords(storageRoot, unreadable) {
|
|
1629
|
+
if (!archiveWarningSilence.getStore()) {
|
|
1630
|
+
// Sorting a copy keeps the shared scan immutable for concurrent callers.
|
|
1631
|
+
const named = [...unreadable].sort();
|
|
1632
|
+
if (named.length === 0) {
|
|
1633
|
+
// If an operator repairs the metadata and damage later recurs, that is a
|
|
1634
|
+
// new incident and deserves a new warning.
|
|
1635
|
+
warnedArchiveDamage.delete(storageRoot);
|
|
1636
|
+
}
|
|
1637
|
+
else {
|
|
1638
|
+
const fingerprint = named.join("\0");
|
|
1639
|
+
if (warnedArchiveDamage.get(storageRoot) !== fingerprint) {
|
|
1640
|
+
warnedArchiveDamage.set(storageRoot, fingerprint);
|
|
1641
|
+
process.emitWarning(`${named.length} archive record${named.length === 1 ? "" : "s"} could not be read and were skipped: ${named.slice(0, 5).join(", ")}${named.length > 5 ? " …" : ""}`, "SealkeepDamagedRecord");
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
}
|
|
444
1645
|
}
|
|
445
1646
|
/**
|
|
446
1647
|
* Registers an X25519 public key that may open future archives. Only the public
|
|
447
1648
|
* key is stored; the matching private key stays on its own device.
|
|
448
1649
|
*/
|
|
1650
|
+
export function projectSharingOf(config, project, projectKey) {
|
|
1651
|
+
const lookup = projectKey || project;
|
|
1652
|
+
if (!lookup)
|
|
1653
|
+
return "passive";
|
|
1654
|
+
return config.projectSharing?.[lookup] ?? "passive";
|
|
1655
|
+
}
|
|
1656
|
+
export async function setProjectSharing(dataDir, project, mode, projectKey) {
|
|
1657
|
+
if (projectKey && !PROJECT_KEY_RE.test(projectKey))
|
|
1658
|
+
fail("invalid_argument", "A stable project key is malformed");
|
|
1659
|
+
let resolvedProjectKey = projectKey ?? null;
|
|
1660
|
+
if (!resolvedProjectKey) {
|
|
1661
|
+
const { projectIdentityOfRecord } = await import("./adapters.js");
|
|
1662
|
+
const keys = new Set();
|
|
1663
|
+
for (const record of await listArchives(dataDir)) {
|
|
1664
|
+
const identity = await projectIdentityOfRecord(record).catch(() => null);
|
|
1665
|
+
if (identity?.project === project && identity.projectKey)
|
|
1666
|
+
keys.add(identity.projectKey);
|
|
1667
|
+
}
|
|
1668
|
+
if (keys.size > 1) {
|
|
1669
|
+
fail("invalid_argument", `More than one stable project is named ${project}; choose the exact folder so sharing cannot cross repositories`);
|
|
1670
|
+
}
|
|
1671
|
+
resolvedProjectKey = [...keys][0] ?? null;
|
|
1672
|
+
}
|
|
1673
|
+
const bindingKey = resolvedProjectKey || project;
|
|
1674
|
+
const next = await mutateConfigAtomically(dataDir, (config) => ({
|
|
1675
|
+
...config, projectSharing: { ...(config.projectSharing ?? {}), [bindingKey]: mode },
|
|
1676
|
+
}));
|
|
1677
|
+
await recordAudit(dataDir, "project.sharing", "allowed", { project, ...(resolvedProjectKey ? { projectKey: resolvedProjectKey } : {}), mode });
|
|
1678
|
+
return next;
|
|
1679
|
+
}
|
|
1680
|
+
const TEAM_SPACE_KEY_RE = /^[A-Za-z0-9_-]{43}$/;
|
|
1681
|
+
const PROJECT_KEY_RE = /^(?:g1|p1):[a-f0-9]{64}$/;
|
|
1682
|
+
function assertTeamBinding(binding) {
|
|
1683
|
+
if (!TEAM_SPACE_KEY_RE.test(binding.spaceKey))
|
|
1684
|
+
fail("invalid_argument", "A team space key must be a 43-character base64url value");
|
|
1685
|
+
if (binding.realtimeKey !== undefined && !TEAM_SPACE_KEY_RE.test(binding.realtimeKey)) {
|
|
1686
|
+
fail("invalid_argument", "A team realtime key must be a 43-character base64url value");
|
|
1687
|
+
}
|
|
1688
|
+
if (binding.keyVersion !== undefined && (!Number.isSafeInteger(binding.keyVersion) || binding.keyVersion <= 0)) {
|
|
1689
|
+
fail("invalid_argument", "A team key version must be a positive integer");
|
|
1690
|
+
}
|
|
1691
|
+
if (binding.role !== undefined && binding.role !== "owner" && binding.role !== "member") {
|
|
1692
|
+
fail("invalid_argument", "A team role must be owner or member");
|
|
1693
|
+
}
|
|
1694
|
+
}
|
|
1695
|
+
export function teamSpaceOf(config, project, projectKey) {
|
|
1696
|
+
// A caller that knows the stable key has supplied all the identity evidence
|
|
1697
|
+
// available. Falling back to a same-named legacy entry would reintroduce the
|
|
1698
|
+
// collision this key exists to prevent, so a miss is deliberately final.
|
|
1699
|
+
const lookup = projectKey || project;
|
|
1700
|
+
if (!lookup)
|
|
1701
|
+
return null;
|
|
1702
|
+
const value = config.teamSpaces?.[lookup];
|
|
1703
|
+
return value && typeof value.spaceKey === "string" ? value : null;
|
|
1704
|
+
}
|
|
1705
|
+
/**
|
|
1706
|
+
* Adds keyed aliases for old label-keyed bindings only when local discovery
|
|
1707
|
+
* proves that label names exactly one stable project. The legacy entry stays
|
|
1708
|
+
* in place for older Sealkeep builds; duplicate labels stay untouched and are
|
|
1709
|
+
* therefore invisible to new exact-key access decisions.
|
|
1710
|
+
*/
|
|
1711
|
+
export async function migrateLegacyTeamSpaceBindings(dataDir, identities) {
|
|
1712
|
+
const keysByLabel = new Map();
|
|
1713
|
+
for (const identity of identities) {
|
|
1714
|
+
if (!identity.project || !identity.projectKey || !PROJECT_KEY_RE.test(identity.projectKey))
|
|
1715
|
+
continue;
|
|
1716
|
+
const set = keysByLabel.get(identity.project) ?? new Set();
|
|
1717
|
+
set.add(identity.projectKey);
|
|
1718
|
+
keysByLabel.set(identity.project, set);
|
|
1719
|
+
}
|
|
1720
|
+
const initial = await readConfig(dataDir);
|
|
1721
|
+
const canMigrate = (legacyLabel, target) => {
|
|
1722
|
+
if (PROJECT_KEY_RE.test(legacyLabel))
|
|
1723
|
+
return false;
|
|
1724
|
+
const matches = [...(keysByLabel.get(legacyLabel) ?? [])];
|
|
1725
|
+
return matches.length === 1 && !target?.[matches[0]];
|
|
1726
|
+
};
|
|
1727
|
+
const needsMigration = Object.keys(initial.teamSpaces ?? {}).some((legacyLabel) => canMigrate(legacyLabel, initial.teamSpaces))
|
|
1728
|
+
|| Object.keys(initial.projectSharing ?? {}).some((legacyLabel) => canMigrate(legacyLabel, initial.projectSharing));
|
|
1729
|
+
if (!needsMigration)
|
|
1730
|
+
return initial;
|
|
1731
|
+
return mutateConfigAtomically(dataDir, (config) => {
|
|
1732
|
+
const teamSpaces = { ...(config.teamSpaces ?? {}) };
|
|
1733
|
+
const projectSharing = { ...(config.projectSharing ?? {}) };
|
|
1734
|
+
let changed = false;
|
|
1735
|
+
for (const [legacyLabel, binding] of Object.entries(config.teamSpaces ?? {})) {
|
|
1736
|
+
if (PROJECT_KEY_RE.test(legacyLabel))
|
|
1737
|
+
continue;
|
|
1738
|
+
const matches = [...(keysByLabel.get(legacyLabel) ?? [])];
|
|
1739
|
+
if (matches.length !== 1 || teamSpaces[matches[0]])
|
|
1740
|
+
continue;
|
|
1741
|
+
teamSpaces[matches[0]] = { ...binding, localProject: legacyLabel };
|
|
1742
|
+
if (config.projectSharing?.[legacyLabel])
|
|
1743
|
+
projectSharing[matches[0]] = config.projectSharing[legacyLabel];
|
|
1744
|
+
changed = true;
|
|
1745
|
+
}
|
|
1746
|
+
// Sharing mode existed before stable project identity and may have no team
|
|
1747
|
+
// binding at all. Copy it independently, but only when local discovery
|
|
1748
|
+
// proves the display label names exactly one key. Ambiguous labels remain
|
|
1749
|
+
// legacy-only and are invisible to keyed callers.
|
|
1750
|
+
for (const [legacyLabel, mode] of Object.entries(config.projectSharing ?? {})) {
|
|
1751
|
+
if (PROJECT_KEY_RE.test(legacyLabel))
|
|
1752
|
+
continue;
|
|
1753
|
+
const matches = [...(keysByLabel.get(legacyLabel) ?? [])];
|
|
1754
|
+
if (matches.length !== 1 || projectSharing[matches[0]])
|
|
1755
|
+
continue;
|
|
1756
|
+
projectSharing[matches[0]] = mode;
|
|
1757
|
+
changed = true;
|
|
1758
|
+
}
|
|
1759
|
+
return changed ? { ...config, teamSpaces, projectSharing } : config;
|
|
1760
|
+
});
|
|
1761
|
+
}
|
|
1762
|
+
/**
|
|
1763
|
+
* Records the dashboard-visible choice that this checkout participates in a
|
|
1764
|
+
* team space. It stores only the opaque space id; encryption keys never enter
|
|
1765
|
+
* config.json. Binding also enables the automatic live lane for that project.
|
|
1766
|
+
*/
|
|
1767
|
+
export async function bindTeamSpace(dataDir, project, binding, projectKey) {
|
|
1768
|
+
if (!project.trim())
|
|
1769
|
+
fail("invalid_argument", "A project is required to join a team space");
|
|
1770
|
+
if (projectKey && !PROJECT_KEY_RE.test(projectKey))
|
|
1771
|
+
fail("invalid_argument", "A stable project key is malformed");
|
|
1772
|
+
assertTeamBinding(binding);
|
|
1773
|
+
const bindingKey = projectKey || project;
|
|
1774
|
+
const storedBinding = projectKey ? { ...binding, localProject: project } : { ...binding };
|
|
1775
|
+
const next = await mutateConfigAtomically(dataDir, (config) => ({
|
|
1776
|
+
...config,
|
|
1777
|
+
// An explicit folder selection is also an explicit reconnect on this
|
|
1778
|
+
// machine, so it clears any earlier local disconnect tombstone.
|
|
1779
|
+
teamSpaceOptOuts: (config.teamSpaceOptOuts ?? []).filter((spaceKey) => spaceKey !== binding.spaceKey),
|
|
1780
|
+
projectSharing: { ...(config.projectSharing ?? {}), [bindingKey]: "active" },
|
|
1781
|
+
teamSpaces: { ...(config.teamSpaces ?? {}), [bindingKey]: storedBinding },
|
|
1782
|
+
// A folder can be a doorway into only one logical project. Once it has a
|
|
1783
|
+
// verified binding it is no longer pending in any project-creation draft.
|
|
1784
|
+
teamProjectDrafts: (config.teamProjectDrafts ?? [])
|
|
1785
|
+
.map((draft) => {
|
|
1786
|
+
if (projectKey && draft.projectSelections) {
|
|
1787
|
+
const projectSelections = draft.projectSelections.filter((item) => item.projectKey !== projectKey);
|
|
1788
|
+
return { ...draft, projectSelections, projects: [...new Set(projectSelections.map((item) => item.project))] };
|
|
1789
|
+
}
|
|
1790
|
+
return { ...draft, projects: draft.projects.filter((name) => name !== project) };
|
|
1791
|
+
})
|
|
1792
|
+
.filter((draft) => draft.projectSelections ? draft.projectSelections.length > 0 : draft.projects.length > 0),
|
|
1793
|
+
}));
|
|
1794
|
+
await recordAudit(dataDir, "project.sharing", "allowed", {
|
|
1795
|
+
project,
|
|
1796
|
+
...(projectKey ? { projectKey } : {}),
|
|
1797
|
+
mode: "active",
|
|
1798
|
+
teamSpace: createHash("sha256").update(binding.spaceKey).digest("hex").slice(0, 12),
|
|
1799
|
+
});
|
|
1800
|
+
return next;
|
|
1801
|
+
}
|
|
1802
|
+
/**
|
|
1803
|
+
* Remember which detected local folders should become one logical project.
|
|
1804
|
+
* No path or project label is sent to the hosted plane here. The account page
|
|
1805
|
+
* creates the logical project, then normal daemon reconciliation consumes this
|
|
1806
|
+
* local draft when the matching project appears.
|
|
1807
|
+
*/
|
|
1808
|
+
export async function stageTeamProjectDraft(dataDir, projectName, projects) {
|
|
1809
|
+
const name = projectName.trim();
|
|
1810
|
+
const keyed = projects.length > 0 && typeof projects[0] !== "string";
|
|
1811
|
+
const projectSelections = keyed
|
|
1812
|
+
? [...new Map(projects.map((item) => [item.projectKey, { projectKey: item.projectKey.trim(), project: item.project.trim() }])).values()]
|
|
1813
|
+
: undefined;
|
|
1814
|
+
const localProjects = projectSelections
|
|
1815
|
+
? [...new Set(projectSelections.map((item) => item.project).filter(Boolean))]
|
|
1816
|
+
: [...new Set(projects.map((project) => project.trim()).filter(Boolean))];
|
|
1817
|
+
if (!name || name.length > 120)
|
|
1818
|
+
fail("invalid_argument", "A project name between 1 and 120 characters is required");
|
|
1819
|
+
if (!localProjects.length || localProjects.length > 50 || localProjects.some((project) => project.length > 240)
|
|
1820
|
+
|| projectSelections?.some((item) => !item.project || !PROJECT_KEY_RE.test(item.projectKey))) {
|
|
1821
|
+
fail("invalid_argument", "Choose between 1 and 50 detected folders for this project");
|
|
1822
|
+
}
|
|
1823
|
+
const draft = {
|
|
1824
|
+
projectName: name,
|
|
1825
|
+
projects: localProjects,
|
|
1826
|
+
...(projectSelections ? { projectSelections } : {}),
|
|
1827
|
+
createdAt: new Date().toISOString(),
|
|
1828
|
+
};
|
|
1829
|
+
await mutateConfigAtomically(dataDir, (config) => {
|
|
1830
|
+
const selected = new Set(localProjects);
|
|
1831
|
+
const selectedKeys = new Set(projectSelections?.map((item) => item.projectKey) ?? []);
|
|
1832
|
+
const sameName = name.toLocaleLowerCase();
|
|
1833
|
+
// Selecting a folder for a new logical project moves it out of any older
|
|
1834
|
+
// unfinished draft. It never creates two copies or two access scopes.
|
|
1835
|
+
const retained = (config.teamProjectDrafts ?? [])
|
|
1836
|
+
.filter((item) => item.projectName.trim().toLocaleLowerCase() !== sameName)
|
|
1837
|
+
.map((item) => {
|
|
1838
|
+
if (selectedKeys.size > 0 && item.projectSelections) {
|
|
1839
|
+
const remaining = item.projectSelections.filter((selection) => !selectedKeys.has(selection.projectKey));
|
|
1840
|
+
return { ...item, projectSelections: remaining, projects: [...new Set(remaining.map((selection) => selection.project))] };
|
|
1841
|
+
}
|
|
1842
|
+
if (!projectSelections)
|
|
1843
|
+
return { ...item, projects: item.projects.filter((project) => !selected.has(project)) };
|
|
1844
|
+
return item;
|
|
1845
|
+
})
|
|
1846
|
+
.filter((item) => item.projectSelections ? item.projectSelections.length > 0 : item.projects.length > 0);
|
|
1847
|
+
return { ...config, teamProjectDrafts: [...retained, draft] };
|
|
1848
|
+
});
|
|
1849
|
+
await recordAudit(dataDir, "project.draft", "allowed", { projectName: name, folders: localProjects.length });
|
|
1850
|
+
return draft;
|
|
1851
|
+
}
|
|
1852
|
+
export async function removeTeamProjectDraft(dataDir, projectName) {
|
|
1853
|
+
const wanted = projectName.trim().toLocaleLowerCase();
|
|
1854
|
+
const next = await mutateConfigAtomically(dataDir, (config) => ({
|
|
1855
|
+
...config,
|
|
1856
|
+
teamProjectDrafts: (config.teamProjectDrafts ?? []).filter((draft) => draft.projectName.trim().toLocaleLowerCase() !== wanted),
|
|
1857
|
+
}));
|
|
1858
|
+
await recordAudit(dataDir, "project.draft", "allowed", { projectName: projectName.trim(), cancelled: true });
|
|
1859
|
+
return next;
|
|
1860
|
+
}
|
|
1861
|
+
export async function unbindTeamSpace(dataDir, project, projectKey) {
|
|
1862
|
+
const bindingKey = projectKey || project;
|
|
1863
|
+
const next = await mutateConfigAtomically(dataDir, (config) => {
|
|
1864
|
+
const teamSpaces = Object.fromEntries(Object.entries(config.teamSpaces ?? {}).filter(([name]) => name !== bindingKey));
|
|
1865
|
+
return { ...config, teamSpaces, projectSharing: { ...(config.projectSharing ?? {}), [bindingKey]: "passive" } };
|
|
1866
|
+
});
|
|
1867
|
+
await recordAudit(dataDir, "project.sharing", "allowed", { project, ...(projectKey ? { projectKey } : {}), mode: "passive", teamSpace: null });
|
|
1868
|
+
return next;
|
|
1869
|
+
}
|
|
1870
|
+
/**
|
|
1871
|
+
* Disconnect one hosted project from this machine without changing cloud
|
|
1872
|
+
* membership. The tombstone is deliberate: daemon reconciliation must not
|
|
1873
|
+
* silently recreate a binding the person just removed.
|
|
1874
|
+
*/
|
|
1875
|
+
export async function disconnectTeamSpaceOnMachine(dataDir, spaceKey) {
|
|
1876
|
+
if (!TEAM_SPACE_KEY_RE.test(spaceKey))
|
|
1877
|
+
fail("invalid_argument", "A team space key must be a 43-character base64url value");
|
|
1878
|
+
let disconnected = [];
|
|
1879
|
+
const next = await mutateConfigAtomically(dataDir, (config) => {
|
|
1880
|
+
disconnected = Object.entries(config.teamSpaces ?? {})
|
|
1881
|
+
.filter(([, binding]) => binding.spaceKey === spaceKey)
|
|
1882
|
+
.map(([key, binding]) => ({ key, project: binding.localProject ?? key }));
|
|
1883
|
+
const removed = new Set(disconnected.map((item) => item.key));
|
|
1884
|
+
const teamSpaces = Object.fromEntries(Object.entries(config.teamSpaces ?? {}).filter(([key]) => !removed.has(key)));
|
|
1885
|
+
const projectSharing = { ...(config.projectSharing ?? {}) };
|
|
1886
|
+
for (const item of disconnected)
|
|
1887
|
+
projectSharing[item.key] = "passive";
|
|
1888
|
+
const teamSpaceOptOuts = [...new Set([...(config.teamSpaceOptOuts ?? []), spaceKey])];
|
|
1889
|
+
return { ...config, teamSpaces, projectSharing, teamSpaceOptOuts };
|
|
1890
|
+
});
|
|
1891
|
+
await Promise.all(disconnected.map(({ key, project }) => recordAudit(dataDir, "project.sharing", "allowed", {
|
|
1892
|
+
project,
|
|
1893
|
+
...(key === project ? {} : { projectKey: key }),
|
|
1894
|
+
mode: "passive",
|
|
1895
|
+
teamSpace: null,
|
|
1896
|
+
reason: "machine-disconnected",
|
|
1897
|
+
})));
|
|
1898
|
+
return next;
|
|
1899
|
+
}
|
|
1900
|
+
/**
|
|
1901
|
+
* Apply a hosted key/version/route update to every local checkout bound to one
|
|
1902
|
+
* logical space. `undefined` patch members are ignored so an older hosted
|
|
1903
|
+
* response cannot erase a newer route already learned by this machine.
|
|
1904
|
+
*/
|
|
1905
|
+
export async function updateTeamSpaceBindings(dataDir, spaceKey, patch) {
|
|
1906
|
+
if (!TEAM_SPACE_KEY_RE.test(spaceKey))
|
|
1907
|
+
fail("invalid_argument", "A team space key must be a 43-character base64url value");
|
|
1908
|
+
const defined = {
|
|
1909
|
+
...(patch.role !== undefined ? { role: patch.role } : {}),
|
|
1910
|
+
...(patch.keyVersion !== undefined ? { keyVersion: patch.keyVersion } : {}),
|
|
1911
|
+
...(patch.realtimeKey !== undefined ? { realtimeKey: patch.realtimeKey } : {}),
|
|
1912
|
+
};
|
|
1913
|
+
assertTeamBinding({ spaceKey, ...defined });
|
|
1914
|
+
return mutateConfigAtomically(dataDir, (config) => {
|
|
1915
|
+
let changed = false;
|
|
1916
|
+
const teamSpaces = Object.fromEntries(Object.entries(config.teamSpaces ?? {}).map(([project, binding]) => {
|
|
1917
|
+
if (binding.spaceKey !== spaceKey)
|
|
1918
|
+
return [project, binding];
|
|
1919
|
+
const nextBinding = { ...binding, ...defined };
|
|
1920
|
+
if (nextBinding.role !== binding.role
|
|
1921
|
+
|| nextBinding.keyVersion !== binding.keyVersion
|
|
1922
|
+
|| nextBinding.realtimeKey !== binding.realtimeKey)
|
|
1923
|
+
changed = true;
|
|
1924
|
+
return [project, nextBinding];
|
|
1925
|
+
}));
|
|
1926
|
+
return changed ? { ...config, teamSpaces } : config;
|
|
1927
|
+
});
|
|
1928
|
+
}
|
|
1929
|
+
/**
|
|
1930
|
+
* Revoke every checkout bound to a removed hosted membership in one atomic
|
|
1931
|
+
* config replacement. The logical space disappears and all affected projects
|
|
1932
|
+
* become passive together; encrypted archives and project files are untouched.
|
|
1933
|
+
*/
|
|
1934
|
+
export async function revokeTeamSpaceBindings(dataDir, spaceKey) {
|
|
1935
|
+
if (!TEAM_SPACE_KEY_RE.test(spaceKey))
|
|
1936
|
+
fail("invalid_argument", "A team space key must be a 43-character base64url value");
|
|
1937
|
+
let revokedProjects = [];
|
|
1938
|
+
const next = await mutateConfigAtomically(dataDir, (config) => {
|
|
1939
|
+
revokedProjects = Object.entries(config.teamSpaces ?? {})
|
|
1940
|
+
.filter(([, binding]) => binding.spaceKey === spaceKey)
|
|
1941
|
+
.map(([project]) => project);
|
|
1942
|
+
if (!revokedProjects.length)
|
|
1943
|
+
return config;
|
|
1944
|
+
const revoked = new Set(revokedProjects);
|
|
1945
|
+
const teamSpaces = Object.fromEntries(Object.entries(config.teamSpaces ?? {}).filter(([project]) => !revoked.has(project)));
|
|
1946
|
+
const projectSharing = { ...(config.projectSharing ?? {}) };
|
|
1947
|
+
for (const project of revokedProjects)
|
|
1948
|
+
projectSharing[project] = "passive";
|
|
1949
|
+
return { ...config, teamSpaces, projectSharing };
|
|
1950
|
+
});
|
|
1951
|
+
await Promise.all(revokedProjects.map((project) => recordAudit(dataDir, "project.sharing", "allowed", {
|
|
1952
|
+
project, mode: "passive", teamSpace: null, reason: "membership-revoked",
|
|
1953
|
+
})));
|
|
1954
|
+
return next;
|
|
1955
|
+
}
|
|
449
1956
|
export async function addRecipient(dataDir, label, publicKeyBase64, group,
|
|
450
1957
|
/**
|
|
451
1958
|
* Tie this key to one project and it becomes a member of that project only:
|
|
@@ -453,36 +1960,100 @@ export async function addRecipient(dataDir, label, publicKeyBase64, group,
|
|
|
453
1960
|
* seals. A colleague on `checkout-rewrite` never receives the rest of your
|
|
454
1961
|
* work, which is the difference between sharing a folder and handing over a
|
|
455
1962
|
* drive.
|
|
456
|
-
|
|
457
|
-
project) {
|
|
1963
|
+
*/
|
|
1964
|
+
project, projectKey) {
|
|
458
1965
|
if (!label.trim())
|
|
459
1966
|
fail("invalid_argument", "A recipient label is required");
|
|
460
1967
|
const raw = Buffer.from(publicKeyBase64, "base64");
|
|
461
1968
|
if (raw.length !== 32)
|
|
462
1969
|
fail("invalid_argument", "An X25519 public key must be 32 bytes, base64 encoded");
|
|
463
|
-
|
|
1970
|
+
if (projectKey && !PROJECT_KEY_RE.test(projectKey))
|
|
1971
|
+
fail("invalid_argument", "A stable project key is malformed");
|
|
464
1972
|
const id = keyRecipientId(raw);
|
|
465
|
-
const
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
1973
|
+
const recipient = { id, label: label.trim(), publicKey: raw.toString("base64"), addedAt: new Date().toISOString(), ...(group ? { group } : {}), ...(project ? { project } : {}), ...(projectKey ? { projectKey } : {}) };
|
|
1974
|
+
const next = await mutateConfigAtomically(dataDir, (config) => ({
|
|
1975
|
+
...config,
|
|
1976
|
+
recipients: [...(config.recipients ?? []).filter((value) => value.id !== id), recipient],
|
|
1977
|
+
}));
|
|
1978
|
+
await recordAudit(dataDir, "recipient.add", "allowed", { recipientId: id, label: label.trim(), group: group ?? null, project: project ?? null, projectKey: projectKey ?? null });
|
|
470
1979
|
return next;
|
|
471
1980
|
}
|
|
472
1981
|
/** Removes a recipient from future archives. Run `sealkeep rewrap` to revoke it on existing ones. */
|
|
473
1982
|
export async function removeRecipient(dataDir, id) {
|
|
474
|
-
const
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
1983
|
+
const next = await mutateConfigAtomically(dataDir, (config) => {
|
|
1984
|
+
const recipients = (config.recipients ?? []).filter((recipient) => recipient.id !== id);
|
|
1985
|
+
if (recipients.length === (config.recipients ?? []).length)
|
|
1986
|
+
fail("invalid_argument", `No recipient with id ${id}`, { recipientId: id });
|
|
1987
|
+
return { ...config, recipients };
|
|
1988
|
+
});
|
|
1989
|
+
// The search index is sealed per project to the members that project had at
|
|
1990
|
+
// build time. Removing someone here without touching those shards left the
|
|
1991
|
+
// revoked key able to open one indefinitely — the documented flow
|
|
1992
|
+
// (`recipients remove` then `rewrap`) rebuilds archives, never the index. So
|
|
1993
|
+
// revocation drops the shards outright; the next `index build` writes fresh
|
|
1994
|
+
// ones for whoever remains.
|
|
1995
|
+
const { invalidateScopedShards } = await import("./search.js");
|
|
1996
|
+
await invalidateScopedShards(dataDir);
|
|
480
1997
|
await recordAudit(dataDir, "recipient.remove", "allowed", { recipientId: id });
|
|
481
1998
|
return next;
|
|
482
1999
|
}
|
|
483
|
-
/**
|
|
2000
|
+
/**
|
|
2001
|
+
* Persists a full config document after validation by the caller. Team state
|
|
2002
|
+
* is deliberately retained from the lock-protected current file: bindings and
|
|
2003
|
+
* revocations are changed only through their dedicated atomic helpers.
|
|
2004
|
+
*/
|
|
484
2005
|
export async function writeConfig(dataDir, config) {
|
|
485
|
-
await
|
|
2006
|
+
await mutateConfigAtomically(dataDir, (current) => ({
|
|
2007
|
+
...config,
|
|
2008
|
+
teamSpaces: current.teamSpaces,
|
|
2009
|
+
projectSharing: current.projectSharing,
|
|
2010
|
+
}));
|
|
2011
|
+
}
|
|
2012
|
+
/**
|
|
2013
|
+
* Installs a vault identity that was authenticated and decrypted by a transfer
|
|
2014
|
+
* protocol before this call. Unlike `initialize`, this must not mint a new id
|
|
2015
|
+
* or phrase; unlike `writeConfig`, it must work before config.json exists.
|
|
2016
|
+
* The storage root is always local to this data directory, regardless of what
|
|
2017
|
+
* the sending machine used, and the same cross-process lock/refusal protects
|
|
2018
|
+
* against replacing a vault that appeared while approval was in flight.
|
|
2019
|
+
*/
|
|
2020
|
+
export async function initializeTransferredVault(dataDir, transferred) {
|
|
2021
|
+
if (transferred.version !== CONFIG_VERSION || !transferred.vaultId || !transferred.recovery?.phraseCheck) {
|
|
2022
|
+
return fail("invalid_argument", "The transferred vault identity is not supported by this SealKeep build");
|
|
2023
|
+
}
|
|
2024
|
+
const config = {
|
|
2025
|
+
...transferred,
|
|
2026
|
+
version: CONFIG_VERSION,
|
|
2027
|
+
storage: { provider: "local", root: archiveDir(dataDir) },
|
|
2028
|
+
};
|
|
2029
|
+
await mkdir(config.storage.root, { recursive: true });
|
|
2030
|
+
await withConfigLock(dataDir, async () => {
|
|
2031
|
+
if (await exists(configPath(dataDir)))
|
|
2032
|
+
fail("vault_already_initialized", `Vault already initialized at ${dataDir}`, { dataDir });
|
|
2033
|
+
await writeJsonAtomic(configPath(dataDir), config);
|
|
2034
|
+
});
|
|
2035
|
+
return config;
|
|
2036
|
+
}
|
|
2037
|
+
/** Merge a top-level patch into the latest lock-protected config document. */
|
|
2038
|
+
export async function updateConfigFields(dataDir, patch) {
|
|
2039
|
+
return mutateConfigAtomically(dataDir, (current) => ({ ...current, ...patch }));
|
|
2040
|
+
}
|
|
2041
|
+
/**
|
|
2042
|
+
* Compare and update top-level config fields while holding the same
|
|
2043
|
+
* cross-process lock as every other supported config mutation. This is the
|
|
2044
|
+
* config-file equivalent of compare-and-set: callers which obtained a value
|
|
2045
|
+
* before an asynchronous network read can refuse to replace a newer local
|
|
2046
|
+
* choice which landed while that read was in flight.
|
|
2047
|
+
*/
|
|
2048
|
+
export async function updateConfigFieldsIf(dataDir, predicate, patch) {
|
|
2049
|
+
let matched = false;
|
|
2050
|
+
const config = await mutateConfigAtomically(dataDir, (current) => {
|
|
2051
|
+
if (!predicate(current))
|
|
2052
|
+
return current;
|
|
2053
|
+
matched = true;
|
|
2054
|
+
return { ...current, ...patch };
|
|
2055
|
+
});
|
|
2056
|
+
return { matched, config };
|
|
486
2057
|
}
|
|
487
2058
|
/** A non-mutating preview. Policy evaluation lives in `src/retention.ts`. */
|
|
488
2059
|
export async function previewRetention(dataDir, olderThanDays = 30) {
|
|
@@ -540,21 +2111,21 @@ export function assertPhraseOpens(config, record, rawPhrase) {
|
|
|
540
2111
|
*/
|
|
541
2112
|
function openFailure(archiveId, error) {
|
|
542
2113
|
if (!(error instanceof Error))
|
|
543
|
-
return new
|
|
2114
|
+
return new SealkeepError("internal", `Unable to open archive ${archiveId}`, { archiveId });
|
|
544
2115
|
const code = error.code;
|
|
545
2116
|
const detail = { archiveId };
|
|
546
2117
|
switch (code) {
|
|
547
2118
|
case "destination_write_failed":
|
|
548
|
-
return new
|
|
2119
|
+
return new SealkeepError("destination_unwritable", error.message, detail);
|
|
549
2120
|
case "chunk_auth_failed":
|
|
550
2121
|
case "chunk_hash_mismatch":
|
|
551
2122
|
case "envelope_malformed":
|
|
552
|
-
return new
|
|
2123
|
+
return new SealkeepError("ciphertext_integrity_failed", `Archive ${archiveId} is damaged: ${error.message}`, detail);
|
|
553
2124
|
case "plaintext_hash_mismatch":
|
|
554
2125
|
case "plaintext_length_mismatch":
|
|
555
|
-
return new
|
|
2126
|
+
return new SealkeepError("plaintext_integrity_failed", `Archive ${archiveId} did not reproduce the bytes it recorded: ${error.message}`, detail);
|
|
556
2127
|
default:
|
|
557
|
-
return new
|
|
2128
|
+
return new SealkeepError("recovery_phrase_mismatch", `Unable to open archive ${archiveId}: ${error.message}`, detail);
|
|
558
2129
|
}
|
|
559
2130
|
}
|
|
560
2131
|
/**
|
|
@@ -573,7 +2144,14 @@ export async function restoreRecordToFile(dataDir, record,
|
|
|
573
2144
|
* shared for it to work. Until this existed, a registered recipient could be
|
|
574
2145
|
* wrapped in and still had no way to decrypt anything.
|
|
575
2146
|
*/
|
|
576
|
-
rawPhrase,
|
|
2147
|
+
rawPhrase,
|
|
2148
|
+
/**
|
|
2149
|
+
* A path, or a Writable to stream the plaintext into. The stream form exists
|
|
2150
|
+
* so an archive can be PROVED to decrypt without spending its own size in
|
|
2151
|
+
* free disk; the delta and plain-mode paths below still need a real file and
|
|
2152
|
+
* say so rather than silently writing somewhere unexpected.
|
|
2153
|
+
*/
|
|
2154
|
+
destination,
|
|
577
2155
|
/** Injectable so the offloaded-archive path can be proved without a bucket. */
|
|
578
2156
|
options = {}) {
|
|
579
2157
|
const withKey = typeof rawPhrase === "object";
|
|
@@ -597,15 +2175,22 @@ options = {}) {
|
|
|
597
2175
|
if (deltaOf(record)) {
|
|
598
2176
|
if (withKey)
|
|
599
2177
|
fail("invalid_argument", "This archive is an append onto an earlier one, and the chain is opened with the phrase. Restore it with --recovery-phrase.");
|
|
2178
|
+
if (typeof destination !== "string")
|
|
2179
|
+
fail("invalid_argument", "A delta chain is rebuilt through files and cannot stream to a sink");
|
|
600
2180
|
const { bytes } = await restoreDeltaChainToFile(dataDir, record, phrase, destination, options);
|
|
601
2181
|
return { bytes };
|
|
602
2182
|
}
|
|
603
|
-
if (config.storageMode === "plain")
|
|
2183
|
+
if (config.storageMode === "plain") {
|
|
2184
|
+
if (typeof destination !== "string")
|
|
2185
|
+
fail("invalid_argument", "A plain-mode vault restores through files and cannot stream to a sink");
|
|
604
2186
|
return restorePlainRecordToFile(dataDir, config, record, phrase, destination, options);
|
|
2187
|
+
}
|
|
605
2188
|
if (!isV2(record)) {
|
|
606
2189
|
// v1 predates chunking: there is one AEAD frame over the whole archive, so
|
|
607
2190
|
// there is nothing to stream and nothing to gain by pretending otherwise.
|
|
608
2191
|
const { plaintext } = await decryptRecord(dataDir, record.id, phrase);
|
|
2192
|
+
if (typeof destination !== "string")
|
|
2193
|
+
fail("invalid_argument", "This archive restores through a file and cannot stream to a sink");
|
|
609
2194
|
await writeFile(destination, plaintext, { mode: 0o600 });
|
|
610
2195
|
return { bytes: plaintext.length };
|
|
611
2196
|
}
|
|
@@ -615,7 +2200,10 @@ options = {}) {
|
|
|
615
2200
|
// the space saving real. An archive that was never offloaded resolves to its
|
|
616
2201
|
// own path and releases to nothing.
|
|
617
2202
|
const { materialiseArchive } = await import("./offload.js");
|
|
618
|
-
const source = await materialiseArchive(dataDir, record, options.client
|
|
2203
|
+
const source = await materialiseArchive(dataDir, record, options.client, {
|
|
2204
|
+
signal: options.signal,
|
|
2205
|
+
scratchWaitMs: options.scratchWaitMs,
|
|
2206
|
+
});
|
|
619
2207
|
try {
|
|
620
2208
|
const { sha256: stored } = await hashFileRange(source.path);
|
|
621
2209
|
if (!equalHex(stored, record.cipher.ciphertextSha256))
|
|
@@ -646,8 +2234,16 @@ options = {}) {
|
|
|
646
2234
|
* base whose recorded length is not what the delta sealed from is refused
|
|
647
2235
|
* here, before any restore starts writing.
|
|
648
2236
|
*/
|
|
649
|
-
export function resolveDeltaChain(archives, target
|
|
650
|
-
|
|
2237
|
+
export function resolveDeltaChain(archives, target,
|
|
2238
|
+
/**
|
|
2239
|
+
* Callers resolving many heads from one ledger may supply its shared index.
|
|
2240
|
+
* Rebuilding the same thousands-entry map once per visible session turned a
|
|
2241
|
+
* linear dashboard question into quadratic work. The optional argument does
|
|
2242
|
+
* not change resolution semantics; it only lets that immutable lookup be
|
|
2243
|
+
* built once for the batch.
|
|
2244
|
+
*/
|
|
2245
|
+
archiveIndex) {
|
|
2246
|
+
const byId = archiveIndex ?? new Map(archives.map((record) => [record.id, record]));
|
|
651
2247
|
const chain = [target];
|
|
652
2248
|
const seen = new Set([target.id]);
|
|
653
2249
|
let cursor = target;
|
|
@@ -770,24 +2366,12 @@ async function restorePlainRecordToFile(dataDir, config, record, phrase, destina
|
|
|
770
2366
|
await source.release();
|
|
771
2367
|
}
|
|
772
2368
|
}
|
|
773
|
-
|
|
774
|
-
* Verifies then decrypts one archive in memory. Ciphertext integrity is checked
|
|
775
|
-
* before the key touches it, and plaintext integrity after.
|
|
776
|
-
*
|
|
777
|
-
* This holds the whole transcript at once; `restoreRecordToFile` is the
|
|
778
|
-
* constant-memory path and is what restoring uses. Callers that genuinely need
|
|
779
|
-
* the bytes in hand — indexing, the v1 migration — still come through here.
|
|
780
|
-
*/
|
|
781
|
-
/**
|
|
782
|
-
* The sealed bytes of a record, wherever they live. A local blob reads
|
|
783
|
-
* straight from disk; an OFFLOADED archive — sealed copy in the cloud, local
|
|
784
|
-
* blob reclaimed — is fetched hash-checked and held only for the read. Found
|
|
785
|
-
* by the live value drill: `sealkeep index build` died with ENOENT on the
|
|
786
|
-
* first offloaded legacy archive it met, because this read assumed the blob.
|
|
787
|
-
*/
|
|
788
|
-
async function readSealedObject(dataDir, record) {
|
|
2369
|
+
async function readSealedObject(dataDir, record, options = {}) {
|
|
789
2370
|
const { materialiseArchive } = await import("./offload.js");
|
|
790
|
-
const materialised = await materialiseArchive(dataDir, record
|
|
2371
|
+
const materialised = await materialiseArchive(dataDir, record, options.client, {
|
|
2372
|
+
signal: options.signal,
|
|
2373
|
+
scratchWaitMs: options.scratchWaitMs,
|
|
2374
|
+
});
|
|
791
2375
|
try {
|
|
792
2376
|
return await readFile(materialised.path);
|
|
793
2377
|
}
|
|
@@ -795,7 +2379,7 @@ async function readSealedObject(dataDir, record) {
|
|
|
795
2379
|
await materialised.release();
|
|
796
2380
|
}
|
|
797
2381
|
}
|
|
798
|
-
export async function decryptRecord(dataDir, id, rawPhrase) {
|
|
2382
|
+
export async function decryptRecord(dataDir, id, rawPhrase, options = {}) {
|
|
799
2383
|
const phrase = canonicalPhrase(rawPhrase);
|
|
800
2384
|
const config = await readConfig(dataDir);
|
|
801
2385
|
const record = await findArchive(dataDir, id);
|
|
@@ -805,11 +2389,11 @@ export async function decryptRecord(dataDir, id, rawPhrase) {
|
|
|
805
2389
|
// decrypt a tail and correctly refuse it as not matching the record.
|
|
806
2390
|
if (deltaOf(record) && config.storageMode !== "plain") {
|
|
807
2391
|
const chain = resolveDeltaChain(await listArchives(dataDir), record);
|
|
808
|
-
const pieces = [(await decryptRecord(dataDir, chain[0].id, phrase)).plaintext];
|
|
2392
|
+
const pieces = [(await decryptRecord(dataDir, chain[0].id, phrase, options)).plaintext];
|
|
809
2393
|
for (const link of chain.slice(1)) {
|
|
810
2394
|
if (!isV2(link))
|
|
811
2395
|
fail("ciphertext_integrity_failed", `Delta ${link.id} is not a v2 archive and cannot hold a sealed range`, { archiveId: link.id });
|
|
812
|
-
const sealedRange = await readSealedObject(dataDir, link);
|
|
2396
|
+
const sealedRange = await readSealedObject(dataDir, link, options);
|
|
813
2397
|
if (!equalHex(sha256(sealedRange), link.cipher.ciphertextSha256))
|
|
814
2398
|
fail("ciphertext_integrity_failed", "Ciphertext integrity check failed before decryption", { archiveId: link.id });
|
|
815
2399
|
try {
|
|
@@ -824,7 +2408,7 @@ export async function decryptRecord(dataDir, id, rawPhrase) {
|
|
|
824
2408
|
fail("plaintext_integrity_failed", "Plaintext integrity check failed after decryption", { archiveId: id });
|
|
825
2409
|
return { record, plaintext: assembled };
|
|
826
2410
|
}
|
|
827
|
-
const ciphertext = await readSealedObject(dataDir, record);
|
|
2411
|
+
const ciphertext = await readSealedObject(dataDir, record, options);
|
|
828
2412
|
if (!equalHex(sha256(ciphertext), record.cipher.ciphertextSha256))
|
|
829
2413
|
fail("ciphertext_integrity_failed", "Ciphertext integrity check failed before decryption", { archiveId: id });
|
|
830
2414
|
// In a plain vault the stored object is a gzip, so "decrypt" is gunzip — but
|