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
|
@@ -1,59 +1,651 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { fail, isSealkeepError } from "./errors.js";
|
|
3
|
+
import { readConfig, listArchives, updateConfigFields, updateConfigFieldsIf } from "./vault.js";
|
|
3
4
|
import { isV2 } from "./types.js";
|
|
4
|
-
import { envVar } from "./env.js";
|
|
5
|
+
import { envFlag, envVar } from "./env.js";
|
|
6
|
+
import { loadProviderCredentialRecord, storeProviderCredentials, } from "./secrets.js";
|
|
7
|
+
import { archiveCopies } from "./archive-copies.js";
|
|
8
|
+
import { STORAGE_PREFIX_DEFAULT } from "./branding.js";
|
|
9
|
+
import { assertSafeStorageEndpoint, parseStorageEndpoint } from "./storage-endpoint.js";
|
|
10
|
+
export { assertSafeStorageEndpoint, parseStorageEndpoint } from "./storage-endpoint.js";
|
|
11
|
+
async function targetPushGenerationMatches(dataDir, options) {
|
|
12
|
+
if (!Object.hasOwn(options, "expectedAuthGeneration"))
|
|
13
|
+
return true;
|
|
14
|
+
try {
|
|
15
|
+
const { cloudAuthGeneration } = await import("./cloud.js");
|
|
16
|
+
return await cloudAuthGeneration(dataDir) === options.expectedAuthGeneration;
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function routingFingerprint(config) {
|
|
23
|
+
// Usage reporting must never revive a destination, cap, pin or priority
|
|
24
|
+
// which the person changed while its network request was in flight. The
|
|
25
|
+
// complete account-owned routing shapes are small and already serialisable;
|
|
26
|
+
// hashing them also avoids persisting another mutable revision counter.
|
|
27
|
+
return createHash("sha256").update(JSON.stringify({
|
|
28
|
+
storageTargets: config.storageTargets ?? null,
|
|
29
|
+
storageTargetsScope: config.storageTargetsScope ?? null,
|
|
30
|
+
remoteStorage: config.remoteStorage ?? null,
|
|
31
|
+
})).digest("hex");
|
|
32
|
+
}
|
|
33
|
+
function normalizedStorageTargetsScope(config) {
|
|
34
|
+
const scope = config.storageTargetsScope;
|
|
35
|
+
if (scope?.kind === "local")
|
|
36
|
+
return { kind: "local" };
|
|
37
|
+
if (scope?.kind === "account"
|
|
38
|
+
&& (scope.accountScope === null || typeof scope.accountScope === "string")) {
|
|
39
|
+
return { kind: "account", accountScope: scope.accountScope };
|
|
40
|
+
}
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
const accountStorageTargetsScope = (accountScope) => ({
|
|
44
|
+
kind: "account",
|
|
45
|
+
accountScope,
|
|
46
|
+
});
|
|
47
|
+
function sameStorageTargetsScope(left, right) {
|
|
48
|
+
return left?.kind === right.kind
|
|
49
|
+
&& (left.kind !== "account" || right.kind !== "account" || left.accountScope === right.accountScope);
|
|
50
|
+
}
|
|
51
|
+
/** Read-only local auth state. `signedIn: null` is an unreadable/transitioning
|
|
52
|
+
* credential store and therefore cannot authorise a legacy unscoped cache. */
|
|
53
|
+
async function localCloudAuthState(dataDir) {
|
|
54
|
+
const { cloudAuthGeneration, cloudToken } = await import("./cloud.js");
|
|
55
|
+
let accountScope;
|
|
56
|
+
try {
|
|
57
|
+
accountScope = await cloudAuthGeneration(dataDir);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return { accountScope: null, signedIn: null };
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
await cloudToken(dataDir, undefined, { renew: false });
|
|
64
|
+
return { accountScope, signedIn: true };
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
return {
|
|
68
|
+
accountScope,
|
|
69
|
+
signedIn: isSealkeepError(error) && error.code === "unauthorized" ? false : null,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
async function localStorageTargetsAreUsable(dataDir, config, authenticatedAccountScope) {
|
|
74
|
+
const scope = normalizedStorageTargetsScope(config);
|
|
75
|
+
if (scope?.kind === "local")
|
|
76
|
+
return true;
|
|
77
|
+
if (authenticatedAccountScope !== undefined) {
|
|
78
|
+
return scope?.kind === "account" && scope.accountScope === authenticatedAccountScope;
|
|
79
|
+
}
|
|
80
|
+
const auth = await localCloudAuthState(dataDir);
|
|
81
|
+
if (scope?.kind === "account") {
|
|
82
|
+
return auth.signedIn === true && auth.accountScope === scope.accountScope;
|
|
83
|
+
}
|
|
84
|
+
// Before this field existed, storageTargets could be either a local setting
|
|
85
|
+
// or a Cloud cache. Preserve signed-out/local vaults, but never guess while a
|
|
86
|
+
// Cloud account (or an unreadable auth transition) may own the old rows.
|
|
87
|
+
return auth.signedIn === false;
|
|
88
|
+
}
|
|
89
|
+
export async function storageTargetAccountBaselineIsCurrent(dataDir, baseline) {
|
|
90
|
+
return targetPushGenerationMatches(dataDir, { expectedAuthGeneration: baseline.accountScope });
|
|
91
|
+
}
|
|
92
|
+
async function targetPushRoutingMatches(dataDir, options) {
|
|
93
|
+
if (!options.expectedRoutingFingerprint)
|
|
94
|
+
return true;
|
|
95
|
+
try {
|
|
96
|
+
return routingFingerprint(await readConfig(dataDir)) === options.expectedRoutingFingerprint;
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
function normalPrefix(prefix) {
|
|
103
|
+
return (prefix ?? STORAGE_PREFIX_DEFAULT).trim().replace(/^\/+|\/+$/g, "");
|
|
104
|
+
}
|
|
105
|
+
function gdriveCredentialAccountId(credentials) {
|
|
106
|
+
if (!credentials || !("kind" in credentials) || credentials.kind !== "gdrive")
|
|
107
|
+
return null;
|
|
108
|
+
const accountId = typeof credentials.accountId === "string" ? credentials.accountId.trim() : "";
|
|
109
|
+
return accountId && accountId.length <= 512 && !/[\u0000-\u001f\u007f]/.test(accountId) ? accountId : null;
|
|
110
|
+
}
|
|
111
|
+
/** Stable, serialisable identity used by both credential and copy bindings. */
|
|
112
|
+
export function storageDestinationIdentity(target, effectiveEndpoint = target.endpoint, gdriveAccountId) {
|
|
113
|
+
if (target.provider === "vaultline")
|
|
114
|
+
fail("invalid_argument", "Managed storage does not use a local bucket destination identity.");
|
|
115
|
+
const bucket = (target.bucket ?? "").trim();
|
|
116
|
+
if (!bucket && target.provider !== "gdrive")
|
|
117
|
+
fail("invalid_argument", `Storage target "${target.id}" needs a bucket.`);
|
|
118
|
+
const endpoint = effectiveEndpoint ? parseStorageEndpoint(effectiveEndpoint).origin : null;
|
|
119
|
+
const hostname = endpoint ? new URL(endpoint).hostname.toLowerCase().replace(/^\[|\]$/g, "") : "";
|
|
120
|
+
const r2Suffix = ".r2.cloudflarestorage.com";
|
|
121
|
+
const account = target.provider === "gdrive"
|
|
122
|
+
? (gdriveAccountId?.trim() || null)
|
|
123
|
+
: target.provider === "r2" && hostname.endsWith(r2Suffix)
|
|
124
|
+
? hostname.slice(0, -r2Suffix.length) || null
|
|
125
|
+
: null;
|
|
126
|
+
return {
|
|
127
|
+
version: 1,
|
|
128
|
+
provider: target.provider,
|
|
129
|
+
bucket,
|
|
130
|
+
prefix: normalPrefix(target.prefix),
|
|
131
|
+
region: target.provider === "gcs" || target.provider === "gdrive"
|
|
132
|
+
? null
|
|
133
|
+
: (target.region ?? "auto").trim().toLowerCase(),
|
|
134
|
+
endpoint,
|
|
135
|
+
account,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
/** Hash of a fixed-order tuple, never of user-controlled object key syntax. */
|
|
139
|
+
export function storageDestinationFingerprint(destination) {
|
|
140
|
+
const canonical = JSON.stringify([
|
|
141
|
+
destination.version,
|
|
142
|
+
destination.provider,
|
|
143
|
+
destination.bucket,
|
|
144
|
+
destination.prefix,
|
|
145
|
+
destination.region,
|
|
146
|
+
destination.endpoint,
|
|
147
|
+
destination.account,
|
|
148
|
+
]);
|
|
149
|
+
return `dest_${createHash("sha256").update(canonical).digest("hex")}`;
|
|
150
|
+
}
|
|
151
|
+
export async function destinationIdentityForTarget(dataDir, target, credentials) {
|
|
152
|
+
let accountId = target.provider === "gdrive" ? gdriveCredentialAccountId(credentials) : null;
|
|
153
|
+
if (target.provider === "gdrive" && !credentials) {
|
|
154
|
+
const config = await readConfig(dataDir);
|
|
155
|
+
const ids = [...new Set([target.id, ...(target.id === "primary" ? [config.vaultId] : [])].filter(Boolean))];
|
|
156
|
+
for (const id of ids) {
|
|
157
|
+
const record = await loadProviderCredentialRecord(dataDir, id).catch(() => null);
|
|
158
|
+
accountId = gdriveCredentialAccountId(record?.credentials);
|
|
159
|
+
if (accountId)
|
|
160
|
+
break;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return storageDestinationIdentity(target, await endpointForTarget(dataDir, target), accountId);
|
|
164
|
+
}
|
|
165
|
+
export async function destinationBindingForTarget(dataDir, target, verifiedAt = new Date().toISOString(), credentials) {
|
|
166
|
+
const destination = await destinationIdentityForTarget(dataDir, target, credentials);
|
|
167
|
+
return { destination, destinationFingerprint: storageDestinationFingerprint(destination), verifiedAt };
|
|
168
|
+
}
|
|
169
|
+
export async function storeProviderCredentialsForTarget(dataDir, target, credentials, preferred, verifiedAt = new Date().toISOString()) {
|
|
170
|
+
if (target.provider === "gdrive" && !gdriveCredentialAccountId(credentials)) {
|
|
171
|
+
fail("invalid_argument", `Google Drive target "${target.id}" has no stable account identity. Disconnect and reconnect it before use.`);
|
|
172
|
+
}
|
|
173
|
+
return storeProviderCredentials(dataDir, target.id, credentials, preferred, await destinationBindingForTarget(dataDir, target, verifiedAt, credentials));
|
|
174
|
+
}
|
|
175
|
+
function validDestinationBinding(binding) {
|
|
176
|
+
return binding.destinationFingerprint === storageDestinationFingerprint(binding.destination);
|
|
177
|
+
}
|
|
178
|
+
/** Provider host information is routing configuration, not a credential, so it
|
|
179
|
+
* travels with the account target and every machine signs against the same
|
|
180
|
+
* intended service. This also prevents a stale setup-wide endpoint from being
|
|
181
|
+
* accidentally reused for a different bucket target. */
|
|
182
|
+
export function endpointOverridesForTarget(target) {
|
|
183
|
+
if (!target.endpoint)
|
|
184
|
+
return {};
|
|
185
|
+
const parsed = parseStorageEndpoint(target.endpoint, `Storage target "${target.id}" endpoint`);
|
|
186
|
+
return {
|
|
187
|
+
host: parsed.hostname,
|
|
188
|
+
...(parsed.port ? { port: Number(parsed.port) } : {}),
|
|
189
|
+
protocol: parsed.protocol === "http:" ? "http" : "https",
|
|
190
|
+
pathStyle: true,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* The setup wizard predates account-wide targets and kept its endpoint in
|
|
195
|
+
* setup.json. That value belongs to the one legacy `remoteStorage` target only:
|
|
196
|
+
* letting it bleed into a named target can sign an R2/S3 request for the right
|
|
197
|
+
* bucket against the wrong provider host. New targets carry their endpoint in
|
|
198
|
+
* routing metadata; only the synthesized `primary` target may use this fallback.
|
|
199
|
+
*/
|
|
200
|
+
export async function endpointForTarget(dataDir, target) {
|
|
201
|
+
if (target.endpoint)
|
|
202
|
+
return target.endpoint;
|
|
203
|
+
if (target.id !== "primary")
|
|
204
|
+
return undefined;
|
|
205
|
+
try {
|
|
206
|
+
const { readFile } = await import("node:fs/promises");
|
|
207
|
+
const { join } = await import("node:path");
|
|
208
|
+
const setup = JSON.parse(await readFile(join(dataDir, "setup.json"), "utf8"));
|
|
209
|
+
return typeof setup.storageEndpoint === "string" && setup.storageEndpoint.trim()
|
|
210
|
+
? setup.storageEndpoint.trim()
|
|
211
|
+
: undefined;
|
|
212
|
+
}
|
|
213
|
+
catch {
|
|
214
|
+
return undefined;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
/** Effective provider overrides, including the narrowly-scoped legacy fallback. */
|
|
218
|
+
export async function endpointOverridesForResolvedTarget(dataDir, target) {
|
|
219
|
+
const endpoint = await endpointForTarget(dataDir, target);
|
|
220
|
+
if (!endpoint)
|
|
221
|
+
return {};
|
|
222
|
+
await assertSafeStorageEndpoint(endpoint, target.provider);
|
|
223
|
+
return endpointOverridesForTarget({ ...target, endpoint });
|
|
224
|
+
}
|
|
5
225
|
const defaultPriority = (target) => target.priority ?? (target.provider === "vaultline" ? 0 : 10);
|
|
226
|
+
function credentialMatches(target, credential) {
|
|
227
|
+
if (target.provider === "gdrive")
|
|
228
|
+
return "kind" in credential && credential.kind === "gdrive";
|
|
229
|
+
if (target.provider === "gcs")
|
|
230
|
+
return "clientEmail" in credential;
|
|
231
|
+
return target.provider !== "vaultline" && "accessKeyId" in credential;
|
|
232
|
+
}
|
|
6
233
|
/**
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
234
|
+
* Provider credentials belong to a machine, while routing rules belong to the
|
|
235
|
+
* account. New installs address a credential by target id so two BYO targets
|
|
236
|
+
* cannot overwrite one another; the vault id remains a read fallback only for
|
|
237
|
+
* the synthesized primary target created by older releases.
|
|
11
238
|
*/
|
|
12
|
-
export async function
|
|
239
|
+
export async function credentialConfigIdForTarget(dataDir, target) {
|
|
240
|
+
if (target.provider === "vaultline")
|
|
241
|
+
return null;
|
|
13
242
|
const config = await readConfig(dataDir);
|
|
243
|
+
// A named account target is an isolation boundary. Two S3-style providers
|
|
244
|
+
// have the same credential shape, so probing the vault-wide legacy secret
|
|
245
|
+
// for every target would make a newly-added bucket appear connected with a
|
|
246
|
+
// different bucket's key. Only the synthesized legacy primary may fall back.
|
|
247
|
+
const ids = [...new Set([target.id, ...(target.id === "primary" ? [config.vaultId] : [])].filter(Boolean))];
|
|
248
|
+
const destination = target.provider === "gdrive" ? null : await destinationIdentityForTarget(dataDir, target);
|
|
249
|
+
const fingerprint = destination ? storageDestinationFingerprint(destination) : null;
|
|
250
|
+
const actualLegacyPrimary = config.remoteStorage
|
|
251
|
+
? { id: "primary", ...config.remoteStorage }
|
|
252
|
+
: null;
|
|
253
|
+
const actualLegacyFingerprint = actualLegacyPrimary && actualLegacyPrimary.provider !== "vaultline" && actualLegacyPrimary.provider !== "gdrive"
|
|
254
|
+
? storageDestinationFingerprint(await destinationIdentityForTarget(dataDir, actualLegacyPrimary))
|
|
255
|
+
: null;
|
|
256
|
+
const synthesizedPrimary = target.id === "primary"
|
|
257
|
+
&& !(config.storageTargets && config.storageTargets.length > 0)
|
|
258
|
+
&& Boolean(config.remoteStorage)
|
|
259
|
+
&& fingerprint === actualLegacyFingerprint;
|
|
260
|
+
for (const id of ids) {
|
|
261
|
+
const record = await loadProviderCredentialRecord(dataDir, id).catch(() => null);
|
|
262
|
+
if (!record || !credentialMatches(target, record.credentials))
|
|
263
|
+
continue;
|
|
264
|
+
const candidateDestination = target.provider === "gdrive"
|
|
265
|
+
? await destinationIdentityForTarget(dataDir, target, record.credentials)
|
|
266
|
+
: destination;
|
|
267
|
+
const candidateFingerprint = candidateDestination
|
|
268
|
+
? storageDestinationFingerprint(candidateDestination)
|
|
269
|
+
: null;
|
|
270
|
+
// Pre-account-binding Drive grants are readable only so the person can
|
|
271
|
+
// disconnect them. Treating one as connected would let a reconnect under
|
|
272
|
+
// account B read/delete a copy that was actually written under account A.
|
|
273
|
+
if (target.provider === "gdrive" && !gdriveCredentialAccountId(record.credentials))
|
|
274
|
+
continue;
|
|
275
|
+
if (record.binding) {
|
|
276
|
+
if (validDestinationBinding(record.binding) && record.binding.destinationFingerprint === candidateFingerprint)
|
|
277
|
+
return id;
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
// A Drive credential carrying its Google-issued account id can be migrated
|
|
281
|
+
// without probing or guessing: the credential itself identifies the grant.
|
|
282
|
+
// Historical credentials without that field take the fail-closed branch
|
|
283
|
+
// above and require fresh consent.
|
|
284
|
+
if (target.provider === "gdrive" && candidateDestination && candidateFingerprint) {
|
|
285
|
+
await storeProviderCredentials(dataDir, id, record.credentials, undefined, {
|
|
286
|
+
destination: candidateDestination,
|
|
287
|
+
destinationFingerprint: candidateFingerprint,
|
|
288
|
+
verifiedAt: null,
|
|
289
|
+
});
|
|
290
|
+
return id;
|
|
291
|
+
}
|
|
292
|
+
// A raw named credential has no evidence of which account endpoint it was
|
|
293
|
+
// tested against. Silently blessing today's route would recreate the
|
|
294
|
+
// target-id repoint vulnerability. The one safe migration is the old
|
|
295
|
+
// machine-owned `remoteStorage` target: its routing and secret predate the
|
|
296
|
+
// account-wide mutable target list and were stored together locally.
|
|
297
|
+
if (synthesizedPrimary && destination) {
|
|
298
|
+
const binding = {
|
|
299
|
+
destination,
|
|
300
|
+
destinationFingerprint: fingerprint,
|
|
301
|
+
// Unknown is represented honestly; this migration does not pretend a
|
|
302
|
+
// historical probe happened now.
|
|
303
|
+
verifiedAt: null,
|
|
304
|
+
};
|
|
305
|
+
await storeProviderCredentials(dataDir, id, record.credentials, undefined, binding);
|
|
306
|
+
return id;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
return null;
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* Exact-copy routing guard. New records must match the immutable fingerprint.
|
|
313
|
+
* Legacy records are accepted only for globally addressed providers without a
|
|
314
|
+
* custom endpoint; R2/custom S3/Drive account identity is ambiguous and fails
|
|
315
|
+
* closed until a verified migration can stamp the copy.
|
|
316
|
+
*/
|
|
317
|
+
export async function remoteCopyMatchesTarget(dataDir, target, copy) {
|
|
318
|
+
if (target.provider === "vaultline")
|
|
319
|
+
return copy.provider === "vaultline";
|
|
320
|
+
if (copy.provider !== target.provider || (target.bucket ?? "") !== copy.bucket)
|
|
321
|
+
return false;
|
|
322
|
+
const destination = await destinationIdentityForTarget(dataDir, target);
|
|
323
|
+
const fingerprint = storageDestinationFingerprint(destination);
|
|
324
|
+
if (copy.destination || copy.destinationFingerprint) {
|
|
325
|
+
return Boolean(copy.destination && copy.destinationFingerprint
|
|
326
|
+
&& copy.destinationFingerprint === storageDestinationFingerprint(copy.destination)
|
|
327
|
+
&& copy.destinationFingerprint === fingerprint);
|
|
328
|
+
}
|
|
329
|
+
const endpoint = await endpointForTarget(dataDir, target);
|
|
330
|
+
return !endpoint && target.provider !== "r2" && target.provider !== "gdrive";
|
|
331
|
+
}
|
|
332
|
+
/** Whether this particular machine can use an account-wide destination now. */
|
|
333
|
+
export async function targetConnectedOnThisMachine(dataDir, target, options = {}) {
|
|
334
|
+
options.signal?.throwIfAborted();
|
|
335
|
+
if (target.provider === "vaultline") {
|
|
336
|
+
try {
|
|
337
|
+
const { cloudToken } = await import("./cloud.js");
|
|
338
|
+
await cloudToken(dataDir, undefined, {
|
|
339
|
+
renew: options.renewCloudToken !== false,
|
|
340
|
+
signal: options.signal,
|
|
341
|
+
});
|
|
342
|
+
return true;
|
|
343
|
+
}
|
|
344
|
+
catch (error) {
|
|
345
|
+
if (options.signal?.aborted)
|
|
346
|
+
throw options.signal.reason ?? error;
|
|
347
|
+
return false;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
const endpoint = await endpointForTarget(dataDir, target);
|
|
351
|
+
if (target.provider === "r2" && !endpoint)
|
|
352
|
+
return false;
|
|
353
|
+
try {
|
|
354
|
+
if (endpoint)
|
|
355
|
+
await assertSafeStorageEndpoint(endpoint, target.provider);
|
|
356
|
+
}
|
|
357
|
+
catch {
|
|
358
|
+
return false;
|
|
359
|
+
}
|
|
360
|
+
return (await credentialConfigIdForTarget(dataDir, target)) !== null;
|
|
361
|
+
}
|
|
362
|
+
export async function targetConnectionEvidence(dataDir, target, options = {}) {
|
|
363
|
+
if (target.provider === "vaultline") {
|
|
364
|
+
const connected = await targetConnectedOnThisMachine(dataDir, target, options);
|
|
365
|
+
return { connected, status: connected ? "account_session" : "credentials_missing", verifiedAt: null };
|
|
366
|
+
}
|
|
367
|
+
const credentialId = await credentialConfigIdForTarget(dataDir, target);
|
|
368
|
+
if (!credentialId || !(await targetConnectedOnThisMachine(dataDir, target, options))) {
|
|
369
|
+
return { connected: false, status: "credentials_missing", verifiedAt: null };
|
|
370
|
+
}
|
|
371
|
+
const record = await loadProviderCredentialRecord(dataDir, credentialId).catch(() => null);
|
|
372
|
+
const verifiedAt = record?.binding?.verifiedAt ?? null;
|
|
373
|
+
return {
|
|
374
|
+
connected: true,
|
|
375
|
+
status: verifiedAt ? "credentials_verified" : "credentials_saved",
|
|
376
|
+
verifiedAt,
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
async function resolveTargetsState(dataDir, options = {}) {
|
|
380
|
+
let config = await readConfig(dataDir);
|
|
381
|
+
const expectedLocalRoutingFingerprint = routingFingerprint(config);
|
|
14
382
|
// The account is the source of truth: every machine of the vault syncs the
|
|
15
383
|
// SAME rules down, so a cap set on the laptop holds on the desktop too.
|
|
16
384
|
// The local config is the offline cache; an explicit local list still wins
|
|
17
385
|
// when the account cannot be reached.
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
386
|
+
const pulled = options.pullFromCloud === false
|
|
387
|
+
? null
|
|
388
|
+
// This boundary is an account-routing read backed by an authenticated
|
|
389
|
+
// local cache. A timeout makes the remote read unavailable; it does not
|
|
390
|
+
// invalidate that cache. `pullTargetsFromCloud` itself still propagates
|
|
391
|
+
// aborts, so write paths which call it directly retain cancellation.
|
|
392
|
+
: await pullTargetsFromCloud(dataDir, options).catch(() => null);
|
|
393
|
+
// The Cloud read may be slow. If a person saved a newer route while it was
|
|
394
|
+
// in flight, neither use nor cache the older reply. Re-resolve strictly from
|
|
395
|
+
// the latest local config so the action which awaited this read also sees
|
|
396
|
+
// the newer choice.
|
|
397
|
+
if (options.pullFromCloud !== false) {
|
|
398
|
+
const localStillCurrent = await targetPushRoutingMatches(dataDir, {
|
|
399
|
+
expectedRoutingFingerprint: expectedLocalRoutingFingerprint,
|
|
400
|
+
});
|
|
401
|
+
if (!localStillCurrent)
|
|
402
|
+
return resolveTargetsState(dataDir, { ...options, pullFromCloud: false });
|
|
403
|
+
}
|
|
404
|
+
// A target reply is account-owned configuration. If login/logout raced the
|
|
405
|
+
// request, it must not overwrite this machine's cached routes or be used by
|
|
406
|
+
// the action which happened to await it.
|
|
407
|
+
const currentAccountScope = pulled
|
|
408
|
+
? await import("./cloud.js").then(({ cloudAuthGeneration }) => cloudAuthGeneration(dataDir)).catch(() => null)
|
|
409
|
+
: null;
|
|
410
|
+
const validPulled = pulled && currentAccountScope === pulled.accountScope ? pulled : null;
|
|
411
|
+
const accountBaseline = validPulled ? {
|
|
412
|
+
targetsJson: validPulled.rawTargetsJson,
|
|
413
|
+
updatedAt: validPulled.updatedAt,
|
|
414
|
+
accountScope: validPulled.accountScope,
|
|
415
|
+
} : null;
|
|
416
|
+
const fromCloud = validPulled && validPulled.rawTargetsJson !== null
|
|
417
|
+
? validPulled.targets
|
|
418
|
+
: null;
|
|
419
|
+
if (fromCloud) {
|
|
420
|
+
const desired = fromCloud.length > 0 ? fromCloud : undefined;
|
|
421
|
+
const desiredScope = accountStorageTargetsScope(validPulled.accountScope);
|
|
422
|
+
if (JSON.stringify(desired ?? null) !== JSON.stringify(config.storageTargets ?? null)
|
|
423
|
+
|| !sameStorageTargetsScope(normalizedStorageTargetsScope(config), desiredScope)) {
|
|
424
|
+
const cached = await updateConfigFieldsIf(dataDir, (current) => routingFingerprint(current) === expectedLocalRoutingFingerprint, { storageTargets: desired, storageTargetsScope: desiredScope }).catch(() => null);
|
|
425
|
+
if (!cached?.matched)
|
|
426
|
+
return resolveTargetsState(dataDir, { ...options, pullFromCloud: false });
|
|
427
|
+
config = cached.config;
|
|
22
428
|
}
|
|
23
|
-
|
|
429
|
+
if (fromCloud.length > 0)
|
|
430
|
+
return { targets: fromCloud, accountBaseline };
|
|
431
|
+
}
|
|
432
|
+
if (validPulled && validPulled.rawTargetsJson === null) {
|
|
433
|
+
const existingScope = normalizedStorageTargetsScope(config);
|
|
434
|
+
// An authenticated "no row" answer must retire a prior account's cache.
|
|
435
|
+
// Keep only an explicit machine-local route, or this same account's local
|
|
436
|
+
// first-save draft whose create request previously failed offline.
|
|
437
|
+
const keepLocalDraft = Boolean(config.storageTargets?.length)
|
|
438
|
+
&& (existingScope?.kind === "local"
|
|
439
|
+
|| (existingScope?.kind === "account" && existingScope.accountScope === validPulled.accountScope));
|
|
440
|
+
const desiredScope = accountStorageTargetsScope(validPulled.accountScope);
|
|
441
|
+
if (!keepLocalDraft && (config.storageTargets !== undefined
|
|
442
|
+
|| !sameStorageTargetsScope(existingScope, desiredScope))) {
|
|
443
|
+
const cached = await updateConfigFieldsIf(dataDir, (current) => routingFingerprint(current) === expectedLocalRoutingFingerprint, { storageTargets: undefined, storageTargetsScope: desiredScope }).catch(() => null);
|
|
444
|
+
if (!cached?.matched)
|
|
445
|
+
return resolveTargetsState(dataDir, { ...options, pullFromCloud: false });
|
|
446
|
+
config = cached.config;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
// `[]` is an authoritative account reset, distinct from an unavailable
|
|
450
|
+
// account response (`null`). Do not resurrect this machine's stale cached
|
|
451
|
+
// routes after another machine removes the explicit list; continue through
|
|
452
|
+
// the ordinary legacy/managed fallback as if the local setting were absent.
|
|
453
|
+
const declared = fromCloud === null
|
|
454
|
+
&& await localStorageTargetsAreUsable(dataDir, config, validPulled ? validPulled.accountScope : undefined)
|
|
455
|
+
? config.storageTargets
|
|
456
|
+
: undefined;
|
|
457
|
+
if (declared && declared.length > 0) {
|
|
458
|
+
const sanitized = sanitizedStorageTargets(declared);
|
|
459
|
+
if (!sanitized)
|
|
460
|
+
fail("invalid_argument", "Stored storage targets are malformed. Review and save the account's destination rules again.");
|
|
461
|
+
return { targets: sanitized, accountBaseline };
|
|
24
462
|
}
|
|
25
|
-
const declared = config.storageTargets;
|
|
26
|
-
if (declared && declared.length > 0)
|
|
27
|
-
return declared;
|
|
28
463
|
if (config.remoteStorage) {
|
|
29
|
-
return
|
|
464
|
+
return {
|
|
465
|
+
targets: [{ id: "primary", provider: config.remoteStorage.provider, bucket: config.remoteStorage.bucket, prefix: config.remoteStorage.prefix, region: config.remoteStorage.region }],
|
|
466
|
+
accountBaseline,
|
|
467
|
+
};
|
|
30
468
|
}
|
|
469
|
+
if (validPulled)
|
|
470
|
+
return { targets: [{ id: "managed", provider: "vaultline" }], accountBaseline };
|
|
31
471
|
try {
|
|
32
472
|
const { cloudToken } = await import("./cloud.js");
|
|
33
|
-
await cloudToken(dataDir
|
|
34
|
-
|
|
473
|
+
await cloudToken(dataDir, undefined, {
|
|
474
|
+
renew: options.renewCloudToken !== false,
|
|
475
|
+
signal: options.signal,
|
|
476
|
+
});
|
|
477
|
+
return { targets: [{ id: "managed", provider: "vaultline" }], accountBaseline };
|
|
35
478
|
}
|
|
36
|
-
catch {
|
|
37
|
-
|
|
479
|
+
catch (error) {
|
|
480
|
+
if (options.signal?.aborted)
|
|
481
|
+
throw options.signal.reason ?? error;
|
|
482
|
+
return { targets: [], accountBaseline };
|
|
38
483
|
}
|
|
39
484
|
}
|
|
485
|
+
export async function resolveTargets(dataDir, options = {}) {
|
|
486
|
+
return (await resolveTargetsState(dataDir, options)).targets;
|
|
487
|
+
}
|
|
488
|
+
/** One coherent edit view: the effective rows and the exact account revision
|
|
489
|
+
* they came from. Callers must carry `accountBaseline` unchanged into
|
|
490
|
+
* `setStorageTargets`; a fresh read at save time would authorise a stale UI. */
|
|
491
|
+
export async function storageTargetsForEditing(dataDir, options = {}) {
|
|
492
|
+
// A first explicit account edit may also be the first operation on this
|
|
493
|
+
// machine. Enrol before sampling the baseline so enrolment cannot advance
|
|
494
|
+
// the auth generation between the edit read and its eventual CAS write.
|
|
495
|
+
const { machineIdentity } = await import("./cloud.js");
|
|
496
|
+
await machineIdentity(dataDir).catch(() => null);
|
|
497
|
+
return resolveTargetsState(dataDir, options);
|
|
498
|
+
}
|
|
40
499
|
/** The account's rules, or null when not signed in / none saved. */
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
500
|
+
function sanitizedStorageTarget(value) {
|
|
501
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
502
|
+
return null;
|
|
503
|
+
const raw = value;
|
|
504
|
+
const providers = new Set(["vaultline", "s3", "r2", "b2", "gcs", "gdrive"]);
|
|
505
|
+
if (typeof raw.id !== "string" || !/^[A-Za-z0-9._-]{1,100}$/.test(raw.id))
|
|
506
|
+
return null;
|
|
507
|
+
if (typeof raw.provider !== "string" || !providers.has(raw.provider))
|
|
508
|
+
return null;
|
|
509
|
+
const provider = raw.provider;
|
|
510
|
+
if (raw.enabled !== undefined && typeof raw.enabled !== "boolean")
|
|
511
|
+
return null;
|
|
512
|
+
const stringField = (name, max) => {
|
|
513
|
+
const found = raw[name];
|
|
514
|
+
if (found === undefined || found === null)
|
|
515
|
+
return undefined;
|
|
516
|
+
return typeof found === "string" && found.length > 0 && found.length <= max ? found : null;
|
|
517
|
+
};
|
|
518
|
+
const bucket = stringField("bucket", 1024);
|
|
519
|
+
const prefix = stringField("prefix", 2048);
|
|
520
|
+
const region = stringField("region", 200);
|
|
521
|
+
const endpoint = stringField("endpoint", 2048);
|
|
522
|
+
if (bucket === null || prefix === null || region === null || endpoint === null)
|
|
523
|
+
return null;
|
|
524
|
+
if (provider !== "vaultline" && provider !== "gdrive" && !bucket)
|
|
525
|
+
return null;
|
|
526
|
+
if (provider === "r2" && !endpoint)
|
|
527
|
+
return null;
|
|
528
|
+
if (endpoint) {
|
|
529
|
+
let parsed;
|
|
530
|
+
try {
|
|
531
|
+
parsed = parseStorageEndpoint(endpoint, `Storage target "${raw.id}" endpoint`);
|
|
532
|
+
}
|
|
533
|
+
catch {
|
|
534
|
+
return null;
|
|
535
|
+
}
|
|
536
|
+
const hostname = parsed.hostname.toLowerCase().replace(/\.$/, "");
|
|
537
|
+
if (provider === "r2" && !hostname.endsWith(".r2.cloudflarestorage.com") && !envFlag("ALLOW_PRIVATE_STORAGE_ENDPOINT"))
|
|
538
|
+
return null;
|
|
539
|
+
}
|
|
540
|
+
const numberField = (name, minimum) => {
|
|
541
|
+
const found = raw[name];
|
|
542
|
+
if (found === undefined || found === null)
|
|
543
|
+
return undefined;
|
|
544
|
+
return typeof found === "number" && Number.isFinite(found) && found >= minimum ? found : null;
|
|
545
|
+
};
|
|
546
|
+
const maxGb = numberField("maxGb", 0);
|
|
547
|
+
const priority = numberField("priority", Number.NEGATIVE_INFINITY);
|
|
548
|
+
const usedBytes = numberField("usedBytes", 0);
|
|
549
|
+
if (maxGb === null || priority === null || usedBytes === null)
|
|
550
|
+
return null;
|
|
551
|
+
const projects = raw.projects === undefined ? undefined : Array.isArray(raw.projects)
|
|
552
|
+
? raw.projects.filter((item) => typeof item === "string" && item.length > 0 && item.length < 200).slice(0, 64)
|
|
553
|
+
: null;
|
|
554
|
+
if (projects === null)
|
|
555
|
+
return null;
|
|
556
|
+
const target = {
|
|
557
|
+
id: raw.id,
|
|
558
|
+
provider,
|
|
559
|
+
...(raw.enabled === false ? { enabled: false } : {}),
|
|
560
|
+
...(bucket ? { bucket } : {}),
|
|
561
|
+
...(prefix ? { prefix } : {}),
|
|
562
|
+
...(region ? { region } : {}),
|
|
563
|
+
...(endpoint ? { endpoint: parseStorageEndpoint(endpoint).origin } : {}),
|
|
564
|
+
...(maxGb !== undefined ? { maxGb } : {}),
|
|
565
|
+
...(projects && projects.length > 0 ? { projects } : {}),
|
|
566
|
+
...(priority !== undefined ? { priority } : {}),
|
|
567
|
+
...(usedBytes !== undefined ? { usedBytes } : {}),
|
|
568
|
+
...(typeof raw.usedAt === "string" ? { usedAt: raw.usedAt } : {}),
|
|
569
|
+
};
|
|
570
|
+
if (typeof raw.connectionHealth === "object" && raw.connectionHealth !== null && !Array.isArray(raw.connectionHealth)) {
|
|
571
|
+
const reports = {};
|
|
572
|
+
for (const [device, report] of Object.entries(raw.connectionHealth).slice(0, 128)) {
|
|
573
|
+
if (!/^[A-Za-z0-9._:-]{1,200}$/.test(device) || typeof report !== "object" || report === null || Array.isArray(report))
|
|
574
|
+
continue;
|
|
575
|
+
const entry = report;
|
|
576
|
+
if (typeof entry.connected !== "boolean" || typeof entry.checkedAt !== "string")
|
|
577
|
+
continue;
|
|
578
|
+
const status = entry.status === "credentials_verified" || entry.status === "credentials_saved"
|
|
579
|
+
|| entry.status === "credentials_missing" || entry.status === "account_session"
|
|
580
|
+
? entry.status : undefined;
|
|
581
|
+
reports[device] = {
|
|
582
|
+
connected: entry.connected,
|
|
583
|
+
checkedAt: entry.checkedAt,
|
|
584
|
+
...(status ? { status } : {}),
|
|
585
|
+
...(entry.verifiedAt === null || typeof entry.verifiedAt === "string" ? { verifiedAt: entry.verifiedAt } : {}),
|
|
586
|
+
...(typeof entry.label === "string" ? { label: entry.label.slice(0, 200) } : {}),
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
if (Object.keys(reports).length > 0)
|
|
590
|
+
target.connectionHealth = reports;
|
|
591
|
+
}
|
|
592
|
+
return target;
|
|
593
|
+
}
|
|
594
|
+
function sanitizedStorageTargets(values) {
|
|
595
|
+
if (!Array.isArray(values) || values.length > 100)
|
|
596
|
+
return null;
|
|
597
|
+
const targets = values.map(sanitizedStorageTarget);
|
|
598
|
+
if (targets.some((target) => target === null))
|
|
599
|
+
return null;
|
|
600
|
+
const result = targets;
|
|
601
|
+
if (new Set(result.map((target) => target.id)).size !== result.length)
|
|
602
|
+
return null;
|
|
603
|
+
return result;
|
|
604
|
+
}
|
|
605
|
+
async function pullTargetsFromCloud(dataDir, options = {}) {
|
|
606
|
+
const { cloudAuthGeneration, cloudToken } = await import("./cloud.js");
|
|
607
|
+
const token = await cloudToken(dataDir, undefined, {
|
|
608
|
+
renew: options.renewCloudToken !== false,
|
|
609
|
+
signal: options.signal,
|
|
610
|
+
}).catch((error) => {
|
|
611
|
+
if (options.signal?.aborted)
|
|
612
|
+
throw options.signal.reason ?? error;
|
|
613
|
+
return null;
|
|
614
|
+
});
|
|
44
615
|
if (!token)
|
|
45
616
|
return null;
|
|
617
|
+
// Sample after cloudToken: an action read may renew a near-expiry token, and
|
|
618
|
+
// renewal legitimately advances the generation before this request begins.
|
|
619
|
+
const accountScope = await cloudAuthGeneration(dataDir).catch(() => null);
|
|
46
620
|
const { DEFAULT_CLOUD_URL } = await import("./cloud.js");
|
|
47
621
|
const base = (envVar("CLOUD_URL") ?? DEFAULT_CLOUD_URL).replace(/\/+$/, "");
|
|
48
|
-
const response = await fetch(`${base}/v1/cloud/storage-targets`, {
|
|
622
|
+
const response = await fetch(`${base}/v1/cloud/storage-targets`, {
|
|
623
|
+
headers: { authorization: `Bearer ${token}` },
|
|
624
|
+
signal: options.signal,
|
|
625
|
+
});
|
|
49
626
|
if (!response.ok)
|
|
50
627
|
return null;
|
|
51
628
|
const body = await response.json();
|
|
52
|
-
if (
|
|
629
|
+
if (body.targets_json === null || body.targets_json === undefined)
|
|
630
|
+
return {
|
|
631
|
+
targets: [],
|
|
632
|
+
accountScope,
|
|
633
|
+
rawTargetsJson: null,
|
|
634
|
+
updatedAt: typeof body.updated_at === "string" ? body.updated_at : null,
|
|
635
|
+
};
|
|
636
|
+
if (typeof body.targets_json !== "string")
|
|
53
637
|
return null;
|
|
54
638
|
try {
|
|
55
|
-
const
|
|
56
|
-
return
|
|
639
|
+
const targets = sanitizedStorageTargets(JSON.parse(body.targets_json));
|
|
640
|
+
return targets ? {
|
|
641
|
+
targets,
|
|
642
|
+
accountScope,
|
|
643
|
+
// CAS compares the exact value which the server returned. Re-encoding
|
|
644
|
+
// the parsed array would make harmless whitespace/key-order differences
|
|
645
|
+
// look like a concurrent update and could never safely authorise PATCH.
|
|
646
|
+
rawTargetsJson: body.targets_json,
|
|
647
|
+
updatedAt: typeof body.updated_at === "string" ? body.updated_at : null,
|
|
648
|
+
} : null;
|
|
57
649
|
}
|
|
58
650
|
catch {
|
|
59
651
|
return null;
|
|
@@ -62,51 +654,269 @@ async function pullTargetsFromCloud(dataDir) {
|
|
|
62
654
|
/** Pushes the rules to the account so every other machine syncs them. Advisory: offline edits still apply locally. */
|
|
63
655
|
/** Re-pushes the current rules with fresh usage stamps — machines are the only
|
|
64
656
|
* ones who can see Drive and BYO bytes, so the account learns them from here. */
|
|
65
|
-
export async function pushTargetUsage(dataDir) {
|
|
657
|
+
export async function pushTargetUsage(dataDir, options = {}) {
|
|
66
658
|
try {
|
|
67
|
-
const
|
|
68
|
-
|
|
659
|
+
const { cloudAuthGeneration } = await import("./cloud.js");
|
|
660
|
+
const expectedAuthGeneration = await cloudAuthGeneration(dataDir);
|
|
661
|
+
const expectedRoutingFingerprint = routingFingerprint(await readConfig(dataDir));
|
|
662
|
+
// Telemetry is never the operation which renews a session or claims a
|
|
663
|
+
// machine. Besides being advisory, doing either here advances auth state
|
|
664
|
+
// and can invalidate a destructive archive action on another lane. The
|
|
665
|
+
// local fingerprint is only a cancellation guard; routing itself comes
|
|
666
|
+
// from the account row inside pushTargetsToCloud.
|
|
667
|
+
if (await cloudAuthGeneration(dataDir) !== expectedAuthGeneration)
|
|
668
|
+
return;
|
|
669
|
+
if (routingFingerprint(await readConfig(dataDir)) !== expectedRoutingFingerprint)
|
|
69
670
|
return;
|
|
70
|
-
await pushTargetsToCloud(dataDir,
|
|
671
|
+
await pushTargetsToCloud(dataDir, [], {
|
|
672
|
+
signal: options.signal,
|
|
673
|
+
renewCloudToken: false,
|
|
674
|
+
claimMachineIdentity: false,
|
|
675
|
+
advisoryUsageOnly: true,
|
|
676
|
+
expectedAuthGeneration,
|
|
677
|
+
expectedRoutingFingerprint,
|
|
678
|
+
});
|
|
71
679
|
}
|
|
72
680
|
catch { /* advisory */ }
|
|
73
681
|
}
|
|
74
|
-
|
|
682
|
+
async function pushTargetsToCloudOutcome(dataDir, targets, options = {}) {
|
|
75
683
|
try {
|
|
684
|
+
if (!await targetPushGenerationMatches(dataDir, options))
|
|
685
|
+
return "unavailable";
|
|
686
|
+
if (!await targetPushRoutingMatches(dataDir, options))
|
|
687
|
+
return "unavailable";
|
|
688
|
+
// Explicit saves may enrol the machine so its connection evidence has an
|
|
689
|
+
// account device key. Do that before reading the routing row: enrolment
|
|
690
|
+
// legitimately advances the local auth generation, and the subsequent
|
|
691
|
+
// GET must sample the post-enrolment account scope. Advisory telemetry
|
|
692
|
+
// uses the read-only peek and therefore never mutates auth state.
|
|
693
|
+
const { machineIdentity, peekMachineCredential } = await import("./cloud.js");
|
|
694
|
+
const machine = options.claimMachineIdentity === false
|
|
695
|
+
? await peekMachineCredential(dataDir).catch(() => null)
|
|
696
|
+
: await machineIdentity(dataDir).catch(() => null);
|
|
697
|
+
if (!await targetPushGenerationMatches(dataDir, options))
|
|
698
|
+
return "unavailable";
|
|
699
|
+
if (!await targetPushRoutingMatches(dataDir, options))
|
|
700
|
+
return "unavailable";
|
|
701
|
+
// A routing write can originate on any signed-in machine. Preserve the
|
|
702
|
+
// account's newest non-secret telemetry before adding this machine's
|
|
703
|
+
// observation: a fresh laptop with no local sidecars must never replace a
|
|
704
|
+
// known positive provider total with zero or erase another device's
|
|
705
|
+
// connection report. Usage is intentionally monotonic here. Lowering it
|
|
706
|
+
// requires a provider/account-authoritative reconciliation; one machine's
|
|
707
|
+
// partial catalog is not enough evidence that shared remote bytes vanished.
|
|
708
|
+
const pulled = options.advisoryUsageOnly
|
|
709
|
+
? await pullTargetsFromCloud(dataDir, options).catch(() => null)
|
|
710
|
+
: options.accountBaseline
|
|
711
|
+
? (() => {
|
|
712
|
+
const rawTargetsJson = options.accountBaseline.targetsJson;
|
|
713
|
+
if (rawTargetsJson === null)
|
|
714
|
+
return {
|
|
715
|
+
targets: [], rawTargetsJson: null,
|
|
716
|
+
updatedAt: options.accountBaseline.updatedAt,
|
|
717
|
+
accountScope: options.accountBaseline.accountScope,
|
|
718
|
+
};
|
|
719
|
+
try {
|
|
720
|
+
const parsed = sanitizedStorageTargets(JSON.parse(rawTargetsJson));
|
|
721
|
+
return parsed ? {
|
|
722
|
+
targets: parsed, rawTargetsJson,
|
|
723
|
+
updatedAt: options.accountBaseline.updatedAt,
|
|
724
|
+
accountScope: options.accountBaseline.accountScope,
|
|
725
|
+
} : null;
|
|
726
|
+
}
|
|
727
|
+
catch {
|
|
728
|
+
return null;
|
|
729
|
+
}
|
|
730
|
+
})()
|
|
731
|
+
: null;
|
|
732
|
+
if (!await targetPushGenerationMatches(dataDir, options))
|
|
733
|
+
return "unavailable";
|
|
734
|
+
if (!await targetPushRoutingMatches(dataDir, options))
|
|
735
|
+
return "unavailable";
|
|
736
|
+
// PUT is permitted only after a successful authenticated GET proved that
|
|
737
|
+
// this account has no routing row. A timeout, 5xx, malformed reply, or
|
|
738
|
+
// missing token is not evidence of absence and must produce no write.
|
|
739
|
+
if (!pulled)
|
|
740
|
+
return "unavailable";
|
|
741
|
+
if (Object.hasOwn(options, "expectedAuthGeneration") && pulled.accountScope !== options.expectedAuthGeneration)
|
|
742
|
+
return "unavailable";
|
|
743
|
+
const currentAccountScope = await import("./cloud.js")
|
|
744
|
+
.then(({ cloudAuthGeneration }) => cloudAuthGeneration(dataDir)).catch(() => null);
|
|
745
|
+
if (currentAccountScope !== pulled.accountScope)
|
|
746
|
+
return "unavailable";
|
|
747
|
+
if (options.advisoryUsageOnly) {
|
|
748
|
+
// Telemetry is not configuration. It may enrich the exact routing row
|
|
749
|
+
// it read, but a stale machine must never publish its cached providers,
|
|
750
|
+
// priority, caps, or project pins. Nor may telemetry create the first
|
|
751
|
+
// row: that is reserved for an explicit setStorageTargets action.
|
|
752
|
+
if (pulled.rawTargetsJson === null)
|
|
753
|
+
return "unavailable";
|
|
754
|
+
targets = pulled.targets;
|
|
755
|
+
}
|
|
756
|
+
else if (pulled.rawTargetsJson !== null) {
|
|
757
|
+
const remoteById = new Map(pulled.targets.map((target) => [target.id, target]));
|
|
758
|
+
targets = targets.map((target) => {
|
|
759
|
+
const remote = remoteById.get(target.id);
|
|
760
|
+
if (!remote || !sameUsageDestination(target, remote))
|
|
761
|
+
return target;
|
|
762
|
+
const remoteUsed = Number.isFinite(remote.usedBytes) && remote.usedBytes >= 0 ? remote.usedBytes : undefined;
|
|
763
|
+
const localUsed = Number.isFinite(target.usedBytes) && target.usedBytes >= 0 ? target.usedBytes : undefined;
|
|
764
|
+
const usedBytes = remoteUsed === undefined ? localUsed : localUsed === undefined ? remoteUsed : Math.max(remoteUsed, localUsed);
|
|
765
|
+
const usedAt = usedBytes === remoteUsed && remoteUsed !== undefined ? remote.usedAt : target.usedAt;
|
|
766
|
+
return {
|
|
767
|
+
...target,
|
|
768
|
+
...(usedBytes !== undefined ? { usedBytes } : {}),
|
|
769
|
+
...(usedAt ? { usedAt } : {}),
|
|
770
|
+
connectionHealth: {
|
|
771
|
+
...(remote.connectionHealth ?? {}),
|
|
772
|
+
...(target.connectionHealth ?? {}),
|
|
773
|
+
},
|
|
774
|
+
};
|
|
775
|
+
});
|
|
776
|
+
}
|
|
76
777
|
const usage = await targetUsage(dataDir, targets).catch(() => null);
|
|
778
|
+
if (!await targetPushGenerationMatches(dataDir, options))
|
|
779
|
+
return "unavailable";
|
|
780
|
+
if (!await targetPushRoutingMatches(dataDir, options))
|
|
781
|
+
return "unavailable";
|
|
77
782
|
if (usage) {
|
|
78
783
|
const now = new Date().toISOString();
|
|
79
784
|
targets = targets.map((t) => {
|
|
80
785
|
const used = usage.get(t.id);
|
|
81
|
-
|
|
786
|
+
// A local zero is not proof that a shared/provider destination is
|
|
787
|
+
// empty. `targetUsage` already preserves the larger account stamp, so
|
|
788
|
+
// this write can raise the conservative lower bound but never make a
|
|
789
|
+
// fresh or sparse machine lower it.
|
|
790
|
+
return used !== undefined ? { ...t, usedBytes: used, usedAt: now } : t;
|
|
82
791
|
});
|
|
83
792
|
}
|
|
84
793
|
const { cloudToken, DEFAULT_CLOUD_URL } = await import("./cloud.js");
|
|
85
|
-
const token = await cloudToken(dataDir);
|
|
794
|
+
const token = await cloudToken(dataDir, undefined, { renew: options.renewCloudToken !== false });
|
|
795
|
+
if (!await targetPushGenerationMatches(dataDir, options))
|
|
796
|
+
return "unavailable";
|
|
797
|
+
if (!await targetPushRoutingMatches(dataDir, options))
|
|
798
|
+
return "unavailable";
|
|
799
|
+
// Routing is account-wide, but credentials are deliberately machine-local.
|
|
800
|
+
// Publish only non-secret credential/probe evidence keyed by the enrolled
|
|
801
|
+
// device. It never becomes a live-reachability claim and never contains a
|
|
802
|
+
// refresh token, access key, recovery phrase, or local API token.
|
|
803
|
+
if (!await targetPushGenerationMatches(dataDir, options))
|
|
804
|
+
return "unavailable";
|
|
805
|
+
if (!await targetPushRoutingMatches(dataDir, options))
|
|
806
|
+
return "unavailable";
|
|
807
|
+
if (machine) {
|
|
808
|
+
const { hostname } = await import("node:os");
|
|
809
|
+
const checkedAt = new Date().toISOString();
|
|
810
|
+
const deviceKey = String(machine.device_id);
|
|
811
|
+
targets = await Promise.all(targets.map(async (target) => {
|
|
812
|
+
const evidence = await targetConnectionEvidence(dataDir, target, { renewCloudToken: options.renewCloudToken });
|
|
813
|
+
return {
|
|
814
|
+
...target,
|
|
815
|
+
connectionHealth: {
|
|
816
|
+
...(target.connectionHealth ?? {}),
|
|
817
|
+
[deviceKey]: { ...evidence, checkedAt, label: hostname() }
|
|
818
|
+
}
|
|
819
|
+
};
|
|
820
|
+
}));
|
|
821
|
+
}
|
|
822
|
+
if (!await targetPushGenerationMatches(dataDir, options))
|
|
823
|
+
return "unavailable";
|
|
824
|
+
if (!await targetPushRoutingMatches(dataDir, options))
|
|
825
|
+
return "unavailable";
|
|
826
|
+
if (await import("./cloud.js").then(({ cloudAuthGeneration }) => cloudAuthGeneration(dataDir)).catch(() => null) !== pulled.accountScope)
|
|
827
|
+
return "unavailable";
|
|
86
828
|
const base = (envVar("CLOUD_URL") ?? DEFAULT_CLOUD_URL).replace(/\/+$/, "");
|
|
829
|
+
const targetsJson = JSON.stringify(targets);
|
|
87
830
|
const response = await fetch(`${base}/v1/cloud/storage-targets`, {
|
|
88
|
-
|
|
831
|
+
// An existing row is always compare-and-set. In particular, a detached
|
|
832
|
+
// usage report may merge an old snapshot, but it can no longer commit
|
|
833
|
+
// after another machine has changed that account routing row.
|
|
834
|
+
method: pulled.rawTargetsJson !== null ? "PATCH" : "PUT",
|
|
89
835
|
headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
|
|
90
|
-
body: JSON.stringify(
|
|
836
|
+
body: JSON.stringify(pulled.rawTargetsJson !== null
|
|
837
|
+
? {
|
|
838
|
+
targets_json: targetsJson,
|
|
839
|
+
expected_targets_json: pulled.rawTargetsJson,
|
|
840
|
+
expected_updated_at: pulled.updatedAt,
|
|
841
|
+
}
|
|
842
|
+
: { targets_json: targetsJson }),
|
|
843
|
+
signal: options.signal,
|
|
91
844
|
});
|
|
92
|
-
|
|
845
|
+
// The request may have committed under account A while this machine
|
|
846
|
+
// switched to account B (or accepted a newer local route) before fetch
|
|
847
|
+
// delivered the response. The remote CAS remains a valid A-side commit,
|
|
848
|
+
// but this caller must not report it as B's synced local state. Returning
|
|
849
|
+
// unavailable lets setStorageTargets apply its generation fence and
|
|
850
|
+
// restore the stale local draft without masking a real 409 below.
|
|
851
|
+
if (!await targetPushGenerationMatches(dataDir, options))
|
|
852
|
+
return "unavailable";
|
|
853
|
+
if (!await targetPushRoutingMatches(dataDir, options))
|
|
854
|
+
return "unavailable";
|
|
855
|
+
if (await import("./cloud.js").then(({ cloudAuthGeneration }) => cloudAuthGeneration(dataDir)).catch(() => null) !== pulled.accountScope) {
|
|
856
|
+
return "unavailable";
|
|
857
|
+
}
|
|
858
|
+
// A CAS conflict is final for this snapshot. Retrying it as a PUT (or
|
|
859
|
+
// silently re-reading and merging again) would let stale advisory work
|
|
860
|
+
// overwrite the user's newer routing choice.
|
|
861
|
+
if (response.status === 409)
|
|
862
|
+
return "conflict";
|
|
863
|
+
return response.ok ? "synced" : "unavailable";
|
|
93
864
|
}
|
|
94
865
|
catch {
|
|
95
|
-
return
|
|
866
|
+
return "unavailable";
|
|
96
867
|
}
|
|
97
868
|
}
|
|
98
|
-
|
|
869
|
+
export async function pushTargetsToCloud(dataDir, targets, options = {}) {
|
|
870
|
+
return await pushTargetsToCloudOutcome(dataDir, targets, options) === "synced";
|
|
871
|
+
}
|
|
872
|
+
/** Non-secret physical identity sufficient for retaining conservative usage
|
|
873
|
+
* across an account routing write. A changed provider/bucket/prefix/endpoint
|
|
874
|
+
* starts a new destination instead of inheriting an old destination's total. */
|
|
875
|
+
function sameUsageDestination(left, right) {
|
|
876
|
+
return left.provider === right.provider
|
|
877
|
+
&& (left.bucket ?? "") === (right.bucket ?? "")
|
|
878
|
+
&& normalPrefix(left.prefix) === normalPrefix(right.prefix)
|
|
879
|
+
&& (left.region ?? "") === (right.region ?? "")
|
|
880
|
+
&& (left.endpoint ?? "") === (right.endpoint ?? "");
|
|
881
|
+
}
|
|
882
|
+
/** Bytes each target currently holds. A mutable display id is never enough to
|
|
883
|
+
* attribute a copy: the record must still name the target's immutable physical
|
|
884
|
+
* destination (or pass the deliberately narrow legacy compatibility rule). */
|
|
99
885
|
export async function targetUsage(dataDir, targets) {
|
|
100
886
|
const usage = new Map(targets.map((target) => [target.id, 0]));
|
|
101
887
|
const records = await listArchives(dataDir).catch(() => []);
|
|
102
888
|
for (const record of records) {
|
|
103
|
-
if (!isV2(record)
|
|
889
|
+
if (!isV2(record))
|
|
104
890
|
continue;
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
891
|
+
for (const copy of archiveCopies(record)) {
|
|
892
|
+
let match;
|
|
893
|
+
// Prefer the recorded label only as a lookup hint. The label can be
|
|
894
|
+
// repointed account-wide, so immutable destination matching remains the
|
|
895
|
+
// authority before usage affects limits or account health.
|
|
896
|
+
const stamped = copy.targetId ? targets.find((target) => target.id === copy.targetId) : undefined;
|
|
897
|
+
if (stamped && await remoteCopyMatchesTarget(dataDir, stamped, copy))
|
|
898
|
+
match = stamped;
|
|
899
|
+
if (!match) {
|
|
900
|
+
for (const target of targets) {
|
|
901
|
+
if (target === stamped)
|
|
902
|
+
continue;
|
|
903
|
+
if (await remoteCopyMatchesTarget(dataDir, target, copy)) {
|
|
904
|
+
match = target;
|
|
905
|
+
break;
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
if (match)
|
|
910
|
+
usage.set(match.id, (usage.get(match.id) ?? 0) + (copy.bytes ?? 0));
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
// Local records are a lower bound on a multi-machine/provider total. Keep a
|
|
914
|
+
// larger account-reported value rather than allowing a sparse/fresh machine
|
|
915
|
+
// to route beyond the configured cap.
|
|
916
|
+
for (const target of targets) {
|
|
917
|
+
if (Number.isFinite(target.usedBytes) && target.usedBytes >= 0) {
|
|
918
|
+
usage.set(target.id, Math.max(usage.get(target.id) ?? 0, target.usedBytes));
|
|
919
|
+
}
|
|
110
920
|
}
|
|
111
921
|
return usage;
|
|
112
922
|
}
|
|
@@ -124,44 +934,142 @@ export async function chooseTarget(dataDir, input) {
|
|
|
124
934
|
const named = targets.find((target) => target.id === input.targetId);
|
|
125
935
|
if (!named)
|
|
126
936
|
fail("invalid_argument", `No storage target named "${input.targetId}". Targets: ${targets.map((target) => target.id).join(", ")}`);
|
|
937
|
+
if (named.enabled === false) {
|
|
938
|
+
fail("storage_not_configured", `Storage target "${named.id}" is paused for new archives. Resume it before copying an archive there.`);
|
|
939
|
+
}
|
|
940
|
+
if (input.requireManaged && named.provider !== "vaultline") {
|
|
941
|
+
fail("storage_not_configured", "Shared project memory must be stored in Sealkeep Cloud so every approved member can reach the same encrypted archive.");
|
|
942
|
+
}
|
|
943
|
+
if (input.requireChunkCapable && named.provider === "gdrive") {
|
|
944
|
+
fail("storage_not_configured", `Storage target "${named.id}" cannot accept streamed chunk archives.`);
|
|
945
|
+
}
|
|
946
|
+
if (!(await targetConnectedOnThisMachine(dataDir, named))) {
|
|
947
|
+
fail("storage_not_configured", `Storage target "${named.id}" is not connected on this machine. Reconnect it before copying an archive there.`);
|
|
948
|
+
}
|
|
949
|
+
if (named.maxGb !== undefined) {
|
|
950
|
+
const used = (await targetUsage(dataDir, targets)).get(named.id) ?? 0;
|
|
951
|
+
if (used + input.bytes > named.maxGb * 1024 ** 3) {
|
|
952
|
+
fail("storage_not_configured", `Storage target "${named.id}" is at its ${named.maxGb} GB limit. Choose another destination or raise its cap.`);
|
|
953
|
+
}
|
|
954
|
+
}
|
|
127
955
|
return named;
|
|
128
956
|
}
|
|
129
957
|
const usage = await targetUsage(dataDir, targets);
|
|
958
|
+
const connected = new Map(await Promise.all(targets.map(async (target) => [target.id, await targetConnectedOnThisMachine(dataDir, target)])));
|
|
130
959
|
const eligible = targets.filter((target) => {
|
|
960
|
+
if (target.enabled === false)
|
|
961
|
+
return false;
|
|
962
|
+
if (input.requireManaged && target.provider !== "vaultline")
|
|
963
|
+
return false;
|
|
131
964
|
if (input.requireChunkCapable && target.provider === "gdrive")
|
|
132
965
|
return false;
|
|
133
966
|
if (target.maxGb !== undefined && (usage.get(target.id) ?? 0) + input.bytes > target.maxGb * 1024 ** 3)
|
|
134
967
|
return false;
|
|
968
|
+
// A Drive/bucket rule can be valid account-wide but unavailable on this
|
|
969
|
+
// machine. Automatic routing falls through to the next connected target;
|
|
970
|
+
// otherwise an unattended daemon retries the same impossible first rule
|
|
971
|
+
// forever while a working managed destination sits underneath it.
|
|
972
|
+
if (!connected.get(target.id))
|
|
973
|
+
return false;
|
|
135
974
|
return true;
|
|
136
975
|
});
|
|
137
976
|
if (eligible.length === 0) {
|
|
138
|
-
|
|
977
|
+
if (input.requireManaged) {
|
|
978
|
+
fail("storage_not_configured", "Shared project memory needs an available Sealkeep Cloud destination. Add it to this account's storage routes or raise its limit.");
|
|
979
|
+
}
|
|
980
|
+
fail("storage_not_configured", "No destination on this machine can take this archive: each target is paused, disconnected, full, or incompatible. Resume or reconnect a destination, or change the routing order.");
|
|
139
981
|
}
|
|
140
|
-
const pinScore = (target) => (input.
|
|
982
|
+
const pinScore = (target) => ((input.projectKey && target.projects?.includes(input.projectKey))
|
|
983
|
+
|| (input.project && target.projects?.includes(input.project))
|
|
984
|
+
? 0
|
|
985
|
+
: 1);
|
|
141
986
|
eligible.sort((a, b) => pinScore(a) - pinScore(b) || defaultPriority(a) - defaultPriority(b));
|
|
142
987
|
return eligible[0];
|
|
143
988
|
}
|
|
144
989
|
/** Persist an explicit target list. Empty array removes the setting (back to single remoteStorage / managed). */
|
|
145
990
|
export async function setStorageTargets(dataDir, targets, options = {}) {
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
991
|
+
let savedTargets;
|
|
992
|
+
if (targets.length > 0) {
|
|
993
|
+
const sanitized = sanitizedStorageTargets(targets);
|
|
994
|
+
if (!sanitized)
|
|
995
|
+
fail("invalid_argument", "Storage targets must have unique ids and valid provider, bucket, endpoint, routing, and health fields.");
|
|
996
|
+
targets = sanitized;
|
|
997
|
+
savedTargets = sanitized;
|
|
998
|
+
}
|
|
999
|
+
// A dashboard/CLI draft belongs to the Cloud login which produced its edit
|
|
1000
|
+
// snapshot. Reject an already-stale draft before it can alter even the local
|
|
1001
|
+
// routing cache. A second fence after the write handles a transition which
|
|
1002
|
+
// begins between this read and the config-file transaction.
|
|
1003
|
+
if (options.accountBaseline
|
|
1004
|
+
&& !await storageTargetAccountBaselineIsCurrent(dataDir, options.accountBaseline)) {
|
|
1005
|
+
return {
|
|
1006
|
+
localSaved: false,
|
|
1007
|
+
accountSynced: false,
|
|
1008
|
+
accountConflict: true,
|
|
1009
|
+
accountStale: true,
|
|
1010
|
+
};
|
|
1011
|
+
}
|
|
1012
|
+
const before = await readConfig(dataDir);
|
|
1013
|
+
const storageTargetsScope = options.accountBaseline
|
|
1014
|
+
? accountStorageTargetsScope(options.accountBaseline.accountScope)
|
|
1015
|
+
: options.push === false
|
|
1016
|
+
? { kind: "local" }
|
|
1017
|
+
: normalizedStorageTargetsScope(before) ?? { kind: "local" };
|
|
1018
|
+
const saved = await updateConfigFields(dataDir, { storageTargets: savedTargets, storageTargetsScope });
|
|
1019
|
+
const expectedRoutingFingerprint = routingFingerprint(saved);
|
|
1020
|
+
const restoreBeforeIfOwned = async () => {
|
|
1021
|
+
await updateConfigFieldsIf(dataDir, (current) => routingFingerprint(current) === expectedRoutingFingerprint, {
|
|
1022
|
+
storageTargets: before.storageTargets,
|
|
1023
|
+
storageTargetsScope: before.storageTargetsScope,
|
|
1024
|
+
}).catch(() => null);
|
|
1025
|
+
};
|
|
1026
|
+
if (options.accountBaseline
|
|
1027
|
+
&& !await storageTargetAccountBaselineIsCurrent(dataDir, options.accountBaseline)) {
|
|
1028
|
+
await restoreBeforeIfOwned();
|
|
1029
|
+
return {
|
|
1030
|
+
localSaved: false,
|
|
1031
|
+
accountSynced: false,
|
|
1032
|
+
accountConflict: true,
|
|
1033
|
+
accountStale: true,
|
|
1034
|
+
};
|
|
1035
|
+
}
|
|
1036
|
+
if (options.push === false)
|
|
1037
|
+
return { localSaved: true, accountSynced: null };
|
|
1038
|
+
// No baseline means the account could not be read (or an older caller did
|
|
1039
|
+
// not carry the edit snapshot). Keep the useful offline local save, but do
|
|
1040
|
+
// not turn a fresh GET at save time into permission to overwrite somebody
|
|
1041
|
+
// else's newer account rules.
|
|
1042
|
+
if (!options.accountBaseline)
|
|
1043
|
+
return { localSaved: true, accountSynced: false };
|
|
1044
|
+
const outcome = await pushTargetsToCloudOutcome(dataDir, targets, {
|
|
1045
|
+
expectedRoutingFingerprint,
|
|
1046
|
+
expectedAuthGeneration: options.accountBaseline.accountScope,
|
|
1047
|
+
accountBaseline: options.accountBaseline,
|
|
1048
|
+
});
|
|
1049
|
+
if (outcome === "synced")
|
|
1050
|
+
return { localSaved: true, accountSynced: true };
|
|
1051
|
+
if (outcome === "unavailable") {
|
|
1052
|
+
if (options.accountBaseline
|
|
1053
|
+
&& !await storageTargetAccountBaselineIsCurrent(dataDir, options.accountBaseline)) {
|
|
1054
|
+
await restoreBeforeIfOwned();
|
|
1055
|
+
return {
|
|
1056
|
+
localSaved: false,
|
|
1057
|
+
accountSynced: false,
|
|
1058
|
+
accountConflict: true,
|
|
1059
|
+
accountStale: true,
|
|
1060
|
+
};
|
|
161
1061
|
}
|
|
162
|
-
|
|
1062
|
+
return { localSaved: true, accountSynced: false };
|
|
163
1063
|
}
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
1064
|
+
// A stale edit must not leave this machine following C while the account
|
|
1065
|
+
// correctly retained B. First try to load/cache B. If that read happens to
|
|
1066
|
+
// fail, restore the pre-edit local A only if nobody made a newer local edit
|
|
1067
|
+
// after C; either is safer than silently activating the rejected draft.
|
|
1068
|
+
const refreshed = await resolveTargetsState(dataDir).catch(() => null);
|
|
1069
|
+
// Content equality is not a freshness signal: another machine may have
|
|
1070
|
+
// legitimately changed B to the same C this stale editor attempted. A
|
|
1071
|
+
// non-null baseline proves that the recovery GET succeeded even in that case.
|
|
1072
|
+
if (!refreshed?.accountBaseline)
|
|
1073
|
+
await restoreBeforeIfOwned();
|
|
1074
|
+
return { localSaved: false, accountSynced: false, accountConflict: true };
|
|
167
1075
|
}
|