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,1757 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import { chmod, mkdir, readFile, rename, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
4
|
+
import { hostname } from "node:os";
|
|
5
|
+
import { extname, join, resolve, sep } from "node:path";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
import { detectAgents, detectSetupAgents, findTranscripts, installAgentHooks, SETUP_AGENT_IDS } from "./adapters.js";
|
|
8
|
+
import { errorPayload, fail, isVaultlineError } from "./errors.js";
|
|
9
|
+
import { ArchiveQueue } from "./queue.js";
|
|
10
|
+
import { drainQueue } from "./worker.js";
|
|
11
|
+
import { runDoctor } from "./doctor.js";
|
|
12
|
+
import { liveness, readHeartbeat } from "./heartbeat.js";
|
|
13
|
+
import { findWebRoot, localApiTokenPath } from "./paths.js";
|
|
14
|
+
import { applyRetention, evaluateRetention, setRetentionPolicy } from "./retention.js";
|
|
15
|
+
import { createActiveLease, createUploadClient } from "./providers/index.js";
|
|
16
|
+
import { presign } from "./providers/sigv4.js";
|
|
17
|
+
import { target as s3Target } from "./providers/s3.js";
|
|
18
|
+
import { signGcsUrl } from "./providers/gcs.js";
|
|
19
|
+
import { hasPasswordLock, unwrapPhrase, wrapPhrase } from "./password-lock.js";
|
|
20
|
+
import { loadProviderCredentials, recallRecoveryPhrase, rememberRecoveryPhrase, storeProviderCredentials } from "./secrets.js";
|
|
21
|
+
import { daemonInvocation, installService, serviceStatus, serviceUnitEnvironment } from "./service.js";
|
|
22
|
+
import { defaultStrategy } from "./trash.js";
|
|
23
|
+
import { isV2 } from "./types.js";
|
|
24
|
+
import * as cloud from "./cloud.js";
|
|
25
|
+
import { configureRemoteStorage, initialize, listArchives, previewRetention, readConfig, vaultStatus, writeRecord } from "./vault.js";
|
|
26
|
+
import { sha256 } from "./crypto.js";
|
|
27
|
+
const LOOPBACK = new Set(["127.0.0.1", "::1", "::ffff:127.0.0.1"]);
|
|
28
|
+
/**
|
|
29
|
+
* `vault.line` and `vault.localhost` join the list because the address bar is
|
|
30
|
+
* part of the product: `127.0.0.1:54142` reads as a malfunction. Neither name
|
|
31
|
+
* weakens the Host check below, which exists to stop a page on the open web
|
|
32
|
+
* reaching this service by pointing a hostname it controls at 127.0.0.1. These
|
|
33
|
+
* two resolve to loopback or nowhere — `vault.line` only exists if this user
|
|
34
|
+
* put it in their own hosts file — so accepting them adds no name an attacker
|
|
35
|
+
* can claim. The bearer token remains the thing that actually grants access.
|
|
36
|
+
*/
|
|
37
|
+
const ALLOWED_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]", "::1", "vault.line", "vault.localhost"]);
|
|
38
|
+
const MAX_BODY_BYTES = 1_000_000;
|
|
39
|
+
const enqueueSchema = z.object({
|
|
40
|
+
sourcePath: z.string().min(1).max(4096),
|
|
41
|
+
agent: z.string().min(1).max(64),
|
|
42
|
+
event: z.string().min(1).max(64).default("manual"),
|
|
43
|
+
sessionId: z.string().min(1).max(200).optional()
|
|
44
|
+
}).strict();
|
|
45
|
+
const retentionSchema = z.object({ olderThanDays: z.number().min(0).max(3650).default(30) }).strict();
|
|
46
|
+
const applySchema = z.object({ confirm: z.boolean().default(false) }).strict();
|
|
47
|
+
/**
|
|
48
|
+
* The retention rules, settable from the window rather than from a terminal.
|
|
49
|
+
*
|
|
50
|
+
* Deciding what may be deleted is the single most consequential choice this
|
|
51
|
+
* product asks anyone to make, and until now it could only be expressed by
|
|
52
|
+
* typing flags — so the page that showed a person their reclaimable space also
|
|
53
|
+
* had to tell them to go and run a command to act on it.
|
|
54
|
+
*
|
|
55
|
+
* `nullable` on the last two is load-bearing: null means "do not consider this
|
|
56
|
+
* rule", which is a different instruction from zero. Zero idle days would mean
|
|
57
|
+
* "reclaim everything however recently it was touched", so conflating them
|
|
58
|
+
* would turn an off switch into its opposite.
|
|
59
|
+
*/
|
|
60
|
+
const retentionPolicySchema = z.object({
|
|
61
|
+
policy: z.enum(["local-only", "sync-only", "archive-and-reclaim", "manual-approval"]).optional(),
|
|
62
|
+
olderThanDays: z.number().min(0).max(3650).optional(),
|
|
63
|
+
graceDays: z.number().min(0).max(3650).optional(),
|
|
64
|
+
sourceIdleDays: z.number().min(0).max(3650).nullable().optional(),
|
|
65
|
+
minSourceBytes: z.number().min(0).nullable().optional(),
|
|
66
|
+
/**
|
|
67
|
+
* Missing from this schema until now, which made the field on the retention
|
|
68
|
+
* page a control that did nothing and said "Saved". `.strict()` meant a body
|
|
69
|
+
* carrying it would have been rejected outright — so the page never sent it,
|
|
70
|
+
* and a person who typed 90 and pressed save was told their instruction had
|
|
71
|
+
* been recorded when it had not. A false success on a rule about *deleting
|
|
72
|
+
* archives* is the worst kind this product can ship.
|
|
73
|
+
*/
|
|
74
|
+
deleteAfterDays: z.number().min(0).max(3650).nullable().optional()
|
|
75
|
+
}).strict();
|
|
76
|
+
/**
|
|
77
|
+
* The pairing code from the account panel, with the email it belongs to.
|
|
78
|
+
*
|
|
79
|
+
* Both, because that is what the panel's own instructions hand over: it shows
|
|
80
|
+
* `sealkeep cloud login --email … --code …`, and the exchange needs the pair.
|
|
81
|
+
*
|
|
82
|
+
* Deliberately not a password and not a recovery phrase: this links a machine
|
|
83
|
+
* to an account someone already has, which grants quota, managed storage and
|
|
84
|
+
* settings — never the ability to open an archive. That is what keeps the "no
|
|
85
|
+
* endpoint accepts a phrase" rule intact while still letting a returning user
|
|
86
|
+
* finish in the window instead of a terminal.
|
|
87
|
+
*/
|
|
88
|
+
const enrollSchema = z.object({
|
|
89
|
+
email: z.string().trim().min(3).max(320),
|
|
90
|
+
code: z.string().trim().min(1).max(200)
|
|
91
|
+
}).strict();
|
|
92
|
+
/**
|
|
93
|
+
* `confirmLastCopy` is the only field, and it exists because deleting a stored
|
|
94
|
+
* copy is usually reversible — the archive is still on this disk — until the
|
|
95
|
+
* day it is not. When the local bytes are already gone, the stored copy is the
|
|
96
|
+
* archive, and removing it must take a second, knowing request rather than the
|
|
97
|
+
* same one that deletes any other row.
|
|
98
|
+
*/
|
|
99
|
+
const deleteCopySchema = z.object({ confirmLastCopy: z.boolean().default(false) }).strict();
|
|
100
|
+
/**
|
|
101
|
+
* Null clears the cap. The floor is 1 GB because zero would read as "keep
|
|
102
|
+
* nothing", which is a bulk delete wearing a setting's clothes — emptying
|
|
103
|
+
* storage is what the copies table above is for, one witnessed row at a time.
|
|
104
|
+
*/
|
|
105
|
+
const rollingCapSchema = z.object({ capGb: z.number().min(1).max(100_000).nullable() }).strict();
|
|
106
|
+
/**
|
|
107
|
+
* The sessions a person picked, by absolute path.
|
|
108
|
+
*
|
|
109
|
+
* Capped because this is the one endpoint that starts real work: a thousand
|
|
110
|
+
* gigabyte transcripts selected by a stuck key should be refused, not attempted.
|
|
111
|
+
*/
|
|
112
|
+
/** Twenty-four words, once, to be kept — never to be read back. */
|
|
113
|
+
const rememberSchema = z.object({ phrase: z.string().trim().min(1).max(2000) }).strict();
|
|
114
|
+
const archiveSelectionSchema = z.object({
|
|
115
|
+
// Enqueue-only since the background drain took over sealing, so a large
|
|
116
|
+
// selection is cheap: "select all 700" arrives as one request. The old cap of
|
|
117
|
+
// 200 made the page split requests while the button offered no such limit.
|
|
118
|
+
paths: z.array(z.string().min(1).max(4096)).min(1).max(5000)
|
|
119
|
+
}).strict();
|
|
120
|
+
/** Mirrors adapters.ts's AgentId union. Zod needs a literal tuple to validate against, not a type. */
|
|
121
|
+
const KNOWN_AGENTS = ["codex", "claude"];
|
|
122
|
+
const TRASH_STRATEGIES = ["auto", "macos-trash", "xdg-trash", "windows-recycle-bin", "vaultline-trash"];
|
|
123
|
+
// Every field is optional: PUT sends only what changed, and the handler merges
|
|
124
|
+
// it onto the settings already on disk — the same partial-override shape
|
|
125
|
+
// retention.ts uses for its own settings. `.strict()` is what makes the
|
|
126
|
+
// read-only remote mirror actually unwritable rather than merely undocumented:
|
|
127
|
+
// a body that names `sync`, `plan`, or `quota` is rejected as invalid_argument
|
|
128
|
+
// before it reaches any handler logic.
|
|
129
|
+
const localSettingsSchema = z.object({
|
|
130
|
+
deviceLabel: z.string().trim().min(1).max(80).optional(),
|
|
131
|
+
watchedAgents: z.array(z.enum(KNOWN_AGENTS)).max(KNOWN_AGENTS.length).optional(),
|
|
132
|
+
watchedPaths: z.array(z.string().min(1).max(4096)).max(200).optional(),
|
|
133
|
+
exclusions: z.array(z.string().min(1).max(4096)).max(500).optional(),
|
|
134
|
+
reclaimEnabled: z.boolean().optional(),
|
|
135
|
+
trashStrategy: z.enum(TRASH_STRATEGIES).optional(),
|
|
136
|
+
schedule: z.object({ intervalMinutes: z.number().min(1).max(1440) }).strict().optional(),
|
|
137
|
+
bandwidth: z.object({ uploadLimitMbps: z.number().min(0).max(100_000).nullable() }).strict().optional(),
|
|
138
|
+
// 200 MB is the hard floor: below that, operating systems fail in ways no
|
|
139
|
+
// archive tool should be the cause of. null returns to automatic.
|
|
140
|
+
diskReserveMb: z.number().min(200).max(102_400).nullable().optional(),
|
|
141
|
+
// Only meaningful when it flips reclaim from off to on; see the PUT handler.
|
|
142
|
+
confirm: z.boolean().default(false)
|
|
143
|
+
}).strict();
|
|
144
|
+
/**
|
|
145
|
+
* First-run setup (PRODUCT_ARCHITECTURE.md §7).
|
|
146
|
+
*
|
|
147
|
+
* Every one of these bodies is `.strict()` for the same reason the settings
|
|
148
|
+
* schema is: a field nobody declared is a field nobody reviewed, and this is
|
|
149
|
+
* the one surface where a browser hands the product real secrets.
|
|
150
|
+
*/
|
|
151
|
+
const setupVaultSchema = z.object({
|
|
152
|
+
confirm: z.boolean().default(false),
|
|
153
|
+
/**
|
|
154
|
+
* Keep the phrase in this machine's keystore so the background service can
|
|
155
|
+
* seal sessions without a human pasting it at every boot. On by default
|
|
156
|
+
* because a first run that ends with a service which cannot encrypt is not a
|
|
157
|
+
* finished setup; secrets.ts states the trade-off this accepts.
|
|
158
|
+
*/
|
|
159
|
+
remember: z.boolean().default(true),
|
|
160
|
+
/**
|
|
161
|
+
* Free tier's unlock: seals the same phrase, on this machine, under a
|
|
162
|
+
* password the person chose (password-lock.ts). It never replaces the phrase
|
|
163
|
+
* and never travels — the response returns the phrase exactly as before, and
|
|
164
|
+
* the wizard still owes the person the write-it-down moment.
|
|
165
|
+
*/
|
|
166
|
+
password: z.string().min(8).max(1024).optional(),
|
|
167
|
+
/**
|
|
168
|
+
* Made here, once, because it cannot be changed later without re-writing
|
|
169
|
+
* every archive: "plain" stores archives as readable gzip for people who
|
|
170
|
+
* chose portability over secrecy. Defaulted so a page that never mentions
|
|
171
|
+
* the choice still creates the vault everyone expects — an encrypted one.
|
|
172
|
+
*/
|
|
173
|
+
storageMode: z.enum(["sealed", "plain"]).default("sealed")
|
|
174
|
+
}).strict();
|
|
175
|
+
/**
|
|
176
|
+
* A password to exchange for the phrase, and nothing else. Length is not
|
|
177
|
+
* policed here: whether it opens the lock is the only judgement an unlock
|
|
178
|
+
* attempt gets, so a short guess hears "wrong password", not a length rule.
|
|
179
|
+
*/
|
|
180
|
+
const setupUnlockSchema = z.object({ password: z.string().min(1).max(1024) }).strict();
|
|
181
|
+
const setupHooksSchema = z.object({
|
|
182
|
+
agents: z.array(z.enum(SETUP_AGENT_IDS)).min(1).max(SETUP_AGENT_IDS.length),
|
|
183
|
+
confirm: z.boolean().default(false)
|
|
184
|
+
}).strict();
|
|
185
|
+
/**
|
|
186
|
+
* Storage credentials, and the only place they are ever accepted.
|
|
187
|
+
*
|
|
188
|
+
* They are validated here and handed straight to `storeProviderCredentials`,
|
|
189
|
+
* which puts them in the OS keystore. Nothing in this file reads them back,
|
|
190
|
+
* and the setup record deliberately holds only the fact that they exist.
|
|
191
|
+
*/
|
|
192
|
+
const awsCredentialsSchema = z.object({
|
|
193
|
+
accessKeyId: z.string().trim().min(1).max(256),
|
|
194
|
+
secretAccessKey: z.string().min(1).max(4096),
|
|
195
|
+
sessionToken: z.string().min(1).max(8192).optional()
|
|
196
|
+
}).strict();
|
|
197
|
+
const serviceAccountSchema = z.object({
|
|
198
|
+
clientEmail: z.string().trim().min(3).max(320),
|
|
199
|
+
privateKey: z.string().min(1).max(16_384)
|
|
200
|
+
}).strict();
|
|
201
|
+
/** Managed storage is provisioned by enrolment, not typed into a form, so it is not offered here. */
|
|
202
|
+
const SETUP_PROVIDERS = ["s3", "r2", "b2", "gcs", "gdrive"];
|
|
203
|
+
const setupStorageSchema = z.object({
|
|
204
|
+
provider: z.enum(SETUP_PROVIDERS),
|
|
205
|
+
bucket: z.string().trim().min(1).max(255),
|
|
206
|
+
prefix: z.string().trim().min(1).max(512).default("vaultline"),
|
|
207
|
+
region: z.string().trim().min(1).max(64).optional(),
|
|
208
|
+
/** Required for R2 and any other S3-compatible host; see providers/s3.ts. */
|
|
209
|
+
endpoint: z.string().trim().min(1).max(2048).optional(),
|
|
210
|
+
credentials: z.union([awsCredentialsSchema, serviceAccountSchema]),
|
|
211
|
+
confirm: z.boolean().default(false)
|
|
212
|
+
}).strict();
|
|
213
|
+
const deleteArchiveSchema = z.object({
|
|
214
|
+
confirm: z.boolean().default(false),
|
|
215
|
+
/** Required when the source file no longer exists: the archive IS the session then, and deleting it is forever. */
|
|
216
|
+
confirmForever: z.boolean().default(false)
|
|
217
|
+
}).strict();
|
|
218
|
+
const setupServiceSchema = z.object({
|
|
219
|
+
confirm: z.boolean().default(false),
|
|
220
|
+
/** Let the service reclaim local files once a remote copy is verified. Off by default, as everywhere else. */
|
|
221
|
+
reclaim: z.boolean().default(false)
|
|
222
|
+
}).strict();
|
|
223
|
+
const webRoot = findWebRoot();
|
|
224
|
+
const CONTENT_TYPES = { ".html": "text/html; charset=utf-8", ".css": "text/css; charset=utf-8", ".js": "text/javascript; charset=utf-8", ".svg": "image/svg+xml" };
|
|
225
|
+
/**
|
|
226
|
+
* Serves the companion dashboard from the same origin as the API, so the page can
|
|
227
|
+
* authenticate with the local token without any cross-origin exception.
|
|
228
|
+
*/
|
|
229
|
+
/**
|
|
230
|
+
* Which page `/` means, which depends on whether there is a vault yet.
|
|
231
|
+
*
|
|
232
|
+
* It used to always mean the dashboard. On a machine with no vault the
|
|
233
|
+
* dashboard has exactly one thing it can say — "no vault here, run this
|
|
234
|
+
* terminal command" — so the first thing a new user saw was an error and an
|
|
235
|
+
* instruction to leave the window they had just been sent to. The wizard was
|
|
236
|
+
* sitting at /setup.html the whole time, reachable only by someone who already
|
|
237
|
+
* knew it existed.
|
|
238
|
+
*/
|
|
239
|
+
async function landingPage(dataDir) {
|
|
240
|
+
try {
|
|
241
|
+
await readConfig(dataDir);
|
|
242
|
+
return "/index.html";
|
|
243
|
+
}
|
|
244
|
+
catch {
|
|
245
|
+
return "/setup.html";
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
async function serveStatic(pathname, response) {
|
|
249
|
+
const relative = pathname === "/" ? "index.html" : pathname.replace(/^\/+/, "");
|
|
250
|
+
const file = resolve(webRoot, relative);
|
|
251
|
+
if (file !== webRoot && !file.startsWith(`${webRoot}${sep}`)) {
|
|
252
|
+
response.writeHead(403, { "content-type": "text/plain; charset=utf-8" }).end("Forbidden");
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
try {
|
|
256
|
+
const body = await readFile(file);
|
|
257
|
+
response.writeHead(200, { "content-type": CONTENT_TYPES[extname(file)] ?? "application/octet-stream", "cache-control": "no-store", "x-content-type-options": "nosniff" });
|
|
258
|
+
response.end(body);
|
|
259
|
+
}
|
|
260
|
+
catch {
|
|
261
|
+
response.writeHead(404, { "content-type": "text/plain; charset=utf-8" }).end("Not found");
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* Proves a storage target works, before anyone trusts it with an archive.
|
|
266
|
+
*
|
|
267
|
+
* Credentials are the step of setup most likely to be wrong, and every way of
|
|
268
|
+
* being wrong used to surface at the same moment: hours later, in a background
|
|
269
|
+
* upload, as a failure nobody was watching. The bucket name has a typo, the key
|
|
270
|
+
* lacks PutObject, the region is the console's display name rather than its id,
|
|
271
|
+
* the B2 key is a native one rather than S3-compatible. All of that is knowable
|
|
272
|
+
* in a second while the person is still looking at the form.
|
|
273
|
+
*
|
|
274
|
+
* It writes a small object, reads it back, and compares the bytes, because
|
|
275
|
+
* anything less proves less: a successful PUT says nothing about whether GET is
|
|
276
|
+
* permitted, and this product's whole promise is that the bytes come back.
|
|
277
|
+
*
|
|
278
|
+
* Nothing is stored. The credential is used to build a client, held for the
|
|
279
|
+
* round trip, and dropped — the caller has not committed to this target yet,
|
|
280
|
+
* and a test that saved a broken configuration would be worse than no test.
|
|
281
|
+
*/
|
|
282
|
+
export async function testStorageTarget(input) {
|
|
283
|
+
const { credentials, endpoint, provider, bucket, prefix, region } = input;
|
|
284
|
+
if ((provider === "gcs") !== ("clientEmail" in credentials)) {
|
|
285
|
+
return { ok: false, detail: provider === "gcs"
|
|
286
|
+
? "GCS needs a service-account credential (clientEmail and privateKey), not an access key"
|
|
287
|
+
: `${provider} needs an access-key credential (accessKeyId and secretAccessKey), not a service account` };
|
|
288
|
+
}
|
|
289
|
+
if (provider === "r2" && !endpoint)
|
|
290
|
+
return { ok: false, detail: "R2 needs its endpoint: https://<account-id>.r2.cloudflarestorage.com" };
|
|
291
|
+
if (endpoint) {
|
|
292
|
+
try {
|
|
293
|
+
assertHttpUrl(endpoint);
|
|
294
|
+
}
|
|
295
|
+
catch {
|
|
296
|
+
return { ok: false, detail: `${endpoint} is not a usable https URL` };
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
// One fixed key, overwritten on each test, so repeated attempts leave exactly
|
|
300
|
+
// one small object behind rather than a scattering of them.
|
|
301
|
+
// Filled from the lease once it exists, so the key reported is the key written.
|
|
302
|
+
let probeKey = `${prefix.replace(/^\/+|\/+$/g, "")}/.vaultline-write-test`;
|
|
303
|
+
const payload = Buffer.from(`sealkeep write test ${randomBytes(16).toString("hex")}\n`);
|
|
304
|
+
const digest = sha256(payload);
|
|
305
|
+
try {
|
|
306
|
+
// VAULTLINE_ENABLE_SIGNER gates *uploading archives* through a provider
|
|
307
|
+
// client, and it is off unless the installed service turns it on. That gate
|
|
308
|
+
// is right for archives and wrong for this: refusing to check a bucket
|
|
309
|
+
// because archives are not enabled yet would mean the only way to find out
|
|
310
|
+
// whether credentials work is to finish setup and wait for a background
|
|
311
|
+
// upload to fail. So the probe runs with it on, for this call only, writing
|
|
312
|
+
// fifty bytes the person explicitly asked to have written, to a bucket they
|
|
313
|
+
// just named, with a credential they just typed. Nothing else in this
|
|
314
|
+
// process gains the flag and the archive path is untouched.
|
|
315
|
+
const probeEnv = { ...process.env, VAULTLINE_ENABLE_SIGNER: "1" };
|
|
316
|
+
const target = { provider, bucket, prefix, ...(region ? { region } : {}) };
|
|
317
|
+
const client = createUploadClient(target, credentials, endpoint ? endpointOverrides(endpoint) : {}, probeEnv);
|
|
318
|
+
const lease = createActiveLease(target, {
|
|
319
|
+
archiveId: ".vaultline-write-test", ciphertextSha256: digest, bytes: payload.length
|
|
320
|
+
}, probeEnv);
|
|
321
|
+
// The lease decides the real key, including its suffix. Reporting anything
|
|
322
|
+
// else would send someone looking in their bucket for a name that is not there.
|
|
323
|
+
probeKey = lease.objectKey;
|
|
324
|
+
await client.upload(lease, payload);
|
|
325
|
+
// Reading the object back is the proof that matters, since the promise is
|
|
326
|
+
// that the bytes come back — but only the S3-family clients can do it, so
|
|
327
|
+
// GCS gets the strongest check it supports and the message says which was
|
|
328
|
+
// run rather than implying they are the same.
|
|
329
|
+
const readable = client;
|
|
330
|
+
if (typeof readable.download === "function") {
|
|
331
|
+
const read = await readable.download(lease);
|
|
332
|
+
if (!read.equals(payload)) {
|
|
333
|
+
return { ok: false, detail: "The bytes read back did not match what was written, so this target cannot be trusted with an archive.", probeKey };
|
|
334
|
+
}
|
|
335
|
+
return { ok: true, detail: `Wrote ${payload.length} bytes to ${bucket}/${probeKey} and read them back unchanged.`, probeKey };
|
|
336
|
+
}
|
|
337
|
+
const seen = await client.head(lease);
|
|
338
|
+
if (!seen.exists)
|
|
339
|
+
return { ok: false, detail: "The object was accepted but is not there afterwards.", probeKey };
|
|
340
|
+
if (seen.bytes !== payload.length)
|
|
341
|
+
return { ok: false, detail: `Wrote ${payload.length} bytes but the object is ${seen.bytes}.`, probeKey };
|
|
342
|
+
return { ok: true, detail: `Wrote ${payload.length} bytes to ${bucket}/${probeKey} and confirmed it is there. This provider does not support reading it back from here, so writing is what was proved.`, probeKey };
|
|
343
|
+
}
|
|
344
|
+
catch (error) {
|
|
345
|
+
// The provider's own words. "Access denied" naming the operation is worth
|
|
346
|
+
// more to whoever has to fix the policy than anything this could invent.
|
|
347
|
+
const message = error instanceof Error ? error.message : "the storage provider refused the request";
|
|
348
|
+
return { ok: false, detail: message, probeKey };
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
/** Agents whose sessions this build can actually seal. Others are detected but not offered. */
|
|
352
|
+
const ARCHIVABLE = ["codex", "claude"];
|
|
353
|
+
/**
|
|
354
|
+
* Every session on this machine, with the two facts a person needs to choose:
|
|
355
|
+
* how big it is, and when they last touched it.
|
|
356
|
+
*
|
|
357
|
+
* This exists because archiving everything automatically is the wrong default
|
|
358
|
+
* for the situation people are actually in — a nearly full disk, a handful of
|
|
359
|
+
* enormous transcripts, and no appetite for a background process deciding which
|
|
360
|
+
* ones to work through. Choosing is cheap when you can see sizes and dates;
|
|
361
|
+
* guessing is not.
|
|
362
|
+
*
|
|
363
|
+
* Already-archived sessions are marked rather than hidden, because "this one is
|
|
364
|
+
* done" is as useful as "this one is not", and hiding them would make the list
|
|
365
|
+
* shrink as you work and lose your place.
|
|
366
|
+
*/
|
|
367
|
+
export async function listSessions(dataDir, queue) {
|
|
368
|
+
const archives = await listArchives(dataDir).catch(() => []);
|
|
369
|
+
// Paths with a live job: queued or currently sealing. The page uses this to
|
|
370
|
+
// mark the row and refuse re-selection — without it, a person mid-batch saw
|
|
371
|
+
// 500 rows still reading "not archived" and could tick them all again.
|
|
372
|
+
const live = queue ? (await queue.list().catch(() => [])).filter((job) => job.status === "ready" || job.status === "leased") : [];
|
|
373
|
+
const inFlight = new Set(live.map((job) => job.sourcePath));
|
|
374
|
+
// A ready job whose last attempt was refused is waiting, not working — the
|
|
375
|
+
// refusal message (today: the disk guard's) rides to the page so the row can
|
|
376
|
+
// say "waiting for room" instead of pretending to seal.
|
|
377
|
+
const heldWhy = new Map(live.filter((job) => job.status === "ready" && job.lastError).map((job) => [job.sourcePath, typeof job.lastError === "string" ? job.lastError : job.lastError.message ?? "waiting to retry"]));
|
|
378
|
+
const sealingPct = new Map(live.filter((job) => job.status === "leased" && job.progress && job.progress.of > 0)
|
|
379
|
+
.map((job) => [job.sourcePath, Math.min(99, Math.floor((job.progress.bytes / job.progress.of) * 100))]));
|
|
380
|
+
// Keyed on path *and* length: a transcript that has grown since it was
|
|
381
|
+
// archived is not covered, and saying otherwise would invite someone to
|
|
382
|
+
// reclaim bytes no archive holds.
|
|
383
|
+
const covered = new Set(archives.map((record) => `${record.source.path}:${record.source.bytes}`));
|
|
384
|
+
// Sealed is not stored: an archive on this disk protects against deletion,
|
|
385
|
+
// a verified remote copy protects against losing the disk. The page filters
|
|
386
|
+
// on that difference, so it is computed here, from the same records.
|
|
387
|
+
const offMachine = new Set(archives
|
|
388
|
+
.filter((record) => record.version === 2 && record.remote?.verifiedAt)
|
|
389
|
+
.map((record) => `${record.source.path}:${record.source.bytes}`));
|
|
390
|
+
const sessions = [];
|
|
391
|
+
for (const agent of ARCHIVABLE) {
|
|
392
|
+
const found = await findTranscripts(agent, process.env.HOME ?? ".", 5_000).catch(() => []);
|
|
393
|
+
for (const item of found) {
|
|
394
|
+
const key = `${item.path}:${item.bytes}`;
|
|
395
|
+
sessions.push({
|
|
396
|
+
...item,
|
|
397
|
+
// `?? null` because the candidate fields are optional pre-resolution;
|
|
398
|
+
// a row promises an answer, even when the answer is "nothing said".
|
|
399
|
+
project: item.project ?? null,
|
|
400
|
+
projectPath: item.projectPath ?? null,
|
|
401
|
+
archived: covered.has(key),
|
|
402
|
+
stored: offMachine.has(key),
|
|
403
|
+
queued: inFlight.has(item.path),
|
|
404
|
+
held: heldWhy.get(item.path) ?? null,
|
|
405
|
+
sealingPct: sealingPct.get(item.path) ?? null
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
// Biggest first: the reason anyone opened this page is disk, and on a real
|
|
410
|
+
// machine forty files hold the space that three hundred others do not.
|
|
411
|
+
sessions.sort((a, b) => b.bytes - a.bytes);
|
|
412
|
+
return {
|
|
413
|
+
sessions,
|
|
414
|
+
totalBytes: sessions.reduce((total, item) => total + item.bytes, 0),
|
|
415
|
+
unarchivedBytes: sessions.filter((item) => !item.archived).reduce((total, item) => total + item.bytes, 0)
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
/**
|
|
419
|
+
* Archives exactly the sessions that were chosen, and nothing else.
|
|
420
|
+
*
|
|
421
|
+
* The recovery phrase is read from this machine's keystore, never from the
|
|
422
|
+
* request: the rule that no endpoint accepts a phrase is what keeps a browser
|
|
423
|
+
* page from being able to hand one over, and it is not weakened by using one
|
|
424
|
+
* the person already chose to store here. With nothing stored, this refuses and
|
|
425
|
+
* says which command stores it, rather than failing halfway through a list.
|
|
426
|
+
*/
|
|
427
|
+
/**
|
|
428
|
+
* One background drain at a time, owned by this process.
|
|
429
|
+
*
|
|
430
|
+
* The manual path used to seal inside the HTTP request — fine for three files,
|
|
431
|
+
* ruinous for five hundred: the browser owned an hours-long loop, a refresh
|
|
432
|
+
* killed everything after the current batch, and nothing resumed. Now a request
|
|
433
|
+
* only queues; this loop seals until the queue is dry, whoever asked. It is
|
|
434
|
+
* also kicked once at server start, so jobs stranded by a restart or a closed
|
|
435
|
+
* page finish on their own.
|
|
436
|
+
*/
|
|
437
|
+
let drainRunning = false;
|
|
438
|
+
export async function ensureDraining(dataDir, queue) {
|
|
439
|
+
if (drainRunning)
|
|
440
|
+
return;
|
|
441
|
+
drainRunning = true;
|
|
442
|
+
try {
|
|
443
|
+
const config = await readConfig(dataDir);
|
|
444
|
+
const phrase = await recallRecoveryPhrase(dataDir, config.vaultId);
|
|
445
|
+
if (!phrase)
|
|
446
|
+
return; // jobs stay queued; storing the phrase in Settings resumes them
|
|
447
|
+
await drainQueue(dataDir, phrase, { queue, max: 100_000, requireSpace: true });
|
|
448
|
+
}
|
|
449
|
+
catch {
|
|
450
|
+
// Jobs stay queued. The next archive request, or the next server start,
|
|
451
|
+
// kicks the drain again — failure here loses nothing.
|
|
452
|
+
}
|
|
453
|
+
finally {
|
|
454
|
+
drainRunning = false;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
export async function archiveSelected(dataDir, paths, queue) {
|
|
458
|
+
const config = await readConfig(dataDir);
|
|
459
|
+
const phrase = await recallRecoveryPhrase(dataDir, config.vaultId);
|
|
460
|
+
if (!phrase) {
|
|
461
|
+
// Rendered verbatim on the Sessions page, which shows plain text: no
|
|
462
|
+
// backticks, no terminal command, and it points at a place that page can
|
|
463
|
+
// actually reach — Settings › Unlocking on the dashboard — not a tab that
|
|
464
|
+
// does not exist here. It also says plainly that nothing was lost.
|
|
465
|
+
fail("recovery_phrase_missing", "Nothing was archived, and nothing was lost — your sessions are untouched. This machine has not stored your recovery phrase yet, so it cannot seal on its own. Open the dashboard, go to Settings › Unlocking, and choose “Remember it on this machine”, then try again.");
|
|
466
|
+
}
|
|
467
|
+
const known = new Map((await listSessions(dataDir)).sessions.map((row) => [row.path, row]));
|
|
468
|
+
const results = [];
|
|
469
|
+
for (const path of paths) {
|
|
470
|
+
const row = known.get(path);
|
|
471
|
+
// Only paths this machine actually reported. A request naming something
|
|
472
|
+
// else is either stale or someone probing, and neither should reach the
|
|
473
|
+
// archiver with an arbitrary path.
|
|
474
|
+
if (!row) {
|
|
475
|
+
results.push({ path, ok: false, error: "not a session this machine reported" });
|
|
476
|
+
continue;
|
|
477
|
+
}
|
|
478
|
+
try {
|
|
479
|
+
await queue.enqueue({ sourcePath: row.path, agent: row.agent, event: "manual" });
|
|
480
|
+
results.push({ path, ok: true, bytes: row.bytes });
|
|
481
|
+
}
|
|
482
|
+
catch (error) {
|
|
483
|
+
results.push({ path, ok: false, error: error instanceof Error ? error.message : "could not queue" });
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
// Queued, not sealed: the response returns immediately and the drain runs in
|
|
487
|
+
// the background, so closing or refreshing the page changes nothing. The
|
|
488
|
+
// page reads live progress from GET /v1/sessions, whose rows carry `queued`.
|
|
489
|
+
void ensureDraining(dataDir, queue);
|
|
490
|
+
return {
|
|
491
|
+
requested: paths.length,
|
|
492
|
+
queued: results.filter((item) => item.ok).length,
|
|
493
|
+
bytes: results.filter((item) => item.ok).reduce((total, item) => total + (item.bytes ?? 0), 0),
|
|
494
|
+
background: true,
|
|
495
|
+
results
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
export { localApiTokenPath };
|
|
499
|
+
/** Mints the loopback token once and keeps it owner-readable. It is never logged or returned by an endpoint. */
|
|
500
|
+
export async function localApiToken(dataDir) {
|
|
501
|
+
const path = localApiTokenPath(dataDir);
|
|
502
|
+
try {
|
|
503
|
+
const existing = (await readFile(path, "utf8")).trim();
|
|
504
|
+
if (existing) {
|
|
505
|
+
await chmod(path, 0o600);
|
|
506
|
+
return existing;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
catch { /* mint below */ }
|
|
510
|
+
const token = randomBytes(32).toString("base64url");
|
|
511
|
+
await mkdir(dataDir, { recursive: true, mode: 0o700 });
|
|
512
|
+
await writeFile(path, `${token}\n`, { mode: 0o600 });
|
|
513
|
+
return token;
|
|
514
|
+
}
|
|
515
|
+
const STATUS_BY_CODE = {
|
|
516
|
+
unauthorized: 401, forbidden: 403,
|
|
517
|
+
vault_not_initialized: 404, archive_not_found: 404, queue_job_not_found: 404,
|
|
518
|
+
signer_not_configured: 501, internal: 500
|
|
519
|
+
};
|
|
520
|
+
function json(response, status, value, headers = {}) {
|
|
521
|
+
response.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store", "x-content-type-options": "nosniff", ...headers });
|
|
522
|
+
response.end(JSON.stringify(value));
|
|
523
|
+
}
|
|
524
|
+
function tokenMatches(presented, token) {
|
|
525
|
+
const a = Buffer.from(presented);
|
|
526
|
+
const b = Buffer.from(token);
|
|
527
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
528
|
+
}
|
|
529
|
+
/**
|
|
530
|
+
* How a request proved itself: the bearer header (the CLI, and a page whose
|
|
531
|
+
* tab still holds the token) or the HttpOnly cookie (a browser coming back).
|
|
532
|
+
*
|
|
533
|
+
* The cookie exists so a person does not re-enter the token every time a tab
|
|
534
|
+
* closes. It is set server-side on the first bearer-authorized request and
|
|
535
|
+
* marked HttpOnly, so no script in the page can ever read it — which makes it
|
|
536
|
+
* strictly harder to steal than the sessionStorage copy the pages hold today.
|
|
537
|
+
* SameSite=Strict means a hostile website cannot ride it, the loopback and
|
|
538
|
+
* Host checks above already refuse everything non-local, and no CORS headers
|
|
539
|
+
* are ever sent, so a cross-origin page cannot read a response either.
|
|
540
|
+
*/
|
|
541
|
+
function authorizedVia(request, token) {
|
|
542
|
+
const header = request.headers.authorization;
|
|
543
|
+
if (typeof header === "string" && header.startsWith("Bearer ")) {
|
|
544
|
+
const presented = header.slice("Bearer ".length).trim();
|
|
545
|
+
// An empty bearer is a page that has no token in hand, not a wrong one —
|
|
546
|
+
// fall through and let the cookie answer for it.
|
|
547
|
+
if (presented.length > 0)
|
|
548
|
+
return tokenMatches(presented, token) ? "bearer" : null;
|
|
549
|
+
}
|
|
550
|
+
const cookies = request.headers.cookie;
|
|
551
|
+
if (typeof cookies === "string") {
|
|
552
|
+
for (const part of cookies.split(";")) {
|
|
553
|
+
const eq = part.indexOf("=");
|
|
554
|
+
if (eq === -1)
|
|
555
|
+
continue;
|
|
556
|
+
if (part.slice(0, eq).trim() !== "vaultline_token")
|
|
557
|
+
continue;
|
|
558
|
+
if (tokenMatches(part.slice(eq + 1).trim(), token))
|
|
559
|
+
return "cookie";
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
return null;
|
|
563
|
+
}
|
|
564
|
+
/** Rejects cross-origin hostnames so a browsing page cannot reach the API by DNS rebinding. */
|
|
565
|
+
function hostAllowed(request) {
|
|
566
|
+
const host = (request.headers.host ?? "").toLowerCase();
|
|
567
|
+
return ALLOWED_HOSTS.has(host.replace(/:\d+$/, ""));
|
|
568
|
+
}
|
|
569
|
+
async function readJsonBody(request) {
|
|
570
|
+
const chunks = [];
|
|
571
|
+
let size = 0;
|
|
572
|
+
for await (const chunk of request) {
|
|
573
|
+
size += chunk.length;
|
|
574
|
+
if (size > MAX_BODY_BYTES)
|
|
575
|
+
fail("invalid_argument", "Request body is too large");
|
|
576
|
+
chunks.push(chunk);
|
|
577
|
+
}
|
|
578
|
+
try {
|
|
579
|
+
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
580
|
+
}
|
|
581
|
+
catch {
|
|
582
|
+
return fail("invalid_argument", "Request body must be valid JSON");
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
function parseBody(schema, value) {
|
|
586
|
+
const result = schema.safeParse(value);
|
|
587
|
+
if (!result.success)
|
|
588
|
+
fail("invalid_argument", result.error.issues.map((issue) => `${issue.path.join(".") || "body"}: ${issue.message}`).join("; "));
|
|
589
|
+
return result.data;
|
|
590
|
+
}
|
|
591
|
+
/** Safe by construction: reclaim is off and nothing is excluded until a person turns them on. */
|
|
592
|
+
function defaultLocalSettings() {
|
|
593
|
+
return {
|
|
594
|
+
version: 1,
|
|
595
|
+
deviceLabel: hostname(),
|
|
596
|
+
watchedAgents: [...KNOWN_AGENTS],
|
|
597
|
+
watchedPaths: [],
|
|
598
|
+
exclusions: [],
|
|
599
|
+
reclaimEnabled: false,
|
|
600
|
+
trashStrategy: "auto",
|
|
601
|
+
schedule: { intervalMinutes: 5 },
|
|
602
|
+
bandwidth: { uploadLimitMbps: null },
|
|
603
|
+
diskReserveMb: null
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
const localSettingsPath = (dataDir) => join(dataDir, "local-settings.json");
|
|
607
|
+
/**
|
|
608
|
+
* Missing or unrecognised fields fall back to the default rather than failing
|
|
609
|
+
* to load, so a settings file written by an older build still reads after a
|
|
610
|
+
* new field is added. Unlike the vault config, these are preferences with
|
|
611
|
+
* nothing cryptographic riding on them, so there is no version to gate on.
|
|
612
|
+
*/
|
|
613
|
+
async function readLocalSettings(dataDir) {
|
|
614
|
+
const fallback = defaultLocalSettings();
|
|
615
|
+
try {
|
|
616
|
+
const saved = JSON.parse(await readFile(localSettingsPath(dataDir), "utf8"));
|
|
617
|
+
return {
|
|
618
|
+
...fallback, ...saved,
|
|
619
|
+
schedule: { ...fallback.schedule, ...saved?.schedule },
|
|
620
|
+
bandwidth: { ...fallback.bandwidth, ...saved?.bandwidth }
|
|
621
|
+
};
|
|
622
|
+
}
|
|
623
|
+
catch {
|
|
624
|
+
return fallback;
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
/** Written beside, never inside, the vault config: per-machine preferences are never synced. */
|
|
628
|
+
async function writeLocalSettings(dataDir, settings) {
|
|
629
|
+
await mkdir(dataDir, { recursive: true, mode: 0o700 });
|
|
630
|
+
const target = localSettingsPath(dataDir);
|
|
631
|
+
const temp = `${target}.${randomBytes(9).toString("hex")}.tmp`;
|
|
632
|
+
await writeFile(temp, JSON.stringify(settings, null, 2) + "\n", { mode: 0o600 });
|
|
633
|
+
await rename(temp, target);
|
|
634
|
+
}
|
|
635
|
+
/**
|
|
636
|
+
* Plan, quota and email come from the account, live when the control plane
|
|
637
|
+
* answers and from the last remembered answer when it does not — the same one
|
|
638
|
+
* cache sync-rules.ts keeps, now holding everything the account said. Fetching
|
|
639
|
+
* live-only meant a machine that went offline forgot its owner was a paying
|
|
640
|
+
* customer: the plan badge vanished and plan-gated surfaces fell back to the
|
|
641
|
+
* free look. `source` says which of the two the reader is looking at.
|
|
642
|
+
*/
|
|
643
|
+
async function cloudPlanAndQuota(dataDir) {
|
|
644
|
+
const { resolveAccount } = await import("./sync-rules.js");
|
|
645
|
+
const { account, source } = await resolveAccount(dataDir);
|
|
646
|
+
return { plan: account.plan, quota: account.quota, email: account.email, source };
|
|
647
|
+
}
|
|
648
|
+
/** The account panel lives at the cloud API's origin with `/api` swapped for `#account`. */
|
|
649
|
+
async function panelUrl() {
|
|
650
|
+
const { DEFAULT_CLOUD_URL } = await import("./cloud.js");
|
|
651
|
+
const origin = (process.env.VAULTLINE_CLOUD_URL ?? DEFAULT_CLOUD_URL).replace(/\/+$/, "").replace(/\/api$/, "");
|
|
652
|
+
return `${origin}/#account`;
|
|
653
|
+
}
|
|
654
|
+
/**
|
|
655
|
+
* Everything the settings screen shows about the account, and none of it
|
|
656
|
+
* writable from here. `editable: false` travels with the data itself as a
|
|
657
|
+
* second guard alongside the PUT schema simply having no field for any of it —
|
|
658
|
+
* a reader of the response alone can tell these values are not a form.
|
|
659
|
+
*/
|
|
660
|
+
async function remoteSettingsMirror(dataDir) {
|
|
661
|
+
// One request to the control plane, not two: resolveAccount fetches and
|
|
662
|
+
// caches the whole answer (rules, plan, quota, email), and the rules are then
|
|
663
|
+
// read back from that same cache. Before this, the rules and the plan were
|
|
664
|
+
// fetched by two separate, identical account calls per settings read.
|
|
665
|
+
const [cloud, url] = await Promise.all([cloudPlanAndQuota(dataDir), panelUrl()]);
|
|
666
|
+
const { cachedSyncRules, DEFAULT_SYNC_RULES } = await import("./sync-rules.js");
|
|
667
|
+
const rules = (await cachedSyncRules(dataDir)) ?? DEFAULT_SYNC_RULES;
|
|
668
|
+
const source = cloud.source === "none" ? "default" : cloud.source;
|
|
669
|
+
return {
|
|
670
|
+
sync: { mode: rules.mode, keepLast: rules.keepLast, minAgeDays: rules.minAgeDays, source },
|
|
671
|
+
plan: cloud.plan,
|
|
672
|
+
quota: cloud.quota,
|
|
673
|
+
panelUrl: url,
|
|
674
|
+
editable: false
|
|
675
|
+
};
|
|
676
|
+
}
|
|
677
|
+
/**
|
|
678
|
+
* Which storage answers for "what is stored remotely".
|
|
679
|
+
*
|
|
680
|
+
* A configured bucket wins, because the vault's records point into it and the
|
|
681
|
+
* account knows nothing about it. With no bucket, being signed in is the fact
|
|
682
|
+
* that makes a vault managed — the same test autopilot applies before telling
|
|
683
|
+
* anyone to configure storage they are already paying us to run.
|
|
684
|
+
*/
|
|
685
|
+
async function storedCopiesMode(dataDir) {
|
|
686
|
+
const config = await readConfig(dataDir);
|
|
687
|
+
if (config.remoteStorage && config.remoteStorage.provider !== "vaultline")
|
|
688
|
+
return "own";
|
|
689
|
+
const { cloudToken } = await import("./cloud.js");
|
|
690
|
+
try {
|
|
691
|
+
await cloudToken(dataDir);
|
|
692
|
+
return "managed";
|
|
693
|
+
}
|
|
694
|
+
catch {
|
|
695
|
+
return "none";
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
/**
|
|
699
|
+
* Whether each archive's bytes still exist on this disk, by asking the disk.
|
|
700
|
+
*
|
|
701
|
+
* The `offloaded` marker says the file was removed, but the question here
|
|
702
|
+
* authorises deleting the only other copy, so the answer comes from a stat
|
|
703
|
+
* rather than from bookkeeping that could have missed a crash or a hand-tidied
|
|
704
|
+
* folder. A record whose object file cannot be proven present counts as absent.
|
|
705
|
+
*/
|
|
706
|
+
async function localArchivePresence(dataDir) {
|
|
707
|
+
const presence = new Map();
|
|
708
|
+
for (const record of await listArchives(dataDir).catch(() => [])) {
|
|
709
|
+
presence.set(record.id, await stat(record.objectPath).then((entry) => entry.isFile()).catch(() => false));
|
|
710
|
+
}
|
|
711
|
+
return presence;
|
|
712
|
+
}
|
|
713
|
+
/**
|
|
714
|
+
* Deletes one object from the customer's own bucket, signed here with the same
|
|
715
|
+
* signers the upload clients use — the upload client itself has no delete, and
|
|
716
|
+
* the archive path should not grow one for the sake of this route. The signer
|
|
717
|
+
* feature flag is deliberately not consulted: it gates background uploads,
|
|
718
|
+
* and this is a person removing one named object they are looking at.
|
|
719
|
+
*/
|
|
720
|
+
async function deleteOwnObject(dataDir, storage, remote) {
|
|
721
|
+
const config = await readConfig(dataDir);
|
|
722
|
+
if (remote.layout?.kind === "chunks" && storage.provider !== "gdrive") {
|
|
723
|
+
// A chunk-folder remote deletes as a folder: every deterministic chunk
|
|
724
|
+
// name plus the envelope sidecar, through the same client that wrote them.
|
|
725
|
+
const { deleteChunkFolder } = await import("./chunk-store.js");
|
|
726
|
+
const { uploadClientFromStore } = await import("./providers/index.js");
|
|
727
|
+
const client = (await uploadClientFromStore(dataDir, config.vaultId, { provider: storage.provider, bucket: storage.bucket, prefix: "unused", region: storage.region }));
|
|
728
|
+
await deleteChunkFolder(client, remote);
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
const credentials = await loadProviderCredentials(dataDir, config.vaultId);
|
|
732
|
+
const { storageEndpoint } = await readSetupRecord(dataDir);
|
|
733
|
+
const overrides = storageEndpoint ? endpointOverrides(storageEndpoint) : {};
|
|
734
|
+
if (storage.provider === "gdrive") {
|
|
735
|
+
// Drive has no presignable delete: the OAuth client is the credential, so
|
|
736
|
+
// the provider client does the deleting itself, locating the file by the
|
|
737
|
+
// vaultlineKey property exactly the way head/download do.
|
|
738
|
+
const { GdriveUploadClient } = await import("./providers/gdrive.js");
|
|
739
|
+
if (!("kind" in credentials) || credentials.kind !== "gdrive")
|
|
740
|
+
fail("invalid_argument", "Google Drive needs its OAuth credential. Run: sealkeep storage connect gdrive");
|
|
741
|
+
const client = new GdriveUploadClient({ folderName: storage.bucket, appData: false }, credentials);
|
|
742
|
+
await client.deleteByKey(remote.objectKey);
|
|
743
|
+
return;
|
|
744
|
+
}
|
|
745
|
+
let url;
|
|
746
|
+
if (storage.provider === "gcs") {
|
|
747
|
+
if (!("clientEmail" in credentials))
|
|
748
|
+
fail("invalid_argument", "GCS needs a service-account credential, not an access key");
|
|
749
|
+
url = signGcsUrl({
|
|
750
|
+
method: "DELETE", bucket: storage.bucket, object: remote.objectKey, serviceAccount: credentials,
|
|
751
|
+
host: overrides.host, port: overrides.port, protocol: overrides.protocol
|
|
752
|
+
}).url;
|
|
753
|
+
}
|
|
754
|
+
else {
|
|
755
|
+
if (!("accessKeyId" in credentials))
|
|
756
|
+
fail("invalid_argument", `${storage.provider} needs an access-key credential, not a service account`);
|
|
757
|
+
const { host, path } = s3Target({
|
|
758
|
+
provider: storage.provider, region: storage.region ?? "auto", bucket: storage.bucket,
|
|
759
|
+
host: overrides.host, port: overrides.port, protocol: overrides.protocol, pathStyle: overrides.pathStyle
|
|
760
|
+
}, remote.objectKey);
|
|
761
|
+
url = presign({ method: "DELETE", host, port: overrides.port, protocol: overrides.protocol, path, region: storage.region ?? "auto", credentials }).url;
|
|
762
|
+
}
|
|
763
|
+
const response = await fetch(url, { method: "DELETE" });
|
|
764
|
+
// 404 passes: the goal is an absent object, and it is absent.
|
|
765
|
+
if (!response.ok && response.status !== 404) {
|
|
766
|
+
fail("internal", `Storage refused the delete: ${response.status} ${response.statusText}. The stored copy may still be there, and nothing was changed here.`);
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
/**
|
|
770
|
+
* The cache file sync-rules.ts owns. Named here as well because the rolling cap
|
|
771
|
+
* rides in that file beside the rules it belongs with; the name agreement is
|
|
772
|
+
* pinned by test/cloud-copies.test.ts, which greps both files.
|
|
773
|
+
*/
|
|
774
|
+
const syncRulesCachePath = (dataDir) => join(dataDir, "sync-rules.json");
|
|
775
|
+
async function readRollingCap(dataDir) {
|
|
776
|
+
try {
|
|
777
|
+
const saved = JSON.parse(await readFile(syncRulesCachePath(dataDir), "utf8"));
|
|
778
|
+
const cap = Number(saved?.rolling_cap_gb);
|
|
779
|
+
return Number.isFinite(cap) && cap > 0 ? cap : null;
|
|
780
|
+
}
|
|
781
|
+
catch {
|
|
782
|
+
return null;
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
/** Merges the cap into the cache without disturbing the rules the account wrote there. */
|
|
786
|
+
async function writeRollingCap(dataDir, capGb) {
|
|
787
|
+
let saved = {};
|
|
788
|
+
try {
|
|
789
|
+
saved = JSON.parse(await readFile(syncRulesCachePath(dataDir), "utf8"));
|
|
790
|
+
}
|
|
791
|
+
catch { /* first write */ }
|
|
792
|
+
await mkdir(dataDir, { recursive: true, mode: 0o700 });
|
|
793
|
+
await writeFile(syncRulesCachePath(dataDir), JSON.stringify({ ...saved, rolling_cap_gb: capGb }, null, 2), { mode: 0o600 });
|
|
794
|
+
}
|
|
795
|
+
/**
|
|
796
|
+
* A coarse ETA, not a promise. Grace period and activity are evaluated per
|
|
797
|
+
* candidate in retention.ts and already explained in full by GET /v1/retention;
|
|
798
|
+
* duplicating that branching here would drift the moment its rules changed.
|
|
799
|
+
* This answers only "open now", "not scheduled", or "not before <date>" from
|
|
800
|
+
* the age gate alone, and points at the real evaluation for the rest.
|
|
801
|
+
*/
|
|
802
|
+
function nextReclaimWindow(evaluation, now) {
|
|
803
|
+
if (evaluation.policy === "local-only" || evaluation.policy === "sync-only")
|
|
804
|
+
return { state: "not-scheduled" };
|
|
805
|
+
if (evaluation.reclaimableBytes > 0)
|
|
806
|
+
return { state: "open" };
|
|
807
|
+
const waiting = evaluation.candidates.filter((candidate) => !candidate.eligible);
|
|
808
|
+
if (waiting.length === 0)
|
|
809
|
+
return { state: "not-scheduled" };
|
|
810
|
+
const earliestMs = Math.min(...waiting.map((candidate) => Date.parse(candidate.createdAt) + evaluation.olderThanDays * 86_400_000));
|
|
811
|
+
return { state: "waiting", notBefore: new Date(Math.max(earliestMs, now)).toISOString(), pending: waiting.length };
|
|
812
|
+
}
|
|
813
|
+
const setupRecordPath = (dataDir) => join(dataDir, "setup.json");
|
|
814
|
+
function defaultSetupRecord() {
|
|
815
|
+
return { version: 1, phraseIssuedAt: null, phraseRemembered: false, storageEndpoint: null, credentials: null, hooks: [] };
|
|
816
|
+
}
|
|
817
|
+
/** A storage endpoint the wizard collected, checked before it is stored or signed against. */
|
|
818
|
+
function assertHttpUrl(value) {
|
|
819
|
+
let parsed;
|
|
820
|
+
try {
|
|
821
|
+
parsed = new URL(value);
|
|
822
|
+
}
|
|
823
|
+
catch {
|
|
824
|
+
return fail("invalid_argument", `Not a valid storage endpoint URL: ${value}`);
|
|
825
|
+
}
|
|
826
|
+
if (parsed.protocol !== "https:" && parsed.protocol !== "http:")
|
|
827
|
+
fail("invalid_argument", "A storage endpoint must be an http or https URL");
|
|
828
|
+
return parsed;
|
|
829
|
+
}
|
|
830
|
+
/** The host overrides a provider client takes. Path-style, because a named host always is one. */
|
|
831
|
+
function endpointOverrides(endpoint) {
|
|
832
|
+
const parsed = assertHttpUrl(endpoint);
|
|
833
|
+
return { host: parsed.hostname, port: parsed.port ? Number(parsed.port) : undefined, protocol: parsed.protocol.replace(":", ""), pathStyle: true };
|
|
834
|
+
}
|
|
835
|
+
/** A missing or unreadable record means "nothing done yet", never a broken setup page. */
|
|
836
|
+
async function readSetupRecord(dataDir) {
|
|
837
|
+
const fallback = defaultSetupRecord();
|
|
838
|
+
try {
|
|
839
|
+
const saved = JSON.parse(await readFile(setupRecordPath(dataDir), "utf8"));
|
|
840
|
+
return { ...fallback, ...saved, hooks: Array.isArray(saved?.hooks) ? saved.hooks : [] };
|
|
841
|
+
}
|
|
842
|
+
catch {
|
|
843
|
+
return fallback;
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
async function writeSetupRecord(dataDir, record) {
|
|
847
|
+
await mkdir(dataDir, { recursive: true, mode: 0o700 });
|
|
848
|
+
const target = setupRecordPath(dataDir);
|
|
849
|
+
const temp = `${target}.${randomBytes(9).toString("hex")}.tmp`;
|
|
850
|
+
await writeFile(temp, JSON.stringify(record, null, 2) + "\n", { mode: 0o600 });
|
|
851
|
+
await rename(temp, target);
|
|
852
|
+
}
|
|
853
|
+
/**
|
|
854
|
+
* The unit this machine would run, built once for both installing and reading it
|
|
855
|
+
* back. The unit's path does not depend on its arguments, so a status read with
|
|
856
|
+
* different flags still finds the file an install wrote.
|
|
857
|
+
*/
|
|
858
|
+
function serviceOptionsFor(dataDir, environment, unit = {}) {
|
|
859
|
+
const invocation = daemonInvocation(dataDir, { reclaim: unit.reclaim });
|
|
860
|
+
return {
|
|
861
|
+
dataDir, executable: invocation.executable, args: invocation.args,
|
|
862
|
+
home: environment.home, platform: environment.platform, exec: environment.exec,
|
|
863
|
+
...(unit.environment ? { environment: unit.environment } : {})
|
|
864
|
+
};
|
|
865
|
+
}
|
|
866
|
+
/**
|
|
867
|
+
* Everything the wizard needs to know about where it got to, assembled from the
|
|
868
|
+
* modules that already own each fact rather than from a progress flag.
|
|
869
|
+
*
|
|
870
|
+
* A vault that exists is the proof the vault step ran; a unit file on disk is
|
|
871
|
+
* the proof the service step ran. The only things read from the setup record
|
|
872
|
+
* are the two facts nothing else on the machine can answer: when the phrase was
|
|
873
|
+
* issued, and that a credential was stored.
|
|
874
|
+
*/
|
|
875
|
+
async function setupState(dataDir, environment) {
|
|
876
|
+
const [record, config, agents, beat] = await Promise.all([
|
|
877
|
+
readSetupRecord(dataDir),
|
|
878
|
+
readConfig(dataDir).catch(() => null),
|
|
879
|
+
detectSetupAgents(environment.home),
|
|
880
|
+
readHeartbeat(dataDir)
|
|
881
|
+
]);
|
|
882
|
+
const service = await serviceStatus(serviceOptionsFor(dataDir, environment));
|
|
883
|
+
const installedHooks = agents.filter((agent) => agent.hooksInstalled).map((agent) => agent.agent);
|
|
884
|
+
// Kept apart because they are different offers to make: `ready` can be hooked
|
|
885
|
+
// by the next call, `manual` can only be handed a fragment to merge.
|
|
886
|
+
const readyHooks = agents.filter((agent) => agent.status === "ready").map((agent) => agent.agent);
|
|
887
|
+
const manualHooks = agents.filter((agent) => agent.status === "manual-merge-required").map((agent) => agent.agent);
|
|
888
|
+
const remote = config?.remoteStorage ?? null;
|
|
889
|
+
// Hooks and storage are skippable by design — §7 is explicit that stopping
|
|
890
|
+
// after the phrase must leave a working local-only vault — so `nextStep` is
|
|
891
|
+
// the next thing worth doing, not a gate the page has to pass.
|
|
892
|
+
const nextStep = !config ? "vault"
|
|
893
|
+
: installedHooks.length === 0 && readyHooks.length + manualHooks.length > 0 ? "hooks"
|
|
894
|
+
: !remote ? "storage"
|
|
895
|
+
: !service.present ? "service"
|
|
896
|
+
: "done";
|
|
897
|
+
return {
|
|
898
|
+
vault: { created: config !== null, vaultId: config?.vaultId ?? null, dataDir, phraseIssuedAt: record.phraseIssuedAt, phraseRemembered: record.phraseRemembered },
|
|
899
|
+
hooks: { installed: installedHooks, ready: readyHooks, manual: manualHooks, detected: agents.filter((agent) => agent.detected).map((agent) => agent.agent) },
|
|
900
|
+
storage: {
|
|
901
|
+
configured: remote !== null,
|
|
902
|
+
provider: remote?.provider ?? null,
|
|
903
|
+
bucket: remote?.bucket ?? null,
|
|
904
|
+
prefix: remote?.prefix ?? null,
|
|
905
|
+
region: remote?.region ?? null,
|
|
906
|
+
endpoint: record.storageEndpoint,
|
|
907
|
+
// Presence only. There is no endpoint anywhere in this file that can turn
|
|
908
|
+
// this `true` back into the credential it describes.
|
|
909
|
+
credentialsStored: record.credentials !== null,
|
|
910
|
+
credentialsBackend: record.credentials?.backend ?? null,
|
|
911
|
+
credentialsStoredAt: record.credentials?.storedAt ?? null
|
|
912
|
+
},
|
|
913
|
+
service: { installed: service.present, kind: service.kind, path: service.path || null, liveness: liveness(beat) },
|
|
914
|
+
nextStep,
|
|
915
|
+
complete: config !== null && service.present
|
|
916
|
+
};
|
|
917
|
+
}
|
|
918
|
+
/**
|
|
919
|
+
* The loopback API shared by the CLI, dashboard, and MCP surfaces. It is the only
|
|
920
|
+
* place local surfaces read state from, so business logic is never duplicated.
|
|
921
|
+
*
|
|
922
|
+
* It deliberately cannot decrypt: no endpoint accepts a recovery phrase, and
|
|
923
|
+
* archive listings omit wrapped key material.
|
|
924
|
+
*/
|
|
925
|
+
export function createLocalApiServer(dataDir, token, options = {}) {
|
|
926
|
+
const queue = options.queue ?? new ArchiveQueue(dataDir);
|
|
927
|
+
// Jobs stranded by a restart or a closed page finish on their own: if the
|
|
928
|
+
// queue holds work and the keystore holds the phrase, sealing resumes now.
|
|
929
|
+
void ensureDraining(dataDir, queue);
|
|
930
|
+
const setupEnvironment = options.setup ?? {};
|
|
931
|
+
/**
|
|
932
|
+
* A promotion the control plane wants shown, fetched through this server
|
|
933
|
+
* because the dashboard speaks only to 127.0.0.1 — and cached, because an
|
|
934
|
+
* offer that changes weekly does not need fetching per page load. The
|
|
935
|
+
* content is Paul's to edit in the backend; an empty answer, a failed
|
|
936
|
+
* fetch, or a paying plan all mean "no banner", silently.
|
|
937
|
+
*/
|
|
938
|
+
let offerCache = null;
|
|
939
|
+
async function currentOffer(plan) {
|
|
940
|
+
if (plan)
|
|
941
|
+
return null; // paying plans are not advertised to
|
|
942
|
+
if (offerCache && Date.now() - offerCache.at < 10 * 60_000)
|
|
943
|
+
return offerCache.value;
|
|
944
|
+
try {
|
|
945
|
+
const base = (process.env.VAULTLINE_CLOUD_URL ?? cloud.DEFAULT_CLOUD_URL).replace(/\/$/, "");
|
|
946
|
+
const response = await fetch(`${base}/v1/public/offer`, { signal: AbortSignal.timeout(4000) });
|
|
947
|
+
const body = response.ok ? await response.json() : null;
|
|
948
|
+
offerCache = { at: Date.now(), value: body?.offer ?? null };
|
|
949
|
+
}
|
|
950
|
+
catch {
|
|
951
|
+
offerCache = { at: Date.now(), value: null };
|
|
952
|
+
}
|
|
953
|
+
return offerCache.value;
|
|
954
|
+
}
|
|
955
|
+
let uploadRun = null;
|
|
956
|
+
let uploadAbort = null;
|
|
957
|
+
const uploadRunPath = join(dataDir, "upload-run.json");
|
|
958
|
+
const saveRun = () => {
|
|
959
|
+
// Fire-and-forget on purpose: run state is a progress report, not a ledger.
|
|
960
|
+
// What MUST survive a restart is the person's decision — cancelled stays
|
|
961
|
+
// cancelled — and the final shape of the last run, so a page opened after
|
|
962
|
+
// a restart shows what happened instead of amnesia.
|
|
963
|
+
if (uploadRun)
|
|
964
|
+
void writeFile(uploadRunPath, JSON.stringify(uploadRun), { mode: 0o600 }).catch(() => undefined);
|
|
965
|
+
};
|
|
966
|
+
// A run interrupted by a restart resumes itself; a run the person stopped
|
|
967
|
+
// stays stopped. This is read once at boot.
|
|
968
|
+
void (async () => {
|
|
969
|
+
try {
|
|
970
|
+
const prior = JSON.parse(await readFile(uploadRunPath, "utf8"));
|
|
971
|
+
if (prior.finishedAt || prior.cancelled) {
|
|
972
|
+
uploadRun = prior;
|
|
973
|
+
return;
|
|
974
|
+
}
|
|
975
|
+
// Active when the process died: resume by recomputing what is still
|
|
976
|
+
// pending — everything already verified skips naturally. The tallies
|
|
977
|
+
// start fresh; pretending continuity across a death would double-count.
|
|
978
|
+
const uploadConfig = await readConfig(dataDir);
|
|
979
|
+
const pendingRecords = (await listArchives(dataDir)).filter((item) => isV2(item) && !item.remote?.verifiedAt);
|
|
980
|
+
const pending = pendingRecords.map((item) => ({ id: item.id, bytes: isV2(item) ? item.cipher.storedBytes : 0 }));
|
|
981
|
+
if (pending.length === 0) {
|
|
982
|
+
uploadRun = { ...prior, finishedAt: new Date().toISOString() };
|
|
983
|
+
saveRun();
|
|
984
|
+
return;
|
|
985
|
+
}
|
|
986
|
+
uploadRun = {
|
|
987
|
+
startedAt: new Date().toISOString(), finishedAt: null, cancelled: false,
|
|
988
|
+
totalCount: pending.length, totalBytes: pending.reduce((sum, item) => sum + item.bytes, 0),
|
|
989
|
+
doneBytes: 0, freedBytes: 0, done: [], failed: [], current: null
|
|
990
|
+
};
|
|
991
|
+
saveRun();
|
|
992
|
+
const { storageEndpoint } = await readSetupRecord(dataDir);
|
|
993
|
+
void runUploads(pending, Boolean(uploadConfig.remoteStorage), uploadConfig.remoteStorage && storageEndpoint ? endpointOverrides(storageEndpoint) : undefined);
|
|
994
|
+
}
|
|
995
|
+
catch { /* no prior run, or unreadable — nothing to resume */ }
|
|
996
|
+
})();
|
|
997
|
+
async function runUploads(pending, viaOwnBucket, overrides) {
|
|
998
|
+
const run = uploadRun;
|
|
999
|
+
uploadAbort = new AbortController();
|
|
1000
|
+
for (const item of pending) {
|
|
1001
|
+
if (run.cancelled)
|
|
1002
|
+
break;
|
|
1003
|
+
run.current = { archiveId: item.id, bytes: item.bytes };
|
|
1004
|
+
saveRun();
|
|
1005
|
+
try {
|
|
1006
|
+
if (viaOwnBucket) {
|
|
1007
|
+
const { uploadArchive } = await import("./upload.js");
|
|
1008
|
+
await uploadArchive(dataDir, item.id, overrides ? { overrides } : {});
|
|
1009
|
+
}
|
|
1010
|
+
else {
|
|
1011
|
+
await cloud.pushArchive(dataDir, item.id, undefined, { signal: uploadAbort.signal });
|
|
1012
|
+
}
|
|
1013
|
+
run.done.push({ archiveId: item.id, bytes: item.bytes });
|
|
1014
|
+
run.doneBytes += item.bytes;
|
|
1015
|
+
// The copy is verified remote as of seconds ago, which makes the local
|
|
1016
|
+
// sealed blob a duplicate — Paul's model: the vault's home is the
|
|
1017
|
+
// cloud; locally we keep originals and metadata, not a second copy of
|
|
1018
|
+
// every gigabyte. Freed as the run goes, so a full disk breathes with
|
|
1019
|
+
// each verify instead of after the whole run.
|
|
1020
|
+
try {
|
|
1021
|
+
const { offloadArchives } = await import("./offload.js");
|
|
1022
|
+
const offload = await offloadArchives(dataDir, { confirm: true, only: new Set([item.id]), freshlyVerified: new Set([item.id]) });
|
|
1023
|
+
if (offload.mode === "apply")
|
|
1024
|
+
run.freedBytes += offload.freedBytes;
|
|
1025
|
+
}
|
|
1026
|
+
catch { /* offload is a bonus on this pass; the sweep gets another chance */ }
|
|
1027
|
+
}
|
|
1028
|
+
catch (error) {
|
|
1029
|
+
run.failed.push({ archiveId: item.id, error: error instanceof Error ? error.message : "upload failed" });
|
|
1030
|
+
// One archive's clear failure is a report; forty-eight identical ones
|
|
1031
|
+
// are noise. When the account or the credentials are the problem,
|
|
1032
|
+
// every remaining push fails the same way — stop and say so once.
|
|
1033
|
+
if (isVaultlineError(error) && (error.code === "unauthorized" || error.code === "storage_not_configured" || error.code === "signer_not_configured"))
|
|
1034
|
+
break;
|
|
1035
|
+
}
|
|
1036
|
+
finally {
|
|
1037
|
+
run.current = null;
|
|
1038
|
+
saveRun();
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
run.finishedAt = new Date().toISOString();
|
|
1042
|
+
uploadAbort = null;
|
|
1043
|
+
saveRun();
|
|
1044
|
+
}
|
|
1045
|
+
return createServer(async (request, response) => {
|
|
1046
|
+
try {
|
|
1047
|
+
if (!LOOPBACK.has(request.socket.remoteAddress ?? ""))
|
|
1048
|
+
return json(response, 403, { error: { code: "forbidden", message: "The local API accepts loopback connections only" } });
|
|
1049
|
+
if (!hostAllowed(request))
|
|
1050
|
+
return json(response, 403, { error: { code: "forbidden", message: "Unexpected Host header for a loopback service" } });
|
|
1051
|
+
const url = new URL(request.url ?? "/", "http://127.0.0.1");
|
|
1052
|
+
const method = request.method ?? "GET";
|
|
1053
|
+
if (method === "GET" && url.pathname === "/health")
|
|
1054
|
+
return json(response, 200, { ok: true, mode: "local-api", version: 1 });
|
|
1055
|
+
// The dashboard shell is static and holds no vault data, so it loads before
|
|
1056
|
+
// authentication; it then presents the token on every /v1 call below.
|
|
1057
|
+
if (method === "GET" && !url.pathname.startsWith("/v1/")) {
|
|
1058
|
+
return serveStatic(url.pathname === "/" ? await landingPage(dataDir) : url.pathname, response);
|
|
1059
|
+
}
|
|
1060
|
+
const via = authorizedVia(request, token);
|
|
1061
|
+
if (!via)
|
|
1062
|
+
return json(response, 401, { error: { code: "unauthorized", message: "A local API bearer token is required" } }, { "www-authenticate": "Bearer" });
|
|
1063
|
+
// A browser that proved itself with the bearer once gets the HttpOnly
|
|
1064
|
+
// cookie, so closing the tab does not mean typing the token again. The
|
|
1065
|
+
// CLI receives it too and simply ignores it.
|
|
1066
|
+
if (via === "bearer" && !(request.headers.cookie ?? "").includes("vaultline_token=")) {
|
|
1067
|
+
response.setHeader("set-cookie", `vaultline_token=${token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=2592000`);
|
|
1068
|
+
}
|
|
1069
|
+
if (url.pathname === "/v1/recover" || url.pathname === "/v1/archives/decrypt") {
|
|
1070
|
+
return json(response, 403, { error: { code: "forbidden", message: "Decryption is a local action. Use `sealkeep recover`; the local API never accepts a recovery phrase." } });
|
|
1071
|
+
}
|
|
1072
|
+
// The two secrets first-run setup handles, refused by name on every method
|
|
1073
|
+
// that is not the one call allowed to produce them. Falling through to the
|
|
1074
|
+
// 404 below would say the same thing far less clearly, and this is the
|
|
1075
|
+
// rule PRODUCT_ARCHITECTURE.md §7 is least willing to see eroded.
|
|
1076
|
+
if (method !== "POST" && (url.pathname === "/v1/setup/vault" || url.pathname === "/v1/setup/storage" || url.pathname === "/v1/setup/remember")) {
|
|
1077
|
+
return json(response, 403, { error: { code: "forbidden", message: "The recovery phrase is returned once, by the call that generates it, and storage credentials are write-only. Neither can be read back." } });
|
|
1078
|
+
}
|
|
1079
|
+
if (method === "GET" && url.pathname === "/v1/status") {
|
|
1080
|
+
// Everything here is read straight from the modules that already own it.
|
|
1081
|
+
// Plan/quota/email are live when the control plane answers and the last
|
|
1082
|
+
// remembered answer when it does not; `planSource` says which one this
|
|
1083
|
+
// is, so the page can label a remembered value instead of hiding it.
|
|
1084
|
+
const [vstatus, queueStats, beat, evaluation, cloud] = await Promise.all([
|
|
1085
|
+
vaultStatus(dataDir), queue.stats(), readHeartbeat(dataDir), evaluateRetention(dataDir), cloudPlanAndQuota(dataDir)
|
|
1086
|
+
]);
|
|
1087
|
+
return json(response, 200, {
|
|
1088
|
+
...vstatus, queue: queueStats,
|
|
1089
|
+
lastSyncAt: beat?.lastTickAt ?? null,
|
|
1090
|
+
plan: cloud.plan,
|
|
1091
|
+
quota: cloud.quota,
|
|
1092
|
+
accountEmail: cloud.email,
|
|
1093
|
+
planSource: cloud.source,
|
|
1094
|
+
// The upload run this server owns, if any — how the page shows
|
|
1095
|
+
// progress, offers cancel, and survives a refresh mid-run.
|
|
1096
|
+
upload: uploadRun,
|
|
1097
|
+
// Where the account is managed. A paying customer gets a door to
|
|
1098
|
+
// their cloud panel; the URL is derived from the same base the
|
|
1099
|
+
// API calls, so the two can never point at different places.
|
|
1100
|
+
panelUrl: cloud.plan ? await panelUrl() : null,
|
|
1101
|
+
nextReclaimWindow: nextReclaimWindow(evaluation, Date.now())
|
|
1102
|
+
});
|
|
1103
|
+
}
|
|
1104
|
+
if (method === "GET" && url.pathname === "/v1/agents")
|
|
1105
|
+
return json(response, 200, await detectAgents());
|
|
1106
|
+
if (method === "GET" && url.pathname === "/v1/sessions") {
|
|
1107
|
+
// Held jobs (disk guard) retry only when the drain is kicked, and the
|
|
1108
|
+
// page polls this endpoint while anything is queued — so the poll IS
|
|
1109
|
+
// the kick. Single-flighted and a stat call when nothing changed;
|
|
1110
|
+
// the moment space frees, the waiting rows start sealing on their own.
|
|
1111
|
+
void ensureDraining(dataDir, queue);
|
|
1112
|
+
return json(response, 200, await listSessions(dataDir, queue));
|
|
1113
|
+
}
|
|
1114
|
+
if (method === "POST" && url.pathname === "/v1/sessions/archive") {
|
|
1115
|
+
const { paths } = parseBody(archiveSelectionSchema, await readJsonBody(request));
|
|
1116
|
+
return json(response, 200, await archiveSelected(dataDir, paths, queue));
|
|
1117
|
+
}
|
|
1118
|
+
// The bulk forms first, so the per-row routes never shadow them.
|
|
1119
|
+
if (method === "POST" && url.pathname === "/v1/archives/check-copies") {
|
|
1120
|
+
const body = (await readJsonBody(request));
|
|
1121
|
+
const ids = Array.isArray(body?.ids) ? body.ids.filter((id) => typeof id === "string").slice(0, 500) : [];
|
|
1122
|
+
if (ids.length === 0)
|
|
1123
|
+
fail("invalid_argument", "Pass ids: the archives whose copies to check.");
|
|
1124
|
+
const { checkRemoteCopyMany } = await import("./offload.js");
|
|
1125
|
+
const results = await checkRemoteCopyMany(dataDir, ids);
|
|
1126
|
+
return json(response, 200, { results: results.map((item, index) => ({ id: ids[index], ...item })) });
|
|
1127
|
+
}
|
|
1128
|
+
if (method === "POST" && url.pathname === "/v1/archives/reclaim-batch") {
|
|
1129
|
+
const body = (await readJsonBody(request));
|
|
1130
|
+
const ids = Array.isArray(body?.ids) ? body.ids.filter((id) => typeof id === "string").slice(0, 500) : [];
|
|
1131
|
+
if (ids.length === 0)
|
|
1132
|
+
fail("invalid_argument", "Pass ids: the sources to move to the trash.");
|
|
1133
|
+
const { reclaimBatch } = await import("./retention.js");
|
|
1134
|
+
return json(response, 200, await reclaimBatch(dataDir, ids, { confirm: body?.confirm === true }));
|
|
1135
|
+
}
|
|
1136
|
+
// Ask the storage, right now, whether the copy is really there — the
|
|
1137
|
+
// question a person asks before trusting it enough to trash the source.
|
|
1138
|
+
if (method === "POST" && /^\/v1\/archives\/[^/]+\/check-copy$/.test(url.pathname)) {
|
|
1139
|
+
const archiveId = decodeURIComponent(url.pathname.split("/")[3]);
|
|
1140
|
+
const { checkRemoteCopy } = await import("./offload.js");
|
|
1141
|
+
return json(response, 200, await checkRemoteCopy(dataDir, archiveId));
|
|
1142
|
+
}
|
|
1143
|
+
// Reclaim ONE source because a person pointed at it. Hard gates only —
|
|
1144
|
+
// verified copy (whole chain), unchanged source, file not in use; the
|
|
1145
|
+
// click is the policy. See reclaimOne for the reasoning.
|
|
1146
|
+
if (method === "POST" && /^\/v1\/archives\/[^/]+\/reclaim$/.test(url.pathname)) {
|
|
1147
|
+
const archiveId = decodeURIComponent(url.pathname.split("/")[3]);
|
|
1148
|
+
const body = (await readJsonBody(request));
|
|
1149
|
+
const { reclaimOne } = await import("./retention.js");
|
|
1150
|
+
const outcome = await reclaimOne(dataDir, archiveId, { confirm: body?.confirm === true });
|
|
1151
|
+
return json(response, 200, { reclaimed: outcome });
|
|
1152
|
+
}
|
|
1153
|
+
if (method === "DELETE" && url.pathname.startsWith("/v1/archives/")) {
|
|
1154
|
+
// Deleting an archive removes every copy Sealkeep holds of it — the
|
|
1155
|
+
// local blob, the record, and the stored copy if one exists. Two
|
|
1156
|
+
// truths decide how hard that is to do. If the original session file
|
|
1157
|
+
// is still on disk, the person loses only the archive and can make
|
|
1158
|
+
// another with one click. If the original is gone, this archive IS
|
|
1159
|
+
// the session, and the server refuses until that is said back to it.
|
|
1160
|
+
const archiveId = decodeURIComponent(url.pathname.slice("/v1/archives/".length));
|
|
1161
|
+
const hasBody = typeof request.headers["content-length"] === "string" && request.headers["content-length"] !== "0";
|
|
1162
|
+
const { confirm, confirmForever } = parseBody(deleteArchiveSchema, hasBody ? await readJsonBody(request) : {});
|
|
1163
|
+
if (!confirm)
|
|
1164
|
+
fail("invalid_argument", "Deleting an archive removes Sealkeep's copies of this session. Resend with confirm: true.");
|
|
1165
|
+
const config = await readConfig(dataDir);
|
|
1166
|
+
const record = (await listArchives(dataDir)).find((item) => item.id === archiveId);
|
|
1167
|
+
if (!record)
|
|
1168
|
+
fail("archive_not_found", `Archive not found: ${archiveId}`, { archiveId });
|
|
1169
|
+
const sourceStillThere = await stat(record.source.path).then((entry) => entry.isFile()).catch(() => false);
|
|
1170
|
+
if (!sourceStillThere && !confirmForever) {
|
|
1171
|
+
fail("invalid_argument", "The original session file is gone, so this archive is the only form this session exists in anywhere Sealkeep can see. Deleting it is forever. Nothing has been deleted — resend with confirmForever: true to do it with that knowledge.", { forever: true });
|
|
1172
|
+
}
|
|
1173
|
+
// The stored copy first: failing to delete remotely must not leave a
|
|
1174
|
+
// record-less object nobody can find again. Reuses the same proven
|
|
1175
|
+
// paths as the stored-copies page.
|
|
1176
|
+
const fullRemote = isV2(record) ? record.remote : undefined;
|
|
1177
|
+
const offloadedTo = isV2(record) ? record.offloaded : undefined;
|
|
1178
|
+
const remoteProvider = fullRemote?.provider ?? offloadedTo?.provider;
|
|
1179
|
+
if (remoteProvider === "vaultline") {
|
|
1180
|
+
if (fullRemote?.layout?.kind === "chunks") {
|
|
1181
|
+
// A managed chunk family deletes as a family: every chunk
|
|
1182
|
+
// reference plus the sidecar, through the same account verbs
|
|
1183
|
+
// that wrote them.
|
|
1184
|
+
const { deleteChunkFolder } = await import("./chunk-store.js");
|
|
1185
|
+
const { managedChunkClient } = await import("./managed-chunks.js");
|
|
1186
|
+
await deleteChunkFolder(managedChunkClient(dataDir), fullRemote);
|
|
1187
|
+
}
|
|
1188
|
+
else {
|
|
1189
|
+
const { deleteCloudArchive } = await import("./cloud.js");
|
|
1190
|
+
await deleteCloudArchive(dataDir, archiveId);
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
else if (remoteProvider && fullRemote && config.remoteStorage && config.remoteStorage.provider !== "vaultline") {
|
|
1194
|
+
await deleteOwnObject(dataDir, { provider: config.remoteStorage.provider, bucket: config.remoteStorage.bucket, region: config.remoteStorage.region }, fullRemote);
|
|
1195
|
+
}
|
|
1196
|
+
const remote = remoteProvider !== undefined;
|
|
1197
|
+
const { rm: removeFile } = await import("node:fs/promises");
|
|
1198
|
+
await removeFile(record.objectPath, { force: true }).catch(() => undefined);
|
|
1199
|
+
await removeFile(join(config.storage.root, `${archiveId}.json`), { force: true });
|
|
1200
|
+
const { recordAudit } = await import("./audit.js");
|
|
1201
|
+
await recordAudit(dataDir, "archive.delete", "allowed", { archiveId, hadRemote: remote, forever: !sourceStillThere }).catch(() => undefined);
|
|
1202
|
+
return json(response, 200, { deleted: true, archiveId, removedRemote: remote, forever: !sourceStillThere });
|
|
1203
|
+
}
|
|
1204
|
+
if (method === "GET" && url.pathname === "/v1/archives") {
|
|
1205
|
+
// Project identity, same derivation the Memory pool uses, so the two
|
|
1206
|
+
// tables speak the same metadata. Claude paths carry theirs; a Codex
|
|
1207
|
+
// rollout's cwd is read from the source file when it still exists —
|
|
1208
|
+
// a reclaimed source honestly reads as no project rather than a guess.
|
|
1209
|
+
const { claudeProjectFromPath, codexProjectFromRollout } = await import("./adapters.js");
|
|
1210
|
+
const identify = async (record) => {
|
|
1211
|
+
try {
|
|
1212
|
+
if (record.source.agent === "claude")
|
|
1213
|
+
return claudeProjectFromPath(record.source.path);
|
|
1214
|
+
if (record.source.agent === "codex")
|
|
1215
|
+
return await codexProjectFromRollout(record.source.path, record.createdAt);
|
|
1216
|
+
}
|
|
1217
|
+
catch { /* an unreadable source is a null project, not an error */ }
|
|
1218
|
+
return { project: null, projectPath: null };
|
|
1219
|
+
};
|
|
1220
|
+
const { deltaOf } = await import("./vault.js");
|
|
1221
|
+
const archives = await Promise.all((await listArchives(dataDir)).map(async (record) => ({
|
|
1222
|
+
id: record.id, createdAt: record.createdAt, agent: record.source.agent, path: record.source.path, bytes: record.source.bytes,
|
|
1223
|
+
ciphertextSha256: record.cipher.ciphertextSha256, format: record.version,
|
|
1224
|
+
...(await identify(record)),
|
|
1225
|
+
// Chain shape, so the vault can fold a session's snapshots into one
|
|
1226
|
+
// row: a delta names its base, and `supersedes` names the archive a
|
|
1227
|
+
// newer full snapshot re-covered.
|
|
1228
|
+
delta: deltaOf(record),
|
|
1229
|
+
supersedes: record.version === 2 && record.supersedes ? record.supersedes.archiveId : null,
|
|
1230
|
+
remote: record.version === 2 && record.remote ? { objectKey: record.remote.objectKey, verifiedAt: record.remote.verifiedAt } : null,
|
|
1231
|
+
// Whether the LOCAL blob was given back to the disk — with `remote`,
|
|
1232
|
+
// the page derives the three honest locations: here, cloud, or both.
|
|
1233
|
+
offloaded: record.version === 2 && record.offloaded ? true : false,
|
|
1234
|
+
reclaimed: record.version === 2 && record.reclaimed ? record.reclaimed.at : null
|
|
1235
|
+
})));
|
|
1236
|
+
return json(response, 200, archives);
|
|
1237
|
+
}
|
|
1238
|
+
if (method === "GET" && url.pathname === "/v1/queue")
|
|
1239
|
+
return json(response, 200, await queue.list());
|
|
1240
|
+
if (method === "GET" && url.pathname === "/v1/doctor")
|
|
1241
|
+
return json(response, 200, await runDoctor(dataDir));
|
|
1242
|
+
if (method === "GET" && url.pathname === "/v1/daemon") {
|
|
1243
|
+
const beat = await readHeartbeat(dataDir);
|
|
1244
|
+
return json(response, 200, { ...liveness(beat), watching: beat?.watching ?? [], last: beat?.last ?? null, startedAt: beat?.startedAt ?? null });
|
|
1245
|
+
}
|
|
1246
|
+
if (method === "GET" && url.pathname === "/v1/recipients")
|
|
1247
|
+
return json(response, 200, (await readConfig(dataDir)).recipients ?? []);
|
|
1248
|
+
if (method === "GET" && url.pathname === "/v1/retention")
|
|
1249
|
+
return json(response, 200, await evaluateRetention(dataDir));
|
|
1250
|
+
if (method === "POST" && url.pathname === "/v1/retention/apply") {
|
|
1251
|
+
// `confirm` must be explicit: the API will not reclaim anything by default.
|
|
1252
|
+
const { confirm } = parseBody(applySchema, await readJsonBody(request));
|
|
1253
|
+
return json(response, 200, await applyRetention(dataDir, { confirm }));
|
|
1254
|
+
}
|
|
1255
|
+
if (method === "POST" && url.pathname === "/v1/upload") {
|
|
1256
|
+
// Uploading needs no recovery phrase: the ciphertext already exists on
|
|
1257
|
+
// disk. The call starts a background run and returns at once — the page
|
|
1258
|
+
// watches it through /v1/status, can stop it, and can be refreshed
|
|
1259
|
+
// without losing it. Pressing the button during a run just re-attaches.
|
|
1260
|
+
if (uploadRun && !uploadRun.finishedAt)
|
|
1261
|
+
return json(response, 200, { started: false, alreadyRunning: true, upload: uploadRun });
|
|
1262
|
+
// Two transports, chosen by how this vault stores remotely: a vault
|
|
1263
|
+
// with its own bucket signs uploads itself; a managed vault pushes
|
|
1264
|
+
// through the account. This endpoint used to know only the first, so
|
|
1265
|
+
// on a managed vault every pending archive failed at once.
|
|
1266
|
+
const uploadConfig = await readConfig(dataDir);
|
|
1267
|
+
const pendingRecords = (await listArchives(dataDir)).filter((item) => isV2(item) && !item.remote?.verifiedAt);
|
|
1268
|
+
const pending = pendingRecords.map((item) => ({ id: item.id, bytes: isV2(item) ? item.cipher.storedBytes : 0 }));
|
|
1269
|
+
uploadRun = {
|
|
1270
|
+
startedAt: new Date().toISOString(), finishedAt: null, cancelled: false,
|
|
1271
|
+
totalCount: pending.length, totalBytes: pending.reduce((sum, item) => sum + item.bytes, 0),
|
|
1272
|
+
doneBytes: 0, freedBytes: 0, done: [], failed: [], current: null
|
|
1273
|
+
};
|
|
1274
|
+
if (pending.length === 0) {
|
|
1275
|
+
uploadRun.finishedAt = uploadRun.startedAt;
|
|
1276
|
+
saveRun();
|
|
1277
|
+
return json(response, 200, { started: true, upload: uploadRun });
|
|
1278
|
+
}
|
|
1279
|
+
saveRun();
|
|
1280
|
+
const { storageEndpoint } = await readSetupRecord(dataDir);
|
|
1281
|
+
void runUploads(pending, Boolean(uploadConfig.remoteStorage), uploadConfig.remoteStorage && storageEndpoint ? endpointOverrides(storageEndpoint) : undefined);
|
|
1282
|
+
return json(response, 200, { started: true, upload: uploadRun });
|
|
1283
|
+
}
|
|
1284
|
+
if (method === "POST" && url.pathname === "/v1/upload/cancel") {
|
|
1285
|
+
if (!uploadRun || uploadRun.finishedAt)
|
|
1286
|
+
return json(response, 200, { idle: true });
|
|
1287
|
+
const hasCancelBody = typeof request.headers["content-length"] === "string" && request.headers["content-length"] !== "0";
|
|
1288
|
+
const cancelBody = hasCancelBody ? await readJsonBody(request) : {};
|
|
1289
|
+
uploadRun.cancelled = true;
|
|
1290
|
+
saveRun();
|
|
1291
|
+
if (cancelBody.hard) {
|
|
1292
|
+
// The transfer in flight is torn down mid-byte. A half-sent object
|
|
1293
|
+
// is unverified by construction — durable is only ever written after
|
|
1294
|
+
// proof — so nothing half-arrived can ever be trusted or reclaimed
|
|
1295
|
+
// against. The archive simply stays pending.
|
|
1296
|
+
uploadAbort?.abort();
|
|
1297
|
+
return json(response, 200, { cancelling: true, hard: true, note: "The transfer in flight was stopped mid-byte. Nothing half-sent counts as stored; that archive stays pending." });
|
|
1298
|
+
}
|
|
1299
|
+
return json(response, 200, { cancelling: true, note: "The archive being sent right now finishes and verifies; nothing after it starts." });
|
|
1300
|
+
}
|
|
1301
|
+
if (method === "POST" && url.pathname === "/v1/upload/rollback") {
|
|
1302
|
+
// The other half of stopping: a person who changed their mind about
|
|
1303
|
+
// this run can take back what it already uploaded. Each copy goes
|
|
1304
|
+
// through the same delete used everywhere else — which refuses when
|
|
1305
|
+
// the stored copy is the last one anywhere (an offloaded local means
|
|
1306
|
+
// exactly that), per item, honestly.
|
|
1307
|
+
if (!uploadRun)
|
|
1308
|
+
return json(response, 200, { idle: true, deleted: [], refused: [] });
|
|
1309
|
+
// A run that is stopped-but-not-closed counts as decided: the person
|
|
1310
|
+
// cancelled it; making them wait for a finishedAt stamp to take back
|
|
1311
|
+
// uploads would be process trivia standing in front of their call.
|
|
1312
|
+
if (!uploadRun.finishedAt && !uploadRun.cancelled)
|
|
1313
|
+
fail("invalid_argument", "The run is still going. Stop it first, then decide about what it uploaded.");
|
|
1314
|
+
const rollbackDeleted = [];
|
|
1315
|
+
const rollbackRefused = [];
|
|
1316
|
+
for (const sent of uploadRun.done) {
|
|
1317
|
+
try {
|
|
1318
|
+
const record = (await listArchives(dataDir)).find((item) => item.id === sent.archiveId);
|
|
1319
|
+
const localCopy = record ? await stat(record.objectPath).then((entry) => entry.isFile()).catch(() => false) : false;
|
|
1320
|
+
if (!localCopy) {
|
|
1321
|
+
rollbackRefused.push({ archiveId: sent.archiveId, reason: "the stored copy is the only copy anywhere now — deleting it would lose the archive" });
|
|
1322
|
+
continue;
|
|
1323
|
+
}
|
|
1324
|
+
const { deleteCloudArchive } = await import("./cloud.js");
|
|
1325
|
+
await deleteCloudArchive(dataDir, sent.archiveId);
|
|
1326
|
+
if (record && record.version === 2 && record.remote) {
|
|
1327
|
+
const { remote: _gone, ...kept } = record;
|
|
1328
|
+
await writeRecord((await readConfig(dataDir)).storage.root, kept);
|
|
1329
|
+
}
|
|
1330
|
+
rollbackDeleted.push(sent.archiveId);
|
|
1331
|
+
}
|
|
1332
|
+
catch (error) {
|
|
1333
|
+
rollbackRefused.push({ archiveId: sent.archiveId, reason: error instanceof Error ? error.message : "delete failed" });
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
const { recordAudit } = await import("./audit.js");
|
|
1337
|
+
await recordAudit(dataDir, "archive.prune", "allowed", { rollback: true, deleted: rollbackDeleted.length, refused: rollbackRefused.length }).catch(() => undefined);
|
|
1338
|
+
return json(response, 200, { deleted: rollbackDeleted, refused: rollbackRefused });
|
|
1339
|
+
}
|
|
1340
|
+
if (method === "GET" && url.pathname === "/v1/offer") {
|
|
1341
|
+
const { plan } = await cloudPlanAndQuota(dataDir);
|
|
1342
|
+
return json(response, 200, { offer: await currentOffer(plan) });
|
|
1343
|
+
}
|
|
1344
|
+
if (method === "DELETE" && url.pathname.startsWith("/v1/queue/")) {
|
|
1345
|
+
// Taking a waiting job back out is an ordinary change of mind, not a
|
|
1346
|
+
// destructive act: the session file is untouched, nothing has been
|
|
1347
|
+
// written, and queueing it again is one click. The queue itself
|
|
1348
|
+
// refuses the two cases where removal would mean something else.
|
|
1349
|
+
const jobIdToRemove = decodeURIComponent(url.pathname.slice("/v1/queue/".length));
|
|
1350
|
+
return json(response, 200, { removed: await queue.remove(jobIdToRemove) });
|
|
1351
|
+
}
|
|
1352
|
+
if (method === "POST" && url.pathname === "/v1/queue") {
|
|
1353
|
+
const input = parseBody(enqueueSchema, await readJsonBody(request));
|
|
1354
|
+
const { job, deduped } = await queue.enqueue(input);
|
|
1355
|
+
return json(response, deduped ? 200 : 201, { job, deduped });
|
|
1356
|
+
}
|
|
1357
|
+
if (method === "PUT" && url.pathname === "/v1/retention/policy") {
|
|
1358
|
+
// Returns the fresh evaluation, not just the saved settings: changing a
|
|
1359
|
+
// rule is only meaningful through what it now protects or releases, and
|
|
1360
|
+
// making the page ask twice would show it a moment out of date.
|
|
1361
|
+
const settings = parseBody(retentionPolicySchema, await readJsonBody(request));
|
|
1362
|
+
await setRetentionPolicy(dataDir, settings);
|
|
1363
|
+
return json(response, 200, await evaluateRetention(dataDir));
|
|
1364
|
+
}
|
|
1365
|
+
if (method === "POST" && url.pathname === "/v1/retention/preview") {
|
|
1366
|
+
const { olderThanDays } = parseBody(retentionSchema, await readJsonBody(request));
|
|
1367
|
+
return json(response, 200, await previewRetention(dataDir, olderThanDays));
|
|
1368
|
+
}
|
|
1369
|
+
if (method === "GET" && url.pathname === "/v1/settings") {
|
|
1370
|
+
const settings = await readLocalSettings(dataDir);
|
|
1371
|
+
return json(response, 200, {
|
|
1372
|
+
local: settings,
|
|
1373
|
+
resolvedTrashStrategy: settings.trashStrategy === "auto" ? defaultStrategy() : settings.trashStrategy,
|
|
1374
|
+
remote: await remoteSettingsMirror(dataDir)
|
|
1375
|
+
});
|
|
1376
|
+
}
|
|
1377
|
+
if (method === "PUT" && url.pathname === "/v1/settings") {
|
|
1378
|
+
const { confirm, ...patch } = parseBody(localSettingsSchema, await readJsonBody(request));
|
|
1379
|
+
const current = await readLocalSettings(dataDir);
|
|
1380
|
+
// Turning reclaim on lets the daemon start moving files to the trash on
|
|
1381
|
+
// its own the next time it runs, so it earns the same explicit confirm
|
|
1382
|
+
// the reclaim endpoint itself already requires before it acts.
|
|
1383
|
+
if (patch.reclaimEnabled === true && !current.reclaimEnabled && !confirm) {
|
|
1384
|
+
fail("invalid_argument", "Turning on reclaim changes what this machine does automatically; resend with confirm: true");
|
|
1385
|
+
}
|
|
1386
|
+
const next = {
|
|
1387
|
+
...current, ...patch,
|
|
1388
|
+
schedule: { ...current.schedule, ...patch.schedule },
|
|
1389
|
+
bandwidth: { ...current.bandwidth, ...patch.bandwidth }
|
|
1390
|
+
};
|
|
1391
|
+
await writeLocalSettings(dataDir, next);
|
|
1392
|
+
return json(response, 200, {
|
|
1393
|
+
local: next,
|
|
1394
|
+
resolvedTrashStrategy: next.trashStrategy === "auto" ? defaultStrategy() : next.trashStrategy,
|
|
1395
|
+
remote: await remoteSettingsMirror(dataDir)
|
|
1396
|
+
});
|
|
1397
|
+
}
|
|
1398
|
+
// ---- Stored copies: see and clean what is held off this machine --------
|
|
1399
|
+
if (method === "GET" && url.pathname === "/v1/cloud/copies") {
|
|
1400
|
+
const mode = await storedCopiesMode(dataDir);
|
|
1401
|
+
const presence = await localArchivePresence(dataDir);
|
|
1402
|
+
let copies = [];
|
|
1403
|
+
if (mode === "managed") {
|
|
1404
|
+
const { listCloudArchives } = await import("./cloud.js");
|
|
1405
|
+
// Chunk families fold into one row per archive: the page reasons
|
|
1406
|
+
// about archives, and the family's bytes are its chunk sum.
|
|
1407
|
+
const { collapseCloudRows } = await import("./managed-chunks.js");
|
|
1408
|
+
copies = collapseCloudRows(await listCloudArchives(dataDir)).map((row) => ({
|
|
1409
|
+
ref: row.vault_ref,
|
|
1410
|
+
bytes: Number(row.bytes) || 0,
|
|
1411
|
+
addedAt: row.durable_at ?? row.created_at ?? null,
|
|
1412
|
+
state: row.state,
|
|
1413
|
+
// A ref this machine has no bytes for — archived elsewhere, or
|
|
1414
|
+
// already offloaded — is a last copy from where this page stands.
|
|
1415
|
+
lastCopy: presence.get(row.vault_ref) !== true
|
|
1416
|
+
}));
|
|
1417
|
+
}
|
|
1418
|
+
else if (mode === "own") {
|
|
1419
|
+
// remote.verifiedAt is the stored set: it is only ever written after
|
|
1420
|
+
// storage proved it holds the bytes, on either upload path.
|
|
1421
|
+
copies = (await listArchives(dataDir).catch(() => [])).flatMap((record) => record.version === 2 && record.remote?.verifiedAt
|
|
1422
|
+
? [{ ref: record.id, bytes: record.remote.bytes, addedAt: record.remote.verifiedAt, state: "durable", lastCopy: presence.get(record.id) !== true }]
|
|
1423
|
+
: []);
|
|
1424
|
+
}
|
|
1425
|
+
return json(response, 200, { copies, mode, rollingCapGb: await readRollingCap(dataDir) });
|
|
1426
|
+
}
|
|
1427
|
+
const copyMatch = /^\/v1\/cloud\/copies\/([^/]+)$/.exec(url.pathname);
|
|
1428
|
+
if (method === "DELETE" && copyMatch) {
|
|
1429
|
+
// A body is optional on DELETE — the page sends one, a curl need not.
|
|
1430
|
+
const hasBody = typeof request.headers["content-length"] === "string" && request.headers["content-length"] !== "0";
|
|
1431
|
+
const { confirmLastCopy } = parseBody(deleteCopySchema, hasBody ? await readJsonBody(request) : {});
|
|
1432
|
+
const ref = decodeURIComponent(copyMatch[1]);
|
|
1433
|
+
const mode = await storedCopiesMode(dataDir);
|
|
1434
|
+
if (mode === "none")
|
|
1435
|
+
fail("archive_not_found", "No storage is connected to this vault, so there is no stored copy to delete. Nothing has changed.");
|
|
1436
|
+
const config = await readConfig(dataDir);
|
|
1437
|
+
const record = (await listArchives(dataDir)).find((item) => item.id === ref);
|
|
1438
|
+
const localCopy = record ? await stat(record.objectPath).then((entry) => entry.isFile()).catch(() => false) : false;
|
|
1439
|
+
if (!localCopy && !confirmLastCopy) {
|
|
1440
|
+
// `lastCopy: true` rides in the payload so the page can recognise
|
|
1441
|
+
// this refusal structurally instead of matching prose that may change.
|
|
1442
|
+
fail("invalid_argument", "This stored copy is the last one anywhere: this machine no longer holds the archive's bytes, so deleting it would lose the archive for good. Nothing has been deleted. Restore the archive first if you want a copy back, or resend with confirmLastCopy: true to delete it with that knowledge.", { lastCopy: true });
|
|
1443
|
+
}
|
|
1444
|
+
if (mode === "managed") {
|
|
1445
|
+
const { deleteCloudArchive, listCloudArchives } = await import("./cloud.js");
|
|
1446
|
+
if (record && record.version === 2 && record.remote?.layout?.kind === "chunks") {
|
|
1447
|
+
const { deleteChunkFolder } = await import("./chunk-store.js");
|
|
1448
|
+
const { managedChunkClient } = await import("./managed-chunks.js");
|
|
1449
|
+
await deleteChunkFolder(managedChunkClient(dataDir), record.remote);
|
|
1450
|
+
}
|
|
1451
|
+
else {
|
|
1452
|
+
// No local record to derive the family from (the collapsed-row
|
|
1453
|
+
// case): the account's own listing names the members, and a ref
|
|
1454
|
+
// with no members deletes as the single object it is.
|
|
1455
|
+
const rows = await listCloudArchives(dataDir);
|
|
1456
|
+
const members = rows.map((row) => row.vault_ref).filter((member) => member === ref || member.startsWith(`${ref}.`));
|
|
1457
|
+
for (const member of members.length > 0 ? members : [ref])
|
|
1458
|
+
await deleteCloudArchive(dataDir, member);
|
|
1459
|
+
}
|
|
1460
|
+
}
|
|
1461
|
+
else {
|
|
1462
|
+
const storage = config.remoteStorage;
|
|
1463
|
+
if (!storage)
|
|
1464
|
+
fail("storage_not_configured", "This vault has no bucket of its own to delete from.");
|
|
1465
|
+
const provider = storage.provider;
|
|
1466
|
+
if (provider === "vaultline")
|
|
1467
|
+
fail("storage_not_configured", "This vault has no bucket of its own to delete from.");
|
|
1468
|
+
if (!record || record.version !== 2 || !record.remote) {
|
|
1469
|
+
fail("archive_not_found", `No stored copy is recorded for ${ref}, so there is nothing to delete. Nothing has changed.`);
|
|
1470
|
+
}
|
|
1471
|
+
await deleteOwnObject(dataDir, { provider, bucket: storage.bucket, region: storage.region }, record.remote);
|
|
1472
|
+
}
|
|
1473
|
+
// Clear the remote marker on both paths, not just bring-your-own:
|
|
1474
|
+
// retention reads remote.verifiedAt as permission to reclaim the local
|
|
1475
|
+
// source, and a verified remote that was just deleted is permission to
|
|
1476
|
+
// lose data. The record stays; only its claim to an off-machine copy goes.
|
|
1477
|
+
if (record && record.version === 2 && record.remote) {
|
|
1478
|
+
const { remote: _deleted, ...kept } = record;
|
|
1479
|
+
await writeRecord(config.storage.root, kept);
|
|
1480
|
+
}
|
|
1481
|
+
const { recordAudit } = await import("./audit.js");
|
|
1482
|
+
await recordAudit(dataDir, "archive.prune", "allowed", { archiveId: ref, remote: true, mode, lastCopy: !localCopy }).catch(() => undefined);
|
|
1483
|
+
return json(response, 200, { deleted: true, ref, mode, lastCopy: !localCopy });
|
|
1484
|
+
}
|
|
1485
|
+
if (method === "PUT" && url.pathname === "/v1/cloud/rolling") {
|
|
1486
|
+
// A thin proxy for a rule the account owns. The control plane has a
|
|
1487
|
+
// dedicated PUT /v1/cloud/rolling (backed by account_pref and enforced
|
|
1488
|
+
// hourly by trim-to-rolling-cap, oldest first, audited) — the cap goes
|
|
1489
|
+
// straight there, and is only cached here once the account accepted it.
|
|
1490
|
+
// Caching an unsent cap would be a settings page saying "Saved" about a
|
|
1491
|
+
// rule nothing will ever enforce.
|
|
1492
|
+
const { capGb } = parseBody(rollingCapSchema, await readJsonBody(request));
|
|
1493
|
+
const { cloudToken, DEFAULT_CLOUD_URL } = await import("./cloud.js");
|
|
1494
|
+
const token = await cloudToken(dataDir);
|
|
1495
|
+
const base = (process.env.VAULTLINE_CLOUD_URL ?? DEFAULT_CLOUD_URL).replace(/\/+$/, "");
|
|
1496
|
+
const pushed = await fetch(`${base}/v1/cloud/rolling`, {
|
|
1497
|
+
method: "PUT",
|
|
1498
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
|
|
1499
|
+
body: JSON.stringify({ capGb })
|
|
1500
|
+
}).catch(() => null);
|
|
1501
|
+
if (!pushed || !pushed.ok) {
|
|
1502
|
+
fail("internal", `Your account could not take the setting${pushed ? ` (${pushed.status})` : ""}, so the cap was not saved. Your stored copies are untouched; try again in a moment.`);
|
|
1503
|
+
}
|
|
1504
|
+
await writeRollingCap(dataDir, capGb);
|
|
1505
|
+
return json(response, 200, { capGb, savedToAccount: true });
|
|
1506
|
+
}
|
|
1507
|
+
// ---- First-run setup (PRODUCT_ARCHITECTURE.md §7) ----------------------
|
|
1508
|
+
// Nothing below reads a secret back. The phrase leaves this process once,
|
|
1509
|
+
// in the response to the call that generated it; credentials leave the
|
|
1510
|
+
// process only into the OS keystore and are never read here at all.
|
|
1511
|
+
if (method === "GET" && url.pathname === "/v1/setup/state")
|
|
1512
|
+
return json(response, 200, await setupState(dataDir, setupEnvironment));
|
|
1513
|
+
// Deliberately a different list from GET /v1/agents: that one answers
|
|
1514
|
+
// "what can Sealkeep archive" and so names only the agents with a
|
|
1515
|
+
// session adapter, while this one answers "what is on this machine and
|
|
1516
|
+
// could be hooked", which is a wider and more honest question for a page
|
|
1517
|
+
// asking somebody to choose.
|
|
1518
|
+
if (method === "GET" && url.pathname === "/v1/setup/agents")
|
|
1519
|
+
return json(response, 200, await detectSetupAgents(setupEnvironment.home));
|
|
1520
|
+
if (method === "POST" && url.pathname === "/v1/setup/remember") {
|
|
1521
|
+
// The one other place a phrase may cross the loopback boundary, and for
|
|
1522
|
+
// the same reason the wizard's vault call may: to put it in this
|
|
1523
|
+
// machine's keystore so the machine can seal unattended. It is checked
|
|
1524
|
+
// against the vault first — storing a wrong phrase would make every
|
|
1525
|
+
// later archive fail in a way that blamed the phrase — and it is never
|
|
1526
|
+
// echoed back, logged, or kept in this process past the call.
|
|
1527
|
+
const { phrase } = parseBody(rememberSchema, await readJsonBody(request));
|
|
1528
|
+
const config = await readConfig(dataDir);
|
|
1529
|
+
const { canonicalPhrase } = await import("./mnemonic.js");
|
|
1530
|
+
const { phraseCheck, equalHex } = await import("./crypto.js");
|
|
1531
|
+
if (!equalHex(config.recovery.phraseCheck, phraseCheck(canonicalPhrase(phrase)))) {
|
|
1532
|
+
fail("recovery_phrase_mismatch", "That is not this vault's recovery phrase. Nothing was stored.");
|
|
1533
|
+
}
|
|
1534
|
+
const remembered = await rememberRecoveryPhrase(dataDir, config.vaultId, canonicalPhrase(phrase));
|
|
1535
|
+
await writeSetupRecord(dataDir, { ...(await readSetupRecord(dataDir)), phraseRemembered: remembered !== null });
|
|
1536
|
+
return json(response, 200, { remembered: remembered !== null, backend: remembered?.backend ?? null });
|
|
1537
|
+
}
|
|
1538
|
+
if (method === "GET" && url.pathname === "/v1/setup/unlock-check") {
|
|
1539
|
+
// What the page needs before it can offer a password form — does a lock
|
|
1540
|
+
// exist, and is unlocking even necessary — stated without touching
|
|
1541
|
+
// either secret. `phraseRemembered` asks the keystore, not setup.json,
|
|
1542
|
+
// because the page is about to act on the answer.
|
|
1543
|
+
const config = await readConfig(dataDir).catch(() => null);
|
|
1544
|
+
const phraseRemembered = config !== null && (await recallRecoveryPhrase(dataDir, config.vaultId).catch(() => null)) !== null;
|
|
1545
|
+
return json(response, 200, { passwordLock: await hasPasswordLock(dataDir), phraseRemembered });
|
|
1546
|
+
}
|
|
1547
|
+
if (method === "POST" && url.pathname === "/v1/setup/unlock") {
|
|
1548
|
+
// The password is traded for the phrase entirely inside this process:
|
|
1549
|
+
// on success the phrase goes into the machine keystore so background
|
|
1550
|
+
// sealing works again, and the response says only that it did. The
|
|
1551
|
+
// phrase itself never crosses HTTP here — the one call that may return
|
|
1552
|
+
// it remains the one that generated it.
|
|
1553
|
+
const { password } = parseBody(setupUnlockSchema, await readJsonBody(request));
|
|
1554
|
+
if (!(await hasPasswordLock(dataDir))) {
|
|
1555
|
+
fail("invalid_argument", "This vault has no password on this machine, so a password cannot unlock it. Nothing has changed. Unlock with your recovery phrase instead.");
|
|
1556
|
+
}
|
|
1557
|
+
const phrase = await unwrapPhrase(dataDir, password);
|
|
1558
|
+
if (phrase === null)
|
|
1559
|
+
fail("forbidden", "That password does not open this vault. Nothing has changed.");
|
|
1560
|
+
const config = await readConfig(dataDir);
|
|
1561
|
+
let remembered = false;
|
|
1562
|
+
try {
|
|
1563
|
+
remembered = (await rememberRecoveryPhrase(dataDir, config.vaultId, phrase)) !== null;
|
|
1564
|
+
}
|
|
1565
|
+
catch {
|
|
1566
|
+
remembered = false;
|
|
1567
|
+
}
|
|
1568
|
+
if (remembered) {
|
|
1569
|
+
await writeSetupRecord(dataDir, { ...(await readSetupRecord(dataDir)), phraseRemembered: true });
|
|
1570
|
+
// The keystore holds the phrase again, so work stranded while the
|
|
1571
|
+
// vault was locked can finish without waiting for the next archive.
|
|
1572
|
+
void ensureDraining(dataDir, queue);
|
|
1573
|
+
}
|
|
1574
|
+
return json(response, 200, { unlocked: true, remembered });
|
|
1575
|
+
}
|
|
1576
|
+
if (method === "POST" && url.pathname === "/v1/setup/vault") {
|
|
1577
|
+
const { confirm, remember, password, storageMode } = parseBody(setupVaultSchema, await readJsonBody(request));
|
|
1578
|
+
if (!confirm) {
|
|
1579
|
+
fail("invalid_argument", "Creating the vault generates a recovery phrase that is shown exactly once and can never be fetched again. Show that warning, then resend with confirm: true.");
|
|
1580
|
+
}
|
|
1581
|
+
// `initialize` refuses an existing vault, so this cannot become the
|
|
1582
|
+
// endpoint that quietly replaces a working one with a new phrase.
|
|
1583
|
+
const { config, phrase } = await initialize(dataDir, undefined, { storageMode });
|
|
1584
|
+
const notes = [];
|
|
1585
|
+
let remembered = null;
|
|
1586
|
+
if (remember) {
|
|
1587
|
+
try {
|
|
1588
|
+
remembered = await rememberRecoveryPhrase(dataDir, config.vaultId, phrase);
|
|
1589
|
+
}
|
|
1590
|
+
catch (error) {
|
|
1591
|
+
notes.push(`This machine's keystore refused the phrase (${error instanceof Error ? error.message.split("\n")[0] : "unknown error"}). The background service will need VAULTLINE_RECOVERY_PHRASE in its environment.`);
|
|
1592
|
+
}
|
|
1593
|
+
}
|
|
1594
|
+
else {
|
|
1595
|
+
notes.push("The phrase was not stored on this machine, so the background service cannot seal sessions until VAULTLINE_RECOVERY_PHRASE is in its environment.");
|
|
1596
|
+
}
|
|
1597
|
+
if (password) {
|
|
1598
|
+
// Failure may not cost the person this response: the vault already
|
|
1599
|
+
// exists and the phrase above is being shown exactly once, so a lock
|
|
1600
|
+
// that cannot be written becomes a note, never an error.
|
|
1601
|
+
try {
|
|
1602
|
+
await wrapPhrase(dataDir, phrase, password);
|
|
1603
|
+
}
|
|
1604
|
+
catch {
|
|
1605
|
+
notes.push("Your password could not be saved on this machine. Nothing else changed: the recovery phrase shown here still opens this vault, and you can choose a password again later.");
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1608
|
+
// The time and the fact, never the phrase. This is the whole of what
|
|
1609
|
+
// setup writes down about it.
|
|
1610
|
+
await writeSetupRecord(dataDir, { ...(await readSetupRecord(dataDir)), phraseIssuedAt: new Date().toISOString(), phraseRemembered: remembered !== null });
|
|
1611
|
+
return json(response, 201, {
|
|
1612
|
+
vaultId: config.vaultId,
|
|
1613
|
+
dataDir,
|
|
1614
|
+
recoveryPhrase: phrase,
|
|
1615
|
+
wordCount: phrase.split(/\s+/).filter(Boolean).length,
|
|
1616
|
+
shownOnce: true,
|
|
1617
|
+
remembered,
|
|
1618
|
+
notes,
|
|
1619
|
+
warning: "This is the only time Sealkeep will show this phrase. Write it down now: no endpoint can return it again, and without it these archives cannot be opened."
|
|
1620
|
+
});
|
|
1621
|
+
}
|
|
1622
|
+
if (method === "POST" && url.pathname === "/v1/setup/hooks") {
|
|
1623
|
+
const { agents, confirm } = parseBody(setupHooksSchema, await readJsonBody(request));
|
|
1624
|
+
// Hook installation edits a file that belongs to another program, so it
|
|
1625
|
+
// gets the same explicit confirm /v1/retention/apply requires before it
|
|
1626
|
+
// touches anything of the user's.
|
|
1627
|
+
if (!confirm)
|
|
1628
|
+
fail("invalid_argument", "Installing hooks edits another program's configuration. Resend with confirm: true.");
|
|
1629
|
+
const results = await installAgentHooks(agents, { dataDir, home: setupEnvironment.home });
|
|
1630
|
+
const record = await readSetupRecord(dataDir);
|
|
1631
|
+
const installedAt = new Date().toISOString();
|
|
1632
|
+
await writeSetupRecord(dataDir, {
|
|
1633
|
+
...record,
|
|
1634
|
+
hooks: [
|
|
1635
|
+
...record.hooks.filter((hook) => !agents.includes(hook.agent)),
|
|
1636
|
+
...results.filter((result) => result.installed).map((result) => ({ agent: result.agent, installedAt, hookFile: result.hookFile }))
|
|
1637
|
+
]
|
|
1638
|
+
});
|
|
1639
|
+
/**
|
|
1640
|
+
* Choosing an agent means both halves: Sealkeep captures its sessions
|
|
1641
|
+
* (the hook) and the agent can ask about them afterwards (the MCP
|
|
1642
|
+
* server). Splitting those into two decisions produced a product where
|
|
1643
|
+
* the recall half shipped, was installed by nothing, and was findable
|
|
1644
|
+
* only by reading the source.
|
|
1645
|
+
*
|
|
1646
|
+
* The MCP server is registered locally and stays local, because
|
|
1647
|
+
* everything it needs is: the content index, the archives and the key
|
|
1648
|
+
* that opens them. Nothing about answering "what did I do last time"
|
|
1649
|
+
* requires a server, so nothing about it goes to one.
|
|
1650
|
+
*
|
|
1651
|
+
* It never fails the hook install. Capture is the part that cannot be
|
|
1652
|
+
* caught up on — a session not recorded today is gone — while recall
|
|
1653
|
+
* can be registered any time afterwards with `sealkeep mcp install`.
|
|
1654
|
+
*/
|
|
1655
|
+
const { installMcpServer } = await import("./mcp-install.js");
|
|
1656
|
+
const mcp = await installMcpServer(dataDir, { home: setupEnvironment.home })
|
|
1657
|
+
.catch((error) => ({ agents: [], error: error instanceof Error ? error.message : "could not register the MCP server" }));
|
|
1658
|
+
// The re-detection is the proof, not the claim: it re-reads the agents'
|
|
1659
|
+
// own files rather than echoing what the installer believed it did.
|
|
1660
|
+
return json(response, 200, { results, mcp, agents: await detectSetupAgents(setupEnvironment.home) });
|
|
1661
|
+
}
|
|
1662
|
+
if (method === "POST" && url.pathname === "/v1/setup/enroll") {
|
|
1663
|
+
// Linking this machine to an account someone already has, using the
|
|
1664
|
+
// pairing code the panel actually issues — the button there reads "Show
|
|
1665
|
+
// a pairing code", and it is spent against /cli/exchange together with
|
|
1666
|
+
// the account's email.
|
|
1667
|
+
//
|
|
1668
|
+
// This deliberately does not use `enroll()`. That path wants a
|
|
1669
|
+
// different code, from a "Devices > Add device" screen that does not
|
|
1670
|
+
// exist in the panel, so pointing anyone at it would be sending them to
|
|
1671
|
+
// look for something that was never built.
|
|
1672
|
+
//
|
|
1673
|
+
// A code and an email, never a password and never a recovery phrase, so
|
|
1674
|
+
// the rule that no endpoint accepts a phrase is untouched: what this
|
|
1675
|
+
// grants is quota, storage and settings, not the ability to open an
|
|
1676
|
+
// archive.
|
|
1677
|
+
const { email, code } = parseBody(enrollSchema, await readJsonBody(request));
|
|
1678
|
+
const { loginWithCode } = await import("./cloud.js");
|
|
1679
|
+
const account = await loginWithCode(dataDir, { email, token: code });
|
|
1680
|
+
return json(response, 200, { account: { plan: account.plan, email: account.email, quotaBytes: account.quota_bytes, usedBytes: account.used_bytes } });
|
|
1681
|
+
}
|
|
1682
|
+
if (method === "POST" && url.pathname === "/v1/setup/storage/test") {
|
|
1683
|
+
return json(response, 200, await testStorageTarget(parseBody(setupStorageSchema, await readJsonBody(request))));
|
|
1684
|
+
}
|
|
1685
|
+
if (method === "POST" && url.pathname === "/v1/setup/storage") {
|
|
1686
|
+
const { credentials, confirm, endpoint, ...target } = parseBody(setupStorageSchema, await readJsonBody(request));
|
|
1687
|
+
// No vault means no vault id to file the credential under, and
|
|
1688
|
+
// `readConfig` already fails with the instruction that says so.
|
|
1689
|
+
const config = await readConfig(dataDir);
|
|
1690
|
+
const record = await readSetupRecord(dataDir);
|
|
1691
|
+
if ((config.remoteStorage || record.credentials) && !confirm) {
|
|
1692
|
+
fail("invalid_argument", "This machine already has storage configured; replacing it changes where archives go and which credential signs for them. Resend with confirm: true.");
|
|
1693
|
+
}
|
|
1694
|
+
// The same pairing the upload client enforces (providers/index.ts),
|
|
1695
|
+
// checked here so a service-account key pasted into an S3 form is
|
|
1696
|
+
// refused while the person is still looking at the form.
|
|
1697
|
+
if ((target.provider === "gcs") !== ("clientEmail" in credentials)) {
|
|
1698
|
+
fail("invalid_argument", target.provider === "gcs"
|
|
1699
|
+
? "GCS needs a service-account credential (clientEmail and privateKey), not an access key"
|
|
1700
|
+
: `${target.provider} needs an access-key credential (accessKeyId and secretAccessKey), not a service account`);
|
|
1701
|
+
}
|
|
1702
|
+
// R2 signs against a per-account host that cannot be derived from the
|
|
1703
|
+
// bucket — providers/s3.ts refuses without it — so refusing here beats
|
|
1704
|
+
// storing a configuration that could never upload.
|
|
1705
|
+
if (target.provider === "r2" && !endpoint)
|
|
1706
|
+
fail("invalid_argument", "R2 needs its endpoint: https://<account-id>.r2.cloudflarestorage.com");
|
|
1707
|
+
if (endpoint)
|
|
1708
|
+
assertHttpUrl(endpoint);
|
|
1709
|
+
await configureRemoteStorage(dataDir, { provider: target.provider, bucket: target.bucket, prefix: target.prefix, ...(target.region ? { region: target.region } : {}) });
|
|
1710
|
+
// Uploads look the credential up by the vault id (see `uploadArchive`),
|
|
1711
|
+
// so filing it under anything else files it where nothing looks.
|
|
1712
|
+
const stored = await storeProviderCredentials(dataDir, config.vaultId, credentials);
|
|
1713
|
+
await writeSetupRecord(dataDir, {
|
|
1714
|
+
...record,
|
|
1715
|
+
storageEndpoint: endpoint ?? null,
|
|
1716
|
+
credentials: { storedAt: new Date().toISOString(), backend: stored.backend, storageConfigId: config.vaultId }
|
|
1717
|
+
});
|
|
1718
|
+
// The credential is gone from this process now. What comes back is the
|
|
1719
|
+
// same storage view GET /v1/setup/state serves: configuration, and the
|
|
1720
|
+
// fact that a credential exists.
|
|
1721
|
+
return json(response, 200, { storage: (await setupState(dataDir, setupEnvironment)).storage });
|
|
1722
|
+
}
|
|
1723
|
+
if (method === "POST" && url.pathname === "/v1/setup/service") {
|
|
1724
|
+
const { confirm, reclaim } = parseBody(setupServiceSchema, await readJsonBody(request));
|
|
1725
|
+
if (!confirm)
|
|
1726
|
+
fail("invalid_argument", "Installing the background service makes this machine run Sealkeep at every login. Resend with confirm: true.");
|
|
1727
|
+
const config = await readConfig(dataDir);
|
|
1728
|
+
const record = await readSetupRecord(dataDir);
|
|
1729
|
+
// Idempotent on purpose: `launchctl load` on an already-loaded unit
|
|
1730
|
+
// fails, so a second call would report an install failure for a service
|
|
1731
|
+
// that is running perfectly well.
|
|
1732
|
+
const existing = await serviceStatus(serviceOptionsFor(dataDir, setupEnvironment));
|
|
1733
|
+
if (existing.present) {
|
|
1734
|
+
return json(response, 200, { service: { kind: existing.kind, installed: true, alreadyInstalled: true, path: existing.path, ranCommands: [], note: null }, phraseAvailable: record.phraseRemembered });
|
|
1735
|
+
}
|
|
1736
|
+
const outcome = await installService(serviceOptionsFor(dataDir, setupEnvironment, {
|
|
1737
|
+
reclaim,
|
|
1738
|
+
// A service manager starts the daemon with a bare environment. Without
|
|
1739
|
+
// this the upload pass is off, and because reclaiming requires a
|
|
1740
|
+
// verified remote copy it would never free a byte either — see the
|
|
1741
|
+
// note on `environment` in service.ts. serviceUnitEnvironment adds
|
|
1742
|
+
// the secret-backend choice, without which the daemon cannot unlock.
|
|
1743
|
+
environment: serviceUnitEnvironment(config.remoteStorage ? { VAULTLINE_ENABLE_SIGNER: "1" } : {})
|
|
1744
|
+
}));
|
|
1745
|
+
return json(response, outcome.installed ? 201 : 200, {
|
|
1746
|
+
service: { kind: outcome.kind, installed: outcome.installed, alreadyInstalled: false, path: outcome.path, ranCommands: outcome.ranCommands, note: outcome.note ?? null },
|
|
1747
|
+
phraseAvailable: record.phraseRemembered
|
|
1748
|
+
});
|
|
1749
|
+
}
|
|
1750
|
+
return json(response, 404, { error: { code: "invalid_argument", message: `No such endpoint: ${method} ${url.pathname}` } });
|
|
1751
|
+
}
|
|
1752
|
+
catch (error) {
|
|
1753
|
+
const payload = errorPayload(error);
|
|
1754
|
+
return json(response, isVaultlineError(error) ? STATUS_BY_CODE[error.code] ?? 400 : 500, payload);
|
|
1755
|
+
}
|
|
1756
|
+
});
|
|
1757
|
+
}
|