sealkeep 0.8.1 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (275) hide show
  1. package/ARCHITECTURE.md +163 -14
  2. package/CHANGELOG.md +244 -1
  3. package/CONTROL_PLANE.md +2 -2
  4. package/LICENSE +1 -1
  5. package/README.md +108 -22
  6. package/THIRD_PARTY.md +2 -2
  7. package/THREAT_MODEL.md +23 -4
  8. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/chunk-access.d.ts +26 -4
  9. package/dist/packages/sealkeep-crypto/src/chunk-access.js +219 -0
  10. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/envelope.d.ts +11 -1
  11. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/envelope.js +58 -21
  12. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/format.d.ts +2 -2
  13. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/index.d.ts +1 -0
  14. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/index.js +1 -0
  15. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/recipients.js +4 -4
  16. package/dist/packages/sealkeep-crypto/src/squeeze.d.ts +6 -0
  17. package/dist/packages/sealkeep-crypto/src/squeeze.js +39 -0
  18. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/stream.d.ts +36 -3
  19. package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/stream.js +196 -37
  20. package/dist/site/index.html +1808 -1904
  21. package/dist/site/llms.txt +67 -0
  22. package/dist/site/trust/architecture-data-flow.html +53 -0
  23. package/dist/site/trust/audit-roadmap.html +37 -0
  24. package/dist/site/trust/deployment-responsibility.html +11 -0
  25. package/dist/site/trust/dpa-sample.html +30 -0
  26. package/dist/site/trust/release-provenance.html +21 -0
  27. package/dist/site/trust/subprocessors.html +15 -0
  28. package/dist/site/trust/threat-model.html +34 -0
  29. package/dist/site/trust/toms.html +41 -0
  30. package/dist/site/trust-document.css +32 -0
  31. package/dist/site/trust.html +73 -0
  32. package/dist/site/visual/assets/index-8Nxnkc7o.js +207 -0
  33. package/dist/site/visual/assets/index-oxLy1bpy.css +1 -0
  34. package/dist/site/visual/index.html +18 -0
  35. package/dist/site.zip +0 -0
  36. package/dist/src/activity.d.ts +9 -0
  37. package/dist/src/activity.js +90 -1
  38. package/dist/src/adapters.d.ts +175 -5
  39. package/dist/src/adapters.js +961 -73
  40. package/dist/src/agent-context.d.ts +135 -0
  41. package/dist/src/agent-context.js +1059 -0
  42. package/dist/src/archive-copies.d.ts +47 -0
  43. package/dist/src/archive-copies.js +179 -0
  44. package/dist/src/audit.d.ts +1 -1
  45. package/dist/src/audit.js +29 -4
  46. package/dist/src/autopilot.d.ts +52 -7
  47. package/dist/src/autopilot.js +143 -25
  48. package/dist/src/background-bandwidth.d.ts +46 -0
  49. package/dist/src/background-bandwidth.js +301 -0
  50. package/dist/src/background-cpu.d.ts +82 -0
  51. package/dist/src/background-cpu.js +212 -0
  52. package/dist/src/background-worker-error.d.ts +12 -0
  53. package/dist/src/background-worker-error.js +18 -0
  54. package/dist/src/branding.d.ts +24 -1
  55. package/dist/src/branding.js +31 -1
  56. package/dist/src/bridge.d.ts +233 -0
  57. package/dist/src/bridge.js +604 -0
  58. package/dist/src/byte-stream.d.ts +91 -0
  59. package/dist/src/byte-stream.js +385 -0
  60. package/dist/src/chunk-store.d.ts +41 -8
  61. package/dist/src/chunk-store.js +161 -65
  62. package/dist/src/cli.js +1746 -166
  63. package/dist/src/cloud.d.ts +841 -31
  64. package/dist/src/cloud.js +3196 -277
  65. package/dist/src/context-background.d.ts +37 -0
  66. package/dist/src/context-background.js +309 -0
  67. package/dist/src/context-drain-child.d.ts +1 -0
  68. package/dist/src/context-drain-child.js +98 -0
  69. package/dist/src/context-reader.d.ts +118 -0
  70. package/dist/src/context-reader.js +447 -0
  71. package/dist/src/control-plane/auth.d.ts +32 -4
  72. package/dist/src/control-plane/auth.js +85 -24
  73. package/dist/src/control-plane/server.js +19 -6
  74. package/dist/src/control-plane.d.ts +17 -1
  75. package/dist/src/control-plane.js +32 -6
  76. package/dist/src/crypto.d.ts +1 -1
  77. package/dist/src/crypto.js +5 -5
  78. package/dist/src/daemon-lease.d.ts +70 -0
  79. package/dist/src/daemon-lease.js +420 -0
  80. package/dist/src/daemon.d.ts +94 -1
  81. package/dist/src/daemon.js +1082 -105
  82. package/dist/src/darwin-service-policy.d.ts +41 -0
  83. package/dist/src/darwin-service-policy.js +60 -0
  84. package/dist/src/dashboard-cli.js +15 -15
  85. package/dist/src/device-authorization.d.ts +37 -0
  86. package/dist/src/device-authorization.js +199 -0
  87. package/dist/src/device-enrollment.d.ts +91 -0
  88. package/dist/src/device-enrollment.js +349 -0
  89. package/dist/src/disk.d.ts +17 -12
  90. package/dist/src/disk.js +43 -17
  91. package/dist/src/doctor.d.ts +35 -1
  92. package/dist/src/doctor.js +316 -41
  93. package/dist/src/durable-ticket-lock.d.ts +24 -0
  94. package/dist/src/durable-ticket-lock.js +232 -0
  95. package/dist/src/enroll.d.ts +1 -1
  96. package/dist/src/enroll.js +13 -7
  97. package/dist/src/env.d.ts +10 -1
  98. package/dist/src/env.js +11 -3
  99. package/dist/src/errors.d.ts +8 -8
  100. package/dist/src/errors.js +6 -6
  101. package/dist/src/flush.d.ts +12 -0
  102. package/dist/src/flush.js +37 -0
  103. package/dist/src/heartbeat.d.ts +86 -12
  104. package/dist/src/heartbeat.js +415 -29
  105. package/dist/src/index-background-watchdog.d.ts +1 -0
  106. package/dist/src/index-background-watchdog.js +94 -0
  107. package/dist/src/index-background-work.d.ts +21 -0
  108. package/dist/src/index-background-work.js +25 -0
  109. package/dist/src/index-background.d.ts +64 -0
  110. package/dist/src/index-background.js +394 -0
  111. package/dist/src/index-build-child.d.ts +1 -0
  112. package/dist/src/index-build-child.js +109 -0
  113. package/dist/src/index-manifest.d.ts +52 -0
  114. package/dist/src/index-manifest.js +444 -0
  115. package/dist/src/index-publication-proof.d.ts +84 -0
  116. package/dist/src/index-publication-proof.js +380 -0
  117. package/dist/src/index-publication-state.d.ts +149 -0
  118. package/dist/src/index-publication-state.js +696 -0
  119. package/dist/src/index-publication-verifier.d.ts +89 -0
  120. package/dist/src/index-publication-verifier.js +341 -0
  121. package/dist/src/index-publish.d.ts +62 -0
  122. package/dist/src/index-publish.js +540 -0
  123. package/dist/src/index-scratch-cleanup.d.ts +19 -0
  124. package/dist/src/index-scratch-cleanup.js +166 -0
  125. package/dist/src/index-segment-types.d.ts +132 -0
  126. package/dist/src/index-segment-types.js +21 -0
  127. package/dist/src/index-segments.d.ts +9 -0
  128. package/dist/src/index-segments.js +516 -0
  129. package/dist/src/index-store.d.ts +123 -0
  130. package/dist/src/index-store.js +495 -0
  131. package/dist/src/index-sync.d.ts +91 -15
  132. package/dist/src/index-sync.js +286 -53
  133. package/dist/src/index-upgrade-publication.d.ts +30 -0
  134. package/dist/src/index-upgrade-publication.js +179 -0
  135. package/dist/src/integration-manager.d.ts +32 -0
  136. package/dist/src/integration-manager.js +394 -0
  137. package/dist/src/leakscan.js +1 -1
  138. package/dist/src/local-api.d.ts +56 -15
  139. package/dist/src/local-api.js +4979 -541
  140. package/dist/src/machine-settings.d.ts +51 -0
  141. package/dist/src/machine-settings.js +166 -0
  142. package/dist/src/managed-chunks.d.ts +5 -2
  143. package/dist/src/managed-chunks.js +14 -14
  144. package/dist/src/mcp-install.d.ts +11 -9
  145. package/dist/src/mcp-install.js +73 -28
  146. package/dist/src/mcp-workspace.d.ts +18 -0
  147. package/dist/src/mcp-workspace.js +50 -0
  148. package/dist/src/mcp.js +294 -25
  149. package/dist/src/migrate.d.ts +1 -0
  150. package/dist/src/migrate.js +33 -5
  151. package/dist/src/notify.d.ts +1 -1
  152. package/dist/src/notify.js +5 -5
  153. package/dist/src/offload.d.ts +201 -14
  154. package/dist/src/offload.js +1848 -140
  155. package/dist/src/onboarding.d.ts +8 -1
  156. package/dist/src/onboarding.js +4 -4
  157. package/dist/src/packages.d.ts +2 -2
  158. package/dist/src/packages.js +10 -2
  159. package/dist/src/passkey.d.ts +0 -1
  160. package/dist/src/passkey.js +2 -7
  161. package/dist/src/password-lock.d.ts +2 -2
  162. package/dist/src/password-lock.js +6 -6
  163. package/dist/src/paths.d.ts +2 -0
  164. package/dist/src/paths.js +2 -0
  165. package/dist/src/presence.d.ts +86 -0
  166. package/dist/src/presence.js +240 -0
  167. package/dist/src/progress-deadline.d.ts +21 -0
  168. package/dist/src/progress-deadline.js +91 -0
  169. package/dist/src/project-repair.d.ts +55 -0
  170. package/dist/src/project-repair.js +131 -0
  171. package/dist/src/providers/gcs.d.ts +28 -7
  172. package/dist/src/providers/gcs.js +35 -24
  173. package/dist/src/providers/gdrive.d.ts +71 -8
  174. package/dist/src/providers/gdrive.js +223 -50
  175. package/dist/src/providers/index.d.ts +11 -3
  176. package/dist/src/providers/index.js +30 -10
  177. package/dist/src/providers/s3.d.ts +30 -8
  178. package/dist/src/providers/s3.js +41 -30
  179. package/dist/src/providers/safe-storage-fetch.d.ts +12 -0
  180. package/dist/src/providers/safe-storage-fetch.js +72 -0
  181. package/dist/src/queue.d.ts +136 -19
  182. package/dist/src/queue.js +862 -96
  183. package/dist/src/reclaim-transaction.d.ts +156 -0
  184. package/dist/src/reclaim-transaction.js +1027 -0
  185. package/dist/src/recovery-codes.d.ts +32 -0
  186. package/dist/src/recovery-codes.js +338 -0
  187. package/dist/src/recovery.js +12 -9
  188. package/dist/src/rehydrate.d.ts +25 -22
  189. package/dist/src/rehydrate.js +319 -23
  190. package/dist/src/restore.d.ts +57 -4
  191. package/dist/src/restore.js +272 -36
  192. package/dist/src/resume-stub.d.ts +92 -0
  193. package/dist/src/resume-stub.js +417 -0
  194. package/dist/src/retention.d.ts +98 -7
  195. package/dist/src/retention.js +1066 -62
  196. package/dist/src/rotate.js +3 -3
  197. package/dist/src/search.d.ts +566 -8
  198. package/dist/src/search.js +5897 -290
  199. package/dist/src/secrets.d.ts +51 -7
  200. package/dist/src/secrets.js +316 -24
  201. package/dist/src/service.d.ts +49 -11
  202. package/dist/src/service.js +776 -35
  203. package/dist/src/share.js +3 -3
  204. package/dist/src/shared-spaces.d.ts +98 -0
  205. package/dist/src/shared-spaces.js +214 -0
  206. package/dist/src/source-reader.d.ts +73 -0
  207. package/dist/src/source-reader.js +715 -0
  208. package/dist/src/spool.d.ts +1 -1
  209. package/dist/src/spool.js +1 -1
  210. package/dist/src/start-tui.js +2 -1
  211. package/dist/src/start.js +2 -2
  212. package/dist/src/storage-endpoint.d.ts +21 -0
  213. package/dist/src/storage-endpoint.js +122 -0
  214. package/dist/src/storage-setup.js +12 -12
  215. package/dist/src/storage-targets.d.ts +109 -6
  216. package/dist/src/storage-targets.js +975 -67
  217. package/dist/src/stream-to-cloud.d.ts +5 -1
  218. package/dist/src/stream-to-cloud.js +34 -14
  219. package/dist/src/sync-rules.d.ts +31 -6
  220. package/dist/src/sync-rules.js +153 -14
  221. package/dist/src/team-backfill-scheduling.d.ts +8 -0
  222. package/dist/src/team-backfill-scheduling.js +33 -0
  223. package/dist/src/team-backfill.d.ts +116 -0
  224. package/dist/src/team-backfill.js +1429 -0
  225. package/dist/src/team-index-cache.d.ts +16 -0
  226. package/dist/src/team-index-cache.js +152 -0
  227. package/dist/src/team-offboarding.d.ts +38 -0
  228. package/dist/src/team-offboarding.js +1043 -0
  229. package/dist/src/team-presence.d.ts +127 -0
  230. package/dist/src/team-presence.js +904 -0
  231. package/dist/src/team-publication-policy.d.ts +20 -0
  232. package/dist/src/team-publication-policy.js +140 -0
  233. package/dist/src/team-realtime.d.ts +68 -0
  234. package/dist/src/team-realtime.js +816 -0
  235. package/dist/src/team-source-facts-cache.d.ts +23 -0
  236. package/dist/src/team-source-facts-cache.js +255 -0
  237. package/dist/src/trash.d.ts +1 -1
  238. package/dist/src/trash.js +2 -2
  239. package/dist/src/tui.js +11 -12
  240. package/dist/src/types.d.ts +173 -7
  241. package/dist/src/types.js +20 -0
  242. package/dist/src/ui-server.d.ts +163 -35
  243. package/dist/src/ui-server.js +712 -72
  244. package/dist/src/ui.d.ts +1 -2
  245. package/dist/src/ui.js +1 -2
  246. package/dist/src/upload.d.ts +27 -0
  247. package/dist/src/upload.js +383 -43
  248. package/dist/src/vault.d.ts +235 -29
  249. package/dist/src/vault.js +1783 -190
  250. package/dist/src/watcher.d.ts +7 -1
  251. package/dist/src/watcher.js +198 -55
  252. package/dist/src/worker.d.ts +27 -3
  253. package/dist/src/worker.js +274 -55
  254. package/package.json +33 -12
  255. package/scripts/native-reboot-rehearsal.mjs +90 -0
  256. package/web/app.js +6032 -343
  257. package/web/bootstrap.js +17 -0
  258. package/web/index.html +255 -57
  259. package/web/rail.js +317 -40
  260. package/web/retention.html +2 -2
  261. package/web/rules-view.js +188 -16
  262. package/web/sessions-view.js +485 -62
  263. package/web/sessions.html +2 -2
  264. package/web/setup-api.js +152 -29
  265. package/web/setup-logic.js +68 -9
  266. package/web/setup.html +113 -44
  267. package/web/setup.js +604 -71
  268. package/web/style.css +513 -98
  269. package/dist/packages/vaultline-crypto/src/chunk-access.js +0 -93
  270. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/aead.d.ts +0 -0
  271. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/aead.js +0 -0
  272. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/format.js +0 -0
  273. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/recipients.d.ts +0 -0
  274. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/sha256-stream.d.ts +0 -0
  275. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/sha256-stream.js +0 -0
package/web/setup.js CHANGED
@@ -3,9 +3,10 @@
3
3
  *
4
4
  * Five screens, in the order the decisions depend on each other: what this
5
5
  * does, how archives are stored, where they go, the key, and what is now
6
- * running. The vault is created on screen 4 and everything else is applied
7
- * after it, which is what makes "stop once your key exists and you still have
8
- * a working local-only vault" true rather than aspirational.
6
+ * running. New accounts create a vault on screen 4. A returning account with
7
+ * preserved memory first adopts its existing vault through device-to-device
8
+ * enrollment, so screen 4 confirms that identity instead of creating a
9
+ * throwaway vault with a different key.
9
10
  *
10
11
  * The phrase lives in exactly one variable in this file and is written to at
11
12
  * most one of two lists — the ceremony's, or the fold a password vault offers
@@ -32,9 +33,13 @@ const draft = {
32
33
  /** The top-level answer. The provider is only a detail underneath one of them. */
33
34
  intent: "local",
34
35
  storage: {},
35
- /** Held until the vault exists: linking needs somewhere to keep the token. */
36
- enrollEmail: "",
37
- enrollCode: "",
36
+ /** Account linking can use bootstrap keystore refs before a vault exists. */
37
+ account: null,
38
+ /** Public hosted approval state only; secrets remain in native storage. */
39
+ authorization: null,
40
+ needsEnrollment: false,
41
+ deviceLabel: "",
42
+ enrollment: null,
38
43
  vault: null,
39
44
  /** Which ceremony made the vault, so re-entering screen 4 redraws the right half. */
40
45
  keyBranch: null,
@@ -43,9 +48,16 @@ const draft = {
43
48
 
44
49
  /** The one secret this page ever holds. Cleared by `forgetPhrase()`. */
45
50
  let phraseWords = null;
51
+ let recoveryCodes = [];
52
+ let recoveryFileCodes = [];
46
53
  let verifyPositions = [];
47
54
  let confirmed = false;
48
55
  let step = 1;
56
+ let enrollmentPollTimer = null;
57
+ let enrollmentReceiptTimer = null;
58
+ let accountAuthorizationPollTimer = null;
59
+ let browserHandoffPollTimer = null;
60
+ let machineReady = false;
49
61
 
50
62
  /* ── Chrome ──────────────────────────────────────────────────────────── */
51
63
 
@@ -85,24 +97,58 @@ function show(next) {
85
97
  if (heading && typeof heading.focus === "function") { heading.tabIndex = -1; heading.focus(); }
86
98
  }
87
99
 
88
- /**
89
- * Asks this browser for the token, rather than telling it to go and be a
90
- * different browser.
91
- *
92
- * `sealkeep ui` signs in whichever browser it opened, by way of a fragment
93
- * that lands in that browser's sessionStorage. Anyone who then types the
94
- * address into a second one — a perfectly reasonable thing to do with a URL
95
- * that is now stable and memorable — arrives with nothing, and the old advice
96
- * was a terminal command that would have opened the first browser again.
97
- */
100
+ /** Shown when this tab has no valid bearer from a secure launch ticket. */
98
101
  function showGate(message) {
99
102
  for (let index = 1; index <= TOTAL_STEPS; index += 1) $(`screen-${index}`).hidden = true;
100
103
  $("screen-unavailable").hidden = true;
101
104
  $("screen-gate").hidden = false;
102
105
  setError("gate-error", message ?? "");
103
106
  for (const button of stepButtons()) { button.disabled = true; button.classList.add("locked"); }
104
- setStatus("Not signed in", "warn");
105
- $("gate-token").focus();
107
+ setStatus("Not connected", "warn");
108
+ const retry = $("gate-retry");
109
+ retry.textContent = api.hasToken() ? "Check connection" : "Request access in this browser";
110
+ retry.focus();
111
+ }
112
+
113
+ function scheduleBrowserHandoffPoll(requestId) {
114
+ if (browserHandoffPollTimer) clearTimeout(browserHandoffPollTimer);
115
+ browserHandoffPollTimer = setTimeout(() => void pollBrowserHandoff(requestId), 1500);
116
+ browserHandoffPollTimer?.unref?.();
117
+ }
118
+
119
+ async function pollBrowserHandoff(requestId) {
120
+ try {
121
+ const result = await api.checkBrowserHandoff(requestId);
122
+ if (result.status === "approved" && typeof result.ticket === "string") {
123
+ api.clearBrowserHandoff();
124
+ // The setup transport owns ticket redemption and keeps the bearer in
125
+ // this tab's sessionStorage only.
126
+ const hash = `#ticket=${encodeURIComponent(result.ticket)}`;
127
+ location.hash = hash.slice(0);
128
+ await boot();
129
+ return;
130
+ }
131
+ if (result.status === "expired") {
132
+ $("gate-retry").textContent = "Request access in this browser";
133
+ setError("gate-error", "That browser sign-in request expired. Request a new one.");
134
+ return;
135
+ }
136
+ setError("gate-error", "Approve this browser in the Sealkeep tab already signed in on this machine. This request expires shortly.");
137
+ scheduleBrowserHandoffPoll(requestId);
138
+ } catch (error) {
139
+ setError("gate-error", error.message);
140
+ scheduleBrowserHandoffPoll(requestId);
141
+ }
142
+ }
143
+
144
+ async function requestBrowserHandoff() {
145
+ try {
146
+ const requestId = await api.beginBrowserHandoff();
147
+ $("gate-retry").textContent = "Waiting for approval…";
148
+ await pollBrowserHandoff(requestId);
149
+ } catch (error) {
150
+ setError("gate-error", error.message);
151
+ }
106
152
  }
107
153
 
108
154
  function showUnavailable(message) {
@@ -129,6 +175,182 @@ function setError(id, message) {
129
175
  node.hidden = !message;
130
176
  }
131
177
 
178
+ function syncManagedContinue() {
179
+ const waitingForAccount = draft.intent === "managed" && !draft.account;
180
+ const waitingForApproval = draft.intent === "managed" && draft.needsEnrollment && draft.enrollment?.state !== "complete";
181
+ $("storage-continue").disabled = !machineReady || waitingForAccount || waitingForApproval;
182
+ }
183
+
184
+ $("show-pairing-form").onclick = () => {
185
+ $("account-pairing-form").hidden = false;
186
+ $("show-pairing-form").hidden = true;
187
+ $("f-enroll-email").focus();
188
+ };
189
+
190
+ /* ── Hosted account approval ─────────────────────────────────────────── */
191
+
192
+ /**
193
+ * The page is only a display and polling surface for this protocol. The
194
+ * verifier, account token, device credential, private enrollment key and
195
+ * request capability stay in the local secret backend; only this opaque
196
+ * request handle and hosted URL are retained in browser memory.
197
+ */
198
+ function scheduleAccountAuthorizationPoll(requestId, delay = 3000) {
199
+ if (accountAuthorizationPollTimer !== null) clearTimeout(accountAuthorizationPollTimer);
200
+ const wait = Math.min(10_000, Math.max(1_000, Number(delay) || 3000));
201
+ accountAuthorizationPollTimer = setTimeout(() => void pollAccountAuthorization(requestId), wait);
202
+ }
203
+
204
+ function clearAccountAuthorizationPoll() {
205
+ if (accountAuthorizationPollTimer !== null) clearTimeout(accountAuthorizationPollTimer);
206
+ accountAuthorizationPollTimer = null;
207
+ }
208
+
209
+ function accountApprovalLink(url) {
210
+ const link = $("account-approval-link");
211
+ link.href = url;
212
+ link.hidden = false;
213
+ }
214
+
215
+ function showAuthorizedAccount(result) {
216
+ draft.account = result.account;
217
+ draft.needsEnrollment = result.memory?.preserved === true && !draft.createdAlready;
218
+ $("account-start-actions").hidden = true;
219
+ $("pairing-fallback").hidden = true;
220
+ $("f-device-label").disabled = true;
221
+ $("account-authorization-error").hidden = true;
222
+
223
+ if (!draft.needsEnrollment) {
224
+ $("account-authorization-status").textContent = "This machine is linked. Setup can create its first vault next.";
225
+ setStatus("Account linked", "ok");
226
+ syncManagedContinue();
227
+ return;
228
+ }
229
+
230
+ $("account-authorization-status").textContent = "This account has preserved memories. Waiting for the device that already opens them…";
231
+ setStatus("Account linked", "ok");
232
+ syncManagedContinue();
233
+ if (result.enrollment?.requestId) {
234
+ resumePreservedMemoryEnrollment(result.enrollment);
235
+ } else {
236
+ // Current hosted services can create the enrollment request from the same
237
+ // public key. This branch supports an older service without making login
238
+ // fail; the existing local enrollment lane still keeps key custody here.
239
+ void beginPreservedMemoryEnrollment();
240
+ }
241
+ }
242
+
243
+ async function pollAccountAuthorization(requestId) {
244
+ if (draft.authorization?.requestId !== requestId || draft.authorization.state !== "waiting_for_approval") return;
245
+ try {
246
+ const result = await api.completeDeviceAuthorization(requestId);
247
+ if (draft.authorization?.requestId !== requestId) return;
248
+ if (result.status === "waiting_for_approval") {
249
+ if (result.expiresAt) draft.authorization.expiresAt = result.expiresAt;
250
+ $("account-authorization-status").textContent = "Finish signing in and approve this machine in the Sealkeep page we opened…";
251
+ setError("account-authorization-error", "");
252
+ scheduleAccountAuthorizationPoll(requestId, result.retryAfterMs);
253
+ return;
254
+ }
255
+ clearAccountAuthorizationPoll();
256
+ if (result.status === "complete" && result.account && result.memory) {
257
+ draft.authorization = { ...draft.authorization, state: "complete" };
258
+ showAuthorizedAccount(result);
259
+ return;
260
+ }
261
+ if (result.status === "denied") {
262
+ draft.authorization = { ...draft.authorization, state: "denied" };
263
+ $("account-authorization-status").textContent = "";
264
+ $("account-approval-link").hidden = true;
265
+ setError("account-authorization-error", "This account did not approve this machine. You can start another approval or use the manual fallback.");
266
+ $("account-authorize").disabled = false;
267
+ $("account-authorize").textContent = "Start another approval";
268
+ return;
269
+ }
270
+ if (result.status === "expired") {
271
+ draft.authorization = { ...draft.authorization, state: "expired" };
272
+ $("account-authorization-status").textContent = "";
273
+ $("account-approval-link").hidden = true;
274
+ setError("account-authorization-error", "This account approval expired. Start another approval; nothing was linked.");
275
+ $("account-authorize").disabled = false;
276
+ $("account-authorize").textContent = "Start another approval";
277
+ return;
278
+ }
279
+ throw new Error("Sealkeep returned an unfamiliar account approval state. Start another approval.");
280
+ } catch (error) {
281
+ if (draft.authorization?.requestId !== requestId || draft.authorization.state !== "waiting_for_approval") return;
282
+ const transient = !error.status || error.status >= 500;
283
+ setError("account-authorization-error", transient
284
+ ? "This machine could not check approval just now. It will keep trying."
285
+ : error.message);
286
+ if (transient) scheduleAccountAuthorizationPoll(requestId);
287
+ else {
288
+ clearAccountAuthorizationPoll();
289
+ $("account-authorize").disabled = false;
290
+ $("account-authorize").textContent = "Start another approval";
291
+ }
292
+ }
293
+ }
294
+
295
+ $("account-authorize").onclick = async () => {
296
+ const label = $("f-device-label").value.trim();
297
+ if (!label) {
298
+ setError("account-authorization-error", "Name this machine before asking the account to approve it.");
299
+ $("f-device-label").focus();
300
+ return;
301
+ }
302
+
303
+ clearAccountAuthorizationPoll();
304
+ const button = $("account-authorize");
305
+ button.disabled = true;
306
+ $("account-approval-link").hidden = true;
307
+ setError("account-authorization-error", "");
308
+ $("account-authorization-status").textContent = "Opening the secure Sealkeep approval page…";
309
+ setStatus("Waiting for approval", "busy");
310
+ // Open a harmless blank tab while the click gesture is still active. Waiting
311
+ // for the local request first makes Safari and stricter popup blockers turn
312
+ // this into a two-click flow. `opener` is removed before it ever navigates
313
+ // to the hosted origin, and the tab is closed again if setup cannot start.
314
+ let approvalWindow = null;
315
+ try {
316
+ approvalWindow = window.open("about:blank", "_blank");
317
+ if (approvalWindow) approvalWindow.opener = null;
318
+ } catch { /* The rendered link below is the blocker-safe fallback. */ }
319
+ try {
320
+ const started = await api.startDeviceAuthorization(label);
321
+ if (!/^[A-Za-z0-9._~-]{1,200}$/.test(String(started.requestId ?? "")) || typeof started.approvalUrl !== "string") {
322
+ throw new Error("Sealkeep did not return a valid account approval request. Nothing was linked.");
323
+ }
324
+ // The local API validates the hosted URL before it reaches this page. The
325
+ // extra protocol check prevents this renderer ever becoming an open link
326
+ // launcher if a future local endpoint regresses.
327
+ if (!started.approvalUrl.startsWith("https://")) {
328
+ throw new Error("Sealkeep did not return a secure account approval page. Nothing was linked.");
329
+ }
330
+ draft.deviceLabel = label;
331
+ draft.authorization = {
332
+ requestId: started.requestId,
333
+ approvalUrl: started.approvalUrl,
334
+ expiresAt: started.expiresAt,
335
+ state: "waiting_for_approval"
336
+ };
337
+ accountApprovalLink(started.approvalUrl);
338
+ $("account-authorization-status").textContent = "Finish signing in and approve this machine in the Sealkeep page we opened…";
339
+ button.textContent = "Waiting for approval";
340
+ if (approvalWindow && !approvalWindow.closed) approvalWindow.location.replace(started.approvalUrl);
341
+ // If a browser blocked the tab, the visible link remains a safe recovery
342
+ // without exposing a code or any local credential.
343
+ scheduleAccountAuthorizationPoll(started.requestId, started.pollAfterMs);
344
+ } catch (error) {
345
+ try { approvalWindow?.close(); } catch { /* already blocked or closed */ }
346
+ $("account-authorization-status").textContent = "";
347
+ setError("account-authorization-error", error.message);
348
+ button.disabled = false;
349
+ button.textContent = "Continue securely in your browser";
350
+ setStatus("Account linking failed", "warn");
351
+ }
352
+ };
353
+
132
354
  /* ── 2 · How archives are stored ─────────────────────────────────────── */
133
355
 
134
356
  // The radios are static markup — two fixed answers need no renderer — so the
@@ -204,6 +426,7 @@ function renderProviderFields() {
204
426
  // The account panel is the managed equivalent of the credentials form — the
205
427
  // one thing that choice does need from the person.
206
428
  $("account-panel").hidden = draft.intent !== "managed";
429
+ syncManagedContinue();
207
430
  setError("storage-error", "");
208
431
  setError("e-bucket", "");
209
432
  setError("e-prefix", "");
@@ -262,25 +485,251 @@ function collectStorage() {
262
485
  * lets a returning user finish here instead of in a terminal without weakening
263
486
  * the rule that no endpoint accepts a phrase.
264
487
  */
265
- $("enroll-go").onclick = () => {
488
+ const comparisonCode = (value) => String(value ?? "").replace(/\D/g, "").replace(/^(\d{3})(\d{3})$/, "$1 $2");
489
+
490
+ function scheduleEnrollmentPoll(requestId, delay = 3000) {
491
+ if (enrollmentPollTimer !== null) clearTimeout(enrollmentPollTimer);
492
+ const wait = Math.min(10_000, Math.max(1_000, Number(delay) || 3000));
493
+ enrollmentPollTimer = setTimeout(() => void pollDeviceEnrollment(requestId), wait);
494
+ }
495
+
496
+ function scheduleReceiptRetry(requestId) {
497
+ if (enrollmentReceiptTimer !== null) clearTimeout(enrollmentReceiptTimer);
498
+ enrollmentReceiptTimer = setTimeout(async () => {
499
+ if (draft.enrollment?.requestId !== requestId || draft.enrollment?.cloudReceiptCleared !== false) return;
500
+ try {
501
+ const result = await api.completeDeviceEnrollment(requestId);
502
+ if (result.status === "complete" && result.cloudReceiptPending !== true) {
503
+ draft.enrollment.cloudReceiptCleared = true;
504
+ return;
505
+ }
506
+ } catch { /* Adoption is complete; acknowledgement remains best-effort. */ }
507
+ scheduleReceiptRetry(requestId);
508
+ }, 5000);
509
+ }
510
+
511
+ function finishDeviceEnrollment(result) {
512
+ draft.createdAlready = true;
513
+ draft.keyBranch = "enrolled";
514
+ draft.vault = { vaultId: result.vaultId };
515
+ draft.enrollment = {
516
+ ...draft.enrollment,
517
+ state: "complete",
518
+ vaultId: result.vaultId,
519
+ cloudReceiptCleared: result.cloudReceiptPending !== true
520
+ };
521
+ $("device-enrollment-status").textContent = result.cloudReceiptPending === true
522
+ ? "Approved. Your existing vault is ready here; Sealkeep is finishing the hosted receipt in the background."
523
+ : "Approved. Your existing vault and preserved memories are ready on this machine.";
524
+ setError("device-enrollment-error", "");
525
+ $("device-enrollment-retry").hidden = true;
526
+ $("storage-continue").textContent = "Continue with your existing vault";
527
+ syncManagedContinue();
528
+ setStatus("Existing vault restored", "ok");
529
+ if (result.cloudReceiptPending === true) scheduleReceiptRetry(result.requestId);
530
+ }
531
+
532
+ const RECOVERY_CODE_PATTERN = /\bSK-(?:[0-9A-HJKMNP-TV-Z]{4}-){6}[0-9A-HJKMNP-TV-Z]{2}\b/gi;
533
+
534
+ function recoveryCodesFromFile(text) {
535
+ return [...new Set(String(text ?? "").match(RECOVERY_CODE_PATTERN) ?? [])].map((code) => code.toUpperCase());
536
+ }
537
+
538
+ function clearRecoveryInputs() {
539
+ $("f-recovery-code").value = "";
540
+ $("f-recovery-code-file").value = "";
541
+ recoveryFileCodes = [];
542
+ }
543
+
544
+ async function recoverWithBackupCode() {
545
+ const pasted = $("f-recovery-code").value.trim();
546
+ const candidates = pasted ? [pasted] : [...recoveryFileCodes];
547
+ if (!candidates.length) { setError("recovery-code-error", "Choose a saved Recovery Kit or paste one backup code."); return; }
548
+ const button = $("recovery-code-go");
549
+ button.disabled = true;
550
+ setError("recovery-code-error", "");
551
+ $("recovery-code-status").textContent = candidates.length > 1 ? "Securely checking the saved one-time codes…" : "Securely checking this one-time code…";
552
+ setStatus("Recovering existing vault", "busy");
553
+ let lastError = null;
554
+ try {
555
+ for (const candidate of candidates) {
556
+ try {
557
+ const result = await api.recoverWithBackupCode(candidate);
558
+ finishDeviceEnrollment({ ...result, cloudReceiptPending: false });
559
+ $("recovery-code-status").textContent = `Recovered. ${result.remaining} unused backup code${result.remaining === 1 ? " remains" : "s remain"}.`;
560
+ return;
561
+ } catch (error) {
562
+ lastError = error;
563
+ if (error.status !== 400 && error.status !== 409 && error.code !== "recovery_phrase_mismatch" && error.code !== "queue_lease_expired") throw error;
564
+ }
565
+ }
566
+ throw lastError ?? new Error("No unused backup code was accepted.");
567
+ } catch (error) {
568
+ setError("recovery-code-error", error.message);
569
+ $("recovery-code-status").textContent = "This recovery code was not used.";
570
+ setStatus("Recovery code was not used", "warn");
571
+ } finally {
572
+ clearRecoveryInputs();
573
+ button.disabled = false;
574
+ }
575
+ }
576
+
577
+ $("recovery-code-go").onclick = () => void recoverWithBackupCode();
578
+ $("f-recovery-code-file").onchange = async () => {
579
+ recoveryFileCodes = [];
580
+ $("f-recovery-code").value = "";
581
+ const file = $("f-recovery-code-file").files?.[0];
582
+ if (!file) return;
583
+ let text = "";
584
+ try {
585
+ text = await file.text();
586
+ recoveryFileCodes = recoveryCodesFromFile(text);
587
+ $("recovery-code-status").textContent = recoveryFileCodes.length
588
+ ? `Found ${recoveryFileCodes.length} formatted backup code${recoveryFileCodes.length === 1 ? "" : "s"}. Recover this machine to try them.`
589
+ : "No formatted backup codes were found. Paste one code below instead.";
590
+ setError("recovery-code-error", "");
591
+ } catch {
592
+ setError("recovery-code-error", "Sealkeep could not read that local text file. Paste one backup code below instead.");
593
+ } finally {
594
+ text = "";
595
+ $("f-recovery-code-file").value = "";
596
+ }
597
+ };
598
+
599
+ async function pollDeviceEnrollment(requestId) {
600
+ if (draft.enrollment?.requestId !== requestId || draft.enrollment?.state === "complete") return;
601
+ try {
602
+ const result = await api.completeDeviceEnrollment(requestId);
603
+ if (result.status === "waiting_for_approval") {
604
+ draft.enrollment.state = "waiting_for_approval";
605
+ if (result.expiresAt) draft.enrollment.expiresAt = result.expiresAt;
606
+ $("device-enrollment-status").textContent = "Waiting for approval on a device that already opens this vault…";
607
+ setError("device-enrollment-error", "");
608
+ scheduleEnrollmentPoll(requestId, result.retryAfterMs);
609
+ return;
610
+ }
611
+ if (result.status === "expired") {
612
+ $("device-enrollment-status").textContent = "";
613
+ setError("device-enrollment-error", "This enrollment request expired before it was approved. Start a new request; no vault was created.");
614
+ $("device-enrollment-retry").hidden = false;
615
+ return;
616
+ }
617
+ if (result.status === "complete" && result.vaultId) {
618
+ finishDeviceEnrollment(result);
619
+ return;
620
+ }
621
+ setError("device-enrollment-error", "Sealkeep returned an unfamiliar enrollment state. Start a new request rather than creating another vault.");
622
+ $("device-enrollment-retry").hidden = false;
623
+ } catch (error) {
624
+ if (draft.enrollment?.requestId !== requestId) return;
625
+ const restartable = error.status === 404 || error.status === 410;
626
+ const transient = !error.status || error.status >= 500;
627
+ setError("device-enrollment-error", transient
628
+ ? "This machine could not check approval just now. It will keep trying."
629
+ : error.message);
630
+ if (transient) scheduleEnrollmentPoll(requestId);
631
+ else $("device-enrollment-retry").hidden = !restartable;
632
+ }
633
+ }
634
+
635
+ async function beginPreservedMemoryEnrollment() {
636
+ const panel = $("device-enrollment");
637
+ const retry = $("device-enrollment-retry");
638
+ panel.hidden = false;
639
+ retry.hidden = true;
640
+ $("device-enrollment-comparison").hidden = true;
641
+ $("device-enrollment-status").textContent = "Creating a private request for this machine…";
642
+ setError("device-enrollment-error", "");
643
+ draft.enrollment = { state: "starting" };
644
+ syncManagedContinue();
645
+ try {
646
+ const started = await api.startDeviceEnrollment(draft.deviceLabel);
647
+ resumePreservedMemoryEnrollment(started);
648
+ } catch (error) {
649
+ draft.enrollment = { state: "error" };
650
+ $("device-enrollment-status").textContent = "";
651
+ setError("device-enrollment-error", error.message);
652
+ retry.hidden = false;
653
+ }
654
+ }
655
+
656
+ /**
657
+ * Uses an enrollment that the hosted authorization service already made from
658
+ * the exact public key the account holder reviewed. There is no second
659
+ * enrollment request in this path, so the approval page and the old machine
660
+ * refer to one target identity.
661
+ */
662
+ function resumePreservedMemoryEnrollment(started) {
663
+ if (!Number.isSafeInteger(started?.requestId) || !/^\d{6}$/.test(String(started.verificationCode ?? ""))) {
664
+ throw new Error("Sealkeep did not return a valid preserved-memory request. No vault was created; start another approval.");
665
+ }
666
+ const panel = $("device-enrollment");
667
+ panel.hidden = false;
668
+ $("device-enrollment-retry").hidden = true;
669
+ draft.enrollment = { ...started, state: "waiting_for_approval" };
670
+ $("device-enrollment-code").textContent = comparisonCode(started.verificationCode);
671
+ $("device-enrollment-comparison").hidden = false;
672
+ $("device-enrollment-expiry").textContent = started.expiresAt
673
+ ? `This request expires at ${new Date(started.expiresAt).toLocaleTimeString()}.`
674
+ : "This request expires shortly.";
675
+ $("device-enrollment-status").textContent = "Waiting for approval on a device that already opens this vault…";
676
+ setError("device-enrollment-error", "");
677
+ scheduleEnrollmentPoll(started.requestId);
678
+ }
679
+
680
+ $("device-enrollment-retry").onclick = () => void beginPreservedMemoryEnrollment();
681
+
682
+ $("enroll-go").onclick = async () => {
266
683
  const email = $("f-enroll-email").value.trim();
267
684
  const code = $("f-enroll-code").value.trim();
685
+ const label = $("f-device-label").value.trim();
268
686
  // Both, because the exchange needs the pair — the panel hands them over
269
687
  // together, in the command it prints beside the code.
270
- const missing = !email ? "Enter the email your account uses." : !code ? "Press “Show a pairing code” on your account page, then paste it here." : "";
688
+ const missing = !email
689
+ ? "Enter the email your account uses."
690
+ : !code
691
+ ? "Press “Show a pairing code” on your account page, then paste it here."
692
+ : !label
693
+ ? "Name this machine so you can recognise it on the approving device."
694
+ : "";
271
695
  setError("e-enroll-code", missing);
272
696
  if (missing) return;
273
697
 
274
- // Held, not sent. Linking stores a token, and there is nowhere to keep it
275
- // until the vault exists — which happens at step 4, after this. The code is
276
- // single use, so spending it here would burn it on a machine that could not
277
- // have kept the result.
278
- draft.enrollEmail = email;
279
- draft.enrollCode = code;
280
- // Not "Saved" — nothing has been checked yet. The pairing code is single-use
281
- // and is only sent when the vault is created, so a wrong or expired one shows
282
- // up on the last screen (where the account row now reports it), not here.
283
- $("enroll-result").textContent = "Got it — this machine links to your account when the vault is created at the end. If the code is wrong, you'll see it on the last screen.";
698
+ const button = $("enroll-go");
699
+ button.disabled = true;
700
+ $("enroll-result").textContent = "Linking this machine now…";
701
+ setStatus("Linking this machine", "busy");
702
+ let enrolled;
703
+ try {
704
+ // Immediate by design. Bootstrap keystore references protect the account
705
+ // token and machine proof even though a fresh device has no config.json yet.
706
+ enrolled = await api.enrollDevice(email, code);
707
+ } catch (error) {
708
+ setError("e-enroll-code", error.message);
709
+ $("enroll-result").textContent = "";
710
+ button.disabled = false;
711
+ setStatus("Account linking failed", "warn");
712
+ return;
713
+ }
714
+
715
+ // A pairing code is single use. Once exchanged it has no reason to remain in
716
+ // a browser field, draft object, URL or later request.
717
+ $("f-enroll-code").value = "";
718
+ draft.account = enrolled.account ?? { plan: "unknown", email };
719
+ draft.deviceLabel = label;
720
+ const hasPreservedMemories = enrolled.memory?.preserved === true;
721
+ draft.needsEnrollment = hasPreservedMemories && !draft.createdAlready;
722
+ for (const id of ["f-enroll-email", "f-enroll-code", "f-device-label"]) $(id).disabled = true;
723
+ $("account-start-actions").hidden = true;
724
+ button.textContent = "Account linked";
725
+ $("enroll-result").textContent = draft.needsEnrollment
726
+ ? "Account linked. Preserved memories were found, so this machine needs approval from one that already opens them."
727
+ : hasPreservedMemories
728
+ ? "Account linked. This machine already has a vault, so its existing local identity remains in place."
729
+ : "Account linked. This account has no preserved memories to transfer, so setup will create its first vault next.";
730
+ setStatus("Account linked", "ok");
731
+ syncManagedContinue();
732
+ if (draft.needsEnrollment) await beginPreservedMemoryEnrollment();
284
733
  };
285
734
 
286
735
  /**
@@ -323,8 +772,15 @@ $("storage-test").onclick = async () => {
323
772
  */
324
773
  function armKeyScreen() {
325
774
  const made = Boolean(phraseWords);
326
- const branch = draft.createdAlready ? "existing" : made ? draft.keyBranch : keyMode(draft.intent);
775
+ const branch = draft.keyBranch === "enrolled"
776
+ ? "enrolled"
777
+ : draft.createdAlready
778
+ ? "existing"
779
+ : made
780
+ ? draft.keyBranch
781
+ : keyMode(draft.intent);
327
782
  $("phrase-existing").hidden = branch !== "existing";
783
+ $("phrase-enrolled").hidden = branch !== "enrolled";
328
784
  $("phrase-before").hidden = branch !== "phrase" || made;
329
785
  $("phrase-after").hidden = branch !== "phrase" || !made;
330
786
  $("password-before").hidden = branch !== "password" || made;
@@ -351,6 +807,8 @@ function wordList(target, words) {
351
807
  }
352
808
 
353
809
  function renderPhrase(words) {
810
+ const recoveryBox = $("recovery-codes");
811
+ $("phrase-words").after(recoveryBox);
354
812
  wordList($("phrase-words"), words);
355
813
 
356
814
  verifyPositions = verificationPositions(words.length);
@@ -396,6 +854,8 @@ function renderPhrase(words) {
396
854
  * browser prompt insisting otherwise would be a lie.
397
855
  */
398
856
  function renderFold(words) {
857
+ const recoveryBox = $("recovery-codes");
858
+ $("password-after").insertBefore(recoveryBox, $("password-after").querySelector(".wizfoot"));
399
859
  wordList($("fold-phrase-words"), words);
400
860
  $("fold-note").textContent = `These ${words.length} words also open the vault — write them down or skip. Skipping is fine: your password already opens it on this machine. The words are the only key that works anywhere else, and this is the only time they are shown.`;
401
861
  armKeyScreen();
@@ -421,14 +881,44 @@ function gatePhrase() {
421
881
  /** After this returns, the phrase does not exist anywhere in this page. */
422
882
  function forgetPhrase() {
423
883
  phraseWords = null;
884
+ recoveryCodes = [];
424
885
  verifyPositions = [];
425
886
  $("phrase-words").replaceChildren();
426
887
  $("fold-phrase-words").replaceChildren();
888
+ $("recovery-code-list").replaceChildren();
889
+ $("recovery-codes").hidden = true;
427
890
  $("fold-note").textContent = "";
428
891
  $("verify-fields").replaceChildren();
429
892
  disarmUnloadGuard();
430
893
  }
431
894
 
895
+ function renderRecoveryCodes(codes) {
896
+ recoveryCodes = Array.isArray(codes) ? codes.filter((code) => typeof code === "string" && code.startsWith("SK-")) : [];
897
+ const box = $("recovery-codes");
898
+ box.hidden = recoveryCodes.length === 0;
899
+ const list = $("recovery-code-list");
900
+ list.replaceChildren(...recoveryCodes.map((code, index) => {
901
+ const item = document.createElement("li");
902
+ item.textContent = `${index + 1}. ${code}`;
903
+ return item;
904
+ }));
905
+ }
906
+
907
+ $("copy-recovery-codes").onclick = async () => {
908
+ await navigator.clipboard.writeText(recoveryCodes.join("\n"));
909
+ $("recovery-codes-note").textContent = "Copied. Paste them into an offline password manager or print them, then clear the clipboard.";
910
+ };
911
+
912
+ $("download-recovery-codes").onclick = () => {
913
+ const body = `SEALKEEP RECOVERY KIT\n\nOne-time backup codes\n\n${recoveryCodes.map((code, index) => `${index + 1}. ${code}`).join("\n")}\n\nSign in with Google on a fresh machine, then enter one unused code. Each code works once.\n`;
914
+ const url = URL.createObjectURL(new Blob([body], { type: "text/plain" }));
915
+ const link = document.createElement("a");
916
+ link.href = url;
917
+ link.download = "sealkeep-recovery-kit.txt";
918
+ link.click();
919
+ setTimeout(() => URL.revokeObjectURL(url), 0);
920
+ };
921
+
432
922
  /* ── Committing ──────────────────────────────────────────────────────── */
433
923
 
434
924
  async function createVault({ password } = {}) {
@@ -459,6 +949,7 @@ async function createVault({ password } = {}) {
459
949
  draft.vault = { dataDir: created.dataDir, vaultId: created.vaultId };
460
950
  draft.keyBranch = password === undefined ? "phrase" : "password";
461
951
  phraseWords = words;
952
+ renderRecoveryCodes(created.recoveryCodes);
462
953
  if (draft.keyBranch === "phrase") {
463
954
  renderPhrase(words);
464
955
  setStatus("Vault created — write the phrase down", "ok");
@@ -494,19 +985,15 @@ async function commitRest() {
494
985
  storageMode: draft.createdAlready ? null : draft.storageMode,
495
986
  password: null,
496
987
  storage: null,
497
- service: null
988
+ service: null,
989
+ mcp: null,
990
+ enrolled: draft.keyBranch === "enrolled"
498
991
  };
499
992
 
500
- // Enrolment first, because it is what tells this machine it has managed
501
- // storage at all: the account's own settings arrive with it.
502
- if (draft.enrollCode) {
503
- try {
504
- const enrolled = await api.enrollDevice(draft.enrollEmail, draft.enrollCode);
505
- result.account = { plan: enrolled.account?.plan ?? "unknown", deviceId: enrolled.deviceId };
506
- } catch (error) {
507
- result.account = { error: error.message };
508
- }
509
- }
993
+ // Account linking already happened on the storage screen. Delaying it until
994
+ // here would make it impossible to discover and adopt an existing vault
995
+ // before the new-user ceremony created a conflicting one.
996
+ if (draft.account) result.account = { plan: draft.account.plan ?? "unknown" };
510
997
 
511
998
  if (needsBucket(draft.providerId)) {
512
999
  const payload = buildStoragePayload(draft.providerId, draft.storage);
@@ -531,8 +1018,17 @@ async function commitRest() {
531
1018
  }
532
1019
 
533
1020
  try {
534
- const service = await api.startService();
535
- result.service = { running: service.running !== false, detail: service.detail };
1021
+ const response = await api.startService();
1022
+ const service = response.service ?? {};
1023
+ result.service = {
1024
+ installed: service.installed === true,
1025
+ running: service.running === true,
1026
+ waitingForUnlock: service.installed === true && response.phraseAvailable === false,
1027
+ phraseAvailable: response.phraseAvailable,
1028
+ detail: service.note ?? (service.alreadyInstalled ? "Already running at login." : undefined)
1029
+ };
1030
+ result.mcp = response.mcp ?? null;
1031
+ result.hooks = response.hooks ?? null;
536
1032
  } catch (error) {
537
1033
  result.service = { running: false, error: error.message };
538
1034
  }
@@ -541,6 +1037,17 @@ async function commitRest() {
541
1037
 
542
1038
  function renderDone(result) {
543
1039
  const rows = summariseDone(result);
1040
+ if (result.enrolled) {
1041
+ const vault = rows.find((row) => row.label === "Vault");
1042
+ if (vault) {
1043
+ vault.badge = { text: "restored", kind: "verified" };
1044
+ vault.detail = "Your existing vault identity was encrypted for this machine by an approved device and opened locally. Setup did not create a replacement vault.";
1045
+ }
1046
+ const account = rows.find((row) => row.label === "Sealkeep account");
1047
+ if (account) account.detail = `This machine is linked to your account${result.account?.plan && result.account.plan !== "unknown" ? ` (${result.account.plan})` : ""}. Managed storage is on, and the restored vault keeps the storage mode you originally chose.`;
1048
+ const storage = rows.find((row) => row.label === "Storage");
1049
+ if (storage) storage.detail = "Your account's storage receives archives from this restored vault. Its existing sealed-or-plain setting was preserved rather than guessed or changed by setup.";
1050
+ }
544
1051
  const headline = doneHeadline(rows);
545
1052
  $("done-headline").textContent = headline;
546
1053
  const list = $("done-list");
@@ -553,19 +1060,27 @@ function renderDone(result) {
553
1060
  list.append(line);
554
1061
  }
555
1062
  const offMachine = (result.storage && !result.storage.error) || (result.account && !result.account.error);
556
- $("next-detail").textContent = offMachine
557
- ? "Nothing else is needed. The next time one of your agents finishes a session, the transcript is queued here, archived, and copied to your storage. It shows up on the dashboard within a few minutes."
558
- : "Nothing else is needed. The next time one of your agents finishes a session, the transcript is queued here and archived onto this disk. It shows up on the dashboard within a few minutes.";
1063
+ const hookAttention = Array.isArray(result.hooks)
1064
+ && result.hooks.some((hook) => hook.installed && hook.active === false);
1065
+ $("next-detail").textContent = result.service?.waitingForUnlock
1066
+ ? "The background service and supported agent connections are installed, but this machine is still locked. Open the dashboard, choose Settings › Unlocking, and store the recovery phrase locally. Queued work then resumes automatically."
1067
+ : hookAttention
1068
+ ? `The background service is already preserving supported sessions automatically${offMachine ? " and copying them to your storage" : " on this disk"}. Codex lifecycle actions remain off until you review or re-enable them in Codex /hooks; Sealkeep did not change that choice.`
1069
+ : offMachine
1070
+ ? "Nothing else is needed. Sealkeep has connected the supported coding agents on this machine. Their finished sessions are preserved and copied to your storage automatically, and their next sessions can search that memory locally."
1071
+ : "Nothing else is needed. Sealkeep has connected the supported coding agents on this machine. Their finished sessions are preserved on this disk automatically, and their next sessions can search that memory locally.";
559
1072
  // A password vault may have skipped the words, and that was allowed out
560
1073
  // loud — so the advice bends rather than scolding.
561
- $("today-key").textContent = result.password
562
- ? "If you kept the recovery phrase, put it somewhere you will still have it in a year — a drawer beats a laptop."
563
- : "Check that your recovery phrase is somewhere you will still have it in a year — a drawer beats a laptop.";
1074
+ $("today-key").textContent = result.enrolled
1075
+ ? "Keep an already-approved device available as a second way to bring this vault onto a future machine."
1076
+ : result.password
1077
+ ? "If you kept the recovery phrase, put it somewhere you will still have it in a year — a drawer beats a laptop."
1078
+ : "Check that your recovery phrase is somewhere you will still have it in a year — a drawer beats a laptop.";
564
1079
  return headline;
565
1080
  }
566
1081
 
567
1082
  async function finish() {
568
- for (const id of ["finish", "finish-existing", "finish-password"]) {
1083
+ for (const id of ["finish", "finish-existing", "finish-password", "finish-enrolled"]) {
569
1084
  const button = $(id);
570
1085
  if (button) button.disabled = true;
571
1086
  }
@@ -582,6 +1097,13 @@ async function finish() {
582
1097
  /* ── Navigation ──────────────────────────────────────────────────────── */
583
1098
 
584
1099
  function go(next) {
1100
+ // Setup state decides whether this is a new vault or an existing one. Until
1101
+ // that answer arrives, advancing could offer a second key ceremony for a
1102
+ // vault that already exists. Keep the page readable, but fail closed.
1103
+ if (!machineReady) {
1104
+ setStatus("Still checking this machine", "busy");
1105
+ return;
1106
+ }
585
1107
  // Leaving the storage step: an unanswered sub-choice is its own error, and a
586
1108
  // named provider still has to have its fields filled in.
587
1109
  if (next === 4 && step === 3) {
@@ -590,6 +1112,14 @@ function go(next) {
590
1112
  return;
591
1113
  }
592
1114
  if (needsBucket(draft.providerId) && !collectStorage()) return;
1115
+ if (draft.intent === "managed" && !draft.account) {
1116
+ setError("storage-error", "Link this machine to your Sealkeep account before continuing.");
1117
+ return;
1118
+ }
1119
+ if (draft.intent === "managed" && draft.needsEnrollment && draft.enrollment?.state !== "complete") {
1120
+ setError("storage-error", "Approve this machine from a device that already opens your vault. Setup will continue after the existing vault arrives.");
1121
+ return;
1122
+ }
593
1123
  }
594
1124
  if (next === 4) armKeyScreen();
595
1125
  show(next);
@@ -607,6 +1137,7 @@ $("f-understood").onchange = gatePhrase;
607
1137
  $("confirm-form").onsubmit = (event) => { event.preventDefault(); if (confirmed) void finish(); };
608
1138
  $("finish-existing").onclick = () => void finish();
609
1139
  $("finish-password").onclick = () => void finish();
1140
+ $("finish-enrolled").onclick = () => void finish();
610
1141
 
611
1142
  // The password is judged as it is typed — by length, the one property the
612
1143
  // hint admits to caring about — and judged again on submit, so the create
@@ -639,10 +1170,19 @@ $("print-phrase").onclick = () => { if (typeof print === "function") print(); };
639
1170
  /* ── Boot ────────────────────────────────────────────────────────────── */
640
1171
 
641
1172
  async function boot() {
642
- api.readTokenFromLocation();
643
- // Nothing works without it, so ask for it up front rather than letting every
644
- // panel fail separately and leaving the reader to assemble the reason.
645
- if (!api.hasToken()) { showGate(); return; }
1173
+ machineReady = false;
1174
+ renderProviders();
1175
+ renderOwnProviders();
1176
+ renderProviderFields();
1177
+ show(1);
1178
+ setStatus("Checking this machine", "busy");
1179
+ // Redeem a single-use launch ticket before any protected setup request. A
1180
+ // returning tab reuses its session-only bearer; a direct visit from another
1181
+ // browser receives its own short-lived ticket from the local service.
1182
+ if (!await api.readTokenFromLocation()) {
1183
+ showGate("This browser is not signed in yet. Request access here; no Sealkeep command or token copy is needed.");
1184
+ return;
1185
+ }
646
1186
  try {
647
1187
  const state = await api.fetchState();
648
1188
  draft.createdAlready = Boolean(state.vault?.created);
@@ -665,13 +1205,19 @@ async function boot() {
665
1205
  draft.storage.prefix = state.storage.prefix ?? "sealkeep";
666
1206
  }
667
1207
  } catch (error) {
1208
+ if (error.status === 401 || error.status === 403) {
1209
+ showGate(error.message);
1210
+ return;
1211
+ }
668
1212
  if (error.missing) {
669
1213
  showUnavailable(`Sealkeep answered on this machine, but it has no ${error.route} endpoint. This build predates the setup wizard.`);
670
1214
  return;
671
1215
  }
672
- setStatus(error.message, "warn");
1216
+ showUnavailable(error.message);
1217
+ return;
673
1218
  }
674
1219
 
1220
+ machineReady = true;
675
1221
  renderProviders();
676
1222
  renderOwnProviders();
677
1223
  renderProviderFields();
@@ -680,25 +1226,12 @@ async function boot() {
680
1226
  setStatus("Connected to this machine", "ok");
681
1227
  }
682
1228
 
683
- /**
684
- * A pasted token is tried before it is trusted: a wrong one that merely got
685
- * stored would put someone back on step 1 with every panel empty and no reason
686
- * given, which is the failure this screen exists to end.
687
- */
688
1229
  $("gate-form").addEventListener("submit", async (event) => {
689
1230
  event.preventDefault();
690
- const value = $("gate-token").value.trim();
691
- if (!value) { setError("gate-error", "Paste the token from the file named above."); return; }
692
-
693
- api.setToken(value);
694
- try {
695
- await api.fetchState();
696
- } catch (error) {
697
- api.setToken("");
698
- setError("gate-error", error.message.includes("not signed in") ? "That token was not accepted." : error.message);
1231
+ if (!api.hasToken()) {
1232
+ await requestBrowserHandoff();
699
1233
  return;
700
1234
  }
701
- $("gate-token").value = "";
702
1235
  await boot();
703
1236
  });
704
1237