sealkeep 0.5.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 +201 -0
- package/CHANGELOG.md +218 -0
- package/CONTROL_PLANE.md +86 -0
- package/LICENSE +34 -0
- package/README.md +249 -0
- package/THIRD_PARTY.md +22 -0
- package/THREAT_MODEL.md +107 -0
- package/dist/packages/vaultline-crypto/src/aead.d.ts +12 -0
- package/dist/packages/vaultline-crypto/src/aead.js +24 -0
- package/dist/packages/vaultline-crypto/src/chunk-access.d.ts +39 -0
- package/dist/packages/vaultline-crypto/src/chunk-access.js +93 -0
- package/dist/packages/vaultline-crypto/src/envelope.d.ts +71 -0
- package/dist/packages/vaultline-crypto/src/envelope.js +188 -0
- package/dist/packages/vaultline-crypto/src/format.d.ts +106 -0
- package/dist/packages/vaultline-crypto/src/format.js +43 -0
- package/dist/packages/vaultline-crypto/src/index.d.ts +5 -0
- package/dist/packages/vaultline-crypto/src/index.js +5 -0
- package/dist/packages/vaultline-crypto/src/recipients.d.ts +42 -0
- package/dist/packages/vaultline-crypto/src/recipients.js +129 -0
- package/dist/packages/vaultline-crypto/src/sha256-stream.d.ts +41 -0
- package/dist/packages/vaultline-crypto/src/sha256-stream.js +206 -0
- package/dist/packages/vaultline-crypto/src/stream.d.ts +139 -0
- package/dist/packages/vaultline-crypto/src/stream.js +477 -0
- package/dist/site/index.html +1542 -0
- package/dist/site.zip +0 -0
- package/dist/src/activity.d.ts +22 -0
- package/dist/src/activity.js +52 -0
- package/dist/src/adapters.d.ts +212 -0
- package/dist/src/adapters.js +533 -0
- package/dist/src/audit.d.ts +24 -0
- package/dist/src/audit.js +41 -0
- package/dist/src/autopilot.d.ts +77 -0
- package/dist/src/autopilot.js +148 -0
- package/dist/src/bip39-wordlist.d.ts +15 -0
- package/dist/src/bip39-wordlist.js +272 -0
- package/dist/src/branding.d.ts +31 -0
- package/dist/src/branding.js +31 -0
- package/dist/src/chunk-store.d.ts +142 -0
- package/dist/src/chunk-store.js +502 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +2035 -0
- package/dist/src/cloud.d.ts +434 -0
- package/dist/src/cloud.js +851 -0
- package/dist/src/control-plane/auth.d.ts +62 -0
- package/dist/src/control-plane/auth.js +123 -0
- package/dist/src/control-plane/server.d.ts +31 -0
- package/dist/src/control-plane/server.js +263 -0
- package/dist/src/control-plane/store.d.ts +101 -0
- package/dist/src/control-plane/store.js +82 -0
- package/dist/src/control-plane-cli.d.ts +2 -0
- package/dist/src/control-plane-cli.js +37 -0
- package/dist/src/control-plane-server.d.ts +10 -0
- package/dist/src/control-plane-server.js +11 -0
- package/dist/src/control-plane.d.ts +78 -0
- package/dist/src/control-plane.js +61 -0
- package/dist/src/crypto.d.ts +56 -0
- package/dist/src/crypto.js +132 -0
- package/dist/src/daemon.d.ts +52 -0
- package/dist/src/daemon.js +142 -0
- package/dist/src/dashboard-cli.d.ts +2 -0
- package/dist/src/dashboard-cli.js +20 -0
- package/dist/src/disk.d.ts +110 -0
- package/dist/src/disk.js +169 -0
- package/dist/src/doctor.d.ts +11 -0
- package/dist/src/doctor.js +198 -0
- package/dist/src/enroll.d.ts +27 -0
- package/dist/src/enroll.js +136 -0
- package/dist/src/errors.d.ts +26 -0
- package/dist/src/errors.js +23 -0
- package/dist/src/heartbeat.d.ts +89 -0
- package/dist/src/heartbeat.js +120 -0
- package/dist/src/index-sync.d.ts +53 -0
- package/dist/src/index-sync.js +147 -0
- package/dist/src/leakscan.d.ts +48 -0
- package/dist/src/leakscan.js +222 -0
- package/dist/src/local-api.d.ts +132 -0
- package/dist/src/local-api.js +1757 -0
- package/dist/src/managed-chunks.d.ts +55 -0
- package/dist/src/managed-chunks.js +108 -0
- package/dist/src/mcp-install.d.ts +52 -0
- package/dist/src/mcp-install.js +140 -0
- package/dist/src/mcp.d.ts +1 -0
- package/dist/src/mcp.js +59 -0
- package/dist/src/migrate.d.ts +35 -0
- package/dist/src/migrate.js +88 -0
- package/dist/src/mnemonic.d.ts +60 -0
- package/dist/src/mnemonic.js +134 -0
- package/dist/src/net.d.ts +2 -0
- package/dist/src/net.js +16 -0
- package/dist/src/notify.d.ts +46 -0
- package/dist/src/notify.js +84 -0
- package/dist/src/offload.d.ts +117 -0
- package/dist/src/offload.js +331 -0
- package/dist/src/onboarding.d.ts +10 -0
- package/dist/src/onboarding.js +44 -0
- package/dist/src/packages.d.ts +126 -0
- package/dist/src/packages.js +114 -0
- package/dist/src/passkey.d.ts +26 -0
- package/dist/src/passkey.js +54 -0
- package/dist/src/password-lock.d.ts +19 -0
- package/dist/src/password-lock.js +156 -0
- package/dist/src/paths.d.ts +9 -0
- package/dist/src/paths.js +24 -0
- package/dist/src/providers/gcs.d.ts +133 -0
- package/dist/src/providers/gcs.js +235 -0
- package/dist/src/providers/gdrive.d.ts +156 -0
- package/dist/src/providers/gdrive.js +335 -0
- package/dist/src/providers/index.d.ts +45 -0
- package/dist/src/providers/index.js +74 -0
- package/dist/src/providers/s3.d.ts +174 -0
- package/dist/src/providers/s3.js +345 -0
- package/dist/src/providers/sigv4.d.ts +78 -0
- package/dist/src/providers/sigv4.js +112 -0
- package/dist/src/queue.d.ts +185 -0
- package/dist/src/queue.js +286 -0
- package/dist/src/recovery.d.ts +40 -0
- package/dist/src/recovery.js +132 -0
- package/dist/src/rehydrate.d.ts +43 -0
- package/dist/src/rehydrate.js +66 -0
- package/dist/src/restore.d.ts +34 -0
- package/dist/src/restore.js +80 -0
- package/dist/src/retention.d.ts +251 -0
- package/dist/src/retention.js +446 -0
- package/dist/src/rotate.d.ts +47 -0
- package/dist/src/rotate.js +95 -0
- package/dist/src/search.d.ts +147 -0
- package/dist/src/search.js +677 -0
- package/dist/src/secrets.d.ts +86 -0
- package/dist/src/secrets.js +220 -0
- package/dist/src/service.d.ts +73 -0
- package/dist/src/service.js +197 -0
- package/dist/src/share.d.ts +34 -0
- package/dist/src/share.js +68 -0
- package/dist/src/spool.d.ts +97 -0
- package/dist/src/spool.js +213 -0
- package/dist/src/start-tui.d.ts +17 -0
- package/dist/src/start-tui.js +113 -0
- package/dist/src/start.d.ts +75 -0
- package/dist/src/start.js +101 -0
- package/dist/src/storage-setup.d.ts +49 -0
- package/dist/src/storage-setup.js +222 -0
- package/dist/src/storage-targets.d.ts +40 -0
- package/dist/src/storage-targets.js +147 -0
- package/dist/src/stream-to-cloud.d.ts +76 -0
- package/dist/src/stream-to-cloud.js +820 -0
- package/dist/src/sync-rules.d.ts +85 -0
- package/dist/src/sync-rules.js +125 -0
- package/dist/src/trash.d.ts +15 -0
- package/dist/src/trash.js +63 -0
- package/dist/src/tui.d.ts +18 -0
- package/dist/src/tui.js +179 -0
- package/dist/src/types.d.ts +191 -0
- package/dist/src/types.js +3 -0
- package/dist/src/ui-server.d.ts +187 -0
- package/dist/src/ui-server.js +293 -0
- package/dist/src/ui.d.ts +41 -0
- package/dist/src/ui.js +102 -0
- package/dist/src/update.d.ts +30 -0
- package/dist/src/update.js +56 -0
- package/dist/src/upload.d.ts +46 -0
- package/dist/src/upload.js +80 -0
- package/dist/src/vault.d.ts +208 -0
- package/dist/src/vault.js +812 -0
- package/dist/src/watcher.d.ts +34 -0
- package/dist/src/watcher.js +121 -0
- package/dist/src/worker.d.ts +52 -0
- package/dist/src/worker.js +190 -0
- package/package.json +65 -0
- package/web/app.js +1372 -0
- package/web/index.html +476 -0
- package/web/rail.js +308 -0
- package/web/retention.html +17 -0
- package/web/rules-view.js +249 -0
- package/web/sessions-view.js +448 -0
- package/web/sessions.html +17 -0
- package/web/setup-api.js +181 -0
- package/web/setup-logic.js +394 -0
- package/web/setup.html +419 -0
- package/web/setup.js +697 -0
- package/web/style.css +990 -0
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
import { mkdtemp, rm, stat, writeFile } from "node:fs/promises";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { fail } from "./errors.js";
|
|
5
|
+
import { recordAudit } from "./audit.js";
|
|
6
|
+
import { isV2 } from "./types.js";
|
|
7
|
+
import { listArchives, readConfig, writeRecord } from "./vault.js";
|
|
8
|
+
import { createActiveLease, uploadClientFromStore } from "./providers/index.js";
|
|
9
|
+
import { sha256 } from "./crypto.js";
|
|
10
|
+
/** Providers this build can fetch back from. GCS uploads but has no download client. */
|
|
11
|
+
// Every provider with a proven read-back path. "vaultline" reads back through
|
|
12
|
+
// the account (pullCiphertext, hash-checked); "gcs" through a signed GET on
|
|
13
|
+
// the provider client. Both were live-proven before earning a place here —
|
|
14
|
+
// this set is the safety gate that keeps "offload" from meaning "lose".
|
|
15
|
+
// gdrive joined when its client grew download() — the live Drive drill hit
|
|
16
|
+
// this stale gate first: uploads landed, the local copy could never leave.
|
|
17
|
+
const FETCHABLE = new Set(["s3", "r2", "b2", "gcs", "gdrive", "vaultline"]);
|
|
18
|
+
export async function offloadable(dataDir) {
|
|
19
|
+
const archives = await listArchives(dataDir);
|
|
20
|
+
const candidates = [];
|
|
21
|
+
const blocked = [];
|
|
22
|
+
for (const record of archives) {
|
|
23
|
+
if (!isV2(record)) {
|
|
24
|
+
blocked.push({ id: record.id, reason: "archive uses format v1; run `sealkeep migrate` first" });
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
if (record.offloaded)
|
|
28
|
+
continue;
|
|
29
|
+
const remote = record.remote;
|
|
30
|
+
if (!remote?.verifiedAt) {
|
|
31
|
+
blocked.push({ id: record.id, reason: "no verified remote copy, so the local one is the only copy" });
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
if (!FETCHABLE.has(remote.provider)) {
|
|
35
|
+
blocked.push({ id: record.id, reason: `${remote.provider} can be uploaded to but not read back by this build, so the local copy has to stay` });
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
const local = await stat(record.objectPath).catch(() => null);
|
|
39
|
+
if (!local) {
|
|
40
|
+
blocked.push({ id: record.id, reason: "no local archive file to remove" });
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
// The remote is only a copy if the sizes agree. For a provider that stores
|
|
44
|
+
// the raw ciphertext that means equality; a managed object carries the
|
|
45
|
+
// self-opening envelope frame around the same ciphertext, so it is larger
|
|
46
|
+
// by exactly that frame — equality there is structurally impossible and
|
|
47
|
+
// demanding it silently blocked every managed offload. The frame only
|
|
48
|
+
// adds, so smaller-than-local is still the same corruption signal, and
|
|
49
|
+
// the true proof either way is the fetch-and-hash below.
|
|
50
|
+
const sameSize = remote.provider === "vaultline" ? remote.bytes >= local.size : remote.bytes === local.size;
|
|
51
|
+
if (!sameSize) {
|
|
52
|
+
blocked.push({ id: record.id, reason: `the remote object is ${remote.bytes} bytes and the local one is ${local.size}; they are not the same archive` });
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
candidates.push({
|
|
56
|
+
id: record.id, agent: record.source.agent, path: record.source.path,
|
|
57
|
+
localBytes: local.size, provider: remote.provider, bucket: remote.bucket, objectKey: remote.objectKey
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
return { candidates, blocked, freeableBytes: candidates.reduce((total, item) => total + item.localBytes, 0) };
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Removes local archives that a bucket has a verified copy of.
|
|
64
|
+
*
|
|
65
|
+
* A dry run unless confirmed. The candidate list is recomputed here rather than
|
|
66
|
+
* accepted from a caller, so a page held open since yesterday cannot ask for
|
|
67
|
+
* the deletion of something that has since become the only copy.
|
|
68
|
+
*/
|
|
69
|
+
export async function offloadArchives(dataDir, options = {}) {
|
|
70
|
+
const all = await offloadable(dataDir);
|
|
71
|
+
// `only` narrows the sweep — the upload run offloads each archive as its
|
|
72
|
+
// verify lands rather than re-walking the whole vault per item.
|
|
73
|
+
const candidates = options.only ? all.candidates.filter((candidate) => options.only.has(candidate.id)) : all.candidates;
|
|
74
|
+
const blocked = all.blocked;
|
|
75
|
+
const freeableBytes = candidates.reduce((total, item) => total + item.localBytes, 0);
|
|
76
|
+
if (!options.confirm)
|
|
77
|
+
return { mode: "preview", wouldOffload: candidates, blocked, freedBytes: freeableBytes };
|
|
78
|
+
const archives = new Map((await listArchives(dataDir)).map((record) => [record.id, record]));
|
|
79
|
+
const offloaded = [];
|
|
80
|
+
const failed = [];
|
|
81
|
+
const config = await readConfig(dataDir);
|
|
82
|
+
for (const candidate of candidates) {
|
|
83
|
+
const record = archives.get(candidate.id);
|
|
84
|
+
if (!record || !isV2(record))
|
|
85
|
+
continue;
|
|
86
|
+
try {
|
|
87
|
+
// Prove the bytes are really there, now, before removing the local ones.
|
|
88
|
+
// The verifiedAt stamp says they were there once; this asks again, because
|
|
89
|
+
// between then and now is exactly where a bucket lifecycle rule lives.
|
|
90
|
+
// The one honest exception: an archive whose verify landed seconds ago in
|
|
91
|
+
// the same run — between then and now is milliseconds, and re-downloading
|
|
92
|
+
// a gigabyte to re-prove it would double the egress of every push.
|
|
93
|
+
if (!options.freshlyVerified?.has(candidate.id)) {
|
|
94
|
+
const fetched = await fetchCiphertext(dataDir, record, options.client);
|
|
95
|
+
if (sha256(fetched) !== record.cipher.ciphertextSha256) {
|
|
96
|
+
failed.push({ id: candidate.id, error: "what the bucket returned is not this archive; local copy kept" });
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
await rm(record.objectPath, { force: true });
|
|
101
|
+
const next = {
|
|
102
|
+
...record,
|
|
103
|
+
offloaded: { at: new Date().toISOString(), provider: candidate.provider, bucket: candidate.bucket, objectKey: candidate.objectKey }
|
|
104
|
+
};
|
|
105
|
+
await writeRecord(config.storage.root, next);
|
|
106
|
+
await recordAudit(dataDir, "archive.offload", "allowed", { archiveId: candidate.id, bytes: candidate.localBytes, bucket: candidate.bucket });
|
|
107
|
+
offloaded.push(candidate);
|
|
108
|
+
}
|
|
109
|
+
catch (error) {
|
|
110
|
+
await recordAudit(dataDir, "archive.offload", "denied", { archiveId: candidate.id });
|
|
111
|
+
failed.push({ id: candidate.id, error: error instanceof Error ? error.message : "could not offload" });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return { mode: "apply", offloaded, failed, blocked, freedBytes: offloaded.reduce((total, item) => total + item.localBytes, 0) };
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Downloads an archive's ciphertext from the bucket it was offloaded to.
|
|
118
|
+
*
|
|
119
|
+
* VAULTLINE_ENABLE_SIGNER gates *uploading*, and it is off unless the installed
|
|
120
|
+
* service turns it on. Reading back an archive in order to restore it is not
|
|
121
|
+
* uploading, and refusing it on that flag would mean an offloaded archive could
|
|
122
|
+
* not be recovered by the person who owns it — which would turn a space-saving
|
|
123
|
+
* feature into data loss. The flag is set for this call only.
|
|
124
|
+
*/
|
|
125
|
+
async function fetchCiphertext(dataDir, record, client) {
|
|
126
|
+
const config = await readConfig(dataDir);
|
|
127
|
+
const remote = record.remote ?? record.offloaded;
|
|
128
|
+
if (!remote)
|
|
129
|
+
fail("archive_not_found", `Archive ${record.id} has no remote copy to fetch`, { archiveId: record.id });
|
|
130
|
+
// A managed copy reads back through the account, not a provider credential:
|
|
131
|
+
// pullCiphertext hash-checks what it downloads before returning it. Branch
|
|
132
|
+
// on the record's own provider — a vault can hold managed history alongside
|
|
133
|
+
// a later own-bucket configuration, and each copy fetches its own way.
|
|
134
|
+
if (remote.provider === "vaultline" && !client && record.remote?.layout?.kind !== "chunks") {
|
|
135
|
+
const { pullCiphertext, unframeObject } = await import("./cloud.js");
|
|
136
|
+
const { ciphertext: blob } = await pullCiphertext(dataDir, record.id);
|
|
137
|
+
// A managed object is envelope-framed — the self-carrying wrapper push
|
|
138
|
+
// added so a bare object can be opened anywhere. What the local record
|
|
139
|
+
// hashes is the ciphertext INSIDE the frame, so that is what this must
|
|
140
|
+
// return; handing back the framed bytes made every integrity check
|
|
141
|
+
// compare an apple to the crate it shipped in. A plain-mode object has
|
|
142
|
+
// no frame and IS the stored bytes.
|
|
143
|
+
const { envelope, ciphertext } = unframeObject(blob);
|
|
144
|
+
return envelope ? ciphertext : blob;
|
|
145
|
+
}
|
|
146
|
+
// Chunk-folder remotes fetch object by object, each hash-checked against
|
|
147
|
+
// the envelope's own chunk headers — the layout was made for exactly this.
|
|
148
|
+
// A managed folder reads back through the account's download leases; an own
|
|
149
|
+
// bucket reads through the provider's object verbs.
|
|
150
|
+
if (record.remote?.layout?.kind === "chunks") {
|
|
151
|
+
const { fetchChunkFolder } = await import("./chunk-store.js");
|
|
152
|
+
let folderClient;
|
|
153
|
+
if (client)
|
|
154
|
+
folderClient = client;
|
|
155
|
+
else if (record.remote.provider === "vaultline") {
|
|
156
|
+
folderClient = (await import("./managed-chunks.js")).managedChunkClient(dataDir);
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
const target = config.remoteStorage ?? fail("storage_not_configured", "This vault has no storage configured, so an offloaded archive cannot be fetched back");
|
|
160
|
+
folderClient = await uploadClientFromStore(dataDir, config.vaultId, target, {}, undefined);
|
|
161
|
+
}
|
|
162
|
+
if (typeof folderClient.getObject !== "function")
|
|
163
|
+
fail("provider_unsupported", `${record.remote.provider} cannot fetch chunk folders with this build`);
|
|
164
|
+
return fetchChunkFolder(folderClient, record.remote, record.envelope);
|
|
165
|
+
}
|
|
166
|
+
// The record routes its own read: provider and bucket come from the copy
|
|
167
|
+
// itself, the prefix from its object key, and a configured target (matched
|
|
168
|
+
// by targetId, then by provider+bucket) fills in the rest. config.remoteStorage
|
|
169
|
+
// is only the last resort — a multi-target vault has none.
|
|
170
|
+
const target = await (async () => {
|
|
171
|
+
if (config.remoteStorage && config.remoteStorage.provider === remote.provider && config.remoteStorage.bucket === remote.bucket)
|
|
172
|
+
return config.remoteStorage;
|
|
173
|
+
try {
|
|
174
|
+
const { resolveTargets } = await import("./storage-targets.js");
|
|
175
|
+
const targets = await resolveTargets(dataDir);
|
|
176
|
+
const stampedId = remote.targetId;
|
|
177
|
+
const match = (stampedId ? targets.find((candidate) => candidate.id === stampedId) : undefined)
|
|
178
|
+
?? targets.find((candidate) => candidate.provider === remote.provider && candidate.bucket === remote.bucket);
|
|
179
|
+
if (match) {
|
|
180
|
+
const derivedPrefix = remote.objectKey.split("/").slice(0, -1).join("/");
|
|
181
|
+
return { provider: match.provider, bucket: match.bucket ?? remote.bucket, prefix: match.prefix ?? (derivedPrefix || "vaultline"), region: match.region };
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
catch { /* fall through */ }
|
|
185
|
+
if (config.remoteStorage)
|
|
186
|
+
return config.remoteStorage;
|
|
187
|
+
return fail("storage_not_configured", "This vault has no storage configured, so an offloaded archive cannot be fetched back");
|
|
188
|
+
})();
|
|
189
|
+
if (target.provider === "vaultline")
|
|
190
|
+
fail("storage_not_configured", "This managed copy should have taken the managed read path");
|
|
191
|
+
const resolved = client ?? await uploadClientFromStore(dataDir, config.vaultId, target, {}, undefined);
|
|
192
|
+
const lease = createActiveLease(target, {
|
|
193
|
+
archiveId: record.id, ciphertextSha256: record.cipher.ciphertextSha256, bytes: record.cipher.storedBytes
|
|
194
|
+
}, { ...process.env, VAULTLINE_ENABLE_SIGNER: "1" });
|
|
195
|
+
const readable = resolved;
|
|
196
|
+
if (typeof readable.download !== "function") {
|
|
197
|
+
fail("provider_unsupported", `${target.provider} cannot be read back by this build, so this archive cannot be fetched`);
|
|
198
|
+
}
|
|
199
|
+
return readable.download(lease);
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* One managed record checked against the account's rows. A single-object copy
|
|
203
|
+
* is one row at the recorded size; a chunk-folder copy is its whole family —
|
|
204
|
+
* every chunk row present, the envelope sidecar present, and the chunk bytes
|
|
205
|
+
* summing to the verified total. The proof standard does not soften because
|
|
206
|
+
* the object count went up: any missing member is "do not trash the original".
|
|
207
|
+
*/
|
|
208
|
+
async function managedCopyCheck(dataDir, record, rows, checkedAt) {
|
|
209
|
+
const remote = record.remote;
|
|
210
|
+
if (remote.layout?.kind === "chunks") {
|
|
211
|
+
const { managedChunkRefs } = await import("./managed-chunks.js");
|
|
212
|
+
const refs = managedChunkRefs(record.id, remote.layout.chunkCount);
|
|
213
|
+
const missing = refs.chunks.filter((ref) => !rows.has(ref)).length + (rows.has(refs.envelope) ? 0 : 1);
|
|
214
|
+
if (missing > 0) {
|
|
215
|
+
await recordAudit(dataDir, "remote.check", "denied", { archiveId: record.id, reason: "absent", missing });
|
|
216
|
+
return { ok: false, provider: remote.provider, bytes: 0, checkedAt, detail: `Your managed storage is missing ${missing} of this archive's ${refs.chunks.length + 1} objects. Do not trash the original — this disk may hold the only complete copy.` };
|
|
217
|
+
}
|
|
218
|
+
const bytes = refs.chunks.reduce((sum, ref) => sum + (rows.get(ref) ?? 0), 0);
|
|
219
|
+
if (bytes !== remote.bytes) {
|
|
220
|
+
await recordAudit(dataDir, "remote.check", "denied", { archiveId: record.id, reason: "size", stored: bytes });
|
|
221
|
+
return { ok: false, provider: remote.provider, bytes, checkedAt, detail: `Your storage reports ${bytes} chunk bytes; ${remote.bytes} were verified. Do not trash the original.` };
|
|
222
|
+
}
|
|
223
|
+
await recordAudit(dataDir, "remote.check", "allowed", { archiveId: record.id, bytes, chunks: refs.chunks.length });
|
|
224
|
+
return { ok: true, provider: remote.provider, bytes, checkedAt, detail: `Your managed storage confirmed all ${refs.chunks.length} chunks and the sidecar at the recorded size, just now.` };
|
|
225
|
+
}
|
|
226
|
+
const stored = rows.get(record.id);
|
|
227
|
+
if (stored === undefined) {
|
|
228
|
+
await recordAudit(dataDir, "remote.check", "denied", { archiveId: record.id, reason: "absent" });
|
|
229
|
+
return { ok: false, provider: remote.provider, bytes: 0, checkedAt, detail: "Your managed storage no longer lists this archive. Do not trash the original — this disk may hold the only copy." };
|
|
230
|
+
}
|
|
231
|
+
if (stored !== remote.bytes) {
|
|
232
|
+
await recordAudit(dataDir, "remote.check", "denied", { archiveId: record.id, reason: "size", stored });
|
|
233
|
+
return { ok: false, provider: remote.provider, bytes: stored, checkedAt, detail: `Your storage reports ${stored} bytes; ${remote.bytes} were verified. Do not trash the original.` };
|
|
234
|
+
}
|
|
235
|
+
await recordAudit(dataDir, "remote.check", "allowed", { archiveId: record.id, bytes: stored });
|
|
236
|
+
return { ok: true, provider: remote.provider, bytes: stored, checkedAt, detail: "Your managed storage confirmed it holds this archive at the recorded size, just now." };
|
|
237
|
+
}
|
|
238
|
+
export async function checkRemoteCopy(dataDir, archiveId, client) {
|
|
239
|
+
const record = (await listArchives(dataDir)).find((item) => item.id === archiveId);
|
|
240
|
+
if (!record)
|
|
241
|
+
fail("archive_not_found", `Archive not found: ${archiveId}`, { archiveId });
|
|
242
|
+
if (!isV2(record))
|
|
243
|
+
fail("invalid_argument", `Archive ${archiveId} uses format v1. Run: sealkeep migrate`, { archiveId });
|
|
244
|
+
const remote = record.remote;
|
|
245
|
+
if (!remote?.verifiedAt)
|
|
246
|
+
fail("invalid_argument", "This archive has no verified cloud copy to check — upload it first.", { archiveId });
|
|
247
|
+
const checkedAt = new Date().toISOString();
|
|
248
|
+
if (remote.provider === "vaultline" && !client) {
|
|
249
|
+
const { listCloudArchives } = await import("./cloud.js");
|
|
250
|
+
const rows = new Map((await listCloudArchives(dataDir)).map((row) => [row.vault_ref, Number(row.bytes) || 0]));
|
|
251
|
+
return managedCopyCheck(dataDir, record, rows, checkedAt);
|
|
252
|
+
}
|
|
253
|
+
const config = await readConfig(dataDir);
|
|
254
|
+
const target = config.remoteStorage;
|
|
255
|
+
if (!target)
|
|
256
|
+
fail("storage_not_configured", "This vault has no storage configured, so the copy cannot be checked");
|
|
257
|
+
const resolved = client ?? await uploadClientFromStore(dataDir, config.vaultId, target, {}, undefined);
|
|
258
|
+
const lease = createActiveLease(target, {
|
|
259
|
+
archiveId: record.id, ciphertextSha256: record.cipher.ciphertextSha256, bytes: record.cipher.storedBytes
|
|
260
|
+
}, { ...process.env, VAULTLINE_ENABLE_SIGNER: "1" });
|
|
261
|
+
const probing = resolved;
|
|
262
|
+
if (typeof probing.head !== "function")
|
|
263
|
+
fail("provider_unsupported", `${target.provider} cannot be probed by this build`);
|
|
264
|
+
const head = await probing.head(lease);
|
|
265
|
+
if (!head.exists) {
|
|
266
|
+
await recordAudit(dataDir, "remote.check", "denied", { archiveId, reason: "absent" });
|
|
267
|
+
return { ok: false, provider: remote.provider, bytes: 0, checkedAt, detail: "The bucket no longer holds this object. Do not trash the original — this disk may hold the only copy." };
|
|
268
|
+
}
|
|
269
|
+
if (head.bytes !== remote.bytes) {
|
|
270
|
+
await recordAudit(dataDir, "remote.check", "denied", { archiveId, reason: "size", stored: head.bytes });
|
|
271
|
+
return { ok: false, provider: remote.provider, bytes: head.bytes, checkedAt, detail: `The bucket holds ${head.bytes} bytes; ${remote.bytes} were verified. Do not trash the original.` };
|
|
272
|
+
}
|
|
273
|
+
await recordAudit(dataDir, "remote.check", "allowed", { archiveId, bytes: head.bytes });
|
|
274
|
+
return { ok: true, provider: remote.provider, bytes: head.bytes, checkedAt, detail: "The bucket answered just now and holds this archive at the recorded size." };
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* The bulk form of `checkRemoteCopy`. Managed rows share ONE control-plane
|
|
278
|
+
* listing instead of asking per archive — checking a 53-row vault is one call
|
|
279
|
+
* plus a HEAD per own-bucket object, not 53 round trips.
|
|
280
|
+
*/
|
|
281
|
+
export async function checkRemoteCopyMany(dataDir, ids, client) {
|
|
282
|
+
const archives = await listArchives(dataDir);
|
|
283
|
+
const wanted = ids.map((id) => archives.find((item) => item.id === id));
|
|
284
|
+
const checkedAt = new Date().toISOString();
|
|
285
|
+
const results = [];
|
|
286
|
+
const managedIds = wanted.filter((record) => !!record && isV2(record) && record.remote?.provider === "vaultline").map((record) => record.id);
|
|
287
|
+
let managedRows = null;
|
|
288
|
+
if (managedIds.length > 0 && !client) {
|
|
289
|
+
const { listCloudArchives } = await import("./cloud.js");
|
|
290
|
+
managedRows = new Map((await listCloudArchives(dataDir)).map((row) => [row.vault_ref, Number(row.bytes) || 0]));
|
|
291
|
+
}
|
|
292
|
+
for (const id of ids) {
|
|
293
|
+
const record = archives.find((item) => item.id === id);
|
|
294
|
+
if (!record || !isV2(record) || !record.remote?.verifiedAt) {
|
|
295
|
+
results.push({ ok: false, provider: "none", bytes: 0, checkedAt, detail: "No verified cloud copy on record for this archive." });
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
if (record.remote.provider === "vaultline" && managedRows) {
|
|
299
|
+
results.push(await managedCopyCheck(dataDir, record, managedRows, checkedAt));
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
try {
|
|
303
|
+
results.push(await checkRemoteCopy(dataDir, id, client));
|
|
304
|
+
}
|
|
305
|
+
catch (error) {
|
|
306
|
+
results.push({ ok: false, provider: record.remote.provider, bytes: 0, checkedAt, detail: error instanceof Error ? error.message : "the check itself failed" });
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
return results;
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* Gives back a local path holding this archive's ciphertext, fetching it first
|
|
313
|
+
* if the local copy was offloaded.
|
|
314
|
+
*
|
|
315
|
+
* Restoring reads the object twice — once to hash it, once to decrypt it — so
|
|
316
|
+
* it needs a file, not a buffer. An offloaded archive is written to a temporary
|
|
317
|
+
* one and removed afterwards, which keeps the disk saving intact: the bytes are
|
|
318
|
+
* on this machine only for as long as the restore takes.
|
|
319
|
+
*/
|
|
320
|
+
export async function materialiseArchive(dataDir, record, client) {
|
|
321
|
+
if (!isV2(record) || !record.offloaded)
|
|
322
|
+
return { path: record.objectPath, release: async () => { } };
|
|
323
|
+
const ciphertext = await fetchCiphertext(dataDir, record, client);
|
|
324
|
+
if (sha256(ciphertext) !== record.cipher.ciphertextSha256) {
|
|
325
|
+
fail("ciphertext_integrity_failed", `The copy of archive ${record.id} in ${record.offloaded.bucket} is not the archive that was stored there`, { archiveId: record.id });
|
|
326
|
+
}
|
|
327
|
+
const scratch = await mkdtemp(join(tmpdir(), "vaultline-fetch-"));
|
|
328
|
+
const path = join(scratch, `${record.id}.vlarchive`);
|
|
329
|
+
await writeFile(path, ciphertext, { mode: 0o600 });
|
|
330
|
+
return { path, release: async () => { await rm(scratch, { recursive: true, force: true }); } };
|
|
331
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { detectAgents } from "./adapters.js";
|
|
2
|
+
export type SetupResult = {
|
|
3
|
+
initialized: boolean;
|
|
4
|
+
vaultId: string;
|
|
5
|
+
recoveryPhrase?: string;
|
|
6
|
+
detectedAgents: Awaited<ReturnType<typeof detectAgents>>;
|
|
7
|
+
files: string[];
|
|
8
|
+
};
|
|
9
|
+
/** Creates only Sealkeep-owned artifacts. Agent settings are never modified. */
|
|
10
|
+
export declare function setupVault(dataDir: string, providedPhrase?: string): Promise<SetupResult>;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { access, mkdir, writeFile } from "node:fs/promises";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { detectAgents, hookConfig } from "./adapters.js";
|
|
5
|
+
import { initialize, vaultStatus } from "./vault.js";
|
|
6
|
+
async function exists(path) { try {
|
|
7
|
+
await access(path, constants.F_OK);
|
|
8
|
+
return true;
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
return false;
|
|
12
|
+
} }
|
|
13
|
+
async function writeJson(path, value) { await mkdir(join(path, ".."), { recursive: true }); await writeFile(path, JSON.stringify(value, null, 2) + "\n", { mode: 0o600 }); }
|
|
14
|
+
/** Creates only Sealkeep-owned artifacts. Agent settings are never modified. */
|
|
15
|
+
export async function setupVault(dataDir, providedPhrase) {
|
|
16
|
+
const configPath = join(dataDir, "config.json");
|
|
17
|
+
let initialized = false;
|
|
18
|
+
let recoveryPhrase;
|
|
19
|
+
if (!(await exists(configPath))) {
|
|
20
|
+
const created = await initialize(dataDir, providedPhrase);
|
|
21
|
+
initialized = true;
|
|
22
|
+
recoveryPhrase = created.phrase;
|
|
23
|
+
}
|
|
24
|
+
const status = await vaultStatus(dataDir);
|
|
25
|
+
const detectedAgents = await detectAgents();
|
|
26
|
+
const integrations = join(dataDir, "integrations");
|
|
27
|
+
const files = [];
|
|
28
|
+
for (const agent of ["codex", "claude"]) {
|
|
29
|
+
const path = join(integrations, `${agent}-hooks.json`);
|
|
30
|
+
await writeJson(path, hookConfig(agent, "vaultline", dataDir));
|
|
31
|
+
files.push(path);
|
|
32
|
+
}
|
|
33
|
+
// Written by `sealkeep mcp install`, which knows how to register with each
|
|
34
|
+
// agent rather than leaving a fragment and hoping. This used to emit a JSON
|
|
35
|
+
// fragment here naming `sealkeep mcp` — a command that did not exist until
|
|
36
|
+
// that install path was built, so anyone who merged it got an agent that
|
|
37
|
+
// failed to start its server.
|
|
38
|
+
const { installMcpServer } = await import("./mcp-install.js");
|
|
39
|
+
const mcp = await installMcpServer(dataDir);
|
|
40
|
+
for (const entry of mcp.agents)
|
|
41
|
+
if (entry.fragmentPath)
|
|
42
|
+
files.push(entry.fragmentPath);
|
|
43
|
+
return { initialized, vaultId: status.vaultId, recoveryPhrase, detectedAgents, files };
|
|
44
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a customer actually buys.
|
|
3
|
+
*
|
|
4
|
+
* A package is a number of gigabytes and nothing else. Which provider holds the
|
|
5
|
+
* bytes is our problem, not the customer's — we pick whichever of B2 or R2 is
|
|
6
|
+
* cheapest and can move between them without anyone needing to know, because
|
|
7
|
+
* the ciphertext is opaque and the envelope travels with it. A customer who
|
|
8
|
+
* wants to name their own provider, region and storage class is not buying a
|
|
9
|
+
* package at all; they point Sealkeep at their own bucket and pay their own
|
|
10
|
+
* storage bill.
|
|
11
|
+
*
|
|
12
|
+
* Prices are monthly, in US dollars, and match what the site publishes — a
|
|
13
|
+
* visitor who has already read the pricing page must not be quoted something
|
|
14
|
+
* else by the CLI.
|
|
15
|
+
*
|
|
16
|
+
* On margin, and the mistake this comment used to make: compression does *not*
|
|
17
|
+
* halve what we store per GB of quota sold. Quota is metered on ciphertext —
|
|
18
|
+
* `src/upload.ts:42` sends `bytes: ciphertext.length` and the control plane
|
|
19
|
+
* bills against exactly that — so at full fill, stored bytes equal quota 1:1.
|
|
20
|
+
* Compression buys the *customer* more sessions per GB; it buys us nothing.
|
|
21
|
+
*
|
|
22
|
+
* Costed correctly, at Backblaze B2's $0.00695/GB-mo with free egress, and
|
|
23
|
+
* noting that `GB` below is 1024^3 while B2 bills decimal GB (+7.4%):
|
|
24
|
+
* gb100 ~$0.75, gb500 ~$3.73, gb2000 ~$14.92 per month at full quota.
|
|
25
|
+
*
|
|
26
|
+
* That is comfortable on B2 and not on the alternatives: at R2 Standard's
|
|
27
|
+
* $0.015 the gb2000 tier costs ~$32 against $30 of revenue, and on the GCS
|
|
28
|
+
* that managed storage still runs on today it costs ~$43. Finishing the B2
|
|
29
|
+
* migration is what makes the top tier viable, not a cost optimisation.
|
|
30
|
+
* See STORAGE.md.
|
|
31
|
+
*/
|
|
32
|
+
export type PackageId = "free" | "pro_lite" | "gb100" | "gb500" | "gb2000";
|
|
33
|
+
export type StoragePackage = {
|
|
34
|
+
id: PackageId;
|
|
35
|
+
name: string;
|
|
36
|
+
/** Gigabytes of managed storage. 0 means the customer runs their own bucket. */
|
|
37
|
+
gigabytes: number;
|
|
38
|
+
/** US dollars per month. 0 is free. */
|
|
39
|
+
monthly: number;
|
|
40
|
+
managed: boolean;
|
|
41
|
+
blurb: string;
|
|
42
|
+
};
|
|
43
|
+
export declare const PACKAGES: readonly StoragePackage[];
|
|
44
|
+
export declare function findPackage(id: string): StoragePackage | undefined;
|
|
45
|
+
export declare function quotaBytes(id: PackageId): number;
|
|
46
|
+
/**
|
|
47
|
+
* Which package an account is on, worked out from what the control plane
|
|
48
|
+
* actually granted rather than from anything the client asked for.
|
|
49
|
+
*
|
|
50
|
+
* Quota is an entitlement: it is set server-side when a subscription is paid,
|
|
51
|
+
* never by the machine doing the uploading. This maps a granted quota back to
|
|
52
|
+
* the nearest package so the panel and the CLI can name it, and falls back to
|
|
53
|
+
* a plain byte count when a quota does not match the catalogue — a custom or
|
|
54
|
+
* grandfathered grant should be reported honestly, not rounded into a tier.
|
|
55
|
+
*/
|
|
56
|
+
export declare function packageForAccount(account: {
|
|
57
|
+
plan?: string;
|
|
58
|
+
quota_bytes?: number;
|
|
59
|
+
}): StoragePackage | {
|
|
60
|
+
id: "custom";
|
|
61
|
+
name: string;
|
|
62
|
+
gigabytes: number;
|
|
63
|
+
monthly: null;
|
|
64
|
+
managed: true;
|
|
65
|
+
blurb: string;
|
|
66
|
+
};
|
|
67
|
+
/** Managed storage providers we may put a package on. Never customer-visible. */
|
|
68
|
+
export type ManagedProvider = "gcs" | "b2" | "r2";
|
|
69
|
+
/**
|
|
70
|
+
* Quota visibility.
|
|
71
|
+
*
|
|
72
|
+
* Today a customer who fills their package gets a bare 402 on their next
|
|
73
|
+
* upload — no warning beforehand, no dashboard number, nothing, even though
|
|
74
|
+
* `CloudAccount` has carried `quota_bytes` and `used_bytes` the whole time.
|
|
75
|
+
* This is the client-side surface for that data: where an account sits
|
|
76
|
+
* against its quota, and the plain-language line that goes with it, shared by
|
|
77
|
+
* `cloud status`, `status`, `doctor`, and the rewritten 402 so all four agree
|
|
78
|
+
* on the same numbers and the same next step.
|
|
79
|
+
*
|
|
80
|
+
* This is groundwork only. It warns and it points at `sealkeep plans`; it
|
|
81
|
+
* does not charge anyone or decide how overage should work, because that
|
|
82
|
+
* product decision has not been made yet.
|
|
83
|
+
*/
|
|
84
|
+
export type QuotaLevel = "ok" | "warn" | "critical" | "over";
|
|
85
|
+
export type QuotaState = {
|
|
86
|
+
usedBytes: number;
|
|
87
|
+
quotaBytes: number;
|
|
88
|
+
/** Floored, and not clamped to 100 — an account genuinely over quota reads
|
|
89
|
+
* as more than 100%, not as a suspiciously round 100. */
|
|
90
|
+
percent: number;
|
|
91
|
+
remainingBytes: number;
|
|
92
|
+
level: QuotaLevel;
|
|
93
|
+
};
|
|
94
|
+
/**
|
|
95
|
+
* Where an account sits against its managed-storage quota.
|
|
96
|
+
*
|
|
97
|
+
* The displayed percentage is floored, not rounded, and the level is compared
|
|
98
|
+
* against the same floored figure, so the two can never disagree — 79.6% reads
|
|
99
|
+
* as "79%" and "ok", never a "80%" that is not flagged as a warning. A quota
|
|
100
|
+
* of zero or less means there is no managed entitlement to speak of — a pure
|
|
101
|
+
* bring-your-own-bucket account — so it reads as "ok" rather than a division
|
|
102
|
+
* by zero.
|
|
103
|
+
*/
|
|
104
|
+
export declare function quotaState(usedBytes: number, quotaBytes: number): QuotaState;
|
|
105
|
+
/**
|
|
106
|
+
* One sentence, always carrying the real numbers.
|
|
107
|
+
*
|
|
108
|
+
* A customer who hits this has no dashboard to cross-check it against — this
|
|
109
|
+
* line, wherever it is shown, is the only accounting they get. `warn` and
|
|
110
|
+
* `critical` both name what to do; `over` says plainly that uploads will fail,
|
|
111
|
+
* because at 100% they will.
|
|
112
|
+
*/
|
|
113
|
+
export declare function quotaMessage(state: QuotaState): string;
|
|
114
|
+
/**
|
|
115
|
+
* Rewrites a bare quota rejection into something a customer can act on.
|
|
116
|
+
*
|
|
117
|
+
* `call()` in cloud.ts maps a 402 straight to a VaultlineError coded
|
|
118
|
+
* "forbidden" whose message is whatever the control plane said — historically
|
|
119
|
+
* just "Quota exceeded", with no numbers and no next step. This asks the same
|
|
120
|
+
* question `cloud status` would have answered in advance: where the account
|
|
121
|
+
* actually stands, and what to do about it.
|
|
122
|
+
*/
|
|
123
|
+
export declare function quotaExceededMessage(account: {
|
|
124
|
+
used_bytes: number;
|
|
125
|
+
quota_bytes: number;
|
|
126
|
+
}): string;
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { bytes } from "./ui.js";
|
|
2
|
+
const GB = 1024 ** 3;
|
|
3
|
+
export const PACKAGES = [
|
|
4
|
+
{
|
|
5
|
+
id: "free", name: "Your own bucket", gigabytes: 0, monthly: 0, managed: false,
|
|
6
|
+
blurb: "Point Sealkeep at storage you already pay for. Your provider, your region, your retention. We never hold the bytes and never see a key."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
id: "pro_lite", name: "Pro Lite", gigabytes: 0, monthly: 2, managed: false,
|
|
10
|
+
blurb: "Your storage, our safety net: a sealed copy of your key in escrow, and every machine signed in. We still hold no bytes."
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
id: "gb100", name: "100 GB", gigabytes: 100, monthly: 6, managed: true,
|
|
14
|
+
blurb: "Enough for years of transcripts on one machine. Nothing to create, nothing to configure."
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
id: "gb500", name: "500 GB", gigabytes: 500, monthly: 12, managed: true,
|
|
18
|
+
blurb: "A whole team's history, or one machine that never deletes anything."
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
id: "gb2000", name: "2 TB", gigabytes: 2000, monthly: 30, managed: true,
|
|
22
|
+
blurb: "Every agent on every machine, kept indefinitely."
|
|
23
|
+
}
|
|
24
|
+
];
|
|
25
|
+
export function findPackage(id) {
|
|
26
|
+
return PACKAGES.find((item) => item.id === id);
|
|
27
|
+
}
|
|
28
|
+
export function quotaBytes(id) {
|
|
29
|
+
return (findPackage(id)?.gigabytes ?? 0) * GB;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Which package an account is on, worked out from what the control plane
|
|
33
|
+
* actually granted rather than from anything the client asked for.
|
|
34
|
+
*
|
|
35
|
+
* Quota is an entitlement: it is set server-side when a subscription is paid,
|
|
36
|
+
* never by the machine doing the uploading. This maps a granted quota back to
|
|
37
|
+
* the nearest package so the panel and the CLI can name it, and falls back to
|
|
38
|
+
* a plain byte count when a quota does not match the catalogue — a custom or
|
|
39
|
+
* grandfathered grant should be reported honestly, not rounded into a tier.
|
|
40
|
+
*/
|
|
41
|
+
export function packageForAccount(account) {
|
|
42
|
+
if (account.plan === "pro_lite")
|
|
43
|
+
return findPackage("pro_lite") ?? PACKAGES[0];
|
|
44
|
+
if (account.plan !== "pro")
|
|
45
|
+
return PACKAGES[0];
|
|
46
|
+
const gigabytes = Math.round((account.quota_bytes ?? 0) / GB);
|
|
47
|
+
const exact = PACKAGES.find((item) => item.managed && item.gigabytes === gigabytes);
|
|
48
|
+
if (exact)
|
|
49
|
+
return exact;
|
|
50
|
+
return {
|
|
51
|
+
id: "custom", name: `${gigabytes} GB`, gigabytes, monthly: null, managed: true,
|
|
52
|
+
blurb: "A custom allocation, not one of the standard packages."
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
const WARN_AT = 0.8;
|
|
56
|
+
const CRITICAL_AT = 0.9;
|
|
57
|
+
/**
|
|
58
|
+
* Where an account sits against its managed-storage quota.
|
|
59
|
+
*
|
|
60
|
+
* The displayed percentage is floored, not rounded, and the level is compared
|
|
61
|
+
* against the same floored figure, so the two can never disagree — 79.6% reads
|
|
62
|
+
* as "79%" and "ok", never a "80%" that is not flagged as a warning. A quota
|
|
63
|
+
* of zero or less means there is no managed entitlement to speak of — a pure
|
|
64
|
+
* bring-your-own-bucket account — so it reads as "ok" rather than a division
|
|
65
|
+
* by zero.
|
|
66
|
+
*/
|
|
67
|
+
export function quotaState(usedBytes, quotaBytes) {
|
|
68
|
+
const used = Math.max(0, usedBytes || 0);
|
|
69
|
+
const quota = Math.max(0, quotaBytes || 0);
|
|
70
|
+
if (quota === 0)
|
|
71
|
+
return { usedBytes: used, quotaBytes: 0, percent: 0, remainingBytes: 0, level: "ok" };
|
|
72
|
+
const percent = Math.floor((used / quota) * 100);
|
|
73
|
+
const level = percent >= 100 ? "over" : percent >= CRITICAL_AT * 100 ? "critical" : percent >= WARN_AT * 100 ? "warn" : "ok";
|
|
74
|
+
return { usedBytes: used, quotaBytes: quota, percent, remainingBytes: Math.max(0, quota - used), level };
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* One sentence, always carrying the real numbers.
|
|
78
|
+
*
|
|
79
|
+
* A customer who hits this has no dashboard to cross-check it against — this
|
|
80
|
+
* line, wherever it is shown, is the only accounting they get. `warn` and
|
|
81
|
+
* `critical` both name what to do; `over` says plainly that uploads will fail,
|
|
82
|
+
* because at 100% they will.
|
|
83
|
+
*/
|
|
84
|
+
export function quotaMessage(state) {
|
|
85
|
+
const used = bytes(state.usedBytes);
|
|
86
|
+
const quota = bytes(state.quotaBytes);
|
|
87
|
+
const left = bytes(state.remainingBytes);
|
|
88
|
+
switch (state.level) {
|
|
89
|
+
// No command in these. This string reaches the dashboard's Health tab
|
|
90
|
+
// through doctor, and the one place a paying customer will definitely see
|
|
91
|
+
// it is the moment their uploads stop — telling them to open a terminal to
|
|
92
|
+
// see prices, right then, is the wrong answer. The CLI adds its own hint.
|
|
93
|
+
case "over":
|
|
94
|
+
return `${used} of ${quota} used (${state.percent}%). New archives are being held on this machine until there is room — free some space, or move to a bigger package.`;
|
|
95
|
+
case "critical":
|
|
96
|
+
return `${used} of ${quota} used (${state.percent}%), ${left} left before uploads pause.`;
|
|
97
|
+
case "warn":
|
|
98
|
+
return `${used} of ${quota} used (${state.percent}%), ${left} left.`;
|
|
99
|
+
default:
|
|
100
|
+
return `${used} of ${quota} used (${state.percent}%), ${left} left.`;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Rewrites a bare quota rejection into something a customer can act on.
|
|
105
|
+
*
|
|
106
|
+
* `call()` in cloud.ts maps a 402 straight to a VaultlineError coded
|
|
107
|
+
* "forbidden" whose message is whatever the control plane said — historically
|
|
108
|
+
* just "Quota exceeded", with no numbers and no next step. This asks the same
|
|
109
|
+
* question `cloud status` would have answered in advance: where the account
|
|
110
|
+
* actually stands, and what to do about it.
|
|
111
|
+
*/
|
|
112
|
+
export function quotaExceededMessage(account) {
|
|
113
|
+
return `Quota exceeded: ${quotaMessage(quotaState(account.used_bytes, account.quota_bytes))}`;
|
|
114
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export declare const PRF_SECRET_BYTES = 32;
|
|
2
|
+
/** A fresh per-vault salt, evaluated by the authenticator to produce the secret. */
|
|
3
|
+
export declare function generatePrfSalt(): string;
|
|
4
|
+
/**
|
|
5
|
+
* The X25519 identity that a given passkey secret always produces.
|
|
6
|
+
*
|
|
7
|
+
* Deterministic on purpose: enrolment registers the public half, and every
|
|
8
|
+
* later unlock re-derives the private half from the same finger. If this were
|
|
9
|
+
* random, a passkey could enrol but never open anything.
|
|
10
|
+
*/
|
|
11
|
+
export declare function keysFromPrfSecret(secret: Buffer): {
|
|
12
|
+
publicKeyBase64: string;
|
|
13
|
+
privateKeyRaw: Buffer;
|
|
14
|
+
};
|
|
15
|
+
/** What a vault remembers about an enrolled passkey. Never the secret, never the key. */
|
|
16
|
+
export type PasskeyRecord = {
|
|
17
|
+
/** The WebAuthn credential id, so a later unlock asks for the right passkey. */
|
|
18
|
+
credentialId: string;
|
|
19
|
+
/** The salt the authenticator evaluates. Public by design: it is useless alone. */
|
|
20
|
+
prfSalt: string;
|
|
21
|
+
label: string;
|
|
22
|
+
addedAt: string;
|
|
23
|
+
/** The recipient this passkey opens, so the two can be removed together. */
|
|
24
|
+
recipientId: string;
|
|
25
|
+
};
|
|
26
|
+
export declare function isPasskeyRecord(value: unknown): value is PasskeyRecord;
|