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.
Files changed (180) hide show
  1. package/ARCHITECTURE.md +201 -0
  2. package/CHANGELOG.md +218 -0
  3. package/CONTROL_PLANE.md +86 -0
  4. package/LICENSE +34 -0
  5. package/README.md +249 -0
  6. package/THIRD_PARTY.md +22 -0
  7. package/THREAT_MODEL.md +107 -0
  8. package/dist/packages/vaultline-crypto/src/aead.d.ts +12 -0
  9. package/dist/packages/vaultline-crypto/src/aead.js +24 -0
  10. package/dist/packages/vaultline-crypto/src/chunk-access.d.ts +39 -0
  11. package/dist/packages/vaultline-crypto/src/chunk-access.js +93 -0
  12. package/dist/packages/vaultline-crypto/src/envelope.d.ts +71 -0
  13. package/dist/packages/vaultline-crypto/src/envelope.js +188 -0
  14. package/dist/packages/vaultline-crypto/src/format.d.ts +106 -0
  15. package/dist/packages/vaultline-crypto/src/format.js +43 -0
  16. package/dist/packages/vaultline-crypto/src/index.d.ts +5 -0
  17. package/dist/packages/vaultline-crypto/src/index.js +5 -0
  18. package/dist/packages/vaultline-crypto/src/recipients.d.ts +42 -0
  19. package/dist/packages/vaultline-crypto/src/recipients.js +129 -0
  20. package/dist/packages/vaultline-crypto/src/sha256-stream.d.ts +41 -0
  21. package/dist/packages/vaultline-crypto/src/sha256-stream.js +206 -0
  22. package/dist/packages/vaultline-crypto/src/stream.d.ts +139 -0
  23. package/dist/packages/vaultline-crypto/src/stream.js +477 -0
  24. package/dist/site/index.html +1542 -0
  25. package/dist/site.zip +0 -0
  26. package/dist/src/activity.d.ts +22 -0
  27. package/dist/src/activity.js +52 -0
  28. package/dist/src/adapters.d.ts +212 -0
  29. package/dist/src/adapters.js +533 -0
  30. package/dist/src/audit.d.ts +24 -0
  31. package/dist/src/audit.js +41 -0
  32. package/dist/src/autopilot.d.ts +77 -0
  33. package/dist/src/autopilot.js +148 -0
  34. package/dist/src/bip39-wordlist.d.ts +15 -0
  35. package/dist/src/bip39-wordlist.js +272 -0
  36. package/dist/src/branding.d.ts +31 -0
  37. package/dist/src/branding.js +31 -0
  38. package/dist/src/chunk-store.d.ts +142 -0
  39. package/dist/src/chunk-store.js +502 -0
  40. package/dist/src/cli.d.ts +2 -0
  41. package/dist/src/cli.js +2035 -0
  42. package/dist/src/cloud.d.ts +434 -0
  43. package/dist/src/cloud.js +851 -0
  44. package/dist/src/control-plane/auth.d.ts +62 -0
  45. package/dist/src/control-plane/auth.js +123 -0
  46. package/dist/src/control-plane/server.d.ts +31 -0
  47. package/dist/src/control-plane/server.js +263 -0
  48. package/dist/src/control-plane/store.d.ts +101 -0
  49. package/dist/src/control-plane/store.js +82 -0
  50. package/dist/src/control-plane-cli.d.ts +2 -0
  51. package/dist/src/control-plane-cli.js +37 -0
  52. package/dist/src/control-plane-server.d.ts +10 -0
  53. package/dist/src/control-plane-server.js +11 -0
  54. package/dist/src/control-plane.d.ts +78 -0
  55. package/dist/src/control-plane.js +61 -0
  56. package/dist/src/crypto.d.ts +56 -0
  57. package/dist/src/crypto.js +132 -0
  58. package/dist/src/daemon.d.ts +52 -0
  59. package/dist/src/daemon.js +142 -0
  60. package/dist/src/dashboard-cli.d.ts +2 -0
  61. package/dist/src/dashboard-cli.js +20 -0
  62. package/dist/src/disk.d.ts +110 -0
  63. package/dist/src/disk.js +169 -0
  64. package/dist/src/doctor.d.ts +11 -0
  65. package/dist/src/doctor.js +198 -0
  66. package/dist/src/enroll.d.ts +27 -0
  67. package/dist/src/enroll.js +136 -0
  68. package/dist/src/errors.d.ts +26 -0
  69. package/dist/src/errors.js +23 -0
  70. package/dist/src/heartbeat.d.ts +89 -0
  71. package/dist/src/heartbeat.js +120 -0
  72. package/dist/src/index-sync.d.ts +53 -0
  73. package/dist/src/index-sync.js +147 -0
  74. package/dist/src/leakscan.d.ts +48 -0
  75. package/dist/src/leakscan.js +222 -0
  76. package/dist/src/local-api.d.ts +132 -0
  77. package/dist/src/local-api.js +1757 -0
  78. package/dist/src/managed-chunks.d.ts +55 -0
  79. package/dist/src/managed-chunks.js +108 -0
  80. package/dist/src/mcp-install.d.ts +52 -0
  81. package/dist/src/mcp-install.js +140 -0
  82. package/dist/src/mcp.d.ts +1 -0
  83. package/dist/src/mcp.js +59 -0
  84. package/dist/src/migrate.d.ts +35 -0
  85. package/dist/src/migrate.js +88 -0
  86. package/dist/src/mnemonic.d.ts +60 -0
  87. package/dist/src/mnemonic.js +134 -0
  88. package/dist/src/net.d.ts +2 -0
  89. package/dist/src/net.js +16 -0
  90. package/dist/src/notify.d.ts +46 -0
  91. package/dist/src/notify.js +84 -0
  92. package/dist/src/offload.d.ts +117 -0
  93. package/dist/src/offload.js +331 -0
  94. package/dist/src/onboarding.d.ts +10 -0
  95. package/dist/src/onboarding.js +44 -0
  96. package/dist/src/packages.d.ts +126 -0
  97. package/dist/src/packages.js +114 -0
  98. package/dist/src/passkey.d.ts +26 -0
  99. package/dist/src/passkey.js +54 -0
  100. package/dist/src/password-lock.d.ts +19 -0
  101. package/dist/src/password-lock.js +156 -0
  102. package/dist/src/paths.d.ts +9 -0
  103. package/dist/src/paths.js +24 -0
  104. package/dist/src/providers/gcs.d.ts +133 -0
  105. package/dist/src/providers/gcs.js +235 -0
  106. package/dist/src/providers/gdrive.d.ts +156 -0
  107. package/dist/src/providers/gdrive.js +335 -0
  108. package/dist/src/providers/index.d.ts +45 -0
  109. package/dist/src/providers/index.js +74 -0
  110. package/dist/src/providers/s3.d.ts +174 -0
  111. package/dist/src/providers/s3.js +345 -0
  112. package/dist/src/providers/sigv4.d.ts +78 -0
  113. package/dist/src/providers/sigv4.js +112 -0
  114. package/dist/src/queue.d.ts +185 -0
  115. package/dist/src/queue.js +286 -0
  116. package/dist/src/recovery.d.ts +40 -0
  117. package/dist/src/recovery.js +132 -0
  118. package/dist/src/rehydrate.d.ts +43 -0
  119. package/dist/src/rehydrate.js +66 -0
  120. package/dist/src/restore.d.ts +34 -0
  121. package/dist/src/restore.js +80 -0
  122. package/dist/src/retention.d.ts +251 -0
  123. package/dist/src/retention.js +446 -0
  124. package/dist/src/rotate.d.ts +47 -0
  125. package/dist/src/rotate.js +95 -0
  126. package/dist/src/search.d.ts +147 -0
  127. package/dist/src/search.js +677 -0
  128. package/dist/src/secrets.d.ts +86 -0
  129. package/dist/src/secrets.js +220 -0
  130. package/dist/src/service.d.ts +73 -0
  131. package/dist/src/service.js +197 -0
  132. package/dist/src/share.d.ts +34 -0
  133. package/dist/src/share.js +68 -0
  134. package/dist/src/spool.d.ts +97 -0
  135. package/dist/src/spool.js +213 -0
  136. package/dist/src/start-tui.d.ts +17 -0
  137. package/dist/src/start-tui.js +113 -0
  138. package/dist/src/start.d.ts +75 -0
  139. package/dist/src/start.js +101 -0
  140. package/dist/src/storage-setup.d.ts +49 -0
  141. package/dist/src/storage-setup.js +222 -0
  142. package/dist/src/storage-targets.d.ts +40 -0
  143. package/dist/src/storage-targets.js +147 -0
  144. package/dist/src/stream-to-cloud.d.ts +76 -0
  145. package/dist/src/stream-to-cloud.js +820 -0
  146. package/dist/src/sync-rules.d.ts +85 -0
  147. package/dist/src/sync-rules.js +125 -0
  148. package/dist/src/trash.d.ts +15 -0
  149. package/dist/src/trash.js +63 -0
  150. package/dist/src/tui.d.ts +18 -0
  151. package/dist/src/tui.js +179 -0
  152. package/dist/src/types.d.ts +191 -0
  153. package/dist/src/types.js +3 -0
  154. package/dist/src/ui-server.d.ts +187 -0
  155. package/dist/src/ui-server.js +293 -0
  156. package/dist/src/ui.d.ts +41 -0
  157. package/dist/src/ui.js +102 -0
  158. package/dist/src/update.d.ts +30 -0
  159. package/dist/src/update.js +56 -0
  160. package/dist/src/upload.d.ts +46 -0
  161. package/dist/src/upload.js +80 -0
  162. package/dist/src/vault.d.ts +208 -0
  163. package/dist/src/vault.js +812 -0
  164. package/dist/src/watcher.d.ts +34 -0
  165. package/dist/src/watcher.js +121 -0
  166. package/dist/src/worker.d.ts +52 -0
  167. package/dist/src/worker.js +190 -0
  168. package/package.json +65 -0
  169. package/web/app.js +1372 -0
  170. package/web/index.html +476 -0
  171. package/web/rail.js +308 -0
  172. package/web/retention.html +17 -0
  173. package/web/rules-view.js +249 -0
  174. package/web/sessions-view.js +448 -0
  175. package/web/sessions.html +17 -0
  176. package/web/setup-api.js +181 -0
  177. package/web/setup-logic.js +394 -0
  178. package/web/setup.html +419 -0
  179. package/web/setup.js +697 -0
  180. 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
+ }