sealkeep 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (274) hide show
  1. package/ARCHITECTURE.md +163 -14
  2. package/CHANGELOG.md +223 -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 +1698 -163
  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.js +27 -21
  150. package/dist/src/notify.d.ts +1 -1
  151. package/dist/src/notify.js +5 -5
  152. package/dist/src/offload.d.ts +201 -14
  153. package/dist/src/offload.js +1848 -140
  154. package/dist/src/onboarding.d.ts +8 -1
  155. package/dist/src/onboarding.js +4 -4
  156. package/dist/src/packages.d.ts +2 -2
  157. package/dist/src/packages.js +10 -2
  158. package/dist/src/passkey.d.ts +0 -1
  159. package/dist/src/passkey.js +2 -7
  160. package/dist/src/password-lock.d.ts +2 -2
  161. package/dist/src/password-lock.js +6 -6
  162. package/dist/src/paths.d.ts +2 -0
  163. package/dist/src/paths.js +2 -0
  164. package/dist/src/presence.d.ts +86 -0
  165. package/dist/src/presence.js +240 -0
  166. package/dist/src/progress-deadline.d.ts +21 -0
  167. package/dist/src/progress-deadline.js +91 -0
  168. package/dist/src/project-repair.d.ts +55 -0
  169. package/dist/src/project-repair.js +131 -0
  170. package/dist/src/providers/gcs.d.ts +28 -7
  171. package/dist/src/providers/gcs.js +35 -24
  172. package/dist/src/providers/gdrive.d.ts +71 -8
  173. package/dist/src/providers/gdrive.js +223 -50
  174. package/dist/src/providers/index.d.ts +11 -3
  175. package/dist/src/providers/index.js +30 -10
  176. package/dist/src/providers/s3.d.ts +30 -8
  177. package/dist/src/providers/s3.js +41 -30
  178. package/dist/src/providers/safe-storage-fetch.d.ts +12 -0
  179. package/dist/src/providers/safe-storage-fetch.js +72 -0
  180. package/dist/src/queue.d.ts +136 -19
  181. package/dist/src/queue.js +862 -96
  182. package/dist/src/reclaim-transaction.d.ts +156 -0
  183. package/dist/src/reclaim-transaction.js +1027 -0
  184. package/dist/src/recovery-codes.d.ts +32 -0
  185. package/dist/src/recovery-codes.js +338 -0
  186. package/dist/src/recovery.js +12 -9
  187. package/dist/src/rehydrate.d.ts +25 -22
  188. package/dist/src/rehydrate.js +319 -23
  189. package/dist/src/restore.d.ts +57 -4
  190. package/dist/src/restore.js +272 -36
  191. package/dist/src/resume-stub.d.ts +92 -0
  192. package/dist/src/resume-stub.js +417 -0
  193. package/dist/src/retention.d.ts +98 -7
  194. package/dist/src/retention.js +1066 -62
  195. package/dist/src/rotate.js +3 -3
  196. package/dist/src/search.d.ts +566 -8
  197. package/dist/src/search.js +5897 -290
  198. package/dist/src/secrets.d.ts +51 -7
  199. package/dist/src/secrets.js +316 -24
  200. package/dist/src/service.d.ts +49 -11
  201. package/dist/src/service.js +776 -35
  202. package/dist/src/share.js +3 -3
  203. package/dist/src/shared-spaces.d.ts +98 -0
  204. package/dist/src/shared-spaces.js +214 -0
  205. package/dist/src/source-reader.d.ts +73 -0
  206. package/dist/src/source-reader.js +715 -0
  207. package/dist/src/spool.d.ts +1 -1
  208. package/dist/src/spool.js +1 -1
  209. package/dist/src/start-tui.js +2 -1
  210. package/dist/src/start.js +2 -2
  211. package/dist/src/storage-endpoint.d.ts +21 -0
  212. package/dist/src/storage-endpoint.js +122 -0
  213. package/dist/src/storage-setup.js +12 -12
  214. package/dist/src/storage-targets.d.ts +109 -6
  215. package/dist/src/storage-targets.js +975 -67
  216. package/dist/src/stream-to-cloud.d.ts +5 -1
  217. package/dist/src/stream-to-cloud.js +34 -14
  218. package/dist/src/sync-rules.d.ts +31 -6
  219. package/dist/src/sync-rules.js +153 -14
  220. package/dist/src/team-backfill-scheduling.d.ts +8 -0
  221. package/dist/src/team-backfill-scheduling.js +33 -0
  222. package/dist/src/team-backfill.d.ts +116 -0
  223. package/dist/src/team-backfill.js +1429 -0
  224. package/dist/src/team-index-cache.d.ts +16 -0
  225. package/dist/src/team-index-cache.js +152 -0
  226. package/dist/src/team-offboarding.d.ts +38 -0
  227. package/dist/src/team-offboarding.js +1043 -0
  228. package/dist/src/team-presence.d.ts +127 -0
  229. package/dist/src/team-presence.js +904 -0
  230. package/dist/src/team-publication-policy.d.ts +20 -0
  231. package/dist/src/team-publication-policy.js +140 -0
  232. package/dist/src/team-realtime.d.ts +68 -0
  233. package/dist/src/team-realtime.js +816 -0
  234. package/dist/src/team-source-facts-cache.d.ts +23 -0
  235. package/dist/src/team-source-facts-cache.js +255 -0
  236. package/dist/src/trash.d.ts +1 -1
  237. package/dist/src/trash.js +2 -2
  238. package/dist/src/tui.js +11 -12
  239. package/dist/src/types.d.ts +173 -7
  240. package/dist/src/types.js +20 -0
  241. package/dist/src/ui-server.d.ts +163 -35
  242. package/dist/src/ui-server.js +712 -72
  243. package/dist/src/ui.d.ts +1 -2
  244. package/dist/src/ui.js +1 -2
  245. package/dist/src/upload.d.ts +27 -0
  246. package/dist/src/upload.js +383 -43
  247. package/dist/src/vault.d.ts +226 -30
  248. package/dist/src/vault.js +1776 -192
  249. package/dist/src/watcher.d.ts +7 -1
  250. package/dist/src/watcher.js +198 -55
  251. package/dist/src/worker.d.ts +27 -3
  252. package/dist/src/worker.js +274 -55
  253. package/package.json +33 -12
  254. package/scripts/native-reboot-rehearsal.mjs +90 -0
  255. package/web/app.js +6032 -343
  256. package/web/bootstrap.js +17 -0
  257. package/web/index.html +255 -57
  258. package/web/rail.js +317 -40
  259. package/web/retention.html +2 -2
  260. package/web/rules-view.js +188 -16
  261. package/web/sessions-view.js +485 -62
  262. package/web/sessions.html +2 -2
  263. package/web/setup-api.js +152 -29
  264. package/web/setup-logic.js +68 -9
  265. package/web/setup.html +113 -44
  266. package/web/setup.js +604 -71
  267. package/web/style.css +513 -98
  268. package/dist/packages/vaultline-crypto/src/chunk-access.js +0 -93
  269. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/aead.d.ts +0 -0
  270. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/aead.js +0 -0
  271. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/format.js +0 -0
  272. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/recipients.d.ts +0 -0
  273. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/sha256-stream.d.ts +0 -0
  274. /package/dist/packages/{vaultline-crypto → sealkeep-crypto}/src/sha256-stream.js +0 -0
package/web/rail.js CHANGED
@@ -8,10 +8,22 @@
8
8
  * `.acct` markup and an `api(path)` helper can render the same card with one
9
9
  * call; pages without the markup are a no-op, never an error.
10
10
  */
11
- function renderAccountRail(api, status) {
11
+ function renderAccountRail(api, status, options = {}) {
12
12
  const $id = (id) => document.getElementById(id);
13
+ // Dashboard callers bind this render to one vault/account generation. A
14
+ // setup, target, or offer request can outlive a sign-in change, so every
15
+ // continuation checks the binding again before it touches the shared rail
16
+ // or its remembered snapshot.
17
+ const isCurrent = typeof options.isCurrent === "function" ? options.isCurrent : () => true;
18
+ if (!isCurrent()) return Promise.resolve();
19
+ try { localStorage.removeItem("sealkeep.localApiToken"); } catch { /* upgrade cleanup is best-effort */ }
13
20
  const card = $id("acct-card");
14
21
  if (!card) return Promise.resolve();
22
+ // A newer rail render owns upload polling from this point onward. Without
23
+ // stopping the prior generation here, its timer can remain installed until
24
+ // the next tick; the new generation then sees a timer and never starts its
25
+ // own poll after the old one retires itself as stale.
26
+ if (!options.cachedOnly) railStopUploadPoll();
15
27
 
16
28
  // Identical to the app's own bytes(): binary units, one decimal. The first
17
29
  // version divided by 1e9, so the same quota read "536.9 GB" in this card and
@@ -27,39 +39,119 @@ function renderAccountRail(api, status) {
27
39
 
28
40
  // The card is a fixed skeleton: the markup reserves every slot, and this
29
41
  // function only ever overwrites text in place — nothing appears or grows, so
30
- // the nav below never jumps. Two paints: first from the last visit's
31
- // snapshot (instant, this browser already knew these numbers), then from the
32
- // live answer, which also refreshes the snapshot.
33
- const SNAPSHOT_KEY = "vaultline.railSnapshot";
42
+ // the nav below never jumps. After live status identifies the vault, two
43
+ // paints: first from the last visit's matching snapshot, then from the
44
+ // remaining live answers, which also refresh the snapshot.
45
+ const SNAPSHOT_KEY = "sealkeep.railSnapshot";
46
+ let activeVaultId = status?.vaultId || null;
47
+ const authBinding = (() => {
48
+ const value = sessionStorage.getItem("sealkeep.localApiToken");
49
+ if (!value) return null;
50
+ let left = 0x811c9dc5;
51
+ let right = 0x9e3779b9;
52
+ for (let index = 0; index < value.length; index += 1) {
53
+ const code = value.charCodeAt(index);
54
+ left = Math.imul(left ^ code, 0x01000193);
55
+ right = Math.imul(right ^ (code + index), 0x85ebca6b);
56
+ }
57
+ return `${(left >>> 0).toString(36)}.${(right >>> 0).toString(36)}.${value.length}`;
58
+ })();
59
+ const readSnapshot = (expectedVaultId = null) => {
60
+ try {
61
+ const snapshot = JSON.parse(localStorage.getItem(SNAPSHOT_KEY) || sessionStorage.getItem(SNAPSHOT_KEY) || "{}");
62
+ // The same loopback origin can later serve another vault. Refuse a legacy
63
+ // or differently bound snapshot before painting even one account fact;
64
+ // live status will repopulate it for this service.
65
+ return authBinding && expectedVaultId && snapshot.authBinding === authBinding && snapshot.vaultId === expectedVaultId ? snapshot : {};
66
+ }
67
+ catch { return {}; }
68
+ };
69
+ const saveSnapshot = (patch) => {
70
+ if (!isCurrent()) return;
71
+ const vaultId = patch.vaultId || activeVaultId;
72
+ if (!vaultId) return;
73
+ try { localStorage.setItem(SNAPSHOT_KEY, JSON.stringify({ ...readSnapshot(vaultId), ...patch, vaultId })); }
74
+ catch { /* a browser refusing storage costs only the instant repaint */ }
75
+ };
76
+ // The HTML reserves these slots so the navigation never jumps, but a bare
77
+ // dash is not a fact. Start each render with unknown slots hidden; the last
78
+ // verified snapshot (or the live response below) unhides only what it can
79
+ // actually name. Re-renders happen in one JS turn, so a remembered rail does
80
+ // not flash away while it is repainted.
81
+ for (const id of ["acct-plan", "acct-email", "acct-meter", "acct-usage", "acct-phrase", "acct-storage", "acct-disk"]) {
82
+ const node = $id(id);
83
+ // Visibility keeps the skeleton's footprint, so the navigation below does
84
+ // not jump while still ensuring no placeholder is user-visible.
85
+ if (node) node.style.visibility = "hidden";
86
+ }
34
87
  const paintIdentity = (snap) => {
88
+ if (!isCurrent()) return;
35
89
  const plan = snap.plan;
36
90
  const isPro = plan === "pro" || plan === "pro_lite";
37
91
  $id("acct-plan").className = `acct-plan${isPro ? " pro" : ""}`;
38
92
  $id("acct-plan-name").textContent = plan === "pro" ? "Sealkeep Pro" : plan === "pro_lite" ? "Sealkeep Pro Lite" : plan ? plan.replace(/^./, (c) => c.toUpperCase()) : "Local vault";
93
+ $id("acct-plan").style.visibility = "visible";
39
94
  const emailEl = $id("acct-email");
40
- if (emailEl) emailEl.textContent = snap.email || "no account — this machine only";
95
+ if (emailEl) {
96
+ emailEl.textContent = snap.email || "no account — this machine only";
97
+ emailEl.style.visibility = "visible";
98
+ }
41
99
  if (snap.limit) {
42
100
  const used = Number(snap.used) || 0;
43
101
  $id("acct-fill").style.width = `${Math.min(100, (used / snap.limit) * 100).toFixed(1)}%`;
44
102
  // A remembered answer is labelled as remembered, never passed off as live.
45
103
  $id("acct-usage").textContent = `${bytes(used)} of ${bytes(snap.limit)}${snap.cached ? " · last known" : ""}`;
104
+ $id("acct-meter").style.visibility = "visible";
105
+ $id("acct-usage").style.visibility = "visible";
46
106
  } else {
47
107
  $id("acct-fill").style.width = "0%";
48
- $id("acct-usage").textContent = snap.plan ? "—" : "nothing stored off this machine";
108
+ // A paid-plan response without quota is incomplete, not a zero or a
109
+ // dash. Local-only is a complete answer and can be stated plainly.
110
+ $id("acct-meter").style.visibility = "hidden";
111
+ $id("acct-usage").style.visibility = snap.plan ? "hidden" : "visible";
112
+ if (!snap.plan) $id("acct-usage").textContent = "nothing stored off this machine";
49
113
  }
50
114
  if (snap.diskText) railWriteDiskFact(snap.diskText);
115
+ if (typeof snap.phraseHeld === "boolean") {
116
+ $id("acct-phrase").style.visibility = "visible";
117
+ $id("acct-phrase").className = snap.phraseHeld ? "good" : "warn";
118
+ $id("acct-phrase-text").textContent = snap.phraseHeld ? "Seals unattended — phrase stored here" : "Recovery phrase is not stored on this machine";
119
+ }
120
+ if (snap.storageText) {
121
+ $id("acct-storage").style.visibility = "visible";
122
+ $id("acct-storage").className = snap.storageWarn ? "warn" : "good";
123
+ $id("acct-storage-text").textContent = snap.storageText;
124
+ }
125
+ if (typeof snap.escrowVisible === "boolean") {
126
+ const escrow = $id("acct-escrow");
127
+ if (escrow) {
128
+ escrow.hidden = !snap.escrowVisible;
129
+ if (snap.escrowVisible && typeof snap.escrowBackedUp === "boolean") {
130
+ escrow.className = snap.escrowBackedUp ? "good" : "warn";
131
+ $id("acct-escrow-text").innerHTML = snap.escrowBackedUp
132
+ ? 'Key backup previously confirmed — <a href="./index.html#recovery-access" class="rail-connect">check access</a>'
133
+ : 'Key backup needs checking — <a href="./index.html#recovery-access" class="rail-connect">check access</a>';
134
+ }
135
+ }
136
+ }
51
137
  if (snap.panelUrl && snap.plan) railPlaceDoor(card, snap.panelUrl);
52
138
  };
53
139
  try {
54
- const remembered = sessionStorage.getItem(SNAPSHOT_KEY);
55
- if (remembered) paintIdentity({ ...JSON.parse(remembered), cached: true });
140
+ const remembered = readSnapshot(status?.vaultId);
141
+ if (Object.keys(remembered).length > 0) paintIdentity({ ...remembered, cached: true });
56
142
  } catch { /* a torn snapshot paints nothing; the live answer is seconds away */ }
57
143
 
144
+ if (options.cachedOnly) return Promise.resolve();
145
+
58
146
  const ready = status ? Promise.resolve(status) : api("/v1/status");
59
147
  return ready.then((state) => {
148
+ if (!isCurrent()) return;
149
+ activeVaultId = state.vaultId || null;
60
150
  const plan = state.plan;
61
151
  const quota = state.quota;
62
152
  const snapshot = {
153
+ authBinding,
154
+ vaultId: state.vaultId || null,
63
155
  plan,
64
156
  email: state.accountEmail || null,
65
157
  used: quota ? Number(quota.usedBytes) || 0 : null,
@@ -68,24 +160,121 @@ function renderAccountRail(api, status) {
68
160
  panelUrl: state.panelUrl || null
69
161
  };
70
162
  paintIdentity(snapshot);
71
- try {
72
- const prior = JSON.parse(sessionStorage.getItem(SNAPSHOT_KEY) || "{}");
73
- sessionStorage.setItem(SNAPSHOT_KEY, JSON.stringify({ ...snapshot, diskText: prior.diskText || null }));
74
- } catch { /* a browser refusing storage costs only the instant repaint */ }
163
+ saveSnapshot(snapshot);
75
164
 
76
165
  // The two facts come from setup state; tolerate its absence rather than
77
166
  // blocking the card.
78
167
  return api("/v1/setup/state").catch(() => null).then((setup) => {
79
- const held = Boolean(setup && setup.vault && setup.vault.phraseRemembered);
168
+ if (!isCurrent()) return;
169
+ if (!setup) {
170
+ // No answer is not "no phrase". Keep the last verified facts painted
171
+ // while this request retries on the next refresh.
172
+ return;
173
+ }
174
+ const held = Boolean(setup.vault && setup.vault.phraseRemembered);
175
+ $id("acct-phrase").style.visibility = "visible";
80
176
  $id("acct-phrase").className = held ? "good" : "warn";
81
- $id("acct-phrase-text").innerHTML = held ? "Seals on its own" : "<b>Phrase not stored</b> — Settings";
177
+ $id("acct-phrase-text").innerHTML = held ? "Seals unattended — phrase stored here" : '<b>Can\u2019t seal unattended</b> — <a href="./index.html#settings" class="rail-connect" id="rail-phrase-link">store your recovery phrase</a>';
178
+ const phraseLink = $id("rail-phrase-link");
179
+ if (phraseLink) phraseLink.addEventListener("click", () => {
180
+ // Same-page navigation: place the cursor. Cross-page, the settings
181
+ // loader finds the hash and does its own focusing on arrival.
182
+ setTimeout(() => document.getElementById("f-remember-phrase")?.focus(), 300);
183
+ });
82
184
 
185
+ // The sealed key backup: a lost laptop is not a lost vault only if a
186
+ // sealed copy of the key lives in the account. Surfaced from setup state,
187
+ // with the fix one click into Settings.
188
+ const escrowLi = $id("acct-escrow");
189
+ const backedUp = typeof setup.vault?.escrowStored === 'boolean' ? setup.vault.escrowStored : null;
190
+ const onAccount = Boolean(plan);
191
+ if (escrowLi) {
192
+ if (!onAccount) { escrowLi.hidden = true; }
193
+ else {
194
+ escrowLi.hidden = false;
195
+ escrowLi.className = backedUp ? "good" : "warn";
196
+ $id("acct-escrow-text").innerHTML = backedUp === true
197
+ ? "Key backed up to your account"
198
+ : backedUp === false
199
+ ? '<b>No sealed key backup found</b> — <a href="./index.html#recovery-access" class="rail-connect">check recovery options</a>'
200
+ : 'Key backup not verified right now — <a href="./index.html#recovery-access" class="rail-connect">check recovery access</a>';
201
+ }
202
+ }
203
+ // (No "setup unfinished" chip: a local-only vault is a finished state by
204
+ // design, and managed sign-in configures storage without a BYO bucket, so
205
+ // storage.configured is false for a fully set-up account — there is no
206
+ // honest rail-wide signal that setup is incomplete. The road back to the
207
+ // wizard lives on the overview metric when a vault truly has no storage.)
208
+
209
+ const NAMES = { s3: "S3-compatible", r2: "Cloudflare R2", b2: "Backblaze B2", gcs: "Google Cloud Storage", gdrive: "Google Drive", vaultline: "Sealkeep Cloud" };
83
210
  const provider = (state.remoteStorage && state.remoteStorage.provider) || (setup && setup.storage && setup.storage.provider);
84
211
  const label = provider
85
- ? { s3: "Amazon S3", r2: "Cloudflare R2", b2: "Backblaze B2", gcs: "Google Cloud", vaultline: "Sealkeep managed" }[provider] || provider
212
+ ? NAMES[provider] || provider
86
213
  : (plan ? "Sealkeep managed" : null);
87
- $id("acct-storage").className = label ? "good" : "";
88
- $id("acct-storage-text").textContent = label ? `Storage: ${label}` : "This disk only";
214
+ const fallbackStorageText = label ? `Storage: ${label}` : "This disk only";
215
+ const rememberedStorageText = readSnapshot(state.vaultId).storageText;
216
+ // `status` only knows the account's default provider. The routing call
217
+ // below knows the complete list. Keep the previous complete answer on
218
+ // screen instead of briefly downgrading it to "Sealkeep managed".
219
+ if (!rememberedStorageText) {
220
+ $id("acct-storage").style.visibility = "visible";
221
+ $id("acct-storage").className = label ? "good" : "";
222
+ $id("acct-storage-text").textContent = fallbackStorageText;
223
+ }
224
+ saveSnapshot({
225
+ phraseHeld: held,
226
+ escrowVisible: onAccount,
227
+ escrowBackedUp: backedUp,
228
+ ...(!rememberedStorageText ? { storageText: fallbackStorageText, storageWarn: false } : {})
229
+ });
230
+ // Every destination, not the first one: a vault routing to managed
231
+ // storage AND Google Drive showed only one, and the other simply did
232
+ // not exist anywhere on the page.
233
+ api("/v1/storage/targets").catch(() => null).then((routed) => {
234
+ if (!isCurrent()) return;
235
+ if (!routed) return;
236
+ const targets = Array.isArray(routed.targets) ? routed.targets : [];
237
+ const finalStorageText = targets.length
238
+ ? `Storage: ${targets.map((t) => NAMES[t.provider] || t.provider).join(" + ")}`
239
+ : fallbackStorageText;
240
+ $id("acct-storage").style.visibility = "visible";
241
+ $id("acct-storage").className = targets.length || label ? "good" : "";
242
+ $id("acct-storage-text").textContent = finalStorageText;
243
+ // A destination this machine cannot reach is named as such, with the
244
+ // fix one click away — not discovered at seal time as a routing error.
245
+ // This must cover every provider. Showing Cloud/R2/S3 as green merely
246
+ // because Drive happened to be the first failed row hid broken routes.
247
+ const disconnected = targets.filter((target) => target.connected === false);
248
+ if (disconnected.length === 0) {
249
+ saveSnapshot({ storageText: finalStorageText, storageWarn: false });
250
+ return;
251
+ }
252
+ const line = $id("acct-storage-text");
253
+ disconnected.forEach((target, index) => {
254
+ line.append(index === 0 ? " · " : " / ");
255
+ const providerName = NAMES[target.provider] || target.provider || "storage";
256
+ const fix = document.createElement("a");
257
+ fix.className = "rail-connect";
258
+ if (target.provider === "gdrive") {
259
+ fix.href = "#";
260
+ fix.textContent = `connect ${providerName}`;
261
+ fix.title = `${providerName} is a destination, but this machine is not signed in. Connect this exact destination to let seals land there.`;
262
+ fix.addEventListener("click", (event) => {
263
+ event.preventDefault();
264
+ fix.textContent = "opening Google…";
265
+ connectGdrive(api, fix, target.id);
266
+ });
267
+ } else {
268
+ fix.href = "./index.html#settings";
269
+ fix.textContent = `reconnect ${providerName}`;
270
+ fix.title = `${providerName} is configured for this account but unavailable on this machine. Open Settings to reconnect it.`;
271
+ }
272
+ line.append(fix);
273
+ });
274
+ $id("acct-storage").className = "warn";
275
+ const unavailable = disconnected.map((target) => NAMES[target.provider] || target.provider || "storage").join(" + ");
276
+ saveSnapshot({ storageText: `${finalStorageText} · ${unavailable} disconnected on this machine`, storageWarn: true });
277
+ });
89
278
 
90
279
  // A paying plan gets its door: the panel where the subscription,
91
280
  // machines and stored copies are managed. The slot for it is reserved in
@@ -98,9 +287,10 @@ function renderAccountRail(api, status) {
98
287
  // dismissed until the backend publishes a different one (new id).
99
288
  if (!state.plan && !document.getElementById("rail-offer")) {
100
289
  return api("/v1/offer").catch(() => null).then((body) => {
290
+ if (!isCurrent()) return;
101
291
  const offer = body && body.offer;
102
292
  if (!offer || !offer.headline) return;
103
- const dismissed = localStorage.getItem("vaultline.offer.dismissed");
293
+ const dismissed = localStorage.getItem("sealkeep.offer.dismissed");
104
294
  if (dismissed && String(offer.id) === dismissed) return;
105
295
  const box = document.createElement("div");
106
296
  box.id = "rail-offer";
@@ -112,7 +302,7 @@ function renderAccountRail(api, status) {
112
302
  const go = document.createElement("a");
113
303
  go.className = "action";
114
304
  go.textContent = offer.cta_label || "Learn more";
115
- go.href = offer.cta_url || "#";
305
+ go.href = sealkeepCloudLink(offer.cta_url || "#");
116
306
  go.target = "_blank";
117
307
  go.rel = "noreferrer";
118
308
  const close = document.createElement("button");
@@ -120,20 +310,23 @@ function renderAccountRail(api, status) {
120
310
  close.className = "rail-offer-close";
121
311
  close.textContent = "✕";
122
312
  close.title = "Dismiss";
123
- close.onclick = () => { localStorage.setItem("vaultline.offer.dismissed", String(offer.id)); box.remove(); };
313
+ close.onclick = () => { localStorage.setItem("sealkeep.offer.dismissed", String(offer.id)); box.remove(); };
124
314
  box.append(close, head, text, go);
125
315
  card.after(box);
126
316
  });
127
317
  }
128
318
  }).then(() => {
319
+ if (!isCurrent()) return;
129
320
  // Rendered last, and each self-guarded: neither the upload strip nor the
130
321
  // disk-footprint fact may cost the card above them a single field.
131
- railSyncUpload(api, bytes, card, state && state.upload);
132
- return railDiskFact(api, bytes);
322
+ railSyncUpload(api, bytes, card, state && state.upload, isCurrent);
323
+ if (options.skipDisk) return;
324
+ return railDiskFact(api, bytes, isCurrent);
133
325
  });
134
326
  }).catch((error) => {
135
- // The skeleton stays put on failure — placeholders are already honest
136
- // ("—") — and the reason is kept for inspection rather than swallowed.
327
+ if (!isCurrent()) return;
328
+ // The last verified facts stay put on failure. Slots that have never had
329
+ // an answer remain hidden, and the reason is kept for inspection.
137
330
  if (typeof window !== "undefined") window.__railError = String((error && error.message) || error);
138
331
  });
139
332
  }
@@ -148,7 +341,12 @@ function renderAccountRail(api, status) {
148
341
  */
149
342
  function railWriteDiskFact(text) {
150
343
  const slot = document.getElementById("acct-disk-text");
151
- if (slot) { slot.textContent = text; return true; }
344
+ if (slot) {
345
+ slot.textContent = text;
346
+ const row = document.getElementById("acct-disk");
347
+ if (row) row.style.visibility = "visible";
348
+ return true;
349
+ }
152
350
  // Older markup without the reserved row: append one, as before.
153
351
  const storage = document.getElementById("acct-storage");
154
352
  const list = storage && storage.parentElement;
@@ -174,27 +372,50 @@ function railPlaceDoor(card, panelUrl) {
174
372
  door.className = "action acct-door";
175
373
  door.target = "_blank";
176
374
  door.rel = "noreferrer";
177
- door.textContent = "Cloud panel ↗";
375
+ door.textContent = "Account panel ↗";
178
376
  const slot = document.getElementById("acct-door-slot");
179
377
  if (slot) slot.append(door);
180
378
  else if (card && typeof card.append === "function") card.append(door);
181
379
  }
182
- door.href = panelUrl;
380
+ door.href = sealkeepCloudLink(panelUrl);
381
+ }
382
+
383
+ // Only a public loopback address travels to Cloud, never the local bearer.
384
+ function sealkeepCloudLink(value) {
385
+ try {
386
+ const url = new URL(value);
387
+ if (url.origin === 'https://sealkeep.spala.ai' && location.protocol === 'http:' && ['127.0.0.1','localhost','[::1]'].includes(location.hostname)) {
388
+ url.searchParams.set('local_return', `${location.origin}/index.html`);
389
+ }
390
+ return url.href;
391
+ } catch { return value; }
392
+ }
393
+
394
+ // Static account links need the same return path as dynamically rendered ones.
395
+ // Run after the page markup (rail.js is loaded at the end of the document).
396
+ function sealkeepCloudLinks(root = document) {
397
+ for (const link of root.querySelectorAll('a[href]')) {
398
+ link.href = sealkeepCloudLink(link.href);
399
+ }
183
400
  }
184
401
 
185
402
  let railDiskFactAsked = false;
186
- function railDiskFact(api, bytes) {
187
- if (railDiskFactAsked) return;
403
+ function railDiskFact(api, bytes, isCurrent = () => true) {
404
+ if (!isCurrent() || railDiskFactAsked) return;
188
405
  railDiskFactAsked = true;
189
406
  return api("/v1/sessions").catch(() => null).then((disk) => {
407
+ if (!isCurrent()) return;
190
408
  const total = disk == null ? NaN : Number(disk.totalBytes);
191
409
  if (!Number.isFinite(total)) return;
192
- const line = `${bytes(total)} of sessions on this disk`;
410
+ // Reworded: "…of sessions on this disk" collided with the Overview
411
+ // headline's "still on this disk", which counts only the UNARCHIVED
412
+ // subset — same words, different numbers, one screen apart.
413
+ const line = `${bytes(total)} across ${(disk.sessions || []).length} sessions found on disk`;
193
414
  railWriteDiskFact(line);
194
415
  // The slow fact joins the snapshot so the NEXT load paints it instantly.
195
416
  try {
196
- const prior = JSON.parse(sessionStorage.getItem("vaultline.railSnapshot") || "{}");
197
- sessionStorage.setItem("vaultline.railSnapshot", JSON.stringify({ ...prior, diskText: line }));
417
+ const prior = JSON.parse(localStorage.getItem("sealkeep.railSnapshot") || sessionStorage.getItem("sealkeep.railSnapshot") || "{}");
418
+ localStorage.setItem("sealkeep.railSnapshot", JSON.stringify({ ...prior, diskText: line }));
198
419
  } catch { /* snapshot is a convenience, never a requirement */ }
199
420
  });
200
421
  }
@@ -228,8 +449,10 @@ function railDrawUpload(run, bytes) {
228
449
  if (!railUploadEls) return;
229
450
  const total = Number(run.totalBytes) || 0;
230
451
  const done = Number(run.doneBytes) || 0;
452
+ const resolved = Array.isArray(run.resolvedAfterRun) ? run.resolvedAfterRun : [];
453
+ const resolvedBytes = resolved.reduce((sum, item) => sum + (Number.isFinite(item?.bytes) ? item.bytes : 0), 0);
231
454
  const active = !run.finishedAt;
232
- const pct = total > 0 ? Math.min(100, (done / total) * 100) : active ? 0 : 100;
455
+ const pct = total > 0 ? Math.min(100, ((active ? done : done + resolvedBytes) / total) * 100) : active ? 0 : 100;
233
456
  railUploadEls.fill.style.width = `${pct.toFixed(1)}%`;
234
457
  const sent = (run.done || []).length;
235
458
  if (active) {
@@ -239,8 +462,10 @@ function railDrawUpload(run, bytes) {
239
462
  const failed = run.failed || [];
240
463
  const word = run.cancelled ? "Stopped" : "Done";
241
464
  railUploadEls.line.textContent = failed.length
242
- ? `${word} — ${sent} sent, ${failed.length} failed — ${failed[0].error}`
243
- : `${word} — ${sent} sent, ${bytes(Number(run.freedBytes) || 0)} freed locally`;
465
+ ? `${word} — ${sent} sent, ${resolved.length} stored later, ${failed.length} failed — ${failed[0].error}`
466
+ : resolved.length
467
+ ? `${word} — ${sent} sent, ${resolved.length} stored later automatically`
468
+ : `${word} — ${sent} sent, ${bytes(Number(run.freedBytes) || 0)} freed locally`;
244
469
  }
245
470
  }
246
471
 
@@ -278,8 +503,9 @@ function railInjectUploadStrip(card) {
278
503
  railUploadEls = { strip, fill, line, done: false };
279
504
  }
280
505
 
281
- function railSyncUpload(api, bytes, card, run) {
506
+ function railSyncUpload(api, bytes, card, run, isCurrent = () => true) {
282
507
  try {
508
+ if (!isCurrent()) return;
283
509
  const active = Boolean(run && !run.finishedAt);
284
510
  if (!active) {
285
511
  railStopUploadPoll();
@@ -299,10 +525,61 @@ function railSyncUpload(api, bytes, card, run) {
299
525
  railUploadEls.done = false;
300
526
  railDrawUpload(run, bytes);
301
527
  if (railUploadTimer) return;
302
- railUploadTimer = setInterval(() => {
528
+ const timer = setInterval(() => {
529
+ if (!isCurrent()) {
530
+ clearInterval(timer);
531
+ if (railUploadTimer === timer) railUploadTimer = null;
532
+ return;
533
+ }
303
534
  api("/v1/status")
304
- .then((fresh) => railSyncUpload(api, bytes, card, fresh && fresh.upload))
305
- .catch(() => { /* a missed poll is the next poll's problem */ });
535
+ .then((fresh) => {
536
+ if (isCurrent()) railSyncUpload(api, bytes, card, fresh && fresh.upload, isCurrent);
537
+ })
538
+ .catch(() => {
539
+ if (!isCurrent()) {
540
+ clearInterval(timer);
541
+ if (railUploadTimer === timer) railUploadTimer = null;
542
+ }
543
+ // A current missed poll is the next poll's problem.
544
+ });
306
545
  }, 3000);
546
+ railUploadTimer = timer;
307
547
  } catch { /* the strip is a garnish: no failure here may cost the rail */ }
308
548
  }
549
+
550
+ // Drive consent, driven from the rail: ask the daemon to start the flow, open
551
+ // Google's consent screen in the user's own tab, then watch until the grant
552
+ // lands in this machine's keystore (or the attempt fails with a reason).
553
+ async function connectGdrive(api, anchor, targetId) {
554
+ try {
555
+ const consentTab = window.open("about:blank", "_blank");
556
+ const started = await api("/v1/storage/targets/gdrive/connect", {
557
+ method: "POST",
558
+ body: JSON.stringify({ targetId })
559
+ }).catch(() => null);
560
+ if (!started || !started.consent_url) {
561
+ consentTab?.close();
562
+ anchor.textContent = (started && started.error) ? `Drive: ${started.error}` : "Drive: could not start";
563
+ return;
564
+ }
565
+ if (consentTab) consentTab.location = started.consent_url;
566
+ else window.open(started.consent_url, "_blank", "noopener");
567
+ anchor.textContent = "waiting for Google…";
568
+ const until = Date.now() + 5 * 60 * 1000;
569
+ while (Date.now() < until) {
570
+ await new Promise((rest) => setTimeout(rest, 2000));
571
+ const state = await api(`/v1/storage/targets/gdrive/connect?targetId=${encodeURIComponent(targetId)}`).catch(() => null);
572
+ if (state && state.status === "connected") {
573
+ anchor.textContent = "Drive connected ✓";
574
+ return;
575
+ }
576
+ if (state && state.status === "failed") {
577
+ anchor.textContent = `Drive: ${state.error || "consent failed"}`;
578
+ return;
579
+ }
580
+ }
581
+ anchor.textContent = "Drive: no consent arrived — click to retry";
582
+ } catch (error) {
583
+ anchor.textContent = "Drive: " + (error && error.message ? error.message : "connect failed");
584
+ }
585
+ }
@@ -9,10 +9,10 @@
9
9
  <body>
10
10
  <!-- The rules editor is a dashboard view now (index.html#rules) — one page,
11
11
  no reloads between views. This stub keeps old bookmarks and links working,
12
- preserving a #token=… fragment when one is carried. -->
12
+ preserving a #ticket=… fragment when one is carried. -->
13
13
  <script>
14
14
  const h = location.hash.replace(/^#/, "");
15
- location.replace(h.includes("token=") ? `./index.html#${h}` : "./index.html#rules");
15
+ location.replace(new URLSearchParams(h).has("ticket") ? `./index.html#${h}` : "./index.html#rules");
16
16
  </script>
17
17
  </body>
18
18
  </html>