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
@@ -1,1905 +1,1809 @@
1
- <!doctype html>
2
- <meta charset="utf-8">
3
- <title>Sealkeep — your agent history, sealed</title>
4
- <meta name="viewport" content="width=device-width,initial-scale=1">
5
- <meta name="description" content="Encrypted local-first archive for Claude Code and Codex sessions. Searchable history, reclaimed disk, and nobody can read it but you.">
6
- <style>
7
- /* Sealkeep
8
- ---------------------------------------------------------------------
9
- The spine. One unbroken vertical rule runs the length of the page.
10
- Left of it is what stays with you — prose, claims, your machine. Right
11
- of it is what leaves — commands, ciphertext, storage, machine output.
12
- The hero does not contain a picture of the encryption boundary; the
13
- page IS the boundary, and every section inherits the same side.
14
-
15
- Monospace is evidence, not costume. It appears only where the content
16
- is genuinely machine-produced: transcripts, ciphertext, commands, refs,
17
- measured values. Prose is set in a humanist sans because prose is for
18
- reading, and mono body copy is a gesture at the subject that charges
19
- the reader for it.
20
-
21
- Chroma means sealed. The page is near-achromatic so the one saturated
22
- field on it is always, only, encrypted material.
23
- --------------------------------------------------------------------- */
24
- :root{
25
- /* @tokens:start — generated from design-system/tokens.css, do not edit here */
26
- --paper: #f4f4f3;
27
- --card: #fbfbfa;
28
- --ink: #111316;
29
- --soft: #5e646c;
30
- --rule: #dadcde;
31
- --seal: #101a3d;
32
- --seal-ink: #96a6e0;
33
- --live: #2a3fb0;
34
- --warn: #8c3220;
35
- --warn-bg: #f4e6e2;
36
- --sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, system-ui, "Helvetica Neue", sans-serif;
37
- --mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, "DejaVu Sans Mono", Consolas, monospace;
38
- /* @tokens:end */
39
-
40
- /* Layout constant for this page's spine; not part of the shared palette. */
41
- --gut: 2.6rem;
42
- }
43
- @media(prefers-color-scheme:dark){:root{
44
- --paper:#0d0f11; --card:#14171a; --ink:#e7e9ec; --soft:#8d949c; --rule:#24282d;
45
- --seal:#111c44; --seal-ink:#a2b2ea; --live:#8195f2; --warn:#d97a62; --warn-bg:#2b1a16;
46
- }}
47
- *{box-sizing:border-box}
48
- html{scroll-behavior:smooth}
49
- @media(prefers-reduced-motion:reduce){html{scroll-behavior:auto}*{animation:none!important;transition:none!important}}
50
- body{margin:0;background:var(--paper);color:var(--ink);
51
- font:400 1rem/1.62 var(--sans);-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility}
52
- .wrap{max-width:64rem;margin:0 auto;padding:0 1.5rem}
53
- a{color:var(--live)}
54
- :focus-visible{outline:2px solid var(--live);outline-offset:3px}
55
- code,pre,.mono{font-family:var(--mono);font-variant-ligatures:none}
56
-
57
- /* Scale carries the voice; the face is deliberately neutral so the mono
58
- columns are the only thing with an accent. */
59
- h1{font-size:clamp(2.55rem,7.4vw,4.5rem);font-weight:800;line-height:1.0;
60
- letter-spacing:-.042em;margin:0 0 1.2rem}
61
- h2{font-size:clamp(1.55rem,3.4vw,2.15rem);font-weight:700;letter-spacing:-.03em;margin:0 0 .5rem}
62
- h3{font-size:1.02rem;font-weight:650;letter-spacing:-.012em;margin:0 0 .35rem}
63
- p{margin:0 0 1rem}
64
-
65
- /* A label is machine-side, so it is mono and it is small. */
66
- .mark,.eyebrow{display:block;font-family:var(--mono);font-size:.72rem;color:var(--live);
67
- margin-bottom:.8rem;letter-spacing:.02em}
68
- .mark::before,.eyebrow::before{content:"";display:inline-block;width:1.4rem;height:1px;
69
- background:currentColor;vertical-align:.3em;margin-right:.55rem}
70
-
71
- nav{display:flex;align-items:center;gap:1.4rem;padding:1.5rem 0;font-size:.86rem}
72
- nav .logo{font-weight:750;letter-spacing:-.03em;font-size:1rem}
73
- nav .sp{flex:1}
74
- /* A finger needs 24px; these were 22px of bare line-height. Padding buys the
75
- height without moving the row, since the nav is already flex-aligned. */
76
- nav a{color:var(--soft);text-decoration:none;display:inline-flex;align-items:center;min-height:26px;padding:.15rem .1rem}
77
- nav a:hover{color:var(--ink)}
78
-
79
- /* ------------------------------------------------------------- the spine
80
- One rule, drawn once on the container, so it never breaks at a section
81
- boundary. Below the breakpoint it retires to the left edge and the two
82
- sides stack in reading order. */
83
- .spined{position:relative}
84
- .duo>.human{padding-bottom:1.6rem}
85
- @media(min-width:58rem){
86
- .spined::before{content:"";position:absolute;top:0;bottom:0;left:50%;width:1px;background:var(--rule)}
87
- .duo{display:grid;grid-template-columns:1fr 1fr;align-items:start}
88
- .duo>.human{padding:0 var(--gut) 0 0}
89
- .duo>.machine{padding:0 0 0 var(--gut)}
90
- .duo.tight{align-items:stretch}
91
- }
92
- @media(max-width:57.99rem){
93
- .spined{padding-left:1.1rem}
94
- .spined::before{content:"";position:absolute;top:0;bottom:0;left:0;width:1px;background:var(--rule)}
95
- }
96
-
97
- header{padding:2.6rem 0 3.4rem}
98
- .lede{font-size:1.14rem;color:var(--soft);max-width:34ch;margin:0 0 2rem}
99
- .cmd{display:flex;gap:.5rem;flex-wrap:wrap;max-width:30rem}
100
- .cmd code{flex:1 1 15rem;background:var(--card);border:1px solid var(--rule);border-radius:3px;
101
- padding:.72rem .85rem;font-size:.86rem;white-space:nowrap;overflow-x:auto}
102
- button{font:inherit;cursor:pointer;border:1px solid transparent;border-radius:3px}
103
- .primary{background:var(--ink);color:var(--paper);padding:.72rem 1.2rem;font-size:.9rem;font-weight:650;letter-spacing:-.01em}
104
- .primary:hover{opacity:.86}
105
- .primary:disabled{opacity:.45;cursor:default}
106
- .ghost{background:none;border:1px solid var(--rule);color:var(--ink);padding:.62rem 1.1rem;font-size:.88rem}
107
- .ghost:hover{border-color:var(--ink)}
108
- /* A caption is prose even when it names versions and carries a command; the
109
- command inside it is already a <code> and keeps the machine voice. */
110
- .fine{font-family:var(--sans);font-size:.78rem;color:var(--soft);margin-top:.9rem}
111
-
112
- /* The machine side of the hero: one session, read out and then sealed. */
113
- .side{font-family:var(--mono);font-size:.7rem;color:var(--soft);margin:0 0 .7rem;letter-spacing:.02em}
114
- .plain{font-family:var(--mono);font-size:.82rem;line-height:1.8;white-space:pre-wrap;word-break:break-word;
115
- margin:0 0 1.1rem;color:var(--ink)}
116
- .plain b{font-weight:400;color:var(--live)}
117
- .cipher{font-family:var(--mono);font-size:.8rem;line-height:1.8;background:var(--seal);color:var(--seal-ink);
118
- word-break:break-all;padding:.95rem 1rem;border-radius:3px;margin:0}
119
- .seal-foot{font-size:.84rem;color:var(--soft);margin:.9rem 0 0}
120
-
121
- section{padding:3.4rem 0;border-top:1px solid var(--rule)}
122
- .sub{color:var(--soft);max-width:46ch;margin:0 0 2rem;font-size:1.02rem}
123
- .row-item{padding:1.6rem 0;border-top:1px solid var(--rule)}
124
- .row-item:first-of-type{border-top:0;padding-top:.4rem}
125
- .row-item p{color:var(--soft);margin:0;font-size:.96rem}
126
- .ev{font-family:var(--mono);font-size:.8rem;color:var(--soft);background:var(--card);
127
- border:1px solid var(--rule);border-radius:3px;padding:.8rem .9rem;line-height:1.75;word-break:break-word}
128
- .ev b{font-weight:400;color:var(--live)}
129
-
130
- /* Free is your side of the line, managed is ours. The tiers sit where they
131
- belong rather than in an arbitrary order. */
132
- .tier{background:var(--card);border:1px solid var(--rule);border-radius:4px;padding:1.5rem;height:100%}
133
- .tier.paid{border-color:var(--live)}
134
- .tier .who{font-family:var(--mono);font-size:.72rem;color:var(--soft)}
135
- .tier .price{font-size:1.6rem;font-weight:750;letter-spacing:-.03em;margin:.4rem 0 .1rem}
136
- .tier .price small{font-family:var(--sans);font-size:.86rem;font-weight:400;color:var(--soft);letter-spacing:0}
137
- .tier ul{list-style:none;padding:0;margin:1.1rem 0 0;font-size:.93rem}
138
- .tier li{padding:.45rem 0 .45rem 1.4rem;position:relative;color:var(--soft);border-top:1px solid var(--rule)}
139
- .tier li:first-child{border-top:0}
140
- .tier li::before{content:"+";position:absolute;left:0;color:var(--live);font-family:var(--mono);font-size:.85rem}
141
- .tier li.cost::before{content:"−";color:var(--warn)}
142
-
143
- .tabs{display:flex;margin-bottom:1.6rem;border-bottom:1px solid var(--rule)}
144
- .tabs button{background:none;color:var(--soft);border:0;border-radius:0;border-bottom:2px solid transparent;
145
- padding:.6rem 1rem;font-size:.88rem}
146
- .tabs button[aria-selected=true]{color:var(--ink);font-weight:650;border-bottom-color:var(--live)}
147
- .pane{display:none}.pane.on{display:block}
148
-
149
- ol.steps{counter-reset:s;list-style:none;padding:0;margin:0}
150
- ol.steps li{counter-increment:s;position:relative;padding:0 0 1.6rem 2.7rem}
151
- ol.steps li::before{content:counter(s,decimal-leading-zero);position:absolute;left:0;top:.16rem;
152
- font-family:var(--mono);font-size:.76rem;color:var(--live);font-weight:600}
153
- ol.steps p{margin:.2rem 0 .55rem;color:var(--soft);font-size:.95rem}
154
- pre{background:var(--card);border:1px solid var(--rule);border-radius:3px;padding:.85rem .9rem;
155
- overflow-x:auto;font-size:.82rem;line-height:1.7;margin:.5rem 0 0}
156
-
157
- .phrase{background:var(--seal);color:#eaedf9;border-radius:4px;padding:1.2rem 1.3rem;margin:2rem 0 0}
158
- .phrase strong{font-weight:700;font-size:1.04rem;letter-spacing:-.018em}
159
- .phrase p{margin:.4rem 0 0;font-size:.94rem;color:var(--seal-ink)}
160
-
161
- label{display:block;font-family:var(--mono);font-size:.72rem;color:var(--soft);margin:1rem 0 .35rem}
162
- input{width:100%;padding:.66rem .75rem;border:1px solid var(--rule);border-radius:3px;
163
- background:var(--card);color:var(--ink);font:inherit;font-size:.95rem}
164
- input:focus-visible{border-color:var(--live)}
165
- .row2{display:grid;gap:1rem;grid-template-columns:1fr}
166
- @media(min-width:34rem){.row2{grid-template-columns:1fr 1fr}}
167
- form button{margin-top:1.3rem}
168
- .msg{font-family:var(--mono);font-size:.78rem;margin:.8rem 0 0;min-height:1.2em;color:var(--soft)}
169
- .msg.err{color:var(--warn)}
170
- .saved{font-family:var(--mono);font-size:.76rem;color:var(--live)}
171
-
172
- .google{display:flex;align-items:center;justify-content:center;gap:.6rem;width:100%;
173
- background:var(--card);border:1px solid var(--rule);color:var(--ink);padding:.7rem 1rem;font-size:.92rem;font-weight:650}
174
- .google:hover{border-color:var(--ink)}
175
- .google svg{width:17px;height:17px;flex:none}
176
- .or{display:flex;align-items:center;gap:.85rem;margin:1.3rem 0;color:var(--soft);
177
- font-family:var(--mono);font-size:.72rem}
178
- .or::before,.or::after{content:"";height:1px;background:var(--rule);flex:1}
179
-
180
- /* ------------------------------------------------------- control panel */
181
- body.panel header,
182
- body.panel section:not(#account),
183
- body.panel .acct-intro,
184
- body.panel nav .guest{display:none}
185
- body.panel #account{border-top:0;padding-top:1.6rem}
186
- body.panel .spined::before{display:none}
187
- body.panel .spined{padding-left:0}
188
-
189
- .ph{display:flex;flex-wrap:wrap;align-items:center;gap:.5rem .9rem;background:var(--card);
190
- border:1px solid var(--rule);border-radius:4px;padding:1.2rem 1.3rem;margin-bottom:1.3rem}
191
- .ph .name{font-size:1.15rem;font-weight:650;letter-spacing:-.02em}
192
- .ph .who{font-family:var(--mono);font-size:.75rem;color:var(--soft);flex:1 1 100%}
193
- /* The strip takes focus when the panel opens so a screen reader is told whose
194
- account this is. It is not in the tab order, so nobody can arrive here by
195
- keyboard and be left without a ring — and a ring drawn on arrival would
196
- compete with the one card that is meant to be asking for attention. */
197
- .ph:focus,.ph:focus-visible{outline:none}
198
- .chip{font-family:var(--mono);font-size:.7rem;min-height:26px;display:inline-flex;align-items:center;border:1px solid var(--rule);border-radius:3px;
199
- color:var(--soft);padding:.2rem .55rem}
200
- .chip.pro{border-color:var(--live);color:var(--live);font-weight:600}
201
- .card{background:var(--card);border:1px solid var(--rule);border-radius:4px;padding:1.35rem;margin-bottom:.9rem}
202
- .card>p{color:var(--soft);font-size:.94rem;margin:0 0 .3rem;max-width:60ch}
203
- .empty{color:var(--soft);font-size:.9rem;margin:.5rem 0 0;max-width:60ch}
204
- .notice{background:var(--warn-bg);border:1px solid var(--warn);border-radius:4px;padding:1rem 1.2rem;
205
- margin-bottom:.9rem;display:flex;flex-wrap:wrap;gap:.75rem;align-items:center}
206
- .notice.soft{background:var(--card);border-color:var(--rule)}
207
- .notice p{margin:0;flex:1 1 17rem;font-size:.94rem}
208
- .notice strong{font-weight:700}
209
- .warncard{border-color:var(--warn)}
210
- .warncard .eyebrow{color:var(--warn)}
211
-
212
- .kv{display:grid;gap:.75rem;grid-template-columns:repeat(auto-fit,minmax(9rem,1fr))}
213
- .kv>div{border:1px solid var(--rule);border-radius:4px;padding:.9rem 1rem}
214
- .kv .cap{display:block;font-size:.78rem;color:var(--soft);margin-top:.25rem}
215
- .kv .k{display:block;font-family:var(--mono);font-size:.72rem;color:var(--soft);margin-bottom:.2rem}
216
- .kv b{font-family:var(--mono);font-size:1rem;font-weight:500;color:var(--soft);letter-spacing:-.02em}
217
- .kv .lead b{font-size:clamp(1.55rem,2.1vw,2.2rem);font-weight:700;color:var(--ink);letter-spacing:-.04em;line-height:1.1;white-space:nowrap}
218
- .bar{height:8px;background:var(--rule);border-radius:4px;margin:1.05rem 0 .4rem;overflow:hidden}
219
- .bar i{display:block;height:100%;width:0;min-width:6px;background:var(--live);transition:width .5s ease}
220
-
221
- table{width:100%;border-collapse:collapse;font-size:.9rem;min-width:31rem}
222
- th{text-align:left;font-family:var(--mono);font-size:.72rem;color:var(--soft);font-weight:400;
223
- padding:.65rem;border-bottom:1px solid var(--rule)}
224
- td{padding:.68rem .65rem;border-bottom:1px solid var(--rule)}
225
- tr:last-child td{border-bottom:0}
226
- td code{font-size:.86em;color:var(--soft)}
227
- .state{font-family:var(--mono);font-size:.72rem;border:1px solid currentColor;border-radius:3px;padding:.18rem .55rem;white-space:nowrap}
228
- .choice .t,.choice .d{font-family:var(--sans)}
229
- .cellsub{display:block;font-family:var(--sans);font-size:.75rem;color:var(--soft);margin-top:.15rem}
230
- .acct-nav{display:flex;gap:.35rem;flex-wrap:wrap;position:sticky;top:0;z-index:5;background:var(--paper);padding:.65rem 0;margin:.4rem 0 .2rem;border-bottom:1px solid var(--rule)}
231
- .acct-nav a{font-family:var(--mono);font-size:.74rem;color:var(--soft);text-decoration:none;border:1px solid var(--rule);border-radius:999px;padding:.4rem .85rem;min-height:26px;display:inline-flex;align-items:center}
232
- .acct-nav a:hover{color:var(--ink);border-color:var(--ink)}
233
- #d-retired details{margin:.5rem 0 .2rem}
234
- #d-retired summary{font-family:var(--mono);font-size:.76rem;color:var(--soft);cursor:pointer;padding:.35rem 0}
235
- #d-retired summary:hover{color:var(--ink)}
236
- #d-retired .lines li{opacity:.75}
237
- .acct-hero{font-family:var(--mono);font-size:1.55rem;font-weight:700;letter-spacing:-.03em;margin:.9rem 0 0;line-height:1.3}
238
- h1.acct-hero:empty{display:none}
239
- .ruleslist{margin:.3rem 0 .9rem;padding-left:1.1rem}
240
- .ruleslist li{margin:.25rem 0;color:var(--soft)}
241
- .ruleslist b{color:var(--ink);font-weight:600}
242
- #tg-body input,#tg-body select{padding:.35rem .5rem;font-size:.85rem;border:1px solid var(--rule);border-radius:4px;background:transparent;color:var(--ink)}
243
- #tg-body input[type=number]{width:4.2rem}
244
- #tg-body input:focus,#tg-body select:focus{border-color:var(--live);outline:none}
245
- #tg-body tr:hover td{background:color-mix(in srgb, var(--rule) 22%, transparent)}
246
- #tg-save-msg{display:block;margin-top:.6rem}
247
- .packrow{display:flex;gap:.6rem;flex-wrap:wrap;margin:.4rem 0 .9rem}
248
- .packchip{display:flex;flex-direction:column;gap:.1rem;border:1px solid var(--rule);border-radius:6px;padding:.6rem .95rem;min-width:6.4rem}
249
- .packchip b{font-family:var(--mono);font-size:.95rem}
250
- .packchip span{font-size:.78rem;color:var(--soft)}
251
- .packchip.now{border-color:var(--live)}
252
- .packchip.now i{font-style:normal;font-family:var(--mono);font-size:.66rem;color:var(--live);letter-spacing:.03em}
253
- #p-account .card h2{font-size:1.02rem;margin:.15rem 0 .5rem}
254
- [id^="anchor-"]{scroll-margin-top:3.4rem}
255
- .state.durable{color:var(--live)}
256
- .state.pending{color:var(--soft);opacity:.75}
257
- .del{background:none;color:var(--soft);border:1px solid var(--rule);padding:.3rem .7rem;
258
- font-family:var(--mono);font-size:.74rem}
259
- .del:hover{border-color:var(--warn);color:var(--warn)}
260
- .del[data-armed=yes]{background:var(--warn);color:var(--card);border-color:var(--warn)}
261
-
262
- .lines{list-style:none;margin:.55rem 0 0;padding:0;font-size:.92rem}
263
- .lines li{display:flex;flex-wrap:wrap;gap:.45rem .85rem;align-items:center;padding:.68rem 0;border-top:1px solid var(--rule)}
264
- .lines li:first-child{border-top:0}
265
- .lines .grow{flex:1 1 11rem}
266
- .lines .when{font-family:var(--mono);font-size:.75rem;color:var(--soft)}
267
- .inline{display:flex;gap:.6rem;flex-wrap:wrap;align-items:center;margin-top:.4rem}
268
- .inline input{flex:1 1 13rem;width:auto}
269
-
270
- select{width:100%;padding:.66rem .7rem;border:1px solid var(--rule);border-radius:3px;
271
- background:var(--card);color:var(--ink);font:inherit;font-size:.95rem}
272
- .why{border-left:2px solid var(--live);padding:.15rem 0 .15rem .8rem;margin:.6rem 0 0;
273
- color:var(--soft);font-size:.86rem}
274
- .rules{border:1px solid var(--rule);border-radius:4px;padding:1.2rem;margin-top:.8rem}
275
- .choice{display:flex;gap:.75rem;align-items:flex-start;padding:.72rem 0;border-top:1px solid var(--rule);cursor:pointer}
276
- .choice:first-of-type{border-top:0;padding-top:0}
277
- .choice input{width:auto;margin:.22rem 0 0;accent-color:var(--live);flex:none}
278
- .choice .t{font-size:.95rem;font-weight:650}
279
- .choice .d{color:var(--soft);font-size:.88rem;margin-top:.15rem;display:block}
280
- .choice .n{width:4.4rem;padding:.3rem .45rem;font-family:var(--mono);font-size:.85rem;margin:.4rem .5rem 0 0;display:inline-block}
281
- .choice[data-on=no] .d,.choice[data-on=no] .n{opacity:.45}
282
-
283
- /* ---------------------------------------------------- first run, pairing
284
- A new account is not a broken account. The card that leads the panel is
285
- the next step, so it borrows the accent already used for "interactive or
286
- currently true" (.tier.paid, .chip.pro) rather than the warning colour.
287
- --warn stays rationed to the one case it is worth: inaction losing data. */
288
- .card.next{border-color:var(--live);padding:1.6rem 1.5rem}
289
- .card.next .eyebrow{color:var(--live)}
290
- h3.lead{font-size:1.24rem;font-weight:750;letter-spacing:-.028em;margin:0 0 .5rem}
291
-
292
- /* The pairing code is the most important string in the panel, so it is the
293
- largest type in it — nothing else here passes 1.32rem. Mono because it is
294
- machine material, and never re-chunked or prettified: what is on screen is
295
- byte-for-byte what Copy writes and what the machine expects back. */
296
- .pair{margin-top:1.25rem}
297
- .pair-field{display:flex;gap:.5rem;flex-wrap:wrap;align-items:stretch;max-width:34rem}
298
- .pair-field pre{flex:1 1 17rem;margin:0}
299
- .pair-code{flex:1 1 15rem;display:flex;align-items:center;background:var(--paper);
300
- border:1px solid var(--live);border-radius:3px;padding:.68rem .9rem;color:var(--ink);
301
- font-family:var(--mono);font-size:clamp(1.3rem,4.4vw,1.8rem);font-weight:600;
302
- letter-spacing:.12em;line-height:1.24;word-break:break-all}
303
- /* An opaque token is still a code. It drops to a readable size rather than
304
- wrapping into a wall, and keeps the same field. */
305
- .pair-code[data-long=yes]{font-size:clamp(.94rem,2.8vw,1.1rem);letter-spacing:.05em;font-weight:500}
306
- .pair-field .primary,.pair-field .ghost{flex:0 0 auto}
307
- .pair .fine{margin-top:.75rem;max-width:66ch}
308
- .alt{margin-top:1.15rem;border-top:1px solid var(--rule);padding-top:.9rem}
309
- .alt summary{font-family:var(--mono);font-size:.76rem;color:var(--soft);cursor:pointer;list-style:none}
310
- .alt summary::-webkit-details-marker{display:none}
311
- .alt summary::before{content:"+ ";color:var(--live)}
312
- .alt[open] summary::before{content:"− "}
313
- .alt summary:hover{color:var(--ink)}
314
- .alt p{margin:.7rem 0 .55rem}
315
-
316
- /* --------------------------------------------------------- coming back
317
- Returning from Google is a return, not a fresh arrival. The page says so
318
- before the network does, so the moment never reads as a failure. One
319
- hairline crossing one track — the spine, laid on its side. */
320
- .boot{display:none;min-height:74vh;flex-direction:column;align-items:flex-start;
321
- justify-content:center;max-width:64rem;margin:0 auto;padding:0 1.5rem}
322
- body.booting>.boot{display:flex}
323
- body.booting>.wrap,body.booting>section,body.booting>footer{display:none}
324
- .boot .logo{font-weight:750;letter-spacing:-.03em;font-size:1rem}
325
- .boot .track{width:min(21rem,100%);height:1px;background:var(--rule);margin-top:1.7rem;overflow:hidden}
326
- .boot .track i{display:block;height:100%;width:34%;background:var(--live);
327
- animation:cross 1.1s ease-in-out infinite}
328
- .boot .track[data-done=yes] i{width:100%;animation:none;transform:none}
329
- .boot p{font-family:var(--mono);font-size:.78rem;color:var(--soft);margin:.95rem 0 0}
330
- @keyframes cross{from{transform:translateX(-105%)}to{transform:translateX(325%)}}
331
- /* Held back a beat, so a session that restores quickly shows plain paper
332
- rather than a flash of text nobody had time to read. */
333
- .boot>*{opacity:0;animation:boot-in .3s ease .24s forwards}
334
- @keyframes boot-in{to{opacity:1}}
335
- @media(prefers-reduced-motion:reduce){
336
- .boot>*{opacity:1;animation:none}
337
- .boot .track i{width:100%;transform:none}
338
- }
339
-
340
- footer{border-top:1px solid var(--rule);padding:2.4rem 0 3.6rem;color:var(--soft);font-size:.9rem}
341
- footer .r{font-family:var(--mono);font-size:.74rem;margin-top:.6rem}
342
- </style>
343
-
344
- <div class="boot" id="boot">
345
- <span class="logo">⌑ Sealkeep</span>
346
- <div class="track" id="boot-track"><i></i></div>
347
- <p id="boot-note" role="status" aria-live="polite">Signing you in</p>
348
- </div>
349
-
350
- <div class="wrap">
351
- <nav>
352
- <span class="logo">⌑ Sealkeep</span>
353
- <span class="sp"></span>
354
- <a class="guest" href="#plans">Plans</a>
355
- <a class="guest" href="#start">Start</a>
356
- <a href="#account">Account</a>
357
- </nav>
358
-
359
- <div class="spined">
360
-
361
- <header>
362
- <h1>Your agent history, sealed.</h1>
363
- <p class="lede">Every Claude Code and Codex session you have ever run — encrypted on your machine, searchable for years, and off your disk.</p>
364
-
365
- <div class="duo">
366
- <div class="human">
367
- <div class="cmd">
368
- <code id="cmd">npm install -g sealkeep</code>
369
- <button class="primary" id="copy">Copy</button>
370
- </div>
371
- <p class="fine">Node 22+ · macOS, Linux, Windows · then <code>sealkeep start</code></p>
372
- <p class="side" style="margin-top:2rem">on this machine · claude · 14:22</p>
373
- <div class="plain"><b>user</b> deploy the payment service
374
- <b>assistant</b> checking the stripe webhook config</div>
375
- </div>
376
- <div class="machine">
377
- <p class="side">in storage · 14:22</p>
378
- <div class="cipher" id="cipher" aria-hidden="true"></div>
379
- <p class="seal-foot">Nothing readable crosses this line. Not to us, not to anyone holding the disk.</p>
380
- </div>
381
- </div>
382
- </header>
383
-
384
- <section>
385
- <h2>What it does</h2>
386
- <p class="sub">Five things, and it does them without ever seeing your work.</p>
387
-
388
- <div class="row-item duo">
389
- <div class="human">
390
- <span class="mark">Search</span>
391
- <h3>Find the session, not the file</h3>
392
- <p>Search by what you said in it. Metadata instantly; full text on any machine holding your phrase.</p>
393
- </div>
394
- <div class="machine"><div class="ev">$ sealkeep search "stripe webhook"<br><b>3 sessions</b> · 12 Aug, 4 Aug, 27 Jul</div></div>
395
- </div>
396
-
397
- <div class="row-item duo">
398
- <div class="human">
399
- <span class="mark">Reclaim</span>
400
- <h3>Get the disk back</h3>
401
- <p>Sources move to the trash only once the archive is verified in storage — never <code>unlink</code>, never early.</p>
402
- </div>
403
- <div class="machine"><div class="ev">verified <b>9f2c1ab4</b> in storage<br>~/.claude/projects/… → trash<br><b>1.4 GB</b> reclaimed</div></div>
404
- </div>
405
-
406
- <div class="row-item duo">
407
- <div class="human">
408
- <span class="mark">Seal</span>
409
- <h3>Encrypted before it moves</h3>
410
- <p>Sealed on your machine. Storage holds ciphertext, and we hold no key that opens it.</p>
411
- </div>
412
- <div class="machine"><div class="ev">ChaCha20-Poly1305 · per-archive key<br>key wrapped to your 24-word phrase<br><b>server sees ciphertext only</b></div></div>
413
- </div>
414
-
415
- <div class="row-item duo">
416
- <div class="human">
417
- <span class="mark">Remember</span>
418
- <h3>One memory across every machine</h3>
419
- <p>The sealed index syncs beside your archives, so the laptop finds what the desktop sealed. Your agents search it too, over MCP — a collective memory of the project, not a folder on one disk.</p>
420
- </div>
421
- <div class="machine"><div class="ev">$ sealkeep search "why we forked the retry logic"<br><b>1 session</b> · sealed on your other machine<br>restore ✓ <b>byte-identical</b></div></div>
422
- </div>
423
-
424
- <div class="row-item duo">
425
- <div class="human">
426
- <span class="mark">Drive</span>
427
- <h3>The Drive space you never use</h3>
428
- <p>Point your agent history at the Google Drive storage you already pay for. One consent connects it; everything is sealed before upload, so Drive holds ciphertext. Cap it, pin projects to it, and let the rest go to managed cloud.</p>
429
- </div>
430
- <div class="machine"><div class="ev">$ sealkeep storage targets add gdrive --max-gb 50<br>connected · folder <b>Sealkeep</b> · cap 50 GB<br>project big-refactor → Drive · rest → cloud</div></div>
431
- </div>
432
-
433
- </section>
434
-
435
- <section id="teams"><div class="wrap">
436
- <h2>For teams and companies</h2>
437
- <p class="sub">Agent transcripts are source code, prompts, and pasted secrets. Treat them like it.</p>
438
-
439
- <div class="row-item duo">
440
- <div class="human">
441
- <span class="mark">Custody</span>
442
- <h3>Your data never changes hands</h3>
443
- <p>Sealed before it leaves the machine, stored in a bucket you own if you choose, opened only by keys we never hold. Data residency is wherever you put the bucket. A secrets scan runs before anything is uploaded, and retention only ever stages to trash — there is no delete path.</p>
444
- </div>
445
- <div class="machine"><div class="ev">sealed on machine · keys stay yours<br>your bucket, your region, your retention<br>leak scan before every upload</div></div>
446
- </div>
447
-
448
- <div class="row-item duo">
449
- <div class="human">
450
- <span class="mark">Audit</span>
451
- <h3>Every action, written down</h3>
452
- <p>Each seal, upload, restore, and reclaim lands in a local audit log, and the control plane keeps its own account-scoped trail — identifiers and counts, never content. When someone asks what happened to a session, the answer is a query, not a shrug.</p>
453
- </div>
454
- <div class="machine"><div class="ev">$ sealkeep audit<br>sealed 9f2c1ab4 · uploaded · verified<br>restored by device macbook · 12 Aug</div></div>
455
- </div>
456
-
457
- <div class="row-item duo">
458
- <div class="human">
459
- <span class="mark">Share</span>
460
- <h3>Hand a session over, not the vault</h3>
461
- <p>One session goes to a teammate as a sealed bundle under a one-time passcode — your recovery phrase never travels. Team spaces — one sealed memory for a whole project, across people, with instant offboarding — are designed on the same envelope machinery and are next on the roadmap.</p>
462
- </div>
463
- <div class="machine"><div class="ev">$ sealkeep share 9f2c1ab4<br>bundle sealed · passcode shown once<br>opens without your phrase</div></div>
464
- </div>
465
-
466
- <p class="why">Straight talk: the encryption is built on published primitives with test vectors and a tamper suite, and has not yet had an independent audit. We say so here because your security review will ask.</p>
467
- </div></section>
468
-
469
- <section id="plans">
470
- <h2>Two ways to run it</h2>
471
- <p class="sub">Same encryption either way. The only difference is which side of the line the bucket sits on.</p>
472
- <div class="duo tight">
473
- <div class="human"><div class="tier">
474
- <div class="who">Free</div>
475
- <div class="price">$0 <small>· your bucket</small></div>
476
- <ul>
477
- <li>Encryption, archiving, search, retention</li>
478
- <li>Guided setup with a least-privilege policy</li>
479
- <li class="cost">You create the bucket and its credential</li>
480
- <li class="cost">The setup time is yours</li>
481
- </ul>
482
- </div></div>
483
- <div class="machine"><div class="tier paid">
484
- <div class="who">Paid</div>
485
- <div class="price">from $6<small>/mo · you pick the size</small></div>
486
- <ul>
487
- <li>100 GB $6 · 500 GB $12 · 2 TB $30 per month</li>
488
- <li>Nothing to configure — no IAM, no keys, no console</li>
489
- <li>Your machine uploads straight to storage</li>
490
- <li>A sealed backup of your key, opened only by your phrase</li>
491
- <li>Usage and quota in one command</li>
492
- </ul>
493
- </div></div>
494
- </div>
495
- </section>
496
-
497
- <section id="start">
498
- <h2>Getting started</h2>
499
- <p class="sub">Four steps either way. The phrase step is the one that matters.</p>
500
- <div class="tabs">
501
- <button id="t-free" aria-selected="true">Free</button>
502
- <button id="t-paid" aria-selected="false">Paid</button>
503
- </div>
504
-
505
- <div class="pane on" id="p-free">
506
- <ol class="steps">
507
- <li><h3>Install</h3><pre>npm install -g sealkeep</pre></li>
508
- <li><h3>Choose free, pick a provider</h3><p>Three questions: provider, bucket, prefix.</p><pre>sealkeep start</pre></li>
509
- <li><h3>Write down the phrase</h3><p>Twenty-four words, shown once, generated here and never sent anywhere.</p></li>
510
- <li><h3>Create the bucket, then run it</h3><p>Sealkeep prints the console links, the commands, and a policy scoped to your prefix.</p><pre>sealkeep storage setup
511
- sealkeep autopilot</pre></li>
512
- </ol>
513
- </div>
514
-
515
- <div class="pane" id="p-paid">
516
- <ol class="steps">
517
- <li><h3>Install</h3><pre>npm install -g sealkeep</pre></li>
518
- <li><h3>Create an account</h3><p>Below, or in the terminal. Nothing else to set up.</p><pre>sealkeep start</pre></li>
519
- <li><h3>Write down the phrase</h3><p>Your keys are generated on your machine. We store a copy sealed with this phrase, which never reaches us.</p></li>
520
- <li><h3>Let it run</h3><p>Your machine requests a signed URL and uploads directly. The bytes never pass through our servers.</p><pre>sealkeep autopilot
521
- sealkeep cloud status</pre></li>
522
- </ol>
523
- </div>
524
-
525
- <div class="phrase">
526
- <strong>The phrase is the only key.</strong>
527
- <p>Lose it and your archives cannot be opened — not by you, not by support, not by whoever holds the storage. That is the design, and it is the reason this is worth using.</p>
528
- </div>
529
- </section>
530
-
531
- <section id="restore" class="spined">
532
- <h2>Getting one back</h2>
533
- <p class="sub">The part most backup tools make you find out about during the emergency.</p>
534
- <div class="duo">
535
- <div class="human">
536
- <h3>Two things, not three</h3>
537
- <p>Every archive carries its own envelope — the wrapped key, the chunk layout, everything needed to open it. So a restore needs the phrase and an account, and nothing else. Not the original machine, not a config file, not a repository index you forgot to copy.</p>
538
- <p>Install Sealkeep on a laptop that has never seen your archives, sign in, and read one back. Same on a machine you are handed at a new job.</p>
539
- <p class="why">Tools that keep the index separate from the data can leave you holding bytes nobody can open. We tested this the only way that counts: archive on one machine, restore on a second that never saw it, compare hashes.</p>
540
- </div>
541
- <div class="machine">
542
- <pre>npm install -g sealkeep
543
- sealkeep cloud login --email you@company.com
544
- sealkeep cloud archives
545
-
546
- ref archived size
547
- 08424eb6… 2 hours ago 5.3 KB
548
- a91c40f7… yesterday 2.5 MB
549
-
550
- sealkeep open 08424eb6… ./session.jsonl \
551
- --recovery-phrase "…"
552
-
553
- ok Opened 5.3 KB from managed storage
554
- to ./session.jsonl</pre>
555
- <p class="why">The hash recorded when the archive was sealed is checked after the download and again after decryption. A truncated or swapped object is refused, not opened.</p>
556
- </div>
557
- </div>
558
- </section>
559
-
560
- </div>
561
- </div>
562
-
563
- <section id="account"><div class="wrap">
564
- <h2 class="acct-intro">Your account</h2>
565
- <p class="sub acct-intro">Create one here, then finish on your machine — that is where your keys are made.</p>
566
-
567
- <div class="tabs" id="auth-tabs">
568
- <button id="t-signup" aria-selected="true">Create account</button>
569
- <button id="t-signin" aria-selected="false">Sign in</button>
570
- </div>
571
-
572
- <form class="pane on" id="p-signup">
573
- <button class="google" type="button" id="g-up">
574
- <svg viewBox="0 0 48 48" aria-hidden="true"><path fill="#EA4335" d="M24 9.5c3.5 0 6.6 1.2 9 3.6l6.8-6.8C35.6 2.4 30.2 0 24 0 14.6 0 6.5 5.4 2.6 13.2l7.9 6.1C12.4 13.2 17.7 9.5 24 9.5z"/><path fill="#4285F4" d="M46.1 24.6c0-1.6-.1-3.1-.4-4.6H24v9.1h12.4c-.5 2.9-2.2 5.3-4.7 7l7.7 6c4.5-4.2 6.7-10.3 6.7-17.5z"/><path fill="#FBBC05" d="M10.5 28.7c-.5-1.4-.8-2.9-.8-4.7s.3-3.3.8-4.7l-7.9-6.1C1 16.3 0 20 0 24s1 7.7 2.6 10.8l7.9-6.1z"/><path fill="#34A853" d="M24 48c6.5 0 11.9-2.1 15.9-5.8l-7.7-6c-2.1 1.4-4.9 2.3-8.2 2.3-6.3 0-11.6-3.7-13.5-9.8l-7.9 6.1C6.5 42.6 14.6 48 24 48z"/></svg>
575
- Continue with Google
576
- </button>
577
- <div class="or">or use a password</div>
578
- <div class="row2">
579
- <div><label for="su-email">Email</label><input id="su-email" type="email" autocomplete="email" required></div>
580
- <div><label for="su-label">Account name</label><input id="su-label" autocomplete="name" required></div>
581
- </div>
582
- <label for="su-pass">Password</label>
583
- <input id="su-pass" type="password" autocomplete="new-password" placeholder="at least 12 characters" required minlength="12">
584
- <button class="primary" id="su-go">Create account</button>
585
- <p class="msg" id="su-msg" role="status" aria-live="polite"></p>
586
- </form>
587
-
588
- <form class="pane" id="p-signin">
589
- <button class="google" type="button" id="g-in">
590
-
591
- Continue with Google
592
- </button>
593
- <div class="or">or use a password</div>
594
- <label for="si-email">Email</label>
595
- <input id="si-email" type="email" autocomplete="email" required>
596
- <label for="si-pass">Password</label>
597
- <input id="si-pass" type="password" autocomplete="current-password" required>
598
- <button class="primary" id="si-go">Sign in</button>
599
- <p class="msg" id="si-msg" role="status" aria-live="polite"></p>
600
- </form>
601
-
602
- <div class="pane" id="p-account">
603
- <div class="ph" id="a-strip" tabindex="-1">
604
- <span class="name" id="a-label">—</span>
605
- <button class="chip" id="a-plan" type="button" title="See your plan">—</button>
606
- <span class="who" id="a-who">—</span>
607
- </div>
608
- <h1 class="acct-hero" id="a-hero"></h1>
609
- <p class="msg" id="a-summary" style="margin:.4rem 0 0"></p>
610
- <nav class="acct-nav" aria-label="Account sections">
611
- <a href="#anchor-storage">Storage</a>
612
- <a href="#anchor-routing">Routing</a>
613
- <a href="#anchor-machines">Machines</a>
614
- <a href="#anchor-archives" id="nav-archives">Archives</a>
615
- <a href="#anchor-plan">Plan</a>
616
- <a href="#anchor-recovery">Recovery</a>
617
- </nav>
618
-
619
- <div class="card next" id="a-firstrun" style="display:none">
620
- <span class="eyebrow">Set up</span>
621
- <h2 class="lead">Connect your first machine</h2>
622
- <p>Sealkeep runs on your machine — that is where your keys are made and where every session is sealed before it moves. Pair one machine and archiving carries on by itself.</p>
623
- <div id="pair-home-first"></div>
624
- </div>
625
-
626
- <div class="notice soft" id="a-verify" style="display:none">
627
- <p><strong>Confirm your email.</strong> It is how we reach you if an archive fails to store. Nothing else is sent to it.</p>
628
- <button class="primary" id="a-verify-go" type="button">Send the link</button>
629
- <span class="saved" id="a-verify-msg" role="status" aria-live="polite"></span>
630
- </div>
631
-
632
- <div class="card" id="c-recovery"><span id="anchor-recovery"></span>
633
- <span class="eyebrow">Recovery key</span>
634
- <h2 id="e-head">Loading…</h2>
635
- <p id="e-text"></p>
636
- </div>
637
-
638
- <div class="card" id="anchor-storage">
639
- <span class="eyebrow">Storage</span>
640
- <div id="a-store-free" style="display:none">
641
- <h2>You run the bucket</h2>
642
- <p>Archives go to storage you own, under your own credentials. Sealkeep never holds them, so there is nothing to meter here.</p>
643
- <p class="empty">Your machine reports what it has stored. Run <code>sealkeep status</code> to see it.</p>
644
- </div>
645
- <div id="a-store-paid" style="display:none">
646
- <h2>What you keep here</h2>
647
- <div class="kv">
648
- <div class="lead"><span class="k">Stored</span><b id="a-used">—</b><span class="cap" id="a-used-cap">sealed before it arrived</span></div>
649
- <div><span class="k">Included</span><b id="a-quota-b">—</b><span class="cap">in your plan</span></div>
650
- <div><span class="k">Archives</span><b id="a-count">—</b><span class="cap">chunk folders count as one</span></div>
651
- <div><span class="k">Machines</span><b id="a-machines">—</b><span class="cap">signing uploads</span></div>
652
- <div><span class="k">Destinations</span><b id="a-dest">—</b><span class="cap">where new seals go</span></div>
653
- </div>
654
- <div class="bar"><i id="a-bar"></i></div>
655
- <p class="msg" id="a-quota"></p>
656
- </div>
657
- </div>
658
-
659
- <div class="card" id="c-targets"><span id="anchor-routing"></span>
660
- <span class="eyebrow">Routing</span>
661
- <h2>Where new archives go</h2>
662
- <p>Rules live on your account — every machine of the vault follows the same ones.</p>
663
- <ul class="ruleslist">
664
- <li><b>Lowest priority number wins.</b></li>
665
- <li><b>A pinned project always goes to its target.</b></li>
666
- <li><b>Full or capped targets pass to the next</b> — Sealkeep cloud is the fallback.</li>
667
- </ul>
668
- <p class="msg" id="tg-msg">Loading…</p>
669
- <div id="tg-wrap" style="display:none;overflow-x:auto">
670
- <table>
671
- <thead><tr><th>Target</th><th>Where</th><th>Priority</th><th>Cap (GB)</th><th>Pinned projects</th><th></th></tr></thead>
672
- <tbody id="tg-body"></tbody>
673
- </table>
674
- </div>
675
- <div class="inline" style="margin-top:.9rem">
676
- <button class="ghost" id="tg-add-drive" type="button">Add Google Drive</button>
677
- <button class="ghost" id="tg-add-bucket" type="button">Add my own bucket</button>
678
- <button class="primary" id="tg-save" type="button">Save routing</button>
679
- <span class="saved" id="tg-save-msg" role="status" aria-live="polite"></span>
680
- </div>
681
- <p class="why" id="tg-note">A project is the folder a session ran in — <code>sealkeep</code>, <code>big-refactor</code>. Pin one and its sessions always go to that destination. Project names never reach us, which is why this page cannot list them for you: run <code>sealkeep projects</code> on your machine to see the exact names it knows, and paste them here separated by commas. Sessions that recorded no project are matched by no pin, and take the ordinary priority order.</p>
682
- <p class="why">Drive holds single objects and skips streaming; big under-pressure seals route to cloud or a bucket on their own. Adding Drive here sets the rule — the one-time consent happens on your machine: <code>sealkeep storage connect gdrive</code>.</p>
683
- </div>
684
-
685
- <div class="card" id="anchor-machines">
686
- <span class="eyebrow">Machines</span>
687
- <h2>Connected machines</h2>
688
- <p>Each connected machine signs its own uploads. Revoking one stops it writing here; the archives it already stored are untouched.</p>
689
- <div id="pair-top"></div>
690
- <ul class="lines" id="d-list"></ul>
691
- <div id="d-retired"></div>
692
- <p class="empty" id="d-msg">Loading…</p>
693
-
694
- <!-- One pairing block exists on the page. It leads the panel while no
695
- machine is connected, and JS moves it back into this slot once one
696
- is, so the same code is never printed in two places. -->
697
- <div id="pair-home-more">
698
- <div class="pair" id="pair">
699
- <p class="side" id="pair-side" style="display:none">Pairing code</p>
700
- <!-- Grouped and labelled, so the copy button is announced as
701
- belonging to the code rather than as a bare "Copy". -->
702
- <div class="pair-field" id="pair-shown" role="group" aria-labelledby="pair-side" style="display:none">
703
- <code class="pair-code" id="pair-code"></code>
704
- <button class="primary" id="pair-copy" type="button">Copy</button>
705
- </div>
706
- <p class="fine" id="pair-note"></p>
707
- <div class="inline" id="pair-ask">
708
- <button class="ghost" id="pair-go" type="button">Show a pairing code</button>
709
- <span class="saved" id="pair-msg" role="status" aria-live="polite"></span>
710
- </div>
711
- <details class="alt" id="pair-alt" style="display:none">
712
- <summary>Set the machine up from the terminal instead</summary>
713
- <p class="empty">The same code, typed as one command. Needs Node 22 or newer.</p>
714
- <div class="pair-field"><pre id="pair-cli"></pre>
715
- <button class="ghost" id="pair-cli-copy" type="button">Copy</button>
716
- </div>
717
- </details>
718
- </div>
719
- </div>
720
- </div>
721
-
722
- <div class="card" id="anchor-archives" style="display:none">
723
- <span class="eyebrow">Archives</span>
724
- <h2>What is stored</h2>
725
- <p id="s-intro">Names live on your machines — your storage holds sealed archives it cannot read, listed here by reference. Deleting one removes the stored copy; if your machine already reclaimed the source, this is the last copy.</p>
726
- <p class="msg" id="s-msg">Loading…</p>
727
- <div id="s-wrap" style="display:none;overflow-x:auto">
728
- <table>
729
- <thead><tr><th>Archive</th><th>Size</th><th>State</th><th>Added</th><th></th></tr></thead>
730
- <tbody id="s-body"></tbody>
731
- </table>
732
- <p class="msg" id="s-foot"></p>
733
- </div>
734
- </div>
735
-
736
-
737
-
738
- <div class="card" id="a-pack-card">
739
- <span id="anchor-plan"></span>
740
- <span class="eyebrow">Your package</span>
741
- <h2 id="a-pack-current">—</h2>
742
- <p class="msg" id="a-pack-context" style="margin:0 0 .8rem"></p>
743
- <div class="packrow" id="a-packs"></div>
744
- <p class="empty" id="a-plan-free-note"></p>
745
- <p class="empty" id="a-pack-alt">Not storing with us? Your own bucket is free with the same encryption, and Pro Lite keeps key escrow and machine sign-in with no metered storage.</p>
746
- <div class="kv" id="a-sub-facts" style="margin-top:1rem">
747
- <div><span class="k">Subscription</span><b id="a-sub-state">—</b></div>
748
- <div><span class="k">Renews</span><b id="a-sub-end">—</b></div>
749
- <div><span class="k">Billed through</span><b id="a-sub-prov">—</b></div>
750
- </div>
751
- <p class="empty" id="a-sub-note"></p>
752
- <p class="why" id="a-pack-note">Changing your package is handled by hand today. Email hello@spala.ai from this address and we will move you within one business day — your archives keep uploading throughout.</p>
753
- </div>
754
-
755
- <div class="card">
756
- <span class="eyebrow">What gets uploaded</span>
757
- <h2>Your upload rules</h2>
758
- <p class="why" style="margin:.1rem 0 .8rem">Saved to your account — every machine you connect follows these, the same way routing rules do.</p>
759
- <p>Everything you do not choose here stays local and is never uploaded.</p>
760
- <p class="empty" id="r-note" style="margin-bottom:.9rem">Enforced on your machine, before a session is read or sealed. Anything held back is never uploaded, so this is a rule about what leaves — not a filter we apply after it arrives.</p>
761
- <div class="rules">
762
- <label class="choice" data-on="yes">
763
- <input type="radio" name="sync" value="all" checked>
764
- <span><span class="t">Every session</span><span class="d">Archive all of it. The safest choice, and the one that uses the most storage.</span></span>
765
- </label>
766
- <label class="choice" data-on="no">
767
- <input type="radio" name="sync" value="recent">
768
- <span><span class="t">Only the most recent</span>
769
- <span class="d">Keep a rolling window. Older sessions stay local and are never uploaded.</span>
770
- <input class="n" id="r-last" type="number" min="1" max="999" value="20" aria-label="How many recent sessions"><span class="d" style="display:inline">sessions</span>
771
- </span>
772
- </label>
773
- <label class="choice" data-on="no">
774
- <input type="radio" name="sync" value="settled">
775
- <span><span class="t">Only once they have settled</span>
776
- <span class="d">Wait before uploading, so work you are still iterating on stays put.</span>
777
- <input class="n" id="r-age" type="number" min="1" max="365" value="7" aria-label="Days before a session is uploaded"><span class="d" style="display:inline">days old</span>
778
- </span>
779
- </label>
780
- <button class="primary" id="r-save" type="button" style="margin-top:1.1rem">Save rules</button>
781
- <span class="saved" id="r-msg" role="status" aria-live="polite"></span>
782
- </div>
783
- </div>
784
-
785
-
786
-
787
- <div class="card">
788
- <span class="eyebrow">Activity</span>
789
- <h2>What happened lately</h2>
790
- <p>Every change made to this account, newest first.</p>
791
- <ul class="lines" id="v-list"></ul>
792
- <p class="empty" id="v-msg">Loading…</p>
793
- </div>
794
-
795
- <div class="card">
796
- <span class="eyebrow">Account</span>
797
- <h2>Name and email</h2>
798
- <label for="a-name">Account name</label>
799
- <div class="inline">
800
- <input id="a-name" maxlength="60" autocomplete="off">
801
- <button class="ghost" id="a-rename" type="button">Save name</button>
802
- <span class="saved" id="a-rename-msg" role="status" aria-live="polite"></span>
803
- </div>
804
-
805
- <!-- The setup commands used to be printed here as well as in the
806
- first-run card. There is now one place to connect a machine, and it
807
- is the pairing block above. -->
808
- <p class="empty" style="margin-top:1.4rem">Your keys are made on your machine, so this page holds nothing that opens an archive. Signing out here does not stop a connected machine from archiving.</p>
809
- <button class="ghost" id="a-out" style="margin-top:1rem">Sign out</button>
810
- </div>
811
- </div>
812
- </div></section>
813
-
814
- <footer><div class="wrap">
815
- Encryption happens on your machine on both plans.
816
- <div class="r">Sealkeep · sealed local-first archive</div>
817
- </div></footer>
818
-
819
- <script>
820
- const $ = (id) => document.getElementById(id);
821
- const API = location.hostname === "shared.spala.ai"
822
- ? "/p04946/api"
823
- : "https://shared.spala.ai/p04946/api";
824
- // sessionStorage, not localStorage: a bearer token should die with the tab
825
- // rather than rest on disk.
826
- const TOKEN = "vaultline.token";
827
- const motionOK = () => !matchMedia("(prefers-reduced-motion: reduce)").matches;
828
-
829
- /* ------------------------------------------------------------- returning
830
- Coming back from Google used to drop the reader at the top of the
831
- marketing page while an unseen fetch decided their fate — indistinguishable
832
- from a sign-in that failed. Three things were racing: the browser restoring
833
- a scroll position, the browser's own anchor scroll, and an async sign-in
834
- that deletes the entire page underneath both of them. The page now claims
835
- the moment up front and routes the hash itself once the outcome is known. */
836
- const params = new URLSearchParams(location.search);
837
- const authCode = params.get("code");
838
- const confirmToken = params.get("confirm");
839
- const confirmAccount = params.get("account");
840
- const savedToken = sessionStorage.getItem(TOKEN);
841
- const returning = Boolean(authCode) || sessionStorage.getItem("vaultline.returning") === "1";
842
- // Only take the wheel when the layout is about to change under it. A plain
843
- // read of the marketing page keeps the browser's own scroll restoration.
844
- const resuming = Boolean(authCode || (confirmToken && confirmAccount) || savedToken);
845
- if (resuming) {
846
- if ("scrollRestoration" in history) history.scrollRestoration = "manual";
847
- document.body.classList.add("booting");
848
- }
849
-
850
- const bootSay = (text) => { $("boot-note").textContent = text; };
851
-
852
- // Landing in the panel is the same act however you got there: put the top of
853
- // the panel at the top of the window, and put the reading position on the
854
- // identity strip so a screen reader is told whose account this is.
855
- function enterPanel() {
856
- window.scrollTo(0, 0);
857
- $("a-strip").focus({ preventScroll: true });
858
- }
859
- function bootExit() {
860
- document.body.classList.remove("booting");
861
- // The panel was rendered while it was still hidden, so nothing could take
862
- // focus or scroll until now.
863
- if (document.body.classList.contains("panel")) enterPanel();
864
- }
865
- // Confirmed exits hold one beat on "Signed in" so the return has an ending
866
- // rather than just a disappearance. A silently resumed session skips it.
867
- function bootDone(confirmed) {
868
- if (!document.body.classList.contains("booting")) return Promise.resolve();
869
- if (!confirmed) { bootExit(); return Promise.resolve(); }
870
- bootSay("Signed in");
871
- $("boot-track").dataset.done = "yes";
872
- return new Promise((finished) => setTimeout(() => { bootExit(); finished(); }, motionOK() ? 460 : 0));
873
- }
874
-
875
- /* --- signature: the seal settles once, then stays still --- */
876
- (function seal() {
877
- const el = $("cipher");
878
- const glyphs = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz0123456789+/";
879
- const target = "vGx8Kq2mNbZ4tRw9YpLd3cHfA7sJeU1oQiT6vXn0MyBkR5aWzDgP4uCjE8hSlF2N"
880
- + "kR9wQ7pAzXm3TbYvL6dHcJ1fUsN4oiGeMy0BW5aKgPjD8uCE2hSlFqT6vXn0Myx";
881
- // The column wraps in CSS now, so the text is one unbroken run.
882
- if (matchMedia("(prefers-reduced-motion: reduce)").matches) { el.textContent = target; return; }
883
- let frame = 0;
884
- const total = 26;
885
- const tick = () => {
886
- // Settle left to right, so it reads as the line being sealed as it
887
- // crosses the boundary rather than as decoration.
888
- const settled = Math.floor((frame / total) * target.length);
889
- let out = target.slice(0, settled);
890
- for (let i = settled; i < target.length; i++) out += glyphs[(Math.random() * glyphs.length) | 0];
891
- el.textContent = out;
892
- if (frame++ < total) requestAnimationFrame(tick);
893
- else el.textContent = target;
894
- };
895
- requestAnimationFrame(tick);
896
- })();
897
-
898
- // One copy control, used everywhere a string is meant to be taken away. The
899
- // pairing code is the string this whole panel exists to hand over, so it gets
900
- // the same affordance the hero already gives an install command.
901
- function copies(buttonId, read) {
902
- const button = $(buttonId);
903
- button.addEventListener("click", async () => {
904
- const label = button.dataset.label || button.textContent;
905
- button.dataset.label = label;
906
- try { await navigator.clipboard.writeText(read()); button.textContent = "Copied"; }
907
- catch { button.textContent = "Copy failed"; }
908
- setTimeout(() => { button.textContent = label; }, 1600);
909
- });
910
- }
911
- copies("copy", () => $("cmd").textContent.trim());
912
- copies("pair-copy", () => $("pair-code").textContent.trim());
913
- copies("pair-cli-copy", () => $("pair-cli").textContent.trim());
914
-
915
- const startTabs = { free: [$("t-free"), $("p-free")], paid: [$("t-paid"), $("p-paid")] };
916
- for (const [key, [tab]] of Object.entries(startTabs))
917
- tab.addEventListener("click", () => {
918
- for (const [other, [b, pane]] of Object.entries(startTabs)) {
919
- b.setAttribute("aria-selected", String(other === key));
920
- pane.classList.toggle("on", other === key);
921
- }
922
- });
923
-
924
- /* ---------------------------------------------------------------- account */
925
- const say = (el, text, cls) => { const n = $(el); n.textContent = text; n.className = "msg " + (cls || ""); };
926
- /* Every error a catch renders into the page goes through this: the first 200
927
- characters are a message, anything past that is a payload nobody meant to
928
- put on screen. */
929
- /* The control plane names its own internals when a step refuses — "Error at
930
- step Precondition: that confirmation link is no longer valid". The half
931
- after the colon is written for a person; the half before it is our
932
- plumbing, and seeing it makes a clear refusal look like a crash. Strip the
933
- prefix, keep the sentence, and capitalise what is left. */
934
- const errText = (error) => {
935
- let text = String((error && error.message) || error || "Something went wrong");
936
- text = text.replace(/^\s*Error at step\s+[^:]{0,60}:\s*/i, "");
937
- text = text.replace(/^\s*(Error|Exception|Failure)\s*:\s*/i, "");
938
- if (text) text = text.charAt(0).toUpperCase() + text.slice(1);
939
- return text.slice(0, 200);
940
- };
941
- const bytes = (n) => {
942
- n = Number(n) || 0;
943
- for (const [u, d] of [["GB", 1024 ** 3], ["MB", 1024 ** 2], ["KB", 1024]])
944
- if (n >= d) return (n / d).toFixed(1) + " " + u;
945
- return n + " B";
946
- };
947
- const ago = (iso) => {
948
- const s = (Date.now() - new Date(iso).getTime()) / 1000;
949
- if (!isFinite(s)) return "—";
950
- for (const [n, u] of [[86400 * 30, "mo"], [86400, "d"], [3600, "h"], [60, "m"]])
951
- if (s >= n) return Math.floor(s / n) + u + " ago";
952
- return "just now";
953
- };
954
-
955
- async function api(path, { method = "GET", body, token } = {}) {
956
- const headers = {};
957
- if (body) headers["content-type"] = "application/json";
958
- if (token) headers.authorization = "Bearer " + token;
959
- // A dropped connection surfaces as TypeError("Failed to fetch") — the
960
- // browser's words, not ours, and they tell a customer nothing about what
961
- // to do. Everything else in this panel refuses in a sentence; so does this.
962
- let res;
963
- try {
964
- res = await fetch(API + path, { method, headers, body: body && JSON.stringify(body) });
965
- } catch {
966
- throw new Error("No answer from the service. Check your connection and try again.");
967
- }
968
- // A gateway answering for a dead service sends an HTML error page, not
969
- // JSON. That markup used to ride error.message straight into spans like
970
- // pair-msg — raw <html> on screen. When the body does not parse, the
971
- // status code is the only fact in it worth repeating; no raw body text
972
- // ever becomes a message.
973
- let data = null;
974
- let parsed = true;
975
- try { data = await res.json(); } catch { parsed = false; }
976
- if (!res.ok) {
977
- const message = parsed
978
- ? String(data?.error || `${res.status} ${res.statusText}`)
979
- : `The service did not answer (HTTP ${res.status}). Try again in a minute.`;
980
- throw new Error(message.slice(0, 200));
981
- }
982
- return data;
983
- }
984
-
985
- function showPane(which) {
986
- const wasPanel = document.body.classList.contains("panel");
987
- for (const [id, on] of [["p-signup", which === "signup"], ["p-signin", which === "signin"], ["p-account", which === "account"]])
988
- $(id).classList.toggle("on", on);
989
- // Signed in, the panel is the page. A customer checking their storage
990
- // should not have to scroll past the pitch that sold them.
991
- document.body.classList.toggle("panel", which === "account");
992
- $("auth-tabs").style.display = which === "account" ? "none" : "";
993
- for (const [b, key] of [["t-signup", "signup"], ["t-signin", "signin"]])
994
- $(b).setAttribute("aria-selected", String(which === key));
995
- // Only on the way in, and only once the panel is actually on screen. A
996
- // refresh after deleting an archive should leave the reader where they
997
- // were rather than throwing them back to the top.
998
- if (which === "account" && !wasPanel && !document.body.classList.contains("booting")) enterPanel();
999
- }
1000
-
1001
- /* In-panel navigation. The pills and the plan chip both land here rather
1002
- than leaving it to the native anchor jump: a programmatic smooth scroll is
1003
- dropped on the floor in some browsers — observed live, where the hash
1004
- changed and the page never moved — so this checks whether anything
1005
- actually happened and takes the plain jump if not. A nav that does nothing
1006
- is worse than one that does not animate. The offset clears the sticky nav. */
1007
- function goTo(el) {
1008
- if (!el) return;
1009
- const start = window.scrollY;
1010
- // The nav wraps to two rows on a phone, so a fixed offset would park the
1011
- // card underneath it. Measure what is actually sticking.
1012
- const nav = document.querySelector(".acct-nav");
1013
- const clearance = (nav ? nav.getBoundingClientRect().height : 0) + 12;
1014
- const target = Math.max(0, el.getBoundingClientRect().top + start - clearance);
1015
- const smooth = motionOK();
1016
- window.scrollTo({ top: target, behavior: smooth ? "smooth" : "instant" });
1017
- if (!smooth) return;
1018
- setTimeout(() => {
1019
- if (Math.abs(window.scrollY - start) < 4 && Math.abs(target - start) > 24) {
1020
- window.scrollTo({ top: target, behavior: "instant" });
1021
- }
1022
- }, 320);
1023
- }
1024
- for (const link of document.querySelectorAll(".acct-nav a")) {
1025
- link.addEventListener("click", (event) => {
1026
- const id = link.getAttribute("href").slice(1);
1027
- const target = document.getElementById(id);
1028
- if (!target) return;
1029
- event.preventDefault();
1030
- history.replaceState(null, "", location.pathname + location.search + "#" + id);
1031
- goTo(target);
1032
- });
1033
- }
1034
-
1035
- /* "Account" is a promise the page has to keep on the first click, not the
1036
- second. Routing it here rather than leaving it to the native anchor means
1037
- exactly one thing is scrolling, and it runs after the state that decides
1038
- where the account actually is. */
1039
- function goAccount() {
1040
- if (document.body.classList.contains("panel")) { window.scrollTo(0, 0); return; }
1041
- $("account").scrollIntoView({ behavior: motionOK() ? "smooth" : "auto", block: "start" });
1042
- }
1043
- for (const link of document.querySelectorAll('a[href="#account"]'))
1044
- link.addEventListener("click", (event) => {
1045
- event.preventDefault();
1046
- if (location.hash !== "#account") history.pushState(null, "", "#account");
1047
- goAccount();
1048
- });
1049
- addEventListener("hashchange", () => { if (location.hash === "#account") goAccount(); });
1050
-
1051
- const on = (id, show) => { $(id).style.display = show ? "" : "none"; };
1052
- const day = (iso) => {
1053
- const d = new Date(iso);
1054
- return isFinite(d.getTime())
1055
- ? d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })
1056
- : "—";
1057
- };
1058
-
1059
- function renderAccount(user) {
1060
- // The free plan writes to the customer's own bucket, so we hold nothing and
1061
- // meter nothing. Showing a quota bar there would be a meter that never moves.
1062
- const paid = user.plan === "pro";
1063
-
1064
- $("a-label").textContent = user.label || "Your vault";
1065
- $("a-name").value = user.label || "";
1066
- $("a-plan").textContent = paid ? "Managed storage" : "Free";
1067
- $("a-plan").className = "chip" + (paid ? " pro" : "");
1068
- $("a-plan").onclick = () => goTo($("anchor-plan"));
1069
- $("a-who").textContent = [
1070
- user.email,
1071
- "joined " + day(user.created_at),
1072
- user.auth_provider === "google" ? "signed in with Google" : null,
1073
- user.state === "suspended" ? "SUSPENDED" : null
1074
- ].filter(Boolean).join(" · ");
1075
-
1076
- on("a-verify", !user.email_verified_at);
1077
-
1078
- // Plan and storage destination are independent: a paid account may choose
1079
- // its own bucket. Metering it against a quota we do not hold would be a
1080
- // bar that never moves for bytes we never see, so the card follows where
1081
- // the archives actually go. loadStorage2 corrects this once the saved
1082
- // destination arrives; the plan is only the opening guess.
1083
- lastPaid = paid;
1084
- applyStorageView();
1085
- on("anchor-archives", paid); on("nav-archives", paid);
1086
- // One card carries billing now. A free account is told what managed
1087
- // storage would give it; a paying one is told the state of what it bought.
1088
- $("a-plan-free-note").textContent = paid
1089
- ? ""
1090
- : "You are on your own storage: unlimited archives, same encryption, your provider's bill. Managed storage means we run the bucket and meter what you store — we still hold no key that opens any of it.";
1091
- on("a-sub-facts", paid);
1092
- on("a-plan-free-note", !paid);
1093
-
1094
- if (paid) {
1095
- const quota = Number(user.quota_bytes) || 0, used = Number(user.used_bytes) || 0;
1096
- $("a-used").textContent = bytes(used);
1097
- $("a-quota-b").textContent = quota ? bytes(quota) : "—";
1098
- $("a-bar").style.width = quota ? Math.min(100, (used / quota) * 100) + "%" : "0%";
1099
- $("a-quota").textContent = quota
1100
- ? bytes(used) + " of " + bytes(quota) + " used" + (used >= quota * 0.9 ? " — close to the limit" : "")
1101
- : "";
1102
-
1103
- const sub = user.subscription;
1104
- $("a-sub-state").textContent = sub ? sub.state : "not recorded";
1105
- $("a-sub-end").textContent = sub && sub.current_period_end ? day(sub.current_period_end) : "—";
1106
- $("a-sub-prov").textContent = sub && sub.provider ? sub.provider : "—";
1107
- $("a-sub-note").textContent = sub && sub.state === "active"
1108
- ? "To change or cancel, write to hello@spala.ai from this address."
1109
- : "We cannot see an active subscription for this account, so uploads are paused. This is ours to fix. Email hello@spala.ai from this address and we will sort it out — nothing already stored is affected, and your machine keeps sealing sessions locally in the meantime.";
1110
- }
1111
-
1112
- rulesFrom(user);
1113
- packsFrom(user);
1114
- showPane("account");
1115
- }
1116
-
1117
- const ACTIONS = {
1118
- "sync_rules.update": "Changed what leaves your machine",
1119
- "device.revoke": "Revoked a machine",
1120
- "email.confirmation_sent": "Sent an email confirmation link",
1121
- "email.confirmed": "Confirmed the email address",
1122
- "auth.google": "Signed in with Google"
1123
- };
1124
-
1125
- async function loadDevices(token) {
1126
- const list = $("d-list");
1127
- let deviceCount = 0;
1128
- list.replaceChildren();
1129
- try {
1130
- const { devices } = await api("/v1/cloud/devices", { token });
1131
- deviceCount = devices.length;
1132
- if (!devices.length) {
1133
- // The instruction lives with the code, not here. loadAccount points at
1134
- // it when the pairing block has moved to the top of the panel.
1135
- $("d-msg").textContent = "No machines connected yet.";
1136
- return deviceCount;
1137
- }
1138
- $("d-msg").textContent = "";
1139
- // Revocation never deletes a machine — the row stays as the record that
1140
- // it once had access. On an account that has re-paired a few times that
1141
- // buries the two machines actually running under a wall of history: 29
1142
- // rows, 1666 px, nearly two screens, all looking alike. Active machines
1143
- // are the answer to "what can write to my account right now"; the rest
1144
- // fold away, counted and reachable, deleted by nobody.
1145
- const active = devices.filter((d) => d.state !== "revoked");
1146
- const retired = devices.filter((d) => d.state === "revoked");
1147
- const foldHost = $("d-retired");
1148
- foldHost.replaceChildren();
1149
- if (retired.length) {
1150
- const fold = document.createElement("details");
1151
- const summary = document.createElement("summary");
1152
- summary.textContent = `${retired.length} revoked machine${retired.length === 1 ? "" : "s"} — kept as the record, none can write`;
1153
- const inner = document.createElement("ul");
1154
- inner.className = "lines";
1155
- for (const d of retired) {
1156
- const li = document.createElement("li");
1157
- const name = document.createElement("span");
1158
- name.className = "grow";
1159
- name.textContent = d.label || "Unnamed machine";
1160
- const when = document.createElement("span");
1161
- when.className = "when";
1162
- when.textContent = "revoked " + ago(d.revoked_at);
1163
- li.append(name, when);
1164
- inner.append(li);
1165
- }
1166
- fold.append(summary, inner);
1167
- foldHost.append(fold);
1168
- }
1169
- for (const d of active) {
1170
- const li = document.createElement("li");
1171
- const name = document.createElement("span");
1172
- name.className = "grow";
1173
- name.textContent = d.label || "Unnamed machine";
1174
- const state = document.createElement("span");
1175
- const revoked = d.state === "revoked";
1176
- state.className = "state " + (revoked ? "pending" : "durable");
1177
- state.textContent = revoked ? "revoked" : "active";
1178
- const seen = document.createElement("span");
1179
- seen.className = "when";
1180
- seen.textContent = revoked ? "revoked " + ago(d.revoked_at) : "enrolled " + ago(d.created_at);
1181
- li.append(name, state, seen);
1182
-
1183
- if (!revoked) {
1184
- const btn = document.createElement("button");
1185
- btn.className = "del"; btn.type = "button"; btn.textContent = "Revoke";
1186
- btn.addEventListener("click", async () => {
1187
- // Two-step, like archive deletion: revoking locks a machine out of
1188
- // its own backups, so a stray click must not do it.
1189
- if (btn.dataset.armed !== "yes") {
1190
- btn.dataset.armed = "yes";
1191
- // The local dashboard escalates to "Destroy forever?" when it can
1192
- // prove a copy is the last one. This page cannot see the customer's
1193
- // disk, so it must not claim either way — it names the stake and
1194
- // lets them decide.
1195
- btn.textContent = "Delete forever?";
1196
- btn.title = "If your machine already reclaimed the source, this is the only copy";
1197
- setTimeout(() => {
1198
- if (btn.dataset.armed === "yes") { btn.dataset.armed = "no"; btn.textContent = "Revoke"; }
1199
- }, 4000);
1200
- return;
1201
- }
1202
- btn.disabled = true; btn.textContent = "Revoking";
1203
- try {
1204
- await api("/v1/cloud/devices/" + encodeURIComponent(d.id) + "/revoke", { method: "POST", token });
1205
- await loadDevices(token);
1206
- await loadAudit(token);
1207
- } catch (error) {
1208
- $("d-msg").textContent = errText(error);
1209
- btn.disabled = false; btn.textContent = "Revoke";
1210
- }
1211
- });
1212
- li.append(btn);
1213
- }
1214
- list.append(li);
1215
- }
1216
- } catch (error) { $("d-msg").textContent = errText(error); }
1217
- return deviceCount;
1218
- }
1219
-
1220
- async function loadAudit(token) {
1221
- const list = $("v-list");
1222
- list.replaceChildren();
1223
- try {
1224
- const { events } = await api("/v1/cloud/audit", { token });
1225
- if (!events.length) { $("v-msg").textContent = "Nothing has changed on this account yet."; return; }
1226
- $("v-msg").textContent = "";
1227
- for (const e of events) {
1228
- const li = document.createElement("li");
1229
- const what = document.createElement("span");
1230
- what.className = "grow";
1231
- what.textContent = ACTIONS[e.action] || e.action;
1232
- const when = document.createElement("span");
1233
- when.className = "when";
1234
- when.textContent = ago(e.at);
1235
- li.append(what, when);
1236
- if (e.outcome === "denied") {
1237
- const bad = document.createElement("span");
1238
- bad.className = "state pending";
1239
- bad.textContent = "did not complete";
1240
- li.append(bad);
1241
- }
1242
- list.append(li);
1243
- }
1244
- } catch (error) { $("v-msg").textContent = errText(error); }
1245
- }
1246
-
1247
- // atRisk is the whole design of this card. --warn is spent only where doing
1248
- // nothing loses data: an account with no archives has nothing to lose yet, so
1249
- // a missing sealed copy there is a step not reached, not a fault. The moment
1250
- // archives exist it becomes the real warning it always claimed to be.
1251
- async function loadEscrow(token, atRisk) {
1252
- try {
1253
- const rows = await api("/v1/cloud/escrow", { token });
1254
- const held = Array.isArray(rows) ? rows : (rows && rows.escrow) || [];
1255
- if (held.length) {
1256
- $("e-head").textContent = "Sealed copy held";
1257
- $("e-text").textContent = "Stored " + ago(held[0].created_at)
1258
- + ". It opens only with your 24-word phrase, which never reaches us.";
1259
- } else if (atRisk) {
1260
- $("e-head").textContent = "No sealed copy yet";
1261
- // Sealing needs the key, and the key is on the machine — there is no
1262
- // web action that could do this, so the command is named. It is set as
1263
- // a command rather than as prose, like every other command on the page.
1264
- const cmd = document.createElement("code");
1265
- cmd.textContent = "sealkeep recovery seal";
1266
- $("e-text").replaceChildren(
1267
- document.createTextNode("You have archives stored. If the machine holding your key is lost, every one of them is lost with it. Run "),
1268
- cmd,
1269
- document.createTextNode(" to store a copy we cannot open."));
1270
- } else {
1271
- $("e-head").textContent = "Nothing to seal yet";
1272
- $("e-text").textContent = "Your key is made on your machine when you connect it. Store a sealed copy here afterwards, so a lost laptop does not take your archives with it.";
1273
- }
1274
- $("c-recovery").classList.toggle("warncard", !held.length && Boolean(atRisk));
1275
- } catch (error) {
1276
- $("e-head").textContent = "Could not read the recovery key";
1277
- $("e-text").textContent = errText(error);
1278
- }
1279
- }
1280
-
1281
- // Managed chunk folders arrive as one row per stored object —
1282
- // <archive>.chunk-000001 … plus <archive>.envelope.vlmeta. A listing is
1283
- // about archives, so fold each family into one row: the archive id, the
1284
- // summed chunk bytes, and durable only when every member is durable.
1285
- // Mirrors collapseCloudRows in the CLI, so both surfaces tell one story.
1286
- const FAMILY_REF = /^(.+)\.(chunk-\d{6}|envelope\.vlmeta)$/;
1287
- function foldFamilies(rows) {
1288
- const families = new Map();
1289
- const plain = [];
1290
- for (const row of rows) {
1291
- const match = FAMILY_REF.exec(String(row.vault_ref));
1292
- if (!match) { plain.push(row); continue; }
1293
- const fam = families.get(match[1]) ?? { rows: [], chunkBytes: 0, chunks: 0, members: [], allDurable: true };
1294
- fam.rows.push(row);
1295
- fam.members.push(row.vault_ref);
1296
- if (match[2].startsWith("chunk-")) { fam.chunkBytes += Number(row.bytes) || 0; fam.chunks++; }
1297
- if (row.state !== "durable") fam.allDurable = false;
1298
- families.set(match[1], fam);
1299
- }
1300
- const folded = [...families.entries()].map(([id, fam]) => ({
1301
- ...fam.rows[0], vault_ref: id, bytes: fam.chunkBytes, chunked: true,
1302
- chunkCount: fam.chunks, members: fam.members, state: fam.allDurable ? "durable" : "pending"
1303
- }));
1304
- return [...plain, ...folded];
1305
- }
1306
-
1307
- /* ------------------------------------------------ routing (storage targets) */
1308
- // The same rules the CLI syncs: GET/PUT /v1/cloud/storage-targets carries
1309
- // {targets_json} — a JSON string of [{id, provider, bucket, prefix, maxGb,
1310
- // projects, priority}]. Editing here lands on every machine of the vault,
1311
- // because resolveTargets is cloud-first.
1312
- let targetRows = [];
1313
- let planIsPaid = false;
1314
- const providerLabel = (p) => ({ vaultline: "Sealkeep cloud", gdrive: "Google Drive", r2: "Cloudflare R2", s3: "S3", gcs: "Google Cloud" }[p] || p);
1315
- function renderTargets() {
1316
- const body = $("tg-body");
1317
- body.replaceChildren();
1318
- if (!targetRows.length) {
1319
- $("tg-msg").className = "empty";
1320
- $("tg-msg").textContent = planIsPaid
1321
- ? "No rules yet — everything goes to Sealkeep cloud. Add a destination here, or set rules from a machine: sealkeep storage targets add …"
1322
- : "No rules yet — each machine seals to the bucket configured on it. Add a destination here to route by project or cap it.";
1323
- $("tg-wrap").style.display = "none";
1324
- return;
1325
- }
1326
- $("tg-msg").textContent = "";
1327
- for (const t of targetRows) {
1328
- const tr = document.createElement("tr");
1329
- const name = document.createElement("td");
1330
- if (t.__editable) {
1331
- const sel = document.createElement("select");
1332
- for (const [v, l] of [["r2", "Cloudflare R2"], ["s3", "S3-compatible"], ["gcs", "Google Cloud"]]) {
1333
- const o = document.createElement("option"); o.value = v; o.textContent = l; sel.append(o);
1334
- }
1335
- sel.value = t.provider;
1336
- sel.setAttribute("aria-label", "Provider");
1337
- sel.addEventListener("change", () => { t.provider = sel.value; });
1338
- name.append(sel);
1339
- } else name.textContent = providerLabel(t.provider);
1340
- const where = document.createElement("td");
1341
- if (t.__editable) {
1342
- const b = document.createElement("input"); b.placeholder = "bucket"; b.style.width = "8rem"; b.value = t.bucket || "";
1343
- b.setAttribute("aria-label", "Bucket name");
1344
- b.addEventListener("input", () => { t.bucket = b.value.trim(); });
1345
- const p = document.createElement("input"); p.placeholder = "prefix/"; p.style.width = "6rem"; p.value = t.prefix || "";
1346
- p.setAttribute("aria-label", "Prefix");
1347
- p.addEventListener("input", () => { t.prefix = p.value.trim() || undefined; });
1348
- where.append(b, document.createTextNode(" "), p);
1349
- } else {
1350
- const wc = document.createElement("code");
1351
- wc.textContent = t.provider === "vaultline" ? "managed"
1352
- : t.provider === "gdrive" ? "folder " + (t.bucket || "Sealkeep")
1353
- : [t.bucket, t.prefix].filter(Boolean).join("/") || "—";
1354
- where.append(wc);
1355
- if (t.usedBytes) {
1356
- const held = document.createElement("span");
1357
- held.className = "cellsub";
1358
- held.textContent = "holds " + bytes(t.usedBytes);
1359
- where.append(held);
1360
- }
1361
- }
1362
- const pr = document.createElement("td");
1363
- const prIn = document.createElement("input"); prIn.type = "number"; prIn.className = "n"; prIn.min = "0"; prIn.max = "99";
1364
- prIn.value = t.priority ?? (t.provider === "vaultline" ? 0 : 10);
1365
- prIn.setAttribute("aria-label", "Priority for " + providerLabel(t.provider));
1366
- prIn.addEventListener("input", () => { t.priority = Number(prIn.value); });
1367
- pr.append(prIn);
1368
- const cap = document.createElement("td");
1369
- const capIn = document.createElement("input"); capIn.type = "number"; capIn.className = "n"; capIn.min = "1"; capIn.max = "99999"; capIn.placeholder = "none";
1370
- if (t.maxGb) capIn.value = t.maxGb;
1371
- capIn.setAttribute("aria-label", "Cap in GB for " + providerLabel(t.provider));
1372
- capIn.addEventListener("input", () => { t.maxGb = capIn.value ? Number(capIn.value) : undefined; });
1373
- cap.append(capIn);
1374
- const proj = document.createElement("td");
1375
- const projIn = document.createElement("input"); projIn.placeholder = "empty = every project"; projIn.style.minWidth = "9rem";
1376
- projIn.value = (t.projects || []).join(", ");
1377
- projIn.setAttribute("aria-label", "Projects pinned to " + providerLabel(t.provider));
1378
- projIn.addEventListener("input", () => {
1379
- const list = projIn.value.split(",").map((x) => x.trim()).filter(Boolean);
1380
- if (list.length) t.projects = list; else delete t.projects;
1381
- });
1382
- proj.append(projIn);
1383
- const act = document.createElement("td");
1384
- const rm = document.createElement("button"); rm.className = "del"; rm.type = "button"; rm.textContent = "Remove";
1385
- rm.addEventListener("click", () => { targetRows = targetRows.filter((x) => x !== t); renderTargets(); });
1386
- act.append(rm);
1387
- tr.append(name, where, pr, cap, proj, act);
1388
- body.append(tr);
1389
- }
1390
- $("tg-wrap").style.display = "";
1391
- }
1392
- async function loadTargets(token) {
1393
- try {
1394
- const data = await api("/v1/cloud/storage-targets", { token });
1395
- try { targetRows = JSON.parse(data.targets_json || "[]"); } catch { targetRows = []; }
1396
- if (!Array.isArray(targetRows)) targetRows = [];
1397
- renderTargets();
1398
- } catch (error) { say("tg-msg", errText(error), "err"); }
1399
- }
1400
- $("tg-add-bucket").addEventListener("click", () => {
1401
- targetRows.push({ id: "byo-" + Math.random().toString(36).slice(2, 8), provider: "r2", priority: 5, __editable: true });
1402
- renderTargets();
1403
- say("tg-save-msg", "Fill bucket and prefix, save — then put the access keys on your machine: sealkeep storage credentials set");
1404
- });
1405
- $("tg-add-drive").addEventListener("click", () => {
1406
- if (targetRows.some((t) => t.provider === "gdrive")) { say("tg-save-msg", "Drive is already in the rules."); return; }
1407
- targetRows.push({ id: "gdrive-" + Math.random().toString(36).slice(2, 8), provider: "gdrive", bucket: "Sealkeep", maxGb: 50, priority: 10 });
1408
- renderTargets();
1409
- say("tg-save-msg", "Added — save, then run the one-time consent on your machine.");
1410
- });
1411
- $("tg-save").addEventListener("click", async () => {
1412
- const token = sessionStorage.getItem(TOKEN);
1413
- if (!token) { say("tg-save-msg", "Sign in again to save.", "err"); return; }
1414
- $("tg-save").disabled = true;
1415
- try {
1416
- const clean = targetRows.map(({ __editable, ...t }) => t);
1417
- await api("/v1/cloud/storage-targets", { method: "PUT", token, body: { targets_json: JSON.stringify(clean) } });
1418
- say("tg-save-msg", "Saved. Every machine follows these on its next seal.");
1419
- } catch (error) { say("tg-save-msg", errText(error), "err"); }
1420
- $("tg-save").disabled = false;
1421
- });
1422
-
1423
- async function loadStorage(token) {
1424
- try {
1425
- const rows = await api("/v1/cloud/archives", { token });
1426
- const body = $("s-body");
1427
- body.replaceChildren();
1428
- if (!rows.length) {
1429
- // A caution about deleting archives is noise when there are none, and
1430
- // an empty state is prose, so it is set in prose. .msg is mono because
1431
- // it reports what a machine did.
1432
- on("s-intro", false);
1433
- $("a-count").textContent = "0";
1434
- $("s-msg").className = "empty";
1435
- $("s-msg").textContent = "Nothing stored yet. Archives appear here on their own once a machine is connected.";
1436
- $("s-wrap").style.display = "none";
1437
- return;
1438
- }
1439
- on("s-intro", true);
1440
-
1441
- for (const r of foldFamilies(rows)) {
1442
- const tr = document.createElement("tr");
1443
- // textContent throughout: an archive ref is data, never markup.
1444
- const ref = document.createElement("td");
1445
- const code = document.createElement("code"); code.textContent = String(r.vault_ref).slice(0, 8); ref.append(code);
1446
- const size = document.createElement("td");
1447
- const sc = document.createElement("code");
1448
- sc.textContent = bytes(r.bytes);
1449
- size.append(sc);
1450
- if (r.chunked) {
1451
- const sub = document.createElement("span");
1452
- sub.className = "cellsub";
1453
- sub.textContent = r.chunkCount + " sealed chunks";
1454
- size.append(sub);
1455
- }
1456
- const st = document.createElement("td");
1457
- const s = document.createElement("span");
1458
- s.className = "state " + (r.state === "durable" ? "durable" : "pending");
1459
- s.textContent = r.state === "durable" ? "stored" : "incomplete";
1460
- st.append(s);
1461
- const when = document.createElement("td"); when.className = "mono"; when.style.fontSize = ".78rem"; when.style.color = "var(--soft)"; when.textContent = ago(r.durable_at || r.created_at);
1462
- const act = document.createElement("td");
1463
- const btn = document.createElement("button");
1464
- btn.className = "del"; btn.type = "button"; btn.textContent = "Delete";
1465
- btn.addEventListener("click", async () => {
1466
- // Two-step: an archive is the last copy once the source is reclaimed,
1467
- // so one stray click must not remove it.
1468
- if (btn.dataset.armed !== "yes") {
1469
- btn.dataset.armed = "yes"; btn.textContent = "Confirm";
1470
- setTimeout(() => { if (btn.dataset.armed === "yes") { btn.dataset.armed = "no"; btn.textContent = "Delete"; } }, 4000);
1471
- return;
1472
- }
1473
- btn.disabled = true; btn.textContent = "Deleting";
1474
- try {
1475
- // A chunked archive is many stored objects; removing it means
1476
- // removing every member, envelope last so a crash leaves an
1477
- // openable family rather than an orphaned envelope.
1478
- const refs = r.members ? [...r.members].sort((a, b) => a.endsWith(".envelope.vlmeta") ? 1 : b.endsWith(".envelope.vlmeta") ? -1 : 0) : [r.vault_ref];
1479
- for (let i = 0; i < refs.length; i++) {
1480
- if (refs.length > 1) btn.textContent = "Deleting " + (i + 1) + "/" + refs.length;
1481
- await api("/v1/cloud/archives/" + encodeURIComponent(refs[i]), { method: "DELETE", token });
1482
- }
1483
- await loadAccount(token);
1484
- } catch (error) { say("s-msg", errText(error), "err"); btn.disabled = false; btn.textContent = "Delete"; }
1485
- });
1486
- act.append(btn);
1487
- tr.append(ref, size, st, when, act);
1488
- body.append(tr);
1489
- }
1490
-
1491
- // Counts describe archives, not stored objects — a chunk family is one.
1492
- const folded = foldFamilies(rows);
1493
- const stored = folded.filter((r) => r.state === "durable").length;
1494
- say("s-msg", "");
1495
- $("a-count").textContent = String(folded.length);
1496
- $("s-foot").textContent = `${folded.length} archive${folded.length === 1 ? "" : "s"} · ${stored} fully stored`
1497
- + (stored < folded.length ? ` · ${folded.length - stored} incomplete, not billed` : "");
1498
- $("s-wrap").style.display = "";
1499
- } catch (error) { say("s-msg", errText(error), "err"); }
1500
- }
1501
-
1502
- async function loadAccount(token) {
1503
- let user;
1504
- try { user = await api("/me", { token }); }
1505
- catch { sessionStorage.removeItem(TOKEN); return false; }
1506
-
1507
- // Set before any card renders: the routing card's empty state asks whether
1508
- // this account stores with us, and it draws during loadTargets below.
1509
- planIsPaid = user.plan === "pro";
1510
- renderAccount(user);
1511
- // Each section fails on its own. One dead panel must not blank the rest.
1512
- if (user.plan === "pro") { $("a-count").textContent = "—"; loadStorage(token); }
1513
- await loadTargets(token);
1514
- loadAudit(token);
1515
- const devices = await loadDevices(token);
1516
-
1517
- // A brand-new account has exactly one useful next step. It leads the panel,
1518
- // and it leads with the code rather than with a terminal — a paying
1519
- // customer should not have to be a terminal user to finish setting up.
1520
- // The glance line: what is connected, where seals go — from data already fetched.
1521
- $("a-machines").textContent = String(devices);
1522
- $("a-dest").textContent = String(Math.max(targetRows.length, 1));
1523
- const destNames = targetRows.slice()
1524
- .sort((a, b) => (a.priority ?? 10) - (b.priority ?? 10))
1525
- .map((t) => providerLabel(t.provider) + (t.maxGb ? " ≤" + t.maxGb + " GB" : ""));
1526
- const driveUsed = targetRows.filter((t) => t.provider === "gdrive" && t.usedBytes)
1527
- .reduce((n, t) => n + t.usedBytes, 0);
1528
- const byoUsed = targetRows.filter((t) => t.provider !== "gdrive" && t.provider !== "vaultline" && t.usedBytes)
1529
- .reduce((n, t) => n + t.usedBytes, 0);
1530
- // Only a paid account stores with us. Saying "0 B in Sealkeep cloud" to
1531
- // someone who runs their own bucket states a number about a place their
1532
- // archives never go — the free line names their storage instead.
1533
- const paidPlan = planIsPaid;
1534
- const parts = [];
1535
- if (paidPlan) parts.push(bytes(Number(user.used_bytes) || 0) + " in Sealkeep cloud");
1536
- if (driveUsed) parts.push(bytes(driveUsed) + " in Google Drive");
1537
- if (byoUsed) parts.push(bytes(byoUsed) + " in your bucket");
1538
- $("a-hero").textContent = parts.length ? parts.join(" · ")
1539
- : "Your archives live in storage you run";
1540
- $("a-summary").textContent =
1541
- (devices ? devices + " machine" + (devices === 1 ? "" : "s") + " connected" : "No machine connected yet")
1542
- + " · new archives go to "
1543
- + (destNames.length ? destNames.join(", then ")
1544
- : paidPlan ? "Sealkeep cloud" : "the bucket each machine is configured with");
1545
- const nothingYet = devices === 0 && Number(user.used_bytes) === 0;
1546
- on("a-firstrun", nothingYet);
1547
- $(nothingYet ? "pair-home-first" : "pair-top").append($("pair"));
1548
- $("pair-side").textContent = nothingYet ? "Your pairing code" : "Pairing code";
1549
- if (devices === 0)
1550
- $("d-msg").textContent = nothingYet
1551
- ? "No machines connected yet. Use the pairing code at the top of this page."
1552
- : "No machines connected yet.";
1553
- // On first run there is one reason to be here, so the code is ready rather
1554
- // than hidden behind a click. Anywhere else, adding a machine stays a
1555
- // deliberate act — codes are single use and should not be minted by
1556
- // accident on every visit.
1557
- if (nothingYet && !pairShown) mintCode(true);
1558
-
1559
- // Escrow last, because how loud it should be depends on whether anything
1560
- // is stored yet.
1561
- loadEscrow(token, !nothingYet);
1562
- return true;
1563
- }
1564
-
1565
- $("a-rename").addEventListener("click", async () => {
1566
- const token = sessionStorage.getItem(TOKEN);
1567
- const label = $("a-name").value.trim();
1568
- if (!label) return void ($("a-rename-msg").textContent = "Give the account a name.");
1569
- $("a-rename").disabled = true;
1570
- $("a-rename-msg").textContent = "Saving";
1571
- try {
1572
- const saved = await api("/account", { method: "PATCH", token, body: { label } });
1573
- $("a-label").textContent = saved.label;
1574
- $("a-rename-msg").textContent = "Saved";
1575
- } catch (error) { $("a-rename-msg").textContent = errText(error); }
1576
- finally {
1577
- $("a-rename").disabled = false;
1578
- setTimeout(() => { $("a-rename-msg").textContent = ""; }, 5000);
1579
- }
1580
- });
1581
-
1582
- // One Google mark in the markup, cloned into the second button. Same node,
1583
- // half the bytes, and still no innerHTML anywhere on the page.
1584
- $("g-in").prepend($("g-up").querySelector("svg").cloneNode(true));
1585
-
1586
- // The destination choice lives in Routing; this card only switches between
1587
- // "we meter it" (paid) and "your machine reports it" (free).
1588
- let lastPaid = false;
1589
- function applyStorageView() {
1590
- on("a-store-free", !lastPaid);
1591
- on("a-store-paid", lastPaid);
1592
- }
1593
-
1594
- /* -------------------------------------------------------------- pairing
1595
- An account created through Google has no password anyone knows, so a
1596
- one-time code is the only way a machine can ever sign in. That makes this
1597
- code the single most important string on the page, and it used to be the
1598
- least supported one: behind a click, in small type, with no way to copy it.
1599
- It is now the largest thing in the panel and it copies in one press. */
1600
- let pairShown = false;
1601
-
1602
- function showCode(code, email) {
1603
- const field = $("pair-code");
1604
- // Never regrouped, spaced or prettified. What is on screen is byte for byte
1605
- // what Copy writes and what the machine will accept back.
1606
- field.textContent = code;
1607
- field.dataset.long = code.length > 16 ? "yes" : "no";
1608
- $("pair-cli").textContent = "sealkeep cloud login \\\n --email " + email + " \\\n --code " + code;
1609
- $("pair-note").textContent = "Single use. Enter it on the machine you want to archive from.";
1610
- on("pair-shown", true);
1611
- on("pair-alt", true);
1612
- on("pair-ask", false);
1613
- // A label with nothing under it is not a label. It arrives with the code.
1614
- on("pair-side", true);
1615
- pairShown = true;
1616
- }
1617
-
1618
- async function mintCode(auto) {
1619
- const token = sessionStorage.getItem(TOKEN);
1620
- $("pair-go").disabled = true;
1621
- $("pair-msg").textContent = auto ? "" : "Making a code";
1622
- try {
1623
- const made = await api("/account/cli-token", { method: "POST", token });
1624
- showCode(made.token, made.email);
1625
- $("pair-msg").textContent = "";
1626
- } catch (error) {
1627
- // A failed mint must leave the button behind, not a dead card.
1628
- $("pair-msg").textContent = errText(error);
1629
- on("pair-ask", true);
1630
- } finally { $("pair-go").disabled = false; }
1631
- }
1632
-
1633
- $("pair-go").addEventListener("click", () => mintCode(false));
1634
-
1635
- $("a-verify-go").addEventListener("click", async () => {
1636
- const token = sessionStorage.getItem(TOKEN);
1637
- $("a-verify-go").disabled = true;
1638
- $("a-verify-msg").textContent = "Sending";
1639
- try {
1640
- const result = await api("/account/send-confirmation", { method: "POST", token });
1641
- // The endpoint reports what the mail provider actually did, so this
1642
- // never claims a message was sent when it was refused.
1643
- $("a-verify-msg").textContent = result.sent
1644
- ? "Sent. Check your inbox."
1645
- : "Email delivery is not switched on yet — nothing was sent.";
1646
- } catch (error) { $("a-verify-msg").textContent = errText(error); }
1647
- finally { $("a-verify-go").disabled = false; }
1648
- });
1649
-
1650
- $("p-signup").addEventListener("submit", async (event) => {
1651
- event.preventDefault();
1652
- const email = $("su-email").value.trim(), label = $("su-label").value.trim(), password = $("su-pass").value;
1653
- if (password.length < 12) return say("su-msg", "Use at least 12 characters.", "err");
1654
- $("su-go").disabled = true; say("su-msg", "Creating your account");
1655
- try {
1656
- await api("/signup", { method: "POST", body: { email, password, label } });
1657
- const { token } = await api("/login", { method: "POST", body: { email, password } });
1658
- sessionStorage.setItem(TOKEN, token);
1659
- say("su-msg", "");
1660
- await loadAccount(token);
1661
- } catch (error) {
1662
- say("su-msg", /exists|unique|duplicate/i.test(error.message)
1663
- ? "That email is already registered. Sign in instead."
1664
- : errText(error), "err");
1665
- } finally { $("su-go").disabled = false; }
1666
- });
1667
-
1668
- $("p-signin").addEventListener("submit", async (event) => {
1669
- event.preventDefault();
1670
- $("si-go").disabled = true; say("si-msg", "Signing in");
1671
- try {
1672
- const { token } = await api("/login", { method: "POST", body: { email: $("si-email").value.trim(), password: $("si-pass").value } });
1673
- sessionStorage.setItem(TOKEN, token);
1674
- say("si-msg", "");
1675
- await loadAccount(token);
1676
- } catch {
1677
- // Never distinguish "no such account" from "wrong password" — that
1678
- // difference tells an attacker which addresses are registered.
1679
- say("si-msg", "Email or password is not correct.", "err");
1680
- } finally { $("si-go").disabled = false; }
1681
- });
1682
-
1683
- $("a-out").addEventListener("click", () => {
1684
- sessionStorage.removeItem(TOKEN);
1685
- showPane("signin");
1686
- say("si-msg", "Signed out. Archiving on your connected machines carries on.");
1687
- goAccount();
1688
- });
1689
- for (const [b, key] of [["t-signup", "signup"], ["t-signin", "signin"]])
1690
- $(b).addEventListener("click", () => showPane(key));
1691
-
1692
- /**
1693
- * #signin and #signup land on the right tab, not merely the right section.
1694
- *
1695
- * The installer sends people here mid-setup — they have chosen managed
1696
- * storage and need an account before they can go on. Dropping them on the
1697
- * page and letting them find the tab is one more thing to get wrong at the
1698
- * exact moment they are furthest from what they came to do, and a returning
1699
- * customer sent to "Create account" reads it as being asked to sign up twice.
1700
- */
1701
- function openAuthFromHash() {
1702
- const wanted = { "#signin": "signin", "#signup": "signup" }[location.hash];
1703
- if (!wanted) return;
1704
- // Never over a signed-in panel: they already have what the link was for.
1705
- if (sessionStorage.getItem(TOKEN)) { goAccount(); return; }
1706
- showPane(wanted);
1707
- goAccount();
1708
- }
1709
- window.addEventListener("hashchange", openAuthFromHash);
1710
-
1711
- /* ----------------------------------------------------------- google auth */
1712
- // Authorization-code flow: the browser only ever carries a short-lived code,
1713
- // and GOOGLE_CLIENT_SECRET stays on the server where it belongs.
1714
- async function google(msgId) {
1715
- say(msgId, "Opening Google");
1716
- try {
1717
- const { url } = await api("/google-auth/authorize");
1718
- if (!url) throw new Error("Google sign-in is not configured yet.");
1719
- sessionStorage.setItem("vaultline.returning", "1");
1720
- location.assign(url);
1721
- } catch (error) {
1722
- say(msgId, /404|not found/i.test(error.message)
1723
- ? "Google sign-in is not switched on yet. Use a password for now."
1724
- : errText(error), "err");
1725
- }
1726
- }
1727
- $("g-up").addEventListener("click", () => google("su-msg"));
1728
- $("g-in").addEventListener("click", () => google("si-msg"));
1729
-
1730
- // A failure that happens off-screen reads as no failure at all. Put the
1731
- // reason next to the retry, and put both in front of the reader.
1732
- async function backToSignIn(message, bad) {
1733
- await bootDone(false);
1734
- showPane("signin");
1735
- say("si-msg", message, bad ? "err" : "");
1736
- goAccount();
1737
- }
1738
-
1739
- async function finishGoogle(code) {
1740
- // Strip the code from the address bar before anything else, so it is not
1741
- // left in history or leaked by a Referer header.
1742
- history.replaceState(null, "", location.pathname + "#account");
1743
- sessionStorage.removeItem("vaultline.returning");
1744
- bootSay("Signing you in");
1745
- try {
1746
- const { token } = await api("/google-auth/callback", { method: "POST", body: { code } });
1747
- sessionStorage.setItem(TOKEN, token);
1748
- say("si-msg", "");
1749
- // loadAccount reports whether the token actually opened the account. A
1750
- // sign-in that half worked must not exit through the confirmed path.
1751
- if (await loadAccount(token)) return void await bootDone(true);
1752
- await backToSignIn("That sign-in did not finish. Try it again below.", true);
1753
- } catch (error) {
1754
- // A code that Google rejects surfaces as an opaque execution failure,
1755
- // which tells the reader nothing. The recovery is the same either way:
1756
- // start again from the button.
1757
- await backToSignIn(/execution failed/i.test(error.message)
1758
- ? "That Google sign-in did not complete. Start again with the button below."
1759
- : errText(error), true);
1760
- }
1761
- }
1762
-
1763
- /* ----------------------------------------------------------- sync rules */
1764
- const choices = [...document.querySelectorAll(".choice")];
1765
- const paintChoices = () => {
1766
- for (const c of choices) c.dataset.on = c.querySelector("input[type=radio]").checked ? "yes" : "no";
1767
- };
1768
- for (const c of choices) c.querySelector("input[type=radio]").addEventListener("change", paintChoices);
1769
- paintChoices();
1770
-
1771
- // Kept in step with src/packages.ts. A package is gigabytes and a price; the
1772
- // provider is deliberately absent, because which one holds the bytes is ours
1773
- // to choose and may change without the customer being involved.
1774
- const PACKAGES = [
1775
- { id: "free", name: "Your own bucket", gb: 0, monthly: 0, managed: false, blurb: "Your provider, your region, your bill. We never hold the bytes." },
1776
- { id: "pro_lite", name: "Pro Lite", gb: 0, monthly: 2, managed: false, blurb: "Your storage, our safety net: a sealed copy of your key in escrow, and every machine signed in. We still hold no bytes." },
1777
- { id: "gb100", name: "100 GB", gb: 100, monthly: 6, managed: true, blurb: "Years of transcripts from one machine." },
1778
- { id: "gb500", name: "500 GB", gb: 500, monthly: 12, managed: true, blurb: "A whole team's history." },
1779
- { id: "gb2000", name: "2 TB", gb: 2000, monthly: 30, managed: true, blurb: "Every agent on every machine, kept indefinitely." }
1780
- ];
1781
-
1782
- const packageIdFor = (user) => {
1783
- if (user.plan === "pro_lite") return "pro_lite";
1784
- if (user.plan !== "pro") return "free";
1785
- const gb = Math.round((Number(user.quota_bytes) || 0) / 1073741824);
1786
- const match = PACKAGES.find((p) => p.managed && p.gb === gb);
1787
- return match ? match.id : "custom";
1788
- };
1789
-
1790
- const packsFrom = (user) => {
1791
- const current = packageIdFor(user);
1792
- const cur = PACKAGES.find((p) => p.id === current);
1793
- $("a-pack-current").textContent = cur
1794
- ? (cur.managed ? `${cur.name} — $${cur.monthly} a month` : `${cur.name} — free`)
1795
- : `Custom allocation`;
1796
- $("a-pack-context").textContent = cur ? cur.blurb : "";
1797
- const host = $("a-packs");
1798
- while (host.firstChild) host.removeChild(host.firstChild);
1799
- for (const pack of PACKAGES.filter((p) => p.managed)) {
1800
- const chip = document.createElement(pack.id === current ? "span" : "a");
1801
- chip.className = "packchip" + (pack.id === current ? " now" : "");
1802
- if (pack.id !== current) {
1803
- // It looked like a picker and did nothing when clicked. Changing a
1804
- // package is handled by a person today, so the chip starts that
1805
- // message rather than pretending to be a checkout.
1806
- chip.href = "mailto:hello@spala.ai?subject=" + encodeURIComponent(`Move me to ${pack.name}`)
1807
- + "&body=" + encodeURIComponent(`Please move my account to ${pack.name}${pack.managed ? ` ($${pack.monthly} a month)` : ""}.`);
1808
- chip.title = `Ask us to move you to ${pack.name}`;
1809
- }
1810
- const name = document.createElement("b"); name.textContent = pack.name;
1811
- const price = document.createElement("span"); price.textContent = `$${pack.monthly}/mo`;
1812
- chip.append(name, price);
1813
- if (pack.id === current) {
1814
- const tag = document.createElement("i"); tag.textContent = "your package"; chip.append(tag);
1815
- }
1816
- host.append(chip);
1817
- }
1818
- if (current === "custom") {
1819
- const gb = Math.round((Number(user.quota_bytes) || 0) / 1073741824);
1820
- $("a-pack-note").textContent = `You are on a custom allocation of ${gb} GB, not one of the standard packages.`;
1821
- }
1822
- };
1823
-
1824
- const rulesFrom = (user) => {
1825
- const mode = user.sync_mode || "all";
1826
- const radio = document.querySelector(`input[name=sync][value="${mode}"]`) || choices[0].querySelector("input");
1827
- radio.checked = true;
1828
- if (user.sync_keep_last) $("r-last").value = user.sync_keep_last;
1829
- if (user.sync_min_age_days) $("r-age").value = user.sync_min_age_days;
1830
- paintChoices();
1831
- };
1832
-
1833
- $("r-save").addEventListener("click", async () => {
1834
- const token = sessionStorage.getItem(TOKEN);
1835
- const mode = document.querySelector("input[name=sync]:checked").value;
1836
- $("r-save").disabled = true;
1837
- $("r-msg").textContent = "Saving";
1838
- try {
1839
- await api("/v1/cloud/sync-rules", {
1840
- method: "POST", token,
1841
- body: {
1842
- sync_mode: mode,
1843
- sync_keep_last: Number($("r-last").value) || 20,
1844
- sync_min_age_days: Number($("r-age").value) || 7
1845
- }
1846
- });
1847
- $("r-msg").textContent = "Saved to your account.";
1848
- } catch (error) {
1849
- $("r-msg").textContent = /404|not found/i.test(error.message)
1850
- ? "Rules are not switched on yet — nothing was changed."
1851
- : errText(error);
1852
- } finally {
1853
- $("r-save").disabled = false;
1854
- setTimeout(() => { $("r-msg").textContent = ""; }, 6000);
1855
- }
1856
- });
1857
-
1858
- /* ------------------------------------------------------ email confirmation */
1859
- async function finishConfirm(accountId, token) {
1860
- history.replaceState(null, "", location.pathname + "#account");
1861
- const signedIn = sessionStorage.getItem(TOKEN);
1862
- try {
1863
- await api("/confirm-email", { method: "POST", body: { account_id: accountId, token } });
1864
- if (signedIn && await loadAccount(signedIn)) {
1865
- await bootDone(returning);
1866
- $("a-verify-msg").textContent = "Email confirmed.";
1867
- return;
1868
- }
1869
- await backToSignIn("Email confirmed. Sign in to continue.");
1870
- } catch (error) {
1871
- if (signedIn && await loadAccount(signedIn)) {
1872
- await bootDone(returning);
1873
- $("a-verify-msg").textContent = errText(error);
1874
- return;
1875
- }
1876
- await backToSignIn(errText(error), true);
1877
- }
1878
- }
1879
-
1880
- /* ------------------------------------------------------------------ start
1881
- One entry point, and the hash is routed only once the outcome is settled.
1882
- Everything that follows a return — where the reader lands, what they are
1883
- told, and whether the marketing page is even in the way — is decided here
1884
- rather than left to whichever of three scroll mechanisms fired last. */
1885
- (async function start() {
1886
- if (authCode) return void finishGoogle(authCode);
1887
- if (confirmToken && confirmAccount) return void finishConfirm(confirmAccount, confirmToken);
1888
- if (savedToken) {
1889
- const opened = await loadAccount(savedToken);
1890
- // A resumed session is not an event, so it exits without the beat.
1891
- // Coming back from a provider is, so it keeps it.
1892
- await bootDone(returning && opened);
1893
- sessionStorage.removeItem("vaultline.returning");
1894
- if (!opened) return void await backToSignIn("That session has expired. Sign in again.");
1895
- if (location.hash === "#account") requestAnimationFrame(goAccount);
1896
- return;
1897
- }
1898
- await bootDone(false);
1899
- if (location.hash === "#account") requestAnimationFrame(goAccount);
1900
- // A link that names a tab opens that tab, on arrival as well as on a later
1901
- // hash change. Someone sent here mid-install should land where they were
1902
- // sent, not on the section and a guess.
1903
- requestAnimationFrame(openAuthFromHash);
1904
- })();
1
+ <!doctype html><meta charset="utf-8"><title>Sealkeep — your agent history, sealed</title><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="description" content="Seal. Keep. Share. Your agent’s memory — sealed on your machine, kept in storage you choose, shared on your terms. Encrypted archive for Claude Code and Codex sessions."><link rel="alternate" type="text/plain" href="/llms.txt" title="Sealkeep product context for AI systems"><style>
2
+ :root{--paper:#161311;--card:#1e1a17;--ink:#f0ede6;--soft:#9a948a;--rule:#2e2a24;--seal:#100e0b;--seal-ink:#e8c793;--live:#e3a455;--warn:#e06c4f;--warn-bg:#33201a;--sans:-apple-system,BlinkMacSystemFont,"Segoe UI",Inter,system-ui,"Helvetica Neue",sans-serif;--mono:ui-monospace,SFMono-Regular,"SF Mono",Menlo,"DejaVu Sans Mono",Consolas,monospace;color-scheme:dark;--nav-h:58px;--ok:#4cbf7a}@font-face{font-family:'Archivo Black';font-style:normal;font-weight:400;font-display:swap;src:url(data:font/woff2;base64,d09GMgABAAAAACZAAA4AAAAAXlQAACXmAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGlgbjz4cgxgGYACEbBEICoGMPOsXC4NQAAE2AiQDhwwEIAWEKgeFIRvpSkUHYtg4AEBTXhpF6SINi6KEcUYl/x8W6BiDa21UWSTEpEJDTGpSA+KiNfHZNsTZ8ebJX5HIXOavw2FhEtPolEWgURCwuJkjNPZJLg/Zfv+emd27BSJhAC0AxSPGP1tgY6saXyGBdL8jX9f8HYJtdihibmKigkgpMaIMUAlBEcRCwCC0sbFqZq3apb+5/cS5NStXrMvN77mPfWz73oin3++35z7cO42s9dN8OkMokIk0sxBp4plM1hi4Y7aJ3DsqU0X0E1yn8hVcXhKmjatcbWezAvj35c90KZaL1VqBixPubMGPy+w4g8HF9rr7UTMTbRo4GZSzXw/sT1KkrVOXuf6/Ovvus5MP91nxIhXVVjXJUjLKEC6Tdc5W8tkOiDrgjtgzk0X0An/a3r//kjb5JbGiLmbbHu6eiIOF//9zpb47J7utBHYponAkTIUC9pT+/Jk5N28zBfxLnEalnEVyddMKRUIxSEBFYOvq0bgaVyEszr9Oc/06NJSA3n64trx1Gr7+/9L/kqycItsX2SF0ciAfoRX5jMo1bnTHUxE2IAfPKQHvRBvi1LnDsHbYO4xL5/rLV2sfKi5dmMgEEe3Vr5tbR7OUk5kQTnJruoYR0hkz5d+P4bS2m6fyViNcgEDQwyu/X7oAzAVAcrPEEhAePEAgIEAEwYDAw4NgYoOQkYFIkAAiiw6EkRFEmXIQlSpBQACzMcWxWzmsVIOAvdy6SrAUFKB/FiC9CJC712s82pgFkIAY2XkVrJITiJZy2ue1rRPenrd9bbh1NmMjNfTw7XBsaBsqB+lAGaB1qu6r/Eqo6HKzjJe2gitupZWW//NtLuTt3JrD2ZQ1WZyK5CYujsR0jEZWSCMgoD564z4IFihHzrLs684XAlqOhnjBSGgYrWfjCBOBh491LBcvv5CwqLiMrEBTvyqqaD5fnkAkmVpYWcNEE0evg9YNPD0lcItgUKoG6gDU5JjwFEUcRVQmp7mSF36EEUUcRVTOHhboCENEVVxnXsCvrWB2UJqjR0qbC2WQIC0AAOHyJ2vBkpyAqJOLnpfhRzgeZRenk5XxJ4cKNnCwX1x6tYt0KmzabPvQYTewTxx4OWab90jijOKwQot/EmBXJFeRUcLwwwh/xLfGU07pEZCmQWHu1jBy0mCAeAeLjIquDbJoHAo7DLCvitIj16qJUoZdIUvRmctdfchDt74ll4y1AUIdSoiADyJNrhBFEyYHcqngcz45NkIKbeR4CFYnWakMrR9UQ9QCHBEMylbfsmIH+7NfdK4rfkY9jCNeLJpNW3LgU1Y4ozjcArQB/zXnPVFAPVgZO9inVgVB0AFc4t/AAAd2zoa82kBkIMbtHXo1n0zAA4JAtcvPM9iuzh+PN3k6RcmOxQFdfhbgBXTwX3PuNXLAFMwgQaAAibY+zjDqjEWYw3b4Vpy5vtk5Ri9dkR2JLgZFwtxsXGcQ6NEh4kpH/cfKKXHiZz8u/13VvCXcC+ufP4WPFoljjgv0cIC+iz83/PYG2oDoUUBBEChX9HQYKllQexXHwJEAH1yne3zFVrREHQNhTWSpfQ/SYVD9a1Z4cNlceeWWJ2EBuxBAqYtLnWvul5G8bUBx8AM4ZuGJAWkAhQSBSu/Z2tnXaGI62PFTAWRDOqivOhDosull+JX1Ug7pQO/OCiRJcLhx92b4QI9FpfYNhZB9RYAAwU0UQiOydVSbgMPxs1+X167C1e6fPCCxxevzv6zWN9SkoweTuPCaYzbpya2PqolMdzPBkwdDnNpRbUfwYXF6LXpDX5DWPjWGy7F738KOA1hZ8pieaiGzC8d/Z3IEiDDWuFdiygXs0JxfDqhuCIAcO5cxvCtHxB7RBw4T7RjizYX6WjbkFvZ4Oxg6ggSYk9txA9NmdFqzpFnonYKlvp49SogAS0GmXsvepEFrDe3sYpjVuaw02UsH+oFzsqrJWiHjSrpZbt+lR58BMEAicAkCfAFdvH1HKYbANEpAQ+8AkA3G/ejYl1exkgOQZ5kBONG6fOxgJwrI3tklz9XPqYAZ3sKl1fQkIAI2j9tXdjpQe2PvRgA+AuRQzgAOCFCy1LoCgVrjTg+hyscB5mHgafcZwoq0vJLP8+VGb0wABo3BYUIwHAwfI8V8wuKwq7BrcJ44n8+fIU6OwcS3w1KN9cL4YVCLZmN4n1LUDwA4C9DxwPTMJ5dPswAYuwVgbH+hfmH/QsuCfMHr9dHXgtfRr2ZfHQQQAFsBJz0E4gaAuNCdyV93sdUuE8667b7dDvjSOhdtZrHdpE32uMbmqh3ugZ4Tiker2I//maHgWhqEBSn43mhT1tvvhS98TXCOGKqkyTxTFENrSLHS7Erm88X/z72e2ecGq21OO++MOc899JVHljvkphmPfeOp60aMeuWWBzZ4bVi7WSuMW2knJw5gXDhy5mopH568eAsUAAkFjoAsBNEyJJdRhOMKFSYKXYUYQiJSYhKx4iVTSpQkg4ZWOoVsBXLlKZLvikK1qlSr0aREMyqTaQcdc9xREJD62QIA4wDxBcQdsOwAgLXzAFgN2ApgAECyZw+qYSeQ0PftBrBXtf1D8ozd4CMpywh7OrGlkRbtE6BslhYHyYY44tTOcrZ9bj8gvBTbCX58CFGqw4T2LRwWLtsE77WpuxSZH5CoCAJZPc0hF13PhNqjA0tNOfR4GgEyAA+BSxrJrIDZOpzq+uXDiRR6ArumV8nEzwFS0YbOYdKizFKADwKjui7GEkuPOatplav4GhppfOPgVy2YYRLVpygFeLjikkYyKzC/uVhxfEXbpr2TCHwQUG3DD3M3JNiPIFGabrvSURegnKKB0YUAQzc3ypwsZC8vQyed/wb+bgqWTQxbmQ47DXiK/bXM6e5jvC3k9KDp9g1It2yQBxmQFpIffbjlKDXfEcCiALBC0l1mLqPqg5zPIeQchIMxfuHtnh9vlwlMCGBg9EtOgeEoqn/xu/k1tgyzrDEMxpkkIb333j5DZNknSvpPd6Mqvp3XKl8zrnS0nDXTC+p4htfD1dN1HrqYPcNZejPHuNisKJQt9doVR1IWqSUO3IN6QmmGcc6tp97Jy41/bR9dNryzB9oBpdThB1OUPKuMesxN8tADSffaG3ppzny10BklisqArBfORvyeRxJqA869RIUgrdAGvVUwO4O+ry8wKkoCUEtuNGotKCmMmsmNxuojsI8cxhv0OTG6iH5rSnzWvVD0zXjzSGwIubnuanNOmX+bDQrOln0E/VUG1syau0jWmWfuxLWFFq8tFylniX93Z4rPQzEZnVlGmVNR+ny1nkq9N6sGs1A5v7Smi/Oiczwfgih/CCktirrITRlzzS4xw1HReRCwr9ksUgUfPcRVUZQStV6hIad4g5QA9s1Ig+mVKwvQlvOYfGgpsHbPJvhkSqsXVAIKiQ43aYhOzh7fiZo6u33A+icpRTlAbLTe0lPM0pi22q9A09bC8zQBul4f6uqsFS8tZkGwjL6tVxAy69NBFn/j6K4k+MGVQangjIZYq8je9v5ScyOvxjALt6Xwj41H/qn8SCcOcZG2VhnM7Z00gTxMKQOjrvkeamThA2k95KaPsWepJFhstcc+pQReUJRg4WiDPZb7Z5Lgqdyf70QP2QzYMzbC+jczOtKg43KKQ56Ta/rBbmWi7zBlKvU6NMxX9ExZUqpsfOWhZosGA1ktpEQnAfV3VlzvSFBS6xlBKZ4Z3+ScE6J4o779TATU3IxRZrGYyomC6Suz3YmOSe1A0JzCfTL1ObWiHO9JgpQuK5aBoq7+Y5JCTQqbDYvWmDxva3As0VfMSod+xCYbadGwWQFwQ5VZ7oomJBnAiQ3AQrIv/vS1wriM7XiH2vth2nuqo8QadVmyh011vq2/Q4+etuJfvn6k64RaUKMRRT3Vjm6JETTGkoEIeHL49k6MHIcvDhQlyezrOnkIDuBZXMGMOUV7Xqx96K1CO0wIIzAJvfYW0qiGsX7FF8I4v5KzjnHprcND6KtCA0EFRZngSngL8sfiFM2Yl5pgAboUmPVibO1U48yOOzN6BmscuDZQfOZN/QytbKRRqSpQCo2ZB+a0ytarp1Akh+8AbIjJI9qOJaur8tTnJKz2ZFodbHc0mLYD9aTdXvmHUPH8brviHqBXn38QvR4vdg8VvMEDm5CS1hykE4RmLr/3BqKxJCE8KEWunfl5xaatPcr78ml6GUng3VoTgL0bGA8/GI81zUfSFTA1KO7opuy2vceKhKXyKnRBGmUGc2V2Aso4gKCidDXOANERZMX92iHsz+cVxj3egcjdkIDRPKMx0af5xmzi1n4cbW1FOtFW5y/pc6rbrz3OsC9gg5Shirk+J4hLyPXZ4UNdVAwkUAy6ZuarVWomAZCmN9xGY5+5+boyAEe0tz54HTEHPtWx8bUdkE8iUZCrlYepS7+ddfdMfZ2DgWhO2dZvaNFbvDAyRw0G/o47ZVvP/RuegHxMR5AQaLGii8qGjVY0SLP9fIJt9/oty1N6hzMFQSlkW9s8a2P+nkq4JVKn8bRIyngWXTDAJcIrutGSH6NPNKF2MgGWJX78A2D8b9+DAKlUFifCG1/g8zVlou0AbrvAeO0RTc6payT978F2kR++hGWN2xZfRSEqlGf0Dc4Sj9jg6aeraswe4ycee1Muh4i2UgyKOwjJKtz9ndROI32KHy571qAes2fo2jxnBSmT94fNNOTs/R7TG4ytKK+kTBluOv+PnOxVnBE4imzR2tHyDelb/AwyIAvyBTk6T1jK5NepnQUt80IzJ21wp7FF26aRHJ8A/Mr9WjRXfYJ/KQCM2iYRYpxR2eugrqK3zhuV06dlL9fLvCh+lOqSmxzUYKUh836VZUbcxPyUDq3EdAsFHV0AWiU/vV/Up1aae0BsJCfaCxEPe8C+VeETL7gDonGJN/6Qyo250NQGSM/eZVwBxmW94btqs9TLkY/5kTZIp57qzoXlEHXbj2vVS1H7+ts0WkJjmncwfD1r7JE0fXTRwIovJaDuMlIHmojR7PgQk2RZdx53d7anKHYaVKJ9itnRCailSe6ptPnLB7CHQ9sao7JY5GCAYuBFsUjn1c4fHXq2svTOZC5eFZwq/KsFBP59wGxyhMGW0PlNLX+QpbVbai945Kvx6LtTTisf7LjUT390nJ2Acmr5mn7sQ/WVqLp8RBwLbs01XLKz7OgMw+7RgBtdLUriJIOol4T2hKTGthsBLwUtysyQbhfnMFiCZQhBdRwDkA8qLwDFyLwoncxC10M1SL1gDEfbnFJitbIuYuEgrercnDQnuSCdyeyc9H4WF+SsMI+z5RwI4Jb+FP/cFJR613M9SmIAEseam+61bJcbPUYsSiFl1pNQ0ke/CICbBtzCsv7qV4ljpYJ4reB67FdOqv7W7GH3ZNE65x/uNMvRHrmJOsH1agpD7RFwdBPcS9XyTwIqKRuyrHAjYkBIy2RL8T+t5Ese2SGH81ezN2QQAPI81TQamrJT0hxH36QoqKPH/sANZnmC9IbPn5cei+2z+vJluasL8BWpjt6QJjDxLQoTWXULSe21gsy0DJt/H4KDUomWChHBXO0Q5Bm3JBGD5UH8P5QELoyXM82+lJL4YGKqduqOkPukMfe76iyDjgK9Hv42QjMiO89XPwKG1T6diE8m8Infz9GsGDxLO+3uuYuKL/b9+9Pv/2ZOfVK//HRjUbnuUzVFPbUBBCrnN+z09T8WyO1f+2ed7D0UEN7peue8tndqxke3fqv4xxz/Htz6Rq6cI53DK6mHiG/Y464vjmwckXIPxEWKa+eWX+01p+vtWMPdhMSf+kyjFg/tE5yO5SHVenoUenhuwBlfL+4rEMJJxYv/Ivk2/zPI8/ScFKcSLvUEvTjReJ+D3UriLHsnK3yMQRP/IIqxk7d8Jm1TqP1Uad8DmUVLYScyO2ow0O7A8TCuSkcLvztz/SfXjb63ES6+r3w2Nu87e2oQt333AYr10MbxB1GCWL5EkJYgUgjUYpFYHHVz7Mt1EzNHDs/8TY7y6RjctW4tFKN3SuHEk2n4WKe4sB1yGIYUBHf3CXFRbHaeODwp4q6+tGRklFBmKEoX+ci6qHUjciBfhaaUEKYz5XnBP73dq+bcvFwgt6RNNmSLJkQlyyezADaMBVrhOvN6AAvsOKNlttAV/Mbbh9w4Qcv24/UA5RbqZHIDZgLz5zyAWbdF6uvUD5YlcMOSklVKkfL2BCSFJbBJW0fNnTU+EkPyxgMHZg9ZLhw5AgIc/nCYn5n/949/UwSoZ6DmqVo+JrS6A1cVdd43kDIn1XTS4qUQkKikJE8xpVSsbh4e5gfT5XzHiUEkEIiM8/NzAGnx/Bjw71qH8X8bcze3LhsC5JZ5kDsjOH4iedrj54+DTX/RYTmOYido+72DHXehTk4ixxz6XzdmIkyj0KJR2gwIwyQmPdXNYVRpaxUg/hFx3NDuHl7rUulmHeia+immVOvc0elpPsjs7PkjFssCs3F75Bq5SJ6iMpniBCg+rkD4Tt973muicxVnQpHFb+RKhbJzZGVH6eqGg/yd9bjYro69XN9L8dVe5/W9HXcW08CKcZmF9dGcQhSWZ5rzsg6B+6hXk7jicl2VRsm0OHHrM7dgJmM5c6oWPK2smBNXlXpCfpFfouPktT3jp6am+K721MUqhE8mfbJfT7wuQU2+AueFGoFASC+eFAWulnx+eJFoiSjc9MVEvi9xfqZ7D1OHxwc5YXB4HbiRIIKAlhPNF7B+ZCrs/JHZ2bmj03XaCRVp64RjScNBu1NHvoQWKzQymYrvbvB4m6RQhNgY9FfzP6p/52cmFcUR3BzeggcnYFXHqgAuEvxinSqFFLvdgaKP06iLXsi/45ao4KWE5DQga9lTP7xztmzoHlfFo+Si/K9Kl6R5VISkZiZWT5f2g0efwTz4embHDPg4cGjNnj2za8a2zG/kR0sjIwWxvI3zW/DUc5hpY40gF8M6GI2anrfk7mmkOKmbqbTDH5afhm5Ah298/bsFJGfsgpZif+ywfJ/UB03se66aDHnfyycTcHFrfwSlO/zXh7J5iavJAi22/PlKCj7Mu8L3fOr5Flpqf3Pb04UKRt6R3kquoGyPbHKLTHCRONkqAyeRG1J0tz0+wAm12MGnaL4C98pgwAtPrU1UpWwJbA8IsuCCLvtBL0GdE8bT+1xCUwhBbClxKogGFpLnOF8mOjM4Qrqtl1h1jRiJgsKr5XZfI4m0vd51tyiumfb1xpgRuwM+0anvymKfaLR3NLi0qPfyx/lS71F+grP7ZTP5fsZUc3/QURdQ3rgm3jM2bRN+LmegrHubMj43vsFcE5cer0rcguzfcwUG+91n7pQyZIxibbtzxjTlLLH5mLAXEKkrqx0eHKodKavkaeGKXY9sH8mas2ws+wxVWxRq0oLbyB2VNXNZeXnVvKqiIv3J2h3m7pFtI4ODnHuEYo6yMXMdzKOre+i4EVqA7QcTQnRVLHccG+6c+tSfhit42sqy2pGhwdrhMt3oR9ujmkFTaJGWeoaNZZ+lAvM9U+2we5LEsXP93MDE6MisL02isL5elBJyXPB4j2dSrsvVdPsrHcJqXlVefhWvOh/MIzcOjOJGeeRw9iJ+LmdV2eianYMj7wNp2l0yLpDfUcBPyTVilao49Nbo7treA43irm17CY7frSBdnFbjH4m0IHYJq6djGgN9igaKhmxDigGFb1G+2gDGQqPEdy67YNXtmmylMHDpDP5IRqYukReZWkiOCiHM8QnET8KqcyjixP5imoigX5vUGuaw9ILrnrKzqzDYHAKhE4vtJOQfvopAvPDd6vsWgXiG2Apwf2lwDL2uMEdrE+BImODX+DDbI9t/ICZxwDZwJRFugwPJP2qsI7Vu4cPaNvDOWgodUuZlET1QfimA0FqRMmBxT9MNrTonIME5vZEbIfK30cAB9zcXHtr4X1cekcbU2B7bVgLJI9ujf+DHfYxsGgGhbFRmUbZqOzuh8efoyZcI3+2W9CyPGd5IylrMYYYCe6QJCHKpkhcLWBHp6IZ4grXjCXJidCAKBdxX5PbjjsaUy1IOpTWa3IqI1BRJDv9/ETHkeJ/UaMNYP3lR9d9v9cpsbfwoZEG9CF2tj8uIFLUu7IvdPNdBCPRHd5wrF4+1J93jNu2EvCvgzKJWD4zWo95GywZa5CwZq6Z/4weQ4arFs1bdrCw/MfL24HuOn3szMpszgR2yBmylb/2g7CoLoZLVg6wtCBcEkUh9GexCm21/ktWQcBWybNV8aNVXMh2x+hCgVrH+wBoUH7JXJDALf/Trf02BiXQW2IFcrUo/GbjFHxmP8woajHU2BMRKBYXrNS1xrSGJqcKTUEhpUBrRmzUV55qFlMRE5a5ObQDWhY0ZBSx2yk6HGElhVHF/hVk0iMLBQkiOONQYEhUXiNZiMFq0r80hQZnXQosFW9wHqq4RqjAR9Y0JzqgMozDRSTGgomxfuwNY3ZLfFjAj9HFHY6pH0Wea7N14cmOQcKJYcqGUg8S9k3ZJ5qs298c58Y6l6KVr7oCaGgxVBV84/xyXHO6oCr9y3MGU8Atz7mD9m/xJaMHk/HXEsT/fpo1A6eG+PPkfWG5sqCkTezSmJCyJv+f+uMDoqDdesn4Gd5P1dzMuqNOQxkb5yAbFXVTsGx42rX9xwF48L7s/8IsAYBY2+9YTMOmBPlkDWUO2IemAtDhOHVpdrZKj04cXyssr7reQzwxvdTno4nQPHguHPfYKgXab5C2Z9JxCDxXcc9bX5xa80w5GM3DTYSfjPNHUgAwvJCAtmNSpqSa1Lut4oSxhihQnFYlHWRzWK7PpGOyTtofvpl+s3ccOHuTQzl3HC+zi9C9L1uXrxzYul5ysX/5dICdxgXnTZsfNu8N318XYTwqeShBgv7WBLTII+DxDukzJObCbNsVnhjJVi39nymQRWwKDppbROBTvWcel5evbVoI91iaWQM3lhqtmoF2CDKF9+srKsdwejjAzwmdGVVysxxcEyVt5QQV47M/kZf9i9vpzRZo2lgasGUjdXLUZcmXFj8LG9ZqsnDU5Y2fHkteFJpbMB1VcAh8dHE12DWIvcmVnW0VMbFmHQL1m6/LyGXIDh1NMoxezQ4odPLf5oDpf7gd3rOuazLOOxN/pDK4onH8t2Z8QW8kUGiU1W5cPbFi9Z/rySssmWhuOwfBkMDEMBoEBPtm6ISORrwbrYPl1ii+lOrDe2syIyxKFbtsGrQyL4+VuMw0Objbl8kLjKqHbtoWKYrOaGAmUfigxhEAmq0OI3GUhPgoiMYBCURCJpSTwyirL6fLlFDD6l/HS1GzQa/Rl1rLY2YrgRqoY4BYMkgh8ejNDNXS5qCEQ89ubyU8LaR5eV1GBaH+3eqiTOF9hBL5SQmaQ6Bo0qzeYYBQbjLli7pI6llQa7rPOm6kI8V7n4/UjEvW3V4V70K6UwAhwyHWTLWezDdgfFyI6Ja9qOv2Vso/f8RsRDSnZwOzWRJLjtBF3hBlCTZvmZSY9ttdb3AkYp1UNEq6+urR18Q1AFZmA7wOKIecJeZjdhxzEZBy/KDozszCajyOzHA7txvDMXpTIFAcEwgeJDBafGeU/SUb4OaFQfn4IXgDgqHnauLkxo7Br5J+pWQMOWvyVckyThC0/gIxXy2OIVJE6ODynKEFc6y/CYsVIJBsbMH4QmNw8v+cVPT0gbdT17yFCpnpzGn61SrlNJH1qagvves611NY2erKZY2bTUwriXJsNBd7hK3yCz/Ad2T6AJrCV4fwHvNnFHg7i3h4/dxwE/yNftkxOpTKUMCee3wEQJbv68OoDme2hDew9Vp56LDVHToPoQ2pmrmVYUPgh7OS7iJM7P5y8chLEaAft2UcVdBQH7R/M1f4tpRX9hKn6fada/FRTiYKKdmRLubl72eUW6mUzLd5Eu+z3tgbVUsf5qQRd1lqWUB9NRD0jXZ5a6nOA7ff4fzcaKjACLVZ7Op2AeSwRxRrbOEm5VoneaxoXrv10GUph3v1L+gXTY7H08TW71+wflx5XgwNjb1jTqcWfdHyRglzs4c3Of8BBu+C9AHi2+zlfhRblOOdAi5yv+SvydLw0XtgfqIDgZJwS9S4sjcfT5QHrwpvjvkMpccnBAag/NIV/jzjZe9Xv5sBC8+Eak0eXhwmIt5tqGMp3ABB75f+Ulsn/4uuaNDrDYksrQ7lTE3jXG2xtUdCEDq2s1Mm7YuDS8NvhG3h/H0IuTnyeANUNK764+cWNFUM36m+WDHDDXcINEruJKa1QC0QNM42zeVAwfDQ77qjMcmpy6Fw3Ar7+e5SzqsqoCrZOWMF9JyDGjCfM8wrBz9ldFUWXfXfA1G0ud3YcqoYN1bv2DLW19gxLAzZYrf59At9XBF8lheybEN9o3aRR+i+jJCDqB29uykDEUyiIBM2mXwbzlQgSWYFgbZJIonjxyXy+PDksQpbC48lSIsBCfqMq6aTrF+dqX7SuJb0Q5jhYDRvKdl6elXrC1qauSjM6NrDqtjcFYFnFnqtY/5ZiROmc7HuGH3w9Hb833DVrOUJM2X+ssRQ/Zdww1VyK0JoMWcwsgwnXfsHcAG5ba0P5Bj7KhvLz8CtPrC6nI9erM2CN6xpRl7HDmKXvrOkAy68b1m23GK5/de2rGwaLZa0ht/3b70u+hZbpz9q+BVP7/Bmp8t4rV6AEEF/uhfeCLxz37TUY9g5sX+JBy3XAuannLFcc8ZXLjlTwEy5Md/+KVirltNu18aLuop4VbtfBG3ti4xq/9LMCpC72XjnWe+yKocsX8cXLvf3leGpgSk5OShC1ava367ar3C6lCl0KzG06unILJQVIrBlGTRofxGjMNKZpvmpGYN01XbFjetx8m4Qh3Q6KTMekR1b7zNgdnYC4N4B2Snb+ks+5S7IRypH2I+CXW+eL8MZkxv8UAoT1+InzeezmJA418f2G/6jjNZHbKn2zvBFTPj5zPuvsYbxiSW5GZXTsdnSgByYuf+eqTe0r15yyHuq09GEyPctjNme4oycO3Hjw1cW7k+53EvyIFGSOHw7ArOtYyRqu/6GgeFKsKIaJD1qBLm5tDyrAk6X0hPWZF6JahyRVE81T0wfN+iiBtgtqiUhkhsVnruKawGTY086npU87d3QZ+WE5/Ehefh4vcnczGLDWo5Kl4S5HEZNI9D1P/wtiP4lfJk2kCAsNVxATOHHRycoGjECA3C/BV8GWbbK83j84ia3C9nzMe1O6I+CjdZdBtzM/X7drl9Fk2mnM2jWUytq502BSC5fLJD3p6ZJeadJo2mXSXmwl7Wl/KDmJpTpNByETn5QTQlMRiSZCq8aUmV1aXlaqU836hixRx/DolK7NDn72BNQ+rv8NKuMeicDHea3u2/a1fhoP13p6aOF44C0ndZJ6ivqK2ohtFKcsFFS+pEDuBkU5Z1La6seP8L9z4H03PQ7eWaUkUmFk0qTEgUnApK9bEaXSNv8Mqb9/8Syjq7nDSgIo/6bapkDiZn9bfyt2KAZubXU/6Hmw8rpxBZYBlv9690efvcxQz72q6NbNjvuUGwS42edEV+HZJpgt8WvrkQm5AcFJ28Gxze2dNwY/QsBp+1rUNbCktfrh2sbreX+ONhwAbPE9okvsWBSwuXwZZK7DfrzvCKgQ8R2SkMjDr1FpTDgIIPK8f/Vtb7Z71D8nxzIB8PUzqREA/jzFcrrf9AfYvnIGMIMdgACiDLX24fB09Logkn39Lb8Ixbo+faWHS2Si/yYlBUbTUNmAlJ/UuQJbdm7EaPsp0kft6X9hB3Im1AOEwmr84BlRSb1gtOI38HZ0Ii5lLmOXs30wLjz66CAJpRzHtVR+8itboJ0eVzGaFopQnj5OoAMWi0oiycD+Aj/hTeYJZkDxrWjeiKpTqtP8G70IzES7syWnewblw1BSE4n++TqaD5R3AB40SSszx5s8Jxidd9ySNJKBErBAAcInNvk0k4QANOFo6m5vTbJ/I0T7KLAwt+cVquz49k+QRLK8lyEZTYyylfIYqqKs5d1UXvIFg3TvmMj44rn06HsgDqEJJCsG4R5u83cxBeuISkvp8sOVbjapLGRTfEGYUJgIyGvWioXSQCAosFNZtZYfA/1Jy5YERI8t3dbmjdtaX7Z8y7tpltCoVFAY1mBQwRzjiYAdX4lgUv9PHR6myOImMonQGHW+/wTBGb9eBQRgXC5HbPbCNVlXMFNRaUvbrGHYl8DGQwT4abydpV6NtyewYzwURQMdeiz9eBgSLpdSANxSd9lV+BjRWvlqlGisqwfR1bJc0mzQ5HlZyeIoidXIV6xEA7MY5XLlK0OjUsikvtbU0CpSw/OjzCphsNAxMYXlvTEwD0xr2pcmkkaCdEkinWhb01nLlGyFVZr9F5MsBoMdDEt4RbWKwk47blLUbCz1Fuarww+qVydmEUoxSGL7tS2aVoUy9fIC891SIXJQoVKJznoKaNSa+1iYO+V3JZmd8TL7UcBWNQ5ErKsQYqo/Tp6D8k1bKViIAkSLSApddd0NZBTLUN10y2130NAxYi/IgxVhu+sekwdWsZjB8XO08vXCRXjokWKPY7AbRvlRNKmS6GqWDnevsEOsOGYyb8WrEujq1eQUEig9URd9LfURzveWHKgUqZqoNGvVpsXO/oOm+YWahtaQdBnaderSEdX9sHo/OcXgvDnbTMSBF28+seM67vo3IbCYETOd9t4HH+Mcs2NOzLWE0EUDArk7AGo3GA84uzhwgeYphlEOARFXbg6bBc8SU/Y765zLjjjqmOMuZaleZzhmgT7rA7fUO786AQMryBq59sSDUxaHFzxng/qNGDaqR7Y3xPEOH2NeeWqcxHPznsW3Jl1SKqHKa+Y6aPmtgWP9gxLmjhiJOWxrDM9ih5xZ5geFn821rdjt8690eUKta9HtBnH73K9kpqk7xNJkK7P+GKNS1eztrNA5OzRX7pMac2N9lbP6RKt82KeL5cO+YGuPG/1ZXdHXc1UUl1TWAQAA) format('woff2');unicode-range:U+0000-00FF,U+2000-206F}body:not(.panel){--paper:#000000;--card:#0d0d0d;--ink:#ffffff;--soft:#9c9c9c;--rule:#242424;--seal:#0a0a0a;--seal-ink:#c2e9f2;--live:#4fc9e8;--warn:#e06c4f;--warn-bg:#2a1712;background:var(--paper)}body:not(.panel) .primary{background:var(--ink);color:var(--paper);border-radius:6px;font-family:var(--mono);font-size:.76rem;letter-spacing:.12em;text-transform:uppercase;padding:.9rem 1.35rem}body:not(.panel) .ghost{border-radius:6px;font-family:var(--mono);font-size:.76rem;letter-spacing:.12em;text-transform:uppercase;padding:.9rem 1.35rem;border-color:color-mix(in srgb,var(--ink) 30%,var(--paper))}body:not(.panel) .logo{font-family:var(--mono);font-weight:700;letter-spacing:.16em;text-transform:uppercase;font-size:.88rem}body:not(.panel) .pills{background:none;border:0;padding:0;gap:1.3rem}body:not(.panel) .pills a{font-family:var(--mono);font-size:.7rem;letter-spacing:.14em;text-transform:uppercase;padding:.3rem 0;border-radius:0}body:not(.panel) .pills a.on{background:none;color:var(--ink);font-weight:700;box-shadow:inset 0 -2px 0 var(--live)}.navlink{font-family:var(--mono);font-size:.7rem;letter-spacing:.14em;text-transform:uppercase;color:var(--soft);text-decoration:none;padding:.3rem 0}.navlink:hover{color:var(--ink)}@media(max-width:56rem){.navlink{display:none}}.npmchip .ver{font-family:var(--mono);font-size:.66rem;letter-spacing:.1em;color:var(--ok);white-space:nowrap;font-weight:700;text-transform:uppercase}body:not(.panel) .hero{text-align:left;padding:5.6rem 0 4.6rem}.promise-eyebrow{display:flex;align-items:center;gap:1rem;font-family:var(--mono);font-size:.7rem;letter-spacing:.26em;color:var(--ink);text-transform:uppercase;margin:0}.promise-eyebrow i{width:2.4rem;height:2px;background:var(--ink)}.mega{font-family:'Archivo Black',var(--sans);font-weight:400;font-size:clamp(3.8rem,10.5vw,7rem);line-height:.9;letter-spacing:.01em;margin:1.7rem 0 1.9rem}.mega .dot{color:transparent;position:relative}.mega .dot::after{content:"";position:absolute;left:.07em;bottom:.06em;width:.15em;height:.15em;border-radius:50%;background:var(--live)}body:not(.panel) .logo::after{content:" .";color:var(--live);font-weight:900}body:not(.panel) .hero .lede{margin:0 0 2.2rem;max-width:36ch;font-size:1.18rem}.hero .lede u{color:var(--ink);text-decoration-color:var(--live);text-decoration-thickness:2px;text-underline-offset:5px}body:not(.panel) .hero .cta-row{justify-content:flex-start}.wrap.hero-wrap{max-width:100rem}body:not(.panel) .hero{display:grid;grid-template-columns:minmax(360px,430px) minmax(0,1fr);align-items:center;gap:clamp(2rem,5vw,6rem);min-height:calc(100vh - var(--nav-h))}.hero-copy{min-width:0}.hero-demo{min-width:0;display:grid;grid-template-rows:auto auto auto;gap:.8rem}.hero-demo-head{display:flex;align-items:end;justify-content:space-between;gap:1.5rem;min-height:2.5rem}.hero-demo-head h2{margin:0;color:var(--ink);font:700 clamp(1.25rem,2vw,1.8rem)/1.1 var(--mono);letter-spacing:-.035em}.hero-demo-head span{flex:none;color:var(--live);font:.67rem var(--mono);letter-spacing:.14em}.hero-demo-frame{position:relative;height:560px;min-height:0;overflow:hidden;border:1px solid var(--rule);background:#161311}.hero-demo-frame::after{content:"";position:absolute;inset:0;pointer-events:none;box-shadow:inset 0 0 0 1px rgb(255 255 255/2%)}.hero-demo-frame iframe{display:block;width:1100px;height:560px;border:0;background:#161311;transform-origin:top left}.hero-demo-progress{display:grid;grid-template-columns:repeat(3,1fr);gap:.45rem}.hero-demo-progress button{position:relative;height:20px;padding:0;border:0;background:transparent;cursor:pointer}.hero-demo-progress button::before{content:"";position:absolute;inset:8px 0 9px;background:#242424}.hero-demo-progress button[aria-current="true"]::before{background:var(--live)}@media(max-width:72rem){body:not(.panel) .hero{grid-template-columns:minmax(320px,390px) minmax(0,1fr);gap:2.2rem}}@media(max-width:64rem){body:not(.panel) .hero{grid-template-columns:1fr;min-height:0}.hero-demo{margin-top:1rem}.hero-demo-head h2{font-size:1.25rem}}@media(max-width:47.5rem){.hero-demo-progress{grid-row:2;position:sticky;top:var(--nav-h);z-index:4;height:36px;background:var(--paper)}.hero-demo-progress button{height:36px}.hero-demo-progress button::before{inset:17px 0 18px}.hero-demo-frame{grid-row:3}}.custody{border-block:1px solid var(--rule);background:var(--paper)}.custody .cols{display:grid;grid-template-columns:repeat(3,1fr);max-width:96rem;margin:0 auto}.custody .col{padding:2.4rem 2rem 2.6rem;border-left:1px solid var(--rule);display:flex;flex-direction:column;min-height:14.5rem}.custody .col:first-child{border-left:0}.custody .idx{font-family:var(--mono);font-size:.64rem;letter-spacing:.18em;color:var(--live);text-transform:uppercase;margin-bottom:1.5rem}.custody h3{font-size:1.02rem;letter-spacing:.035em;text-transform:uppercase;font-weight:800;margin:0 0 .55rem}.custody p{color:var(--soft);font-size:.9rem;max-width:40ch;margin:0}.custody .tail{margin-top:auto;padding-top:2.4rem;font-family:var(--mono);font-size:.6rem;letter-spacing:.2em;color:color-mix(in srgb,var(--soft) 55%,var(--paper));text-transform:uppercase}@media(max-width:47rem){.custody .cols{grid-template-columns:1fr}.custody .col{border-left:0;border-top:1px solid var(--rule);min-height:0}.custody .col:first-child{border-top:0}}*{box-sizing:border-box}html{scroll-behavior:smooth}@media(prefers-reduced-motion:reduce){html{scroll-behavior:auto}*{animation:none!important;transition:none!important}}body{margin:0;background:radial-gradient(1100px 520px at 50% -140px,color-mix(in srgb,var(--live) 8%,var(--paper)) 0%,var(--paper) 60%) no-repeat,var(--paper);color:var(--ink);font:400 1rem/1.62 var(--sans);-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility}.wrap{max-width:64rem;margin:0 auto;padding:0 1.5rem}a{color:var(--live)}:focus-visible{outline:2px solid var(--live);outline-offset:3px}code,pre,.mono{font-family:var(--mono);font-variant-ligatures:none}h1{font-size:clamp(2.6rem,7vw,4.2rem);font-weight:800;line-height:1.04;letter-spacing:-.04em;margin:0 0 1.1rem}h2{font-size:clamp(1.7rem,3.6vw,2.4rem);font-weight:750;letter-spacing:-.032em;margin:0 0 .6rem}h3{font-size:1.16rem;font-weight:700;letter-spacing:-.018em;margin:0 0 .4rem}p{margin:0 0 1rem}.sub{color:var(--soft);font-size:1.05rem;max-width:52ch;margin:0 0 2rem}.primary{background:var(--live);color:var(--paper);padding:.74rem 1.35rem;font-size:.94rem;font-weight:650;letter-spacing:-.01em;border:1px solid transparent;border-radius:12px;cursor:pointer;text-decoration:none;display:inline-flex;align-items:center}.primary:hover{filter:brightness(1.08)}.primary:disabled{opacity:.45;cursor:default}.ghost{background:none;border:1px solid var(--rule);color:var(--ink);padding:.68rem 1.15rem;font-size:.92rem;border-radius:12px;cursor:pointer;text-decoration:none;display:inline-flex;align-items:center}.ghost:hover{border-color:var(--ink)}button{font:inherit}.kicker{display:block;font-family:var(--mono);font-size:.72rem;letter-spacing:.14em;text-transform:uppercase;color:var(--live);text-align:center;margin:0 0 .9rem}.eyebrow,.ck{display:block;font-family:var(--mono);font-size:.7rem;letter-spacing:.12em;text-transform:uppercase;color:var(--soft);margin:0 0 .8rem}.navbar{position:sticky;top:0;z-index:40;background:color-mix(in srgb,var(--paper) 86%,transparent);backdrop-filter:blur(10px);border-bottom:1px solid var(--rule)}.navbar .row{display:flex;align-items:center;gap:1rem;min-height:var(--nav-h)}.logo{font-weight:750;letter-spacing:-.03em;font-size:1.02rem;color:var(--ink);text-decoration:none}.navbar #nav-logo{display:inline-flex;align-items:center;min-height:24px}.pills{display:flex;gap:.2rem;background:var(--card);border:1px solid var(--rule);border-radius:999px;padding:.22rem;margin:0 auto}.pills a{font-size:.86rem;color:var(--soft);text-decoration:none;padding:.34rem .95rem;border-radius:999px;min-height:26px;display:inline-flex;align-items:center}.pills a:hover{color:var(--ink)}.pills a.on{background:var(--live);color:var(--paper);font-weight:650}.npmchip{display:flex;align-items:center;gap:.45rem;border:1px solid var(--rule);border-radius:999px;padding:.3rem .35rem .3rem .85rem}.npmchip code{font-size:.78rem;color:var(--soft);white-space:nowrap}.npmchip button{background:none;border:0;color:var(--live);font-family:var(--mono);font-size:.72rem;cursor:pointer;padding:.2rem .5rem;border-radius:999px}.npmchip button:hover{background:color-mix(in srgb,var(--live) 14%,transparent)}@media(max-width:47rem){.npmchip{display:none}.pills{margin:0 0 0 auto}}.hero{padding:4.6rem 0 2.6rem;text-align:center}.hero .lede{font-size:1.12rem;color:var(--soft);max-width:44ch;margin:0 auto 1.9rem}.cta-row{display:flex;gap:.7rem;justify-content:center;flex-wrap:wrap}.hero .meta{font-family:var(--mono);font-size:.76rem;color:var(--soft);margin:1.5rem 0 0;letter-spacing:.02em}.term{background:var(--seal);border:1px solid color-mix(in srgb,var(--seal-ink) 14%,var(--seal));border-radius:14px;margin:2.6rem auto 0;max-width:54rem;text-align:left;overflow:hidden}.term header{display:flex;align-items:center;gap:.8rem;padding:.7rem 1rem;border-bottom:1px solid color-mix(in srgb,var(--seal-ink) 10%,var(--seal))}.term .dots{font-size:.5rem;letter-spacing:.28em;color:color-mix(in srgb,var(--seal-ink) 30%,var(--seal))}.term .title{font-family:var(--mono);font-size:.74rem;color:var(--soft);flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.term .run{font-family:var(--mono);font-size:.68rem;letter-spacing:.1em;color:var(--ok)}.term pre{margin:0;background:none;border:0;padding:1.1rem 1.2rem 1.3rem;font-size:.82rem;line-height:1.85;color:var(--seal-ink);overflow-x:auto}.term pre b{font-weight:400;color:var(--live)}.term pre i{font-style:normal;color:var(--ok)}.term pre .dim{color:color-mix(in srgb,var(--seal-ink) 55%,var(--seal))}.belt{display:grid;grid-template-columns:repeat(4,1fr);border-block:1px solid var(--rule);margin:3.2rem 0 0}.belt>div{padding:1.5rem 1.3rem;border-left:1px solid var(--rule)}.belt>div:first-child{border-left:0}.belt .b-v{font-size:1.28rem;font-weight:750;letter-spacing:-.02em;line-height:1.25;margin-top:.4rem}.belt .amber{color:var(--live)}@media(max-width:47rem){.belt{grid-template-columns:1fr 1fr}.belt>div:nth-child(odd){border-left:0}.belt>div{border-top:1px solid var(--rule)}.belt>div:nth-child(-n+2){border-top:0}}section{padding:4.2rem 0}.center{text-align:center}.center .sub{margin-inline:auto}.how-grid{display:grid;gap:1rem;grid-template-columns:1fr 1fr;margin-top:2.2rem}.step-card{background:var(--card);border:1px solid var(--rule);border-radius:14px;padding:1.5rem;text-align:left}.step-card .num{display:inline-flex;align-items:center;justify-content:center;width:1.9rem;height:1.9rem;border-radius:999px;background:color-mix(in srgb,var(--ink) 10%,var(--card));font-family:var(--mono);font-size:.8rem;margin-right:.6rem;vertical-align:.2rem}.step-card h3{display:inline}.step-card p{color:var(--soft);font-size:.95rem;margin:.8rem 0 0}.step-card .foot{font-family:var(--mono);font-size:.74rem;color:var(--soft);border-top:1px solid var(--rule);margin-top:1rem;padding-top:.8rem}.step-card.hot{background:color-mix(in srgb,var(--live) 9%,var(--card));border-color:color-mix(in srgb,var(--live) 35%,var(--rule))}.step-card.hot .foot{color:var(--live);border-top-color:color-mix(in srgb,var(--live) 30%,var(--rule))}.how-note{color:var(--soft);text-align:center;max-width:56ch;margin:1.8rem auto 0;font-size:.97rem}@media(max-width:47rem){.how-grid{grid-template-columns:1fr}}.bento{display:grid;gap:1rem;grid-template-columns:1fr 1fr;margin-top:2.2rem}.b-card{background:var(--card);border:1px solid var(--rule);border-radius:14px;padding:1.6rem;text-align:left}.b-card.span2{grid-column:1/-1}.b-card p{color:var(--soft);font-size:.96rem}.b-card .foot{font-family:var(--mono);font-size:.74rem;color:var(--soft);line-height:1.8;margin:.9rem 0 0}.b-card .green{color:var(--ok);font-size:.9rem;margin:.8rem 0 0}.b-card.glow{background:color-mix(in srgb,var(--live) 8%,var(--card));border-color:color-mix(in srgb,var(--live) 30%,var(--rule))}.b-term{background:var(--seal);border-radius:10px;padding:.9rem 1rem;font-family:var(--mono);font-size:.78rem;line-height:1.85;color:var(--seal-ink);overflow-x:auto;margin:.9rem 0 0;white-space:pre-wrap;word-break:break-word}.b-term b{font-weight:400;color:var(--live)}.b-term i{font-style:normal;color:var(--ok)}.b-term .dim{color:color-mix(in srgb,var(--seal-ink) 55%,var(--seal))}.b-term .row{display:flex;gap:.9rem;justify-content:space-between;flex-wrap:wrap}.cipherbox{font-family:var(--mono);font-size:.76rem;line-height:1.8;background:var(--seal);color:var(--seal-ink);word-break:break-all;padding:.9rem 1rem;border-radius:10px;margin:.9rem 0 0}.b-split{display:grid;gap:1.4rem;grid-template-columns:1.1fr 1fr;align-items:center}@media(max-width:47rem){.bento{grid-template-columns:1fr}.b-split{grid-template-columns:1fr}}.zk{background:var(--seal);border-block:1px solid color-mix(in srgb,var(--seal-ink) 12%,var(--seal));margin:2rem calc(50% - 50vw) 0;padding:4.2rem 0}.zk .cols{display:grid;gap:2.6rem;grid-template-columns:1fr 1.1fr;align-items:start}.zk h2{margin-top:.2rem}.zk p{color:var(--soft)}.zk blockquote{margin:1.6rem 0 0;padding:.2rem 0 .2rem 1.1rem;border-left:3px solid var(--live);font-size:.98rem;color:var(--soft)}.zk blockquote strong{color:var(--ink)}.holds{display:grid;grid-template-columns:1fr 1fr;border:1px solid color-mix(in srgb,var(--seal-ink) 14%,var(--seal));border-radius:14px;overflow:hidden}.holds .h{font-family:var(--mono);font-size:.68rem;letter-spacing:.12em;text-transform:uppercase;color:var(--live);padding:1rem 1.2rem .4rem}.holds .h+.h{border-left:1px solid color-mix(in srgb,var(--seal-ink) 12%,var(--seal))}.holds div{padding:.85rem 1.2rem;font-size:.92rem;color:var(--ink);border-top:1px solid color-mix(in srgb,var(--seal-ink) 10%,var(--seal))}.holds div:nth-child(even){border-left:1px solid color-mix(in srgb,var(--seal-ink) 12%,var(--seal))}@media(max-width:47rem){.zk .cols{grid-template-columns:1fr}}.kit{background:var(--card);border:1px solid var(--rule);border-radius:16px;padding:1.6rem;margin-top:2.2rem;text-align:left}.kit .head{display:flex;gap:1rem;justify-content:space-between;flex-wrap:wrap;font-family:var(--mono);font-size:.7rem;letter-spacing:.1em;text-transform:uppercase;color:var(--soft);margin-bottom:1.2rem}.kit .head b{color:var(--live);font-weight:500;text-transform:none;letter-spacing:.02em}.words{display:grid;gap:.55rem;grid-template-columns:repeat(6,1fr)}.words span{display:flex;gap:.5rem;align-items:baseline;border:1px solid var(--rule);border-radius:10px;padding:.5rem .7rem;font-family:var(--mono);font-size:.84rem}.words i{font-style:normal;font-size:.66rem;color:var(--soft)}.kit .verify{color:var(--soft);font-size:.94rem;margin:1.3rem 0 0}.kit .verify em{color:var(--soft)}@media(max-width:56rem){.words{grid-template-columns:repeat(4,minmax(0,1fr))}}@media(max-width:36rem){.kit{padding:1rem}.words{grid-template-columns:repeat(2,minmax(0,1fr))}.words span{min-width:0;padding:.5rem;gap:.35rem;overflow:hidden}}.pricing{display:grid;gap:1rem;grid-template-columns:1fr 1fr;margin-top:2.2rem;align-items:stretch}.price-card{border-radius:16px;padding:1.8rem;text-align:left;border:1px solid var(--rule);background:var(--card)}.price-card .p-name{font-size:1.2rem;font-weight:750;letter-spacing:-.02em}.price-card .p-price{font-size:2.3rem;font-weight:800;letter-spacing:-.04em;margin:.7rem 0 0}.price-card .p-price small{font-size:.9rem;font-weight:400;letter-spacing:0;color:var(--soft)}.price-card .pick{margin:1.1rem 0 0;font-size:.95rem}.price-card ul{list-style:none;margin:1.1rem 0 0;padding:0;font-size:.93rem}.price-card li{padding:.5rem 0 .5rem 1.5rem;position:relative;border-top:1px solid var(--rule)}.price-card li::before{content:"\2713";position:absolute;left:0;font-family:var(--mono);color:var(--live)}.price-card li.dash::before{content:"\2014";color:var(--soft)}.price-card.slim .p-price{font-size:1.7rem}.acct-why{display:grid;gap:1rem;grid-template-columns:1fr 1fr;margin:2rem 0 1.4rem;text-align:left}.why-card{background:var(--card);border:1px solid var(--rule);border-radius:14px;padding:1.2rem 1.3rem;display:flex;flex-direction:column}.why-card h3{margin:.2rem 0 .35rem}.why-card p{font-size:.92rem;color:var(--soft);margin:0}.why-card .ck{color:var(--live)}.why-card .tailln{margin-top:.9rem;font-family:var(--mono);font-size:.66rem;letter-spacing:.12em;text-transform:uppercase;color:color-mix(in srgb,var(--soft) 60%,var(--paper))}.acct-loop{font-size:.95rem;color:var(--soft);max-width:56ch;margin:0 auto 2.4rem;text-align:center}@media(max-width:47rem){.acct-why{grid-template-columns:1fr}}.price-card.light{background:var(--ink);color:var(--paper);border-color:var(--ink)}.price-card.light .pick,.price-card.light li{color:color-mix(in srgb,var(--paper) 78%,var(--ink))}.price-card.light li{border-top-color:color-mix(in srgb,var(--paper) 14%,var(--ink))}.price-card.light .p-price small{color:color-mix(in srgb,var(--paper) 60%,var(--ink))}.price-card.light li::before{color:color-mix(in srgb,var(--live) 55%,var(--paper))}.price-card.light .ghost{border-color:color-mix(in srgb,var(--paper) 30%,var(--ink));color:var(--paper)}.managed-chip{font-family:var(--mono);font-size:.64rem;letter-spacing:.12em;background:var(--live);color:var(--paper);border-radius:999px;padding:.24rem .6rem;vertical-align:.24rem;margin-left:.5rem}.packs{display:flex;gap:.5rem;flex-wrap:wrap;margin:1.2rem 0 0}.packs span{font-family:var(--mono);font-size:.8rem;border:1px solid var(--rule);border-radius:10px;padding:.5rem .8rem}.packs span.now{border-color:var(--live)}.price-note{color:var(--soft);text-align:center;max-width:60ch;margin:1.8rem auto 0;font-size:.95rem}.price-card .cta{margin-top:1.4rem}@media(max-width:47rem){.pricing{grid-template-columns:1fr}}.enterprise{margin:2rem calc(50% - 50vw) 0;padding:5.2rem 0 0;background:#f1f2ee;color:#090909;border-block:1px solid #d2d4cf;scroll-margin-top:var(--nav-h)}.enterprise .enterprise-wrap{max-width:80rem}.enterprise-head{display:grid;grid-template-columns:minmax(18rem,.82fr) minmax(32rem,1.18fr);gap:clamp(3rem,7vw,7rem);align-items:center}.enterprise-copy .kicker{color:#087d99;text-align:left;margin-bottom:1.2rem}.enterprise h2{font-family:'Archivo Black',var(--sans);font-size:clamp(2.75rem,5.7vw,5.1rem);line-height:.94;letter-spacing:-.025em;margin:0 0 1.5rem;max-width:9ch}.enterprise-copy>p{color:#4f524e;font-size:1.06rem;max-width:42ch;margin-bottom:1.8rem}.enterprise .cta-row{justify-content:flex-start}body:not(.panel) .enterprise .primary{background:#090909;color:#fff;border-color:#090909}body:not(.panel) .enterprise .ghost{color:#090909;border-color:#9c9f99}body:not(.panel) .enterprise .ghost:hover{border-color:#090909}.enterprise-boundary{background:#fff;border:1px solid #cfd1cc;box-shadow:0 22px 70px rgb(0 0 0/8%)}.enterprise-boundary-head{display:flex;align-items:center;justify-content:space-between;gap:1rem;padding:1rem 1.25rem;border-bottom:1px solid #d9dbd6;font-family:var(--mono);font-size:.67rem;letter-spacing:.13em;text-transform:uppercase;color:#5f625e}.enterprise-boundary-head strong{color:#087d99;font-weight:700}.enterprise-stack{padding:0 1.25rem}.enterprise-layer{display:grid;grid-template-columns:2.25rem minmax(0,1fr) auto;gap:1rem;align-items:center;padding:1.05rem 0;border-top:1px solid #e2e3df}.enterprise-layer:first-child{border-top:0}.enterprise-layer .num{font:700 .64rem var(--mono);color:#087d99}.enterprise-layer b{display:block;font-size:.92rem;line-height:1.25}.enterprise-layer small{display:block;color:#696c68;font-size:.77rem;margin-top:.2rem;line-height:1.35}.enterprise-layer .owner{font:700 .62rem var(--mono);letter-spacing:.1em;text-transform:uppercase;color:#393b38;white-space:nowrap}.enterprise-boundary-foot{display:grid;grid-template-columns:1fr auto;gap:1rem;align-items:center;padding:1rem 1.25rem;background:#090909;color:#fff;font:700 .68rem var(--mono);letter-spacing:.12em;text-transform:uppercase}.enterprise-boundary-foot strong{color:#52cee9;font-weight:700}.enterprise-proof{display:grid;grid-template-columns:repeat(4,1fr);margin-top:4.5rem;border-top:1px solid #cfd1cc}.enterprise-proof>div{padding:1.55rem 1.5rem 1.8rem;border-left:1px solid #cfd1cc}.enterprise-proof>div:first-child{border-left:0}.enterprise-proof span{display:block;color:#087d99;font:700 .63rem var(--mono);letter-spacing:.13em;text-transform:uppercase;margin-bottom:.75rem}.enterprise-proof strong{display:block;font-size:.96rem;line-height:1.35}.enterprise-proof p{color:#5d605c;font-size:.83rem;line-height:1.5;margin:.5rem 0 0}.enterprise-modes{display:grid;grid-template-columns:1fr 1fr;border-top:1px solid #cfd1cc}.enterprise-mode{padding:2.5rem 1.5rem 3rem}.enterprise-mode+.enterprise-mode{border-left:1px solid #cfd1cc}.enterprise-mode .mode-name{display:flex;align-items:center;gap:.65rem;margin-bottom:.65rem}.enterprise-mode .mode-name i{width:.55rem;height:.55rem;border-radius:50%;background:#9a9d97;flex:none}.enterprise-mode.isolated .mode-name i{background:#087d99;box-shadow:0 0 0 4px rgb(8 125 153/12%)}.enterprise-mode h3{font-size:1.08rem;margin:0}.enterprise-mode p{color:#5d605c;font-size:.91rem;max-width:53ch;margin:0}.enterprise-docs{display:flex;align-items:center;gap:2rem;justify-content:space-between;background:#090909;color:#fff;padding:1.5rem max(1.5rem,calc((100vw - 80rem)/2));margin-top:0}.enterprise-docs p{margin:0;color:#bec1bc;font-size:.9rem}.enterprise-docs strong{color:#fff}.enterprise-docs a{display:inline-flex;align-items:center;min-height:2.75rem;color:#52cee9;font:700 .7rem var(--mono);letter-spacing:.12em;text-transform:uppercase;text-decoration:none;white-space:nowrap}.enterprise-docs a:hover{text-decoration:underline;text-underline-offset:4px}@media(max-width:64rem){.enterprise-head{grid-template-columns:1fr;gap:2.5rem}.enterprise h2{max-width:12ch}.enterprise-proof{grid-template-columns:1fr 1fr}.enterprise-proof>div:nth-child(3){border-left:0}.enterprise-proof>div:nth-child(n+3){border-top:1px solid #cfd1cc}}@media(max-width:47rem){.enterprise{padding-top:4rem}.enterprise h2{font-size:clamp(2.65rem,14vw,4rem)}.enterprise-layer{grid-template-columns:1.8rem minmax(0,1fr)}.enterprise-layer .owner{grid-column:2;white-space:normal}.enterprise-proof,.enterprise-modes{grid-template-columns:1fr}.enterprise-proof>div{border-left:0;border-top:1px solid #cfd1cc}.enterprise-proof>div:first-child{border-top:0}.enterprise-mode+.enterprise-mode{border-left:0;border-top:1px solid #cfd1cc}.enterprise-docs{display:block}.enterprise-docs a{display:inline-block;margin-top:1rem;white-space:normal}}body.enterprise-page{background:#f1f2ee}body.enterprise-page .enterprise{display:block;margin:0;padding:0;background:#f1f2ee}body.enterprise-page .enterprise-wrap{max-width:90rem}.enterprise-page-hero{position:relative;display:grid;grid-template-columns:1fr;gap:2rem;min-height:calc(100vh - var(--nav-h));padding:3.2rem 0 4.5rem;color:#f7f1e6;background:radial-gradient(circle at 48% 55%,rgb(224 165 44/11%),transparent 24%),linear-gradient(rgb(119 151 101/6%) 1px,transparent 1px),linear-gradient(90deg,rgb(119 151 101/6%) 1px,transparent 1px),#0a100c;background-size:auto,34px 34px,34px 34px,auto;box-shadow:0 0 0 100vmax #0a100c;clip-path:inset(0 -100vmax)}.enterprise-copy{display:grid;grid-template-columns:minmax(0,1fr) minmax(19rem,24rem);grid-template-rows:auto auto;column-gap:clamp(3rem,6vw,6rem);align-items:start}.enterprise-copy h1{grid-column:1;grid-row:1;font-family:'Archivo Black',var(--sans);font-size:clamp(3.1rem,3.7vw,3.65rem);font-weight:400;line-height:.96;letter-spacing:-.045em;margin:0 0 1.25rem;max-width:none;color:#f8f2e8}.enterprise-copy>p{grid-column:1;grid-row:2;color:#b0b8af;font-size:1.04rem;line-height:1.6;max-width:58rem;margin:0}.enterprise-copy .cta-row{grid-column:2;grid-row:1/3;display:grid;gap:1rem;align-content:center;margin:0}body:not(.panel) .enterprise-copy .primary{background:#e0a52c;color:#10150f;border-color:#e0a52c;box-shadow:0 12px 34px rgb(224 165 44/16%)}body:not(.panel) .enterprise-copy .primary:hover{background:#efb744;border-color:#efb744}body:not(.panel) .enterprise-copy .ghost{color:#c9d889;border-color:#7d8d47;background:rgb(18 29 19/62%)}body:not(.panel) .enterprise-copy .ghost:hover{color:#fff;border-color:#b6cb75}.enterprise-story{min-width:0;overflow:hidden;border:1px solid #405039;border-radius:13px;background:#0a100c;box-shadow:0 30px 90px rgb(0 0 0/46%),0 0 0 1px rgb(224 165 44/6%)}.enterprise-story-frame{position:relative;height:620px;overflow:hidden}.enterprise-story-frame iframe{position:relative;z-index:2;display:block;width:1360px;height:620px;border:0;background:#0a100c;opacity:0;transform-origin:top left;transition:opacity .18s ease}.enterprise-story-frame.is-ready iframe{opacity:1}.enterprise-story-preview{position:absolute;z-index:1;inset:0;display:grid;grid-template-rows:auto auto 1fr;gap:1.15rem;padding:1.35rem;background:radial-gradient(circle at 50% 55%,rgb(224 165 44/12%),transparent 30%),#0a100c;color:#f4eee3;transition:opacity .16s ease,visibility .16s ease}.enterprise-story-frame.is-ready .enterprise-story-preview{visibility:hidden;opacity:0}.enterprise-preview-tabs{display:grid;grid-template-columns:repeat(4,1fr);border:1px solid #35432f;border-radius:999px;overflow:hidden;color:#9da99d;font:700 .58rem var(--mono);letter-spacing:.055em;text-transform:uppercase}.enterprise-preview-tabs span{padding:.55rem .7rem;border-left:1px solid #35432f;text-align:center}.enterprise-preview-tabs span:first-child{border-left:0}.enterprise-preview-tabs span:first-child{background:#183321;color:#e0a52c}.enterprise-preview-title{display:flex;align-items:end;justify-content:space-between;gap:1rem;padding:0 .2rem}.enterprise-preview-title strong{max-width:21ch;font-size:clamp(1.3rem,2.3vw,2rem);line-height:1.02;letter-spacing:-.035em}.enterprise-preview-title small{color:#a9c779;font:700 .6rem var(--mono);letter-spacing:.08em;text-transform:uppercase}.enterprise-preview-flow{display:grid;grid-template-columns:repeat(3,1fr);gap:1rem;min-height:0}.enterprise-preview-flow article{display:flex;flex-direction:column;gap:.75rem;padding:1.2rem;border:1px solid #35432f;background:#101a12}.enterprise-preview-flow article:nth-child(2){border-color:#916d28;background:linear-gradient(145deg,rgb(224 165 44/11%),transparent),#101a12}.enterprise-preview-flow b{font-size:.85rem}.enterprise-preview-flow i{height:1px;background:#35432f}.enterprise-preview-flow span{color:#aab3aa;font:.62rem/1.6 var(--mono)}body.enterprise-page .enterprise-page-hero~*{content-visibility:auto;contain-intrinsic-size:auto 620px}.enterprise-section-head{max-width:58rem;margin:7.5rem auto 2.5rem;text-align:center}.enterprise-section-head.compact{margin-top:6.5rem}.enterprise-section-head .kicker{text-align:center;margin-bottom:1rem}.enterprise-section-head h2{font-size:clamp(2.35rem,5vw,4.4rem);line-height:.98;letter-spacing:-.045em;margin:0 auto 1.3rem;max-width:13ch}.enterprise-section-head p{color:#555954;font-size:1.02rem;line-height:1.7;max-width:68ch;margin:0 auto}.enterprise-needs{display:grid;grid-template-columns:1fr 1fr;border-top:1px solid #cfd1cc;border-bottom:1px solid #cfd1cc}.enterprise-needs article{min-height:15.5rem;padding:2.5rem;border-left:1px solid #cfd1cc}.enterprise-needs article:nth-child(odd){border-left:0}.enterprise-needs article:nth-child(n+3){border-top:1px solid #cfd1cc}.enterprise-needs span,.enterprise-responsibility-grid>div>span,.enterprise-review-grid span{display:block;color:#087d99;font:700 .63rem var(--mono);letter-spacing:.13em;text-transform:uppercase;margin-bottom:1.1rem}.enterprise-needs h3{font-size:1.45rem;line-height:1.15;letter-spacing:-.025em;max-width:22ch;margin:0 0 .9rem}.enterprise-needs p{color:#5d605c;font-size:.92rem;line-height:1.65;max-width:48ch;margin:0}.enterprise-value-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));border:1px solid #cfd1cc;background:#fff}.enterprise-value{min-height:19rem;padding:2.65rem;border-left:1px solid #cfd1cc;border-top:1px solid #cfd1cc}.enterprise-value:nth-child(odd){border-left:0}.enterprise-value:nth-child(-n+2){border-top:0}.enterprise-value .value-num{display:block;color:#087d99;font:700 .63rem var(--mono);letter-spacing:.13em;text-transform:uppercase;margin-bottom:2.4rem}.enterprise-value h3{font-size:clamp(1.55rem,2.5vw,2.15rem);line-height:1.04;letter-spacing:-.035em;max-width:18ch;margin:0 0 1rem}.enterprise-value p{color:#5d605c;font-size:.94rem;line-height:1.68;max-width:52ch;margin:0}.enterprise-value .value-proof{display:block;margin-top:1.4rem;padding-top:1rem;border-top:1px solid #e0e2dd;color:#343734;font:600 .68rem/1.55 var(--mono);letter-spacing:.035em;text-transform:uppercase}.enterprise-operations{display:grid;grid-template-columns:minmax(16rem,.72fr) minmax(28rem,1.28fr);gap:3.5rem;margin-top:2rem;padding:3rem;background:#0f1211;color:#fff}.enterprise-operations .kicker{color:#52cee9;text-align:left;margin-bottom:1rem}.enterprise-operations h3{font-family:'Archivo Black',var(--sans);font-size:clamp(2rem,3.3vw,3.25rem);font-weight:400;line-height:.98;letter-spacing:-.04em;max-width:10ch;margin:0 0 1.2rem}.enterprise-operations-copy p{color:#aeb5b0;font-size:.91rem;line-height:1.65;max-width:43ch;margin:0}.enterprise-interfaces{border-top:1px solid #303733}.enterprise-interface{display:grid;grid-template-columns:minmax(11rem,.8fr) 1.2fr;gap:1.5rem;padding:1.3rem 0;border-bottom:1px solid #303733}.enterprise-interface code{color:#78d9ed;font:600 .75rem/1.45 var(--mono);word-break:break-word}.enterprise-interface p{color:#c3c9c5;font-size:.82rem;line-height:1.55;margin:0}.enterprise-interface b{color:#fff;font-weight:700}.enterprise-interface-note{color:#838c86;font-size:.74rem;line-height:1.55;margin:1.25rem 0 0}.enterprise-mode ul,.enterprise-responsibility-grid ul{list-style:none;margin:1.4rem 0 0;padding:0;border-top:1px solid #d9dbd6}.enterprise-mode li,.enterprise-responsibility-grid li{position:relative;padding:.75rem 0 .75rem 1.25rem;border-bottom:1px solid #d9dbd6;color:#555954;font-size:.84rem;line-height:1.45}.enterprise-mode li::before,.enterprise-responsibility-grid li::before{content:"✓";position:absolute;left:0;color:#087d99;font:700 .74rem var(--mono)}.enterprise-responsibility{margin-top:1rem}.enterprise-responsibility-grid{display:grid;grid-template-columns:1fr 1fr;background:#fff;border:1px solid #cfd1cc}.enterprise-responsibility-grid>div{padding:2.5rem}.enterprise-responsibility-grid>div+div{border-left:1px solid #cfd1cc}.enterprise-review{padding-bottom:7rem}.enterprise-review-grid{display:grid;grid-template-columns:repeat(3,1fr);border-top:1px solid #cfd1cc;border-bottom:1px solid #cfd1cc}.enterprise-review-grid>div{min-height:12.5rem;padding:2rem;border-left:1px solid #cfd1cc}.enterprise-review-grid>div:nth-child(3n+1){border-left:0}.enterprise-review-grid>div:nth-child(n+4){border-top:1px solid #cfd1cc}.enterprise-review-grid b{display:block;font-size:1rem;line-height:1.3;margin-bottom:.65rem}.enterprise-review-grid small{display:block;color:#5d605c;font-size:.79rem;line-height:1.55}.enterprise-review-grid .artifact-link{display:inline-block;margin-top:1.1rem;color:#087d99;font:700 .66rem var(--mono);letter-spacing:.08em;text-transform:uppercase;text-decoration:none}.enterprise-review-grid .artifact-link:hover{text-decoration:underline;text-underline-offset:4px}.enterprise-assurance{display:grid;grid-template-columns:minmax(12rem,.35fr) 1fr;gap:2rem;margin-top:2.5rem;padding:1.5rem 0;border-top:1px solid #cfd1cc;border-bottom:1px solid #cfd1cc}.enterprise-assurance strong{color:#8a4e41;font:700 .72rem var(--mono);letter-spacing:.08em;text-transform:uppercase}.enterprise-assurance p{color:#5d605c;font-size:.84rem;line-height:1.65;margin:0}.enterprise-docs>div{display:flex;gap:1.5rem;align-items:center;flex:none}.enterprise-docs>div a+a{color:#fff}body.enterprise-page footer{background:#090909;color:#8f938d;border-top:1px solid #222}body.enterprise-page footer .logo{color:#fff}@media(max-width:72rem){.enterprise-page-hero{min-height:0}.enterprise-copy{grid-template-columns:1fr}.enterprise-copy h1,.enterprise-copy>p,.enterprise-copy .cta-row{grid-column:1}.enterprise-copy h1{grid-row:1;max-width:14ch}.enterprise-copy>p{grid-row:2}.enterprise-copy .cta-row{grid-row:3;grid-template-columns:auto auto;justify-content:start;margin-top:1.6rem}.enterprise-story{margin-top:.5rem}.enterprise-operations{grid-template-columns:1fr;gap:2rem}.enterprise-review-grid{grid-template-columns:1fr 1fr}.enterprise-review-grid>div:nth-child(3n+1){border-left:1px solid #cfd1cc}.enterprise-review-grid>div:nth-child(odd){border-left:0}.enterprise-review-grid>div:nth-child(n+3){border-top:1px solid #cfd1cc}}@media(max-width:47.5rem){.navbar .row{gap:.55rem}body:not(.panel) #nav-logo{display:inline-flex;align-items:center;width:1.75rem;min-height:1.75rem;overflow:hidden;white-space:nowrap;font-size:0}body:not(.panel) #nav-logo::before{content:"⌑";font:700 1rem var(--mono)}body:not(.panel) #nav-logo::after{content:""}body:not(.panel) .pills{gap:.65rem}body:not(.panel) .pills a{font-size:.58rem;letter-spacing:.08em}.enterprise-page-hero{padding:2.8rem 0 3.5rem;gap:1.4rem}.enterprise-copy h1{font-size:clamp(2.75rem,13vw,4rem)}.enterprise-copy>p{font-size:.97rem;line-height:1.55}.enterprise-copy .cta-row{grid-template-columns:1fr;width:100%}.enterprise-copy .cta-row a{text-align:center}.enterprise-story{margin-inline:-.45rem;border-radius:10px}.enterprise-story-frame{height:2100px}.enterprise-story-frame iframe{width:100%;height:2100px;transform:none}.enterprise-story-preview{align-content:start;padding:1rem}.enterprise-preview-tabs{grid-template-columns:1fr 1fr;border-radius:12px}.enterprise-preview-tabs span:nth-child(3){border-left:0}.enterprise-preview-tabs span:nth-child(n+3){border-top:1px solid #35432f}.enterprise-preview-title{display:grid;padding-top:1rem}.enterprise-preview-flow{grid-template-columns:1fr}.enterprise-preview-flow article{min-height:13rem}.enterprise-section-head,.enterprise-section-head.compact{margin-top:5rem;text-align:left}.enterprise-section-head .kicker{text-align:left}.enterprise-section-head h2{margin-left:0}.enterprise-needs,.enterprise-value-grid,.enterprise-responsibility-grid,.enterprise-review-grid{grid-template-columns:1fr}.enterprise-needs article,.enterprise-needs article:nth-child(n),.enterprise-value:nth-child(n),.enterprise-responsibility-grid>div+div,.enterprise-review-grid>div:nth-child(n){border-left:0}.enterprise-needs article+article,.enterprise-value+.enterprise-value,.enterprise-responsibility-grid>div+div,.enterprise-review-grid>div+div{border-top:1px solid #cfd1cc}.enterprise-needs article{min-height:0;padding:2rem 0}.enterprise-responsibility-grid>div{padding:2rem 1.25rem}.enterprise-review-grid>div{min-height:0;padding:1.6rem 0}.enterprise-value{min-height:0;padding:2rem 1.25rem}.enterprise-value .value-num{margin-bottom:1.5rem}.enterprise-operations{padding:2rem 1.25rem;margin-inline:-1.25rem}.enterprise-interface{grid-template-columns:1fr;gap:.55rem}.enterprise-assurance{grid-template-columns:1fr;gap:.8rem}.enterprise-docs>div{display:block}.enterprise-docs>div a{display:block;margin-top:1rem}}body.enterprise-page{--ent-bg:#08100b;--ent-band:#0d1710;--ent-surface:#111d14;--ent-rule:#35432f;--ent-rule-soft:#223025;--ent-ink:#f4eee3;--ent-soft:#aab3aa;--ent-sage:#a9c779;--ent-amber:#e0a52c;background:var(--ent-bg);color:var(--ent-ink)}body.enterprise-page .navbar{background:rgb(8 16 11/91%);border-color:var(--ent-rule-soft)}body.enterprise-page .navbar .logo{color:var(--ent-ink)}body.enterprise-page .pills a{color:#aab3aa}body.enterprise-page .pills a:hover,body.enterprise-page .pills a.on{color:var(--ent-ink)}body.enterprise-page .pills a.on{box-shadow:inset 0 -2px 0 var(--ent-amber)}body.enterprise-page .npmchip{border-color:var(--ent-rule);color:var(--ent-soft)}body.enterprise-page .npmchip code{color:var(--ent-soft)}body.enterprise-page .enterprise{background:var(--ent-bg);color:var(--ent-ink);overflow:hidden}body.enterprise-page .enterprise .kicker{color:var(--ent-sage);text-align:left}body.enterprise-page .enterprise-page-hero{border-bottom:1px solid var(--ent-rule-soft)}.enterprise-loss-story{display:grid;grid-template-columns:minmax(0,.9fr) minmax(31rem,1.1fr);gap:clamp(3rem,6vw,7rem);padding:7.5rem 0 6rem;border-bottom:1px solid var(--ent-rule);position:relative}.enterprise-loss-copy h2{font-family:'Archivo Black',var(--sans);font-size:clamp(2.45rem,4.5vw,4.75rem);font-weight:400;line-height:.96;letter-spacing:-.045em;max-width:10.5ch;margin:0 0 2rem;color:var(--ent-ink)}.enterprise-loss-copy>p{color:var(--ent-sage);font:700 .78rem/1.5 var(--mono);letter-spacing:.04em;margin:0 0 1.25rem}.enterprise-loss-copy ol{list-style:none;margin:0;padding:0;border-top:1px solid var(--ent-rule-soft)}.enterprise-loss-copy li{display:grid;grid-template-columns:2.4rem 1fr;gap:.8rem;align-items:center;padding:.9rem 0;border-bottom:1px solid var(--ent-rule-soft);color:#dce1d9;font-size:.91rem}.enterprise-loss-copy li span{color:var(--ent-sage);font:700 .68rem var(--mono)}.enterprise-loss-ledger{display:grid;grid-template-columns:minmax(11rem,.62fr) 2rem minmax(16rem,1fr);align-items:center;gap:1rem}.enterprise-loss-ledger>div:not(.loss-arrow){min-height:24rem;border:1px solid var(--ent-rule);background:linear-gradient(145deg,rgb(169 199 121/5%),transparent 62%),var(--ent-band);padding:2rem;display:flex;flex-direction:column}.enterprise-loss-ledger span,.enterprise-ownership span{color:var(--ent-sage);font:700 .68rem var(--mono);letter-spacing:.08em;text-transform:uppercase}.enterprise-loss-ledger strong{font-size:1.45rem;letter-spacing:-.025em;margin:.65rem 0 1.6rem;color:var(--ent-ink)}.enterprise-loss-ledger code{display:block;border:1px solid var(--ent-rule-soft);background:#09100b;padding:1rem;color:#c7d5bd;font:.73rem/1.8 var(--mono)}.enterprise-loss-ledger small{margin-top:auto;color:#7f897f;font:600 .65rem var(--mono);text-transform:uppercase;letter-spacing:.06em}.enterprise-loss-ledger .loss-sealkeep{border-color:#a87920!important;box-shadow:0 0 44px rgb(224 165 44/8%)}.loss-sealkeep span,.enterprise-aha,.sealkeep-owner strong,.sealkeep-owner span{color:var(--ent-amber)!important}.loss-sealkeep ul{list-style:none;margin:0;padding:0}.loss-sealkeep li{position:relative;padding:.72rem 0 .72rem 1.45rem;border-top:1px solid var(--ent-rule-soft);color:#d7ded4;font-size:.83rem}.loss-sealkeep li::before{content:'✓';position:absolute;left:0;color:var(--ent-amber);font:700 .72rem var(--mono)}.loss-arrow{color:var(--ent-amber);font-size:2rem;text-align:center}.enterprise-aha{grid-column:1/-1;text-align:center;font-size:clamp(1.25rem,2.3vw,2rem);font-weight:750;letter-spacing:-.025em;margin:0}.enterprise-ownership{display:grid;grid-template-columns:repeat(4,1fr);margin:0 0 1px;border-bottom:1px solid var(--ent-rule);background:var(--ent-band)}.enterprise-ownership>div{min-height:14rem;padding:2rem;border-left:1px solid var(--ent-rule-soft)}.enterprise-ownership>div:first-child{border-left:0}.enterprise-ownership strong{display:block;font:700 1.03rem var(--mono);margin-bottom:1rem}.enterprise-ownership p{color:var(--ent-soft);font-size:.84rem;line-height:1.55;margin:2rem 0 0}.enterprise-ownership .sealkeep-owner{background:linear-gradient(150deg,rgb(224 165 44/12%),transparent 75%);box-shadow:inset 0 0 0 1px #8f6a22}body.enterprise-page .enterprise-proof{margin:0;background:#0a120d;border:0;border-bottom:1px solid var(--ent-rule)}body.enterprise-page .enterprise-proof>div{padding:2rem 1.6rem;border-color:var(--ent-rule-soft)}body.enterprise-page .enterprise-proof span{color:var(--ent-amber)}body.enterprise-page .enterprise-proof strong{color:var(--ent-ink)}body.enterprise-page .enterprise-proof p{color:var(--ent-soft)}body.enterprise-page .enterprise-section-head{margin:8rem 0 3rem;text-align:left;max-width:64rem}body.enterprise-page .enterprise-section-head .kicker{text-align:left}body.enterprise-page .enterprise-section-head h2{color:var(--ent-ink);font-size:clamp(2.55rem,5vw,5rem);max-width:12ch;margin:0 0 1.35rem}body.enterprise-page .enterprise-section-head p{color:var(--ent-soft);margin:0;max-width:62ch}body.enterprise-page .enterprise-value-grid{display:block;border:1px solid var(--ent-rule);background:var(--ent-band)}body.enterprise-page .enterprise-value{display:grid;grid-template-columns:9rem minmax(13rem,.75fr) minmax(18rem,1fr);column-gap:clamp(2rem,4vw,4.5rem);min-height:0;padding:2.6rem;border:0;border-top:1px solid var(--ent-rule);align-items:start}body.enterprise-page .enterprise-value:first-child{border-top:0}body.enterprise-page .enterprise-value:nth-child(even){background:rgb(169 199 121/4%)}body.enterprise-page .enterprise-value.featured{padding-block:3.4rem;background:linear-gradient(90deg,rgb(224 165 44/5%),transparent 45%)}body.enterprise-page .enterprise-value .value-num{color:var(--ent-sage);margin:0}body.enterprise-page .enterprise-value h3{color:var(--ent-ink);font-size:clamp(1.65rem,2.6vw,2.65rem);max-width:17ch;margin:0}body.enterprise-page .enterprise-value p{color:var(--ent-soft);margin:0;max-width:60ch}body.enterprise-page .enterprise-value p b{color:#ede5d8}body.enterprise-page .enterprise-value .value-proof{grid-column:3;margin-top:1.25rem;padding-top:1rem;border-color:var(--ent-rule-soft);color:#c1cfb7}body.enterprise-page .value-flow{grid-column:2/4;display:grid;grid-template-columns:1fr auto 1fr;gap:1rem;align-items:center;max-width:34rem;margin-top:1.6rem;padding-top:1.3rem;border-top:1px solid var(--ent-rule-soft)}body.enterprise-page .value-flow i{display:grid;place-items:center;min-height:4.2rem;padding:.7rem;border:1px dashed #64764d;color:#c7d5bd;background:#0a120d;font:700 .65rem/1.4 var(--mono);font-style:normal;letter-spacing:.06em;text-align:center;text-transform:uppercase}body.enterprise-page .value-flow em{color:var(--ent-amber);font:400 1.4rem var(--sans);font-style:normal}body.enterprise-page .enterprise-operations{margin:1px 0 0;padding:5rem clamp(2rem,5vw,5rem);background:radial-gradient(circle at 10% 20%,rgb(224 165 44/7%),transparent 30%),#090f0b;border:1px solid var(--ent-rule);color:var(--ent-ink)}body.enterprise-page .enterprise-operations .kicker{color:var(--ent-amber)}body.enterprise-page .enterprise-operations h3{font-size:clamp(2.2rem,3.6vw,3.75rem);max-width:13ch}body.enterprise-page .enterprise-operations-copy p{color:var(--ent-soft)}body.enterprise-page .enterprise-interfaces{border-color:var(--ent-rule)}body.enterprise-page .enterprise-interface{border-color:var(--ent-rule)}body.enterprise-page .enterprise-interface code{color:var(--ent-sage);font-size:.8rem}body.enterprise-page .enterprise-interface p{color:#c3cbc1}body.enterprise-page .enterprise-interface-note{color:#7f897f}body.enterprise-page .enterprise-needs{border-color:var(--ent-rule);background:var(--ent-band)}body.enterprise-page .enterprise-needs article{border-color:var(--ent-rule);background:transparent}body.enterprise-page .enterprise-needs span,body.enterprise-page .enterprise-responsibility-grid>div>span,body.enterprise-page .enterprise-review-grid span{color:var(--ent-amber)}body.enterprise-page .enterprise-needs h3{color:var(--ent-ink)}body.enterprise-page .enterprise-needs p{color:var(--ent-soft)}body.enterprise-page .enterprise-modes{border:1px solid var(--ent-rule);background:var(--ent-band)}body.enterprise-page .enterprise-mode{padding:3rem;border-color:var(--ent-rule)}body.enterprise-page .enterprise-mode.isolated{background:linear-gradient(145deg,rgb(224 165 44/7%),transparent 65%)}body.enterprise-page .enterprise-mode h3{color:var(--ent-ink);font-size:1.3rem}body.enterprise-page .enterprise-mode p{color:var(--ent-soft)}body.enterprise-page .enterprise-mode ul,body.enterprise-page .enterprise-responsibility-grid ul{border-color:var(--ent-rule)}body.enterprise-page .enterprise-mode li,body.enterprise-page .enterprise-responsibility-grid li{border-color:var(--ent-rule-soft);color:var(--ent-soft)}body.enterprise-page .enterprise-mode li::before,body.enterprise-page .enterprise-responsibility-grid li::before{color:var(--ent-sage)}body.enterprise-page .enterprise-mode.isolated .mode-name i{background:var(--ent-amber);box-shadow:0 0 0 4px rgb(224 165 44/12%)}body.enterprise-page .enterprise-responsibility-grid{background:var(--ent-band);border-color:var(--ent-rule)}body.enterprise-page .enterprise-responsibility-grid>div+div{border-color:var(--ent-rule)}body.enterprise-page .enterprise-review{padding-bottom:7rem}body.enterprise-page .enterprise-review-grid{border-color:var(--ent-rule);background:var(--ent-band)}body.enterprise-page .enterprise-review-grid>div{border-color:var(--ent-rule);background:transparent}body.enterprise-page .enterprise-review-grid b{color:var(--ent-ink)}body.enterprise-page .enterprise-review-grid small{color:var(--ent-soft)}body.enterprise-page .enterprise-assurance{border-color:#705722;background:rgb(224 165 44/6%);padding:1.5rem}body.enterprise-page .enterprise-assurance strong{color:var(--ent-amber)}body.enterprise-page .enterprise-assurance p{color:#c4cbc1}body.enterprise-page .enterprise-docs{background:#101b12;border-top:1px solid var(--ent-rule);padding-block:3.2rem;color:var(--ent-ink)}body.enterprise-page .enterprise-docs p{font-size:clamp(1.4rem,2.6vw,2.15rem);line-height:1.12;color:var(--ent-ink);max-width:28ch}body.enterprise-page .enterprise-docs p span{display:block;color:var(--ent-soft);font:400 .85rem/1.55 var(--sans);margin-top:.8rem;max-width:62ch}body.enterprise-page .enterprise-docs a{border:1px solid #788a45;color:var(--ent-sage);padding:.75rem 1rem;text-decoration:none}body.enterprise-page .enterprise-docs a.primary{background:var(--ent-amber);border-color:var(--ent-amber);color:#10150f}body.enterprise-page footer{background:#060b08;color:#778077;border-color:var(--ent-rule-soft)}@media(max-width:72rem){.enterprise-loss-story{grid-template-columns:1fr;gap:3rem}.enterprise-loss-copy h2{max-width:13ch}.enterprise-loss-copy ol{max-width:42rem}.enterprise-ownership{grid-template-columns:1fr 1fr}.enterprise-ownership>div:nth-child(odd){border-left:0}.enterprise-ownership>div:nth-child(n+3){border-top:1px solid var(--ent-rule-soft)}body.enterprise-page .enterprise-value{grid-template-columns:10rem minmax(13rem,.8fr) minmax(15rem,1fr);gap:2rem}}@media(max-width:47.5rem){.enterprise-loss-story{padding:5rem 0 4rem}.enterprise-loss-copy h2{font-size:clamp(2.3rem,11.5vw,3.4rem)}.enterprise-loss-ledger{grid-template-columns:1fr}.enterprise-loss-ledger>div:not(.loss-arrow){min-height:0}.loss-arrow{transform:rotate(90deg)}.enterprise-aha{text-align:left}.enterprise-ownership{grid-template-columns:1fr}.enterprise-ownership>div:nth-child(n){min-height:0;border-left:0;border-top:1px solid var(--ent-rule-soft)}.enterprise-ownership>div:first-child{border-top:0}body.enterprise-page .enterprise-proof{grid-template-columns:1fr}.enterprise-proof>div:nth-child(n){border-left:0;border-top:1px solid var(--ent-rule-soft)}.enterprise-proof>div:first-child{border-top:0}body.enterprise-page .enterprise-section-head{margin-top:5.5rem}body.enterprise-page .enterprise-value{grid-template-columns:1fr;gap:1rem;padding:2rem 1.3rem}.enterprise-value h3{margin-bottom:.4rem!important}.enterprise-value .value-proof,.enterprise-value .value-flow{grid-column:1!important}.enterprise-value .value-flow{width:100%}body.enterprise-page .enterprise-operations{padding:2.5rem 1.25rem;margin-inline:-.45rem}body.enterprise-page .enterprise-needs article{padding:2rem 1.25rem}body.enterprise-page .enterprise-mode{padding:2rem 1.25rem}body.enterprise-page .enterprise-docs{padding-inline:1.25rem}.enterprise-docs>div{width:100%}.enterprise-docs a{text-align:center}}.ship{border:1px solid var(--rule);border-radius:16px;margin-top:2.2rem;overflow:hidden;text-align:left}.ship>div{display:grid;grid-template-columns:minmax(0,1fr) auto;column-gap:2rem;align-items:center;min-height:88px;padding:1.25rem 1.5rem;border-top:1px solid var(--rule)}.ship>div:first-child{border-top:0}.ship .name{min-width:0;font-weight:700;letter-spacing:-.01em}.ship .d{display:block;max-width:82ch;color:var(--soft);font-size:.9rem;line-height:1.55;margin-top:.25rem;font-weight:400}.ship .tag{justify-self:end;font-family:var(--mono);font-size:.66rem;letter-spacing:.1em;border:1px solid currentColor;border-radius:999px;padding:.32rem .75rem;white-space:nowrap}.ship .tag.ok{color:var(--ok)}.ship .tag.warn{color:var(--live)}.ship-note{color:var(--soft);text-align:center;max-width:62ch;margin:1.8rem auto 0;font-size:.95rem}.ship-note strong{color:var(--ink)}@media(max-width:42rem){.ship>div{grid-template-columns:1fr;row-gap:.9rem;min-height:0;padding:1.25rem 1.15rem}.ship .tag{justify-self:start}}.faq{background:var(--card);border:1px solid var(--rule);border-radius:14px;margin-top:1rem;text-align:left}.faq summary{cursor:pointer;list-style:none;display:flex;align-items:center;gap:.45rem;padding:1.15rem 1.4rem;font-size:1.04rem;font-weight:700;letter-spacing:-.014em}.faq summary::-webkit-details-marker{display:none}.faq summary::after{content:"+";font-family:var(--mono);color:var(--soft);font-weight:400;margin-left:auto}.faq[open] summary::after{content:"\2212"}.faq div{padding:0 1.4rem 1.2rem;color:var(--soft);font-size:.97rem;max-width:72ch}.cta-final{text-align:center;padding:5rem 0 5.4rem}.cta-final h2{font-size:clamp(2.1rem,5.4vw,3.2rem)}.cta-final .sub{margin-inline:auto}.cta-final .meta{font-family:var(--mono);font-size:.76rem;color:var(--soft);margin-top:1.4rem}#enterprise{display:none}body.enterprise-page #enterprise{display:block}#docs{display:none}body.docs #docs{display:block}body.docs main#home{display:none}body.docs #account{display:none}body.enterprise-page main#home,body.enterprise-page #docs,body.enterprise-page #account{display:none}body.account-page main#home,body.account-page #enterprise,body.account-page #docs{display:none}body.account-page #account{border-top:0;min-height:calc(100vh - var(--nav-h));padding-top:3.4rem}body.account-page .acct-why,body.account-page .acct-loop{display:none}.cmdtable{border:1px solid var(--rule);border-radius:16px;overflow:hidden;margin-top:2rem}.cmdtable>div{display:grid;gap:.3rem 1.6rem;grid-template-columns:minmax(15rem,2fr) 3fr;padding:1rem 1.4rem;border-top:1px solid var(--rule);align-items:baseline}.cmdtable>div:first-child{border-top:0}.cmdtable code{color:var(--live);font-size:.86rem}.cmdtable p{margin:0;color:var(--soft);font-size:.94rem}@media(max-width:47rem){.cmdtable>div{grid-template-columns:1fr}}.arch{display:grid;gap:1rem;grid-template-columns:1fr 1fr;margin-top:2.4rem}.arch .b-card p strong{color:var(--ink)}.arch .caution{color:var(--warn);font-size:.92rem;margin:.9rem 0 0}@media(max-width:47rem){.arch{grid-template-columns:1fr}}@media(min-width:58rem){#p-account .cardgrid{display:grid;gap:1rem;grid-template-columns:1fr 1fr;align-items:start}#p-account .cardgrid>.card{margin-bottom:0}#p-account .cardgrid>.wide{grid-column:1/-1}}.tabs{display:flex;margin-bottom:1.6rem;border-bottom:1px solid var(--rule)}.tabs button{background:none;color:var(--soft);border:0;border-radius:0;border-bottom:2px solid transparent;padding:.6rem 1rem;font-size:.88rem}.tabs button[aria-selected=true]{color:var(--ink);font-weight:650;border-bottom-color:var(--live)}.pane{display:none}.pane.on{display:block}ol.steps{counter-reset:s;list-style:none;padding:0;margin:0}ol.steps li{counter-increment:s;position:relative;padding:0 0 1.6rem 2.7rem}ol.steps li::before{content:counter(s,decimal-leading-zero);position:absolute;left:0;top:.16rem;font-family:var(--mono);font-size:.76rem;color:var(--live);font-weight:600}ol.steps p{margin:.2rem 0 .55rem;color:var(--soft);font-size:.95rem}pre{background:var(--card);border:1px solid var(--rule);border-radius:10px;padding:.85rem .9rem;overflow-x:auto;font-size:.82rem;line-height:1.7;margin:.5rem 0 0}.phrase{background:var(--seal);color:var(--ink);border-radius:12px;padding:1.2rem 1.3rem;margin:2rem 0 0}.phrase strong{font-weight:700;font-size:1.04rem;letter-spacing:-.018em}.phrase p{margin:.4rem 0 0;font-size:.94rem;color:var(--seal-ink)}label{display:block;font-family:var(--mono);font-size:.72rem;color:var(--soft);margin:1rem 0 .35rem}input{width:100%;padding:.66rem .75rem;border:1px solid var(--rule);border-radius:10px;background:var(--card);color:var(--ink);font:inherit;font-size:.95rem}input:focus-visible{border-color:var(--live)}.row2{display:grid;gap:1rem;grid-template-columns:1fr}@media(min-width:34rem){.row2{grid-template-columns:1fr 1fr}}form button{margin-top:1.3rem}.msg{font-family:var(--mono);font-size:.78rem;margin:.8rem 0 0;min-height:1.2em;color:var(--soft)}.msg.err{color:var(--warn)}.saved{font-family:var(--mono);font-size:.76rem;color:var(--live)}.google{display:flex;align-items:center;justify-content:center;gap:.6rem;width:100%;background:var(--card);border:1px solid var(--rule);color:var(--ink);padding:.7rem 1rem;font-size:.92rem;font-weight:650}.google:hover{border-color:var(--ink)}.google svg{width:17px;height:17px;flex:none}.or{display:flex;align-items:center;gap:.85rem;margin:1.3rem 0;color:var(--soft);font-family:var(--mono);font-size:.72rem}.or::before,.or::after{content:"";height:1px;background:var(--rule);flex:1}body.panel main#home,body.panel #enterprise,body.panel #docs,body.panel .acct-intro,body.panel .guest{display:none}body.panel #account{border-top:0;padding-top:1.6rem;scroll-margin-top:calc(var(--nav-h) + 1px)}.ph{display:flex;flex-wrap:wrap;align-items:center;gap:.5rem .9rem;background:var(--card);border:1px solid var(--rule);border-radius:12px;padding:1.2rem 1.3rem;margin-bottom:1.3rem}.ph .name{font-size:1.15rem;font-weight:650;letter-spacing:-.02em}.ph .who{font-family:var(--mono);font-size:.75rem;color:var(--soft);flex:1 1 100%}.ph:focus,.ph:focus-visible{outline:none}.chip{font-family:var(--mono);font-size:.7rem;min-height:26px;display:inline-flex;align-items:center;border:1px solid var(--rule);border-radius:999px;color:var(--soft);padding:.2rem .55rem}.chip.pro{border-color:var(--live);color:var(--live);font-weight:600}.card{background:var(--card);border:1px solid var(--rule);border-radius:12px;padding:1.35rem;margin-bottom:.9rem}.card>p{color:var(--soft);font-size:.94rem;margin:0 0 .3rem;max-width:60ch}.empty{color:var(--soft);font-size:.9rem;margin:.5rem 0 0;max-width:60ch}.notice{background:var(--warn-bg);border:1px solid var(--warn);border-radius:12px;padding:1rem 1.2rem;margin-bottom:.9rem;display:flex;flex-wrap:wrap;gap:.75rem;align-items:center}.notice.soft{background:var(--card);border-color:var(--rule)}.notice p{margin:0;flex:1 1 17rem;font-size:.94rem}.notice strong{font-weight:700}.warncard{border-color:var(--warn)}.warncard .eyebrow{color:var(--warn)}.card-remedy{display:flex;align-items:center;gap:.6rem;flex-wrap:wrap;margin-top:1rem;padding-top:1rem;border-top:1px solid var(--rule)}.card-remedy[hidden]{display:none}.card-remedy [hidden]{display:none!important}.card-remedy .msg{margin:0;flex:1 1 13rem}.card-remedy .primary,.card-remedy .ghost{justify-content:center}@media(max-width:32rem){.card-remedy .primary,.card-remedy .ghost{width:100%}}.kv{display:grid;gap:.75rem;grid-template-columns:repeat(auto-fit,minmax(9rem,1fr))}.kv>div{border:1px solid var(--rule);border-radius:12px;padding:.9rem 1rem}.kv .cap{display:block;font-size:.78rem;color:var(--soft);margin-top:.25rem}.kv .k{display:block;font-family:var(--mono);font-size:.72rem;color:var(--soft);margin-bottom:.2rem}.kv b{font-family:var(--mono);font-size:1rem;font-weight:500;color:var(--soft);letter-spacing:-.02em}.kv .lead b{font-size:clamp(1.55rem,2.1vw,2.2rem);font-weight:700;color:var(--ink);letter-spacing:-.04em;line-height:1.1;white-space:nowrap}.bar{height:8px;background:var(--rule);border-radius:999px;margin:1.05rem 0 .4rem;overflow:hidden}.bar i{display:block;height:100%;width:0;min-width:6px;background:var(--live);transition:width .5s ease}table{width:100%;border-collapse:collapse;font-size:.9rem;min-width:31rem}th{text-align:left;font-family:var(--mono);font-size:.72rem;color:var(--soft);font-weight:400;padding:.65rem;border-bottom:1px solid var(--rule)}td{padding:.68rem .65rem;border-bottom:1px solid var(--rule)}tr:last-child td{border-bottom:0}td code{font-size:.86em;color:var(--soft)}.state{font-family:var(--mono);font-size:.72rem;border:1px solid currentColor;border-radius:999px;padding:.18rem .55rem;white-space:nowrap}.choice .t,.choice .d{font-family:var(--sans)}.cellsub{display:block;font-family:var(--sans);font-size:.75rem;color:var(--soft);margin-top:.15rem}.cellsub.connection-disconnected{color:var(--warn);font-weight:650}.cellsub.connection-partial,.cellsub.connection-unknown{color:var(--live)}.cellsub.connection-connected{color:var(--ok)}.target-reconnect{display:inline;padding:0;border:0;background:none;color:inherit;font:inherit;font-weight:700;text-decoration:underline;text-underline-offset:3px;cursor:pointer}.target-place-editor{display:grid;grid-template-columns:repeat(2,minmax(8rem,1fr));gap:.45rem;min-width:22rem}.target-place-editor label{display:grid;gap:.2rem;margin:0;color:var(--soft);font:600 .62rem var(--mono);letter-spacing:.05em;text-transform:uppercase}.target-place-editor label:last-child:nth-child(odd){grid-column:1/-1}.target-place-editor input{width:100%;min-width:0}@media(max-width:47rem){.target-place-editor{grid-template-columns:1fr;min-width:14rem}.target-place-editor label{grid-column:1!important}}.acct-nav{display:flex;gap:.35rem;flex-wrap:wrap;position:sticky;top:58px;z-index:5;background:var(--paper);padding:.65rem 0;margin:.4rem 0 .2rem;border-bottom:1px solid var(--rule)}.acct-nav a{font-family:var(--mono);font-size:.74rem;color:var(--soft);text-decoration:none;border:1px solid var(--rule);border-radius:999px;padding:.4rem .85rem;min-height:26px;display:inline-flex;align-items:center}.acct-nav a:hover{color:var(--ink);border-color:var(--ink)}.acct-nav a[aria-current="page"]{color:var(--ink);border-color:var(--live);background:color-mix(in srgb,var(--live) 12%,var(--card))}[data-account-section][hidden]{display:none!important}#d-retired details{margin:.5rem 0 .2rem}#d-retired summary{font-family:var(--mono);font-size:.76rem;color:var(--soft);cursor:pointer;padding:.35rem 0}#d-retired summary:hover{color:var(--ink)}#d-retired .lines li{opacity:.75}.acct-hero{font-family:var(--mono);font-size:1.55rem;font-weight:700;letter-spacing:-.03em;margin:.9rem 0 0;line-height:1.3}h1.acct-hero:empty{display:none}.ruleslist{margin:.3rem 0 .9rem;padding-left:1.1rem}.ruleslist li{margin:.25rem 0;color:var(--soft)}.ruleslist b{color:var(--ink);font-weight:600}#tg-body input,#tg-body select{padding:.35rem .5rem;font-size:.85rem;border:1px solid var(--rule);border-radius:12px;background:transparent;color:var(--ink)}#tg-body input[type=number]{width:4.2rem}#tg-body input:focus,#tg-body select:focus{border-color:var(--live);outline:none}#tg-body tr:hover td{background:color-mix(in srgb,var(--rule) 22%,transparent)}#tg-save-msg{display:block;margin-top:.6rem}.packrow{display:flex;gap:.6rem;flex-wrap:wrap;margin:.4rem 0 .9rem}.packchip{display:flex;flex-direction:column;gap:.1rem;border:1px solid var(--rule);border-radius:10px;padding:.6rem .95rem;min-width:6.4rem}.packchip b{font-family:var(--mono);font-size:.95rem}.packchip span{font-size:.78rem;color:var(--soft)}.packchip.now{border-color:var(--live)}.packchip.now i{font-style:normal;font-family:var(--mono);font-size:.66rem;color:var(--live);letter-spacing:.03em}#p-account .card h2{font-size:1.02rem;margin:.15rem 0 .5rem}[id^="anchor-"]{scroll-margin-top:3.4rem}.state.durable{color:var(--live)}.state.pending{color:var(--soft);opacity:.75}.del{background:none;color:var(--soft);border:1px solid var(--rule);padding:.3rem .7rem;font-family:var(--mono);font-size:.74rem}.del:hover{border-color:var(--warn);color:var(--warn)}.del[data-armed=yes]{background:var(--warn);color:var(--card);border-color:var(--warn)}.lines{list-style:none;margin:.55rem 0 0;padding:0;font-size:.92rem}.lines li{display:flex;flex-wrap:wrap;gap:.45rem .85rem;align-items:center;padding:.68rem 0;border-top:1px solid var(--rule)}.lines li:first-child{border-top:0}.lines .grow{flex:1 1 11rem}.lines .when{font-family:var(--mono);font-size:.75rem;color:var(--soft)}.inline{display:flex;gap:.6rem;flex-wrap:wrap;align-items:center;margin-top:.4rem}.inline input{flex:1 1 13rem;width:auto}select{width:100%;padding:.66rem .7rem;border:1px solid var(--rule);border-radius:10px;background:var(--card);color:var(--ink);font:inherit;font-size:.95rem}.why{border-left:2px solid var(--live);padding:.15rem 0 .15rem .8rem;margin:.6rem 0 0;color:var(--soft);font-size:.86rem}.rules{border:1px solid var(--rule);border-radius:12px;padding:1.2rem;margin-top:.8rem}.choice{display:flex;gap:.75rem;align-items:flex-start;padding:.72rem 0;border-top:1px solid var(--rule);cursor:pointer}.choice:first-of-type{border-top:0;padding-top:0}.choice input{width:auto;margin:.22rem 0 0;accent-color:var(--live);flex:none}.choice .t{font-size:.95rem;font-weight:650}.choice .d{color:var(--soft);font-size:.88rem;margin-top:.15rem;display:block}.choice .n{width:4.4rem;padding:.3rem .45rem;font-family:var(--mono);font-size:.85rem;margin:.4rem .5rem 0 0;display:inline-block}.choice[data-on=no] .d,.choice[data-on=no] .n{opacity:.45}.card.next{border-color:var(--live);padding:1.6rem 1.5rem}.card.next .eyebrow{color:var(--live)}h3.lead{font-size:1.24rem;font-weight:750;letter-spacing:-.028em;margin:0 0 .5rem}.pair{margin-top:1.25rem}.pair-field{display:flex;gap:.5rem;flex-wrap:wrap;align-items:stretch;max-width:34rem}.pair-field pre{flex:1 1 17rem;margin:0}.pair-code{flex:1 1 15rem;display:flex;align-items:center;background:var(--paper);border:1px solid var(--live);border-radius:10px;padding:.68rem .9rem;color:var(--ink);font-family:var(--mono);font-size:clamp(1.3rem,4.4vw,1.8rem);font-weight:600;letter-spacing:.12em;line-height:1.24;word-break:break-all}.pair-code[data-long=yes]{font-size:clamp(.94rem,2.8vw,1.1rem);letter-spacing:.05em;font-weight:500}.pair-field .primary,.pair-field .ghost{flex:0 0 auto}.pair .fine{margin-top:.75rem;max-width:66ch}.alt{margin-top:1.15rem;border-top:1px solid var(--rule);padding-top:.9rem}.alt summary{font-family:var(--mono);font-size:.76rem;color:var(--soft);cursor:pointer;list-style:none}.alt summary::-webkit-details-marker{display:none}.alt summary::before{content:"+ ";color:var(--live)}.alt[open] summary::before{content:"− "}.alt summary:hover{color:var(--ink)}.alt p{margin:.7rem 0 .55rem}.projects-card{width:min(86rem,calc(100vw - 3rem));margin-left:50%;transform:translateX(-50%);padding:0;overflow:hidden}.projects-top{display:flex;align-items:flex-start;justify-content:space-between;gap:1.5rem;padding:1.4rem 1.5rem;border-bottom:1px solid var(--rule)}.projects-top h2{font:700 1.25rem/1.2 var(--mono)!important;letter-spacing:-.03em;margin:.2rem 0 .35rem!important}.projects-top p{margin:0;color:var(--soft);font-size:.9rem;max-width:62ch}.projects-privacy{display:flex;align-items:center;gap:.5rem;color:var(--soft);font:500 .69rem var(--mono);white-space:nowrap;padding-top:.15rem}.projects-privacy::before{content:"";width:.48rem;height:.48rem;border-radius:50%;background:var(--ok);box-shadow:0 0 0 3px color-mix(in srgb,var(--ok) 12%,transparent)}.invite-inbox{padding:0 1.5rem;background:color-mix(in srgb,var(--live) 4%,var(--card));border-bottom:1px solid var(--rule)}.invite-inbox:empty{display:none}.invite-card{display:flex;align-items:center;gap:1rem;padding:1rem 0}.invite-card+.invite-card{border-top:1px solid var(--rule)}.member-avatar{display:grid;place-items:center;width:2.2rem;height:2.2rem;flex:none;border:1px solid color-mix(in srgb,var(--live) 42%,var(--rule));border-radius:8px;background:color-mix(in srgb,var(--live) 9%,var(--card));color:var(--live);font:700 .75rem var(--mono);text-transform:uppercase}.invite-copy,.member-copy{min-width:0;flex:1}.invite-copy strong,.member-copy strong{display:block;font-size:.88rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.invite-copy span,.member-copy span{display:block;color:var(--soft);font-size:.77rem;margin-top:.15rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.invite-actions{display:flex;gap:.45rem;flex:none}.invite-actions button{padding:.5rem .75rem;font-size:.76rem}.projects-layout{display:grid;grid-template-columns:15rem minmax(0,1fr);min-height:31rem}.projects-layout.drawer-open{grid-template-columns:14rem minmax(22rem,1fr) 20rem}.project-rail{border-right:1px solid var(--rule);padding:1rem .75rem;background:color-mix(in srgb,var(--seal) 35%,var(--card))}.project-rail-head{display:flex;align-items:center;justify-content:space-between;gap:.6rem;padding:0 .35rem .8rem}.project-rail-head span{color:var(--soft);font:600 .66rem var(--mono);letter-spacing:.12em;text-transform:uppercase}.project-add{display:inline-flex;align-items:center;justify-content:center;gap:.4rem;min-height:2rem;padding:.42rem .68rem;border:1px solid var(--live);border-radius:7px;background:color-mix(in srgb,var(--live) 10%,var(--card));color:var(--ink);font:650 .72rem/1 var(--mono)}.project-add:hover{background:color-mix(in srgb,var(--live) 18%,var(--card))}.project-list{list-style:none;margin:0;padding:0;display:grid;gap:.28rem}.project-list button{width:100%;display:flex;align-items:center;gap:.65rem;text-align:left;border:1px solid transparent;background:transparent;color:var(--soft);padding:.7rem .65rem;border-radius:8px;font-size:.82rem}.project-list button:hover{color:var(--ink);background:color-mix(in srgb,var(--rule) 32%,transparent)}.project-list button[aria-current=true]{color:var(--ink);border-color:var(--rule);background:var(--card)}.project-mark{width:.52rem;height:.52rem;border-radius:50%;background:var(--ok);flex:none}.project-mark.pending{background:var(--live);animation:project-pulse 1.5s ease-in-out infinite}@keyframes project-pulse{50%{opacity:.28}}.project-list .project-label{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1}.project-list .project-count{color:var(--soft);font:500 .68rem var(--mono)}.project-empty-rail{color:var(--soft);font-size:.78rem;padding:.75rem .45rem;line-height:1.5}.project-main{min-width:0;padding:1.4rem 1.5rem}.project-empty-state{min-height:26rem;display:grid;place-items:center;text-align:center;color:var(--soft)}.project-empty-state>div{max-width:28rem}.project-empty-state .cloud-glyph{width:3.4rem;height:3.4rem;margin:0 auto 1rem;display:grid;place-items:center;border:1px solid var(--rule);border-radius:50%;color:var(--live);font-size:1.4rem}.project-empty-state h3{color:var(--ink);font:700 1.08rem var(--mono);margin:0 0 .5rem}.project-empty-state p{font-size:.86rem;line-height:1.6;margin:0 0 1rem}.project-head{display:flex;align-items:flex-start;justify-content:space-between;gap:1.2rem;padding-bottom:1.25rem;border-bottom:1px solid var(--rule)}.project-head h3{font:700 1.12rem var(--mono);margin:0 0 .35rem}.project-meta{display:flex;flex-wrap:wrap;gap:.45rem .8rem;color:var(--soft);font:500 .7rem var(--mono)}.project-meta .ready{color:var(--ok)}.project-head .primary{padding:.6rem .85rem;font-size:.76rem}.project-wait{margin-top:1.1rem;padding:1rem;border:1px solid color-mix(in srgb,var(--live) 45%,var(--rule));border-radius:9px;background:color-mix(in srgb,var(--live) 5%,var(--card))}.project-wait strong{display:block;font-size:.88rem;color:var(--ink)}.project-wait span{display:block;color:var(--soft);font-size:.8rem;margin-top:.3rem;line-height:1.5}.project-wait button{padding:.4rem .65rem;font-size:.72rem}.project-wait>button{margin-top:.8rem}.project-wait-actions{display:flex;flex-wrap:wrap;gap:.5rem;margin-top:.8rem}.project-wait-actions button{margin:0}.members-head{display:flex;justify-content:space-between;align-items:baseline;margin:1.25rem 0 .3rem}.members-head h4{margin:0;font:600 .7rem var(--mono);letter-spacing:.12em;text-transform:uppercase;color:var(--soft)}.members-head span{font:500 .68rem var(--mono);color:var(--soft)}.member-list{list-style:none;margin:0;padding:0}.member-row{display:flex;align-items:center;gap:.8rem;padding:.88rem 0;border-bottom:1px solid var(--rule)}.member-row:last-child{border-bottom:0}.member-role{flex:none;color:var(--soft);font:500 .69rem var(--mono);border:1px solid var(--rule);border-radius:999px;padding:.2rem .5rem}.member-role.owner{color:var(--live);border-color:color-mix(in srgb,var(--live) 45%,var(--rule))}.member-row .ghost{flex:none;padding:.4rem .65rem;font-size:.71rem}.team-drawer{border-left:1px solid var(--rule);padding:1.25rem;background:color-mix(in srgb,var(--seal) 28%,var(--card))}.team-drawer[hidden]{display:none}.drawer-head{display:flex;justify-content:space-between;align-items:flex-start;gap:1rem}.drawer-head h3{font:700 .98rem var(--mono);margin:0 0 .25rem}.drawer-head p{color:var(--soft);font-size:.78rem;margin:0;line-height:1.45}.drawer-close{padding:0;border:0;background:transparent;color:var(--soft);font-size:1.1rem}.team-drawer label{margin-top:1.2rem}.team-drawer .primary{width:100%;margin-top:.65rem}.drawer-note{color:var(--soft);font-size:.74rem;line-height:1.5;margin-top:.8rem}.team-drawer .msg{white-space:normal}.project-dialog{width:min(29rem,calc(100vw - 2rem));border:1px solid var(--rule);border-radius:12px;background:var(--card);color:var(--ink);padding:0;box-shadow:0 24px 80px rgb(0 0 0/.55)}.project-dialog::backdrop{background:rgb(0 0 0/.65);backdrop-filter:blur(2px)}.project-dialog form{padding:1.35rem}.project-dialog h3{font:700 1.05rem var(--mono);margin:0 0 .4rem}.project-dialog p{color:var(--soft);font-size:.84rem;line-height:1.55;margin:0}.project-dialog .dialog-actions{display:flex;justify-content:flex-end;gap:.55rem;margin-top:1.2rem}.project-dialog .dialog-actions button{margin:0}.local-task-dialog{width:min(34rem,calc(100vw - 2rem))}.local-task-place{margin:1rem 0 0;padding:1rem;border:1px solid var(--rule);border-radius:9px;background:var(--paper)}.local-task-place strong{display:block;font-size:.83rem;margin-bottom:.3rem}.local-task-place p{margin:0}.local-task-fallback{margin-top:1rem;border-top:1px solid var(--rule);padding-top:.85rem}.local-task-fallback summary{cursor:pointer;color:var(--soft);font:600 .72rem var(--mono)}.local-task-command{display:flex;align-items:center;gap:.5rem;margin-top:.7rem}.local-task-command code{flex:1;padding:.65rem .75rem;border:1px solid var(--rule);border-radius:8px;background:var(--paper);color:var(--ink)}.local-task-command button{flex:none;padding:.55rem .7rem}@media(max-width:70rem){.projects-layout.drawer-open{grid-template-columns:13rem minmax(0,1fr)}.team-drawer{grid-column:1/-1;border-left:0;border-top:1px solid var(--rule)}}@media(max-width:47rem){.projects-card{width:auto;margin-left:0;transform:none;scroll-margin-top:10rem}.projects-top{padding:1.1rem;display:block}.projects-privacy{margin-top:.8rem}.invite-inbox{padding:0 1.1rem}.invite-card{align-items:flex-start;flex-wrap:wrap}.invite-actions{width:100%;padding-left:3.2rem}.projects-layout,.projects-layout.drawer-open{grid-template-columns:1fr;min-height:0}.project-rail{border-right:0;border-bottom:1px solid var(--rule);padding:.9rem}.project-rail-head{padding:0 0 .65rem}.project-list{display:grid;overflow:visible;padding:0}.project-list li{max-width:none}.project-list button{min-width:0}.project-empty-rail{padding:.5rem}.project-main{padding:1.1rem}.project-empty-state{min-height:17rem}.project-head{display:grid;grid-template-columns:minmax(0,1fr);align-items:start}.project-head .primary{justify-self:start;margin-top:.3rem}.members-head{display:block}.members-head span{display:block;margin-top:.35rem}.member-row{display:grid;grid-template-columns:2.2rem minmax(0,1fr);align-items:start}.member-row .member-avatar{grid-column:1;grid-row:1}.member-row .member-copy{grid-column:2;grid-row:1;min-width:0}.member-row .member-role,.member-row .ghost{grid-column:2;justify-self:start;margin-top:.1rem}.team-drawer{grid-column:auto;border-top:1px solid var(--rule);padding:1.1rem}}.boot{display:none;min-height:74vh;flex-direction:column;align-items:flex-start;justify-content:center;max-width:64rem;margin:0 auto;padding:0 1.5rem}body.booting>.boot{display:flex}body.booting>:not(.boot){display:none}.boot .logo{font-weight:750;letter-spacing:-.03em;font-size:1rem}.boot .track{width:min(21rem,100%);height:1px;background:var(--rule);margin-top:1.7rem;overflow:hidden}.boot .track i{display:block;height:100%;width:34%;background:var(--live);animation:cross 1.1s ease-in-out infinite}.boot .track[data-done=yes] i{width:100%;animation:none;transform:none}.boot p{font-family:var(--mono);font-size:.78rem;color:var(--soft);margin:.95rem 0 0}@keyframes cross{from{transform:translateX(-105%)}to{transform:translateX(325%)}}.boot>*{opacity:0;animation:boot-in .3s ease .24s forwards}@keyframes boot-in{to{opacity:1}}@media(prefers-reduced-motion:reduce){.boot>*{opacity:1;animation:none}.boot .track i{width:100%;transform:none}}footer{border-top:1px solid var(--rule);padding:2.4rem 0 3.6rem;color:var(--soft);font-size:.9rem}footer .r{font-family:var(--mono);font-size:.74rem;margin-top:.6rem}:root{--site-wide:80rem}.navbar>.wrap,.wrap.hero-wrap,.wrap.enterprise-wrap{max-width:var(--site-wide)}body.enterprise-page .wrap.enterprise-wrap{max-width:var(--site-wide)}.navbar #nav-logo{flex:none;white-space:nowrap;min-height:44px}body:not(.panel) .pills a,.navlink{min-height:44px;display:inline-flex;align-items:center}body:not(.panel) .pills a{font-size:.78rem;letter-spacing:.06em}.npmchip button{min-height:36px}.hero-demo-progress button{min-height:44px}.hero-demo-progress button::before{inset:21px 0 21px}.enterprise-story-frame{height:640px}.enterprise-story-frame iframe{width:100%;height:640px;transform:none}.enterprise-contact{margin:1rem 0 0;color:var(--ent-soft);font-size:.875rem}.enterprise-contact a{color:inherit;text-underline-offset:4px}body.account-page #account>.wrap{max-width:34rem}body.account-page #account h1.acct-intro{font-size:2.25rem;line-height:1.15;letter-spacing:-.04em}body.account-page #account .pane form{max-width:100%}body.account-page #account form>.primary{width:100%;justify-content:center}#account input{font-size:1rem}.docs-start{padding-bottom:2.5rem;border-bottom:1px solid var(--rule);margin-bottom:2.5rem}.docs-start h1{font-size:clamp(2.25rem,5vw,3.25rem)}.docs-paths{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:1.5rem;margin-top:2rem}.docs-paths a{display:block;color:var(--ink);text-underline-offset:4px;font-weight:650;min-height:44px}.docs-paths p{font-size:.9rem;color:var(--soft);margin:0}#docs [id]{scroll-margin-top:calc(var(--nav-h) + 24px)}@media(max-width:70rem){.navbar .navlink{display:none}.navbar .npmchip{display:none}.navbar .pills{margin-left:auto;margin-right:0}}@media(max-width:47.5rem){body:not(.panel) #nav-logo{width:auto;min-height:44px;font-size:.88rem;overflow:visible}body:not(.panel) #nav-logo::before{content:none}body:not(.panel) #nav-logo::after{content:" ."}body:not(.panel) .pills{gap:1rem}body:not(.panel) .pills a{font-size:.78rem;letter-spacing:.02em}.hero-demo-progress{height:44px}.hero-demo-progress button::before{inset:21px 0 21px}.enterprise-story{margin-inline:0}.enterprise-story-frame,.enterprise-story-frame iframe{height:1400px}body:not(.panel) .hero{padding-top:3rem;padding-bottom:3rem;gap:1.5rem}.hero-demo{padding:1rem 0 0;margin:0}.docs-paths{grid-template-columns:1fr;gap:1.25rem}}@media(max-width:38rem){body:not(.panel){--nav-h:104px}body:not(.panel) .navbar .row{display:grid;grid-template-columns:1fr;gap:0;padding-top:4px;padding-bottom:4px}body:not(.panel) #nav-logo{justify-self:start}body:not(.panel) .navbar .pills{display:grid;grid-template-columns:repeat(4,1fr);width:100%;gap:0;margin:0}body:not(.panel) .pills a{justify-content:center;padding:.3rem .2rem;min-height:48px;font-size:.78rem;letter-spacing:0}body:not(.panel) .navbar>.wrap{padding-inline:16px}body:not(.panel) .hero{padding-top:2.5rem}}.wrap.hero-wrap{max-width:100rem}body:not(.panel):not(.enterprise-page):not(.docs):not(.account-page) .navbar>.wrap{max-width:100rem}body:not(.panel) .hero{grid-template-columns:minmax(340px,400px) minmax(0,1fr);align-items:start;gap:clamp(2.5rem,4vw,4rem);min-height:0;padding:4rem 0}.hero-copy .mega{font-size:clamp(3.8rem,6.5vw,6rem)}.hero-demo{padding:0;margin:0}.hero-demo-head{align-items:center;min-height:1.25rem}.hero-demo-head h2{font-size:clamp(1.2rem,1.65vw,1.55rem)}@media(max-width:80rem) and (min-width:75.01rem){body:not(.panel) .hero{grid-template-columns:340px minmax(0,1fr);gap:2.5rem}.hero-copy .mega{font-size:5.3rem}}@media(max-width:75rem){body:not(.panel) .hero{grid-template-columns:1fr;gap:2.5rem;padding:3rem 0}.hero-demo{margin:0;padding:0}}@media(min-width:48rem) and (max-width:75rem){.hero-copy .mega br{display:none}.hero-copy .mega{font-size:clamp(3.5rem,6.8vw,5rem);white-space:nowrap}body:not(.panel) .hero .lede{max-width:56ch}}@media(max-width:38rem){body:not(.panel) .hero{padding:2.5rem 0;gap:2rem}}</style><div class="boot" id="boot"><span class="logo">⌑ Sealkeep</span><div class="track" id="boot-track"><i></i></div><p id="boot-note" role="status" aria-live="polite">Signing you in</p></div><div class="navbar"><div class="wrap row"><a class="logo" href="#home" id="nav-logo">⌑ Sealkeep</a><a class="navlink guest" href="#how">How it works</a><a class="navlink guest" href="#get">Features</a><nav class="pills" aria-label="Site"><a href="#home" id="pill-home" class="guest on">Product</a><a href="#enterprise" id="pill-enterprise" class="guest">Enterprise</a><a href="#docs" id="pill-docs" class="guest">Docs</a><a href="#account" id="pill-account">Account</a></nav><span class="npmchip guest"><b class="ver">● v0.10</b><code id="cmd">npm install -g sealkeep</code><button id="copy" type="button">Copy</button></span></div></div><main id="home"><div class="wrap hero-wrap"><div class="hero"><div class="hero-copy"><p class="promise-eyebrow"><i></i>The custody promise</p><h1 class="mega" aria-label="Seal. Keep. Share.">SEAL<span class="dot">.</span><br>KEEP<span class="dot">.</span><br>SHARE<span class="dot">.</span></h1><p class="lede">Your agent's memory — <u>sealed</u> on your machine, <u>kept</u> in storage you choose, <u>shared</u> on your terms.</p><div class="cta-row"><a class="primary" href="#start">Get started →</a><a class="ghost" href="#docs">Read the docs</a></div><p class="meta">Node 22+ · macOS, Linux, Windows · one CLI · npm install -g sealkeep</p></div><section class="hero-demo" aria-label="How SealKeep works"><div class="hero-demo-head"><h2 id="hero-story-title">Preserve your agent memories</h2><span id="hero-story-index">01 / 03</span></div><div class="hero-demo-frame"><iframe id="hero-story" title="Preserve your agent memories" src="visual/index.html?embed=1#agent-memory" loading="eager"></iframe></div><div class="hero-demo-progress" role="group" aria-label="SealKeep capabilities"><button type="button" aria-label="Preserve your agent memories" aria-current="true"></button><button type="button" aria-label="Continue your work anywhere" aria-current="false"></button><button type="button" aria-label="Team cooperation" aria-current="false"></button></div></section></div></div><section class="custody" id="promise" aria-label="The custody promise"><div class="cols"><div class="col"><span class="idx">01 // Local E2EE</span><h3>Sealed on-device</h3><p>Encrypted before a byte leaves your machine. We don't have the keys. We never touch the plaintext.</p><span class="tail">Zero-knowledge architecture</span></div><div class="col"><span class="idx">02 // Sovereignty</span><h3>Kept where you choose</h3><p>Use your own bucket, personal Google Drive, or managed Sealkeep Cloud. Every destination receives only encrypted, verified archives.</p><span class="tail">User-controlled routing</span></div><div class="col"><span class="idx">03 // Collaboration</span><h3>Shared on your terms</h3><p>Bundles, recipients, and live bridges. Share across machines, models, and teams.</p><span class="tail">Cross-model memory</span></div></div></section><div class="wrap"><section class="center" id="how"><span class="kicker">How it works</span><h2>The order is the safety property</h2><p class="sub">Nothing is reclaimed before it is verified. Nothing is verified before it is sealed.</p><div class="term"><header><span class="dots">● ● ●</span><span class="title">you@yours — sealkeep autopilot</span><span class="run">● RUNNING</span></header>
3
+ <pre>$ sealkeep autopilot --reclaim
4
+ <i>✓</i> vault created <b>7a126017…</b> <i>✓</i> recovery phrase shown — <b>24 words</b> · once · not stored
5
+ <i>✓</i> hooks installed — claude, codex <span class="dim">preserve + upload automatically · reclaim explicitly enabled</span>
6
+
7
+ $ sealkeep autopilot status
8
+ sealed <b>412</b> · verified <b>412</b> · reclaimed <b>3.4 GB</b>
9
+ <span class="dim">last cycle 40s ago · next in 5m</span></pre>
10
+ </div><div class="how-grid"><div class="step-card"><span class="num">1</span><h3>Catch</h3><p>Your agent's session hook records that a transcript exists and where it lives. Nothing is read yet.</p><div class="foot">holds no secret · no network call · milliseconds</div></div><div class="step-card"><span class="num">2</span><h3>Seal</h3><p>A worker encrypts the session with a key made on this machine and wrapped twice — once for your phrase and once per trusted device.</p><div class="foot">revoking a laptop rewraps keys, not your data</div></div><div class="step-card"><span class="num">3</span><h3>Verify</h3><p>The sealed archive goes to your chosen storage, and Sealkeep reads it back. Byte count and checksum must match what was computed locally.</p><div class="foot">no match → not durable → local file stays</div></div><div class="step-card hot"><span class="num">4</span><h3>Reclaim</h3><p>Only a verified, searchable, unchanged and idle session becomes eligible. When you enable reclaim, eligible sessions can have their full local bytes removed. Supported top-level (non-subagent) Codex sessions keep a tiny same-ID resume pointer; unsupported and subagent Codex sessions keep their full local original.</p><div class="foot">off by default · verified remote copy required</div></div></div><p class="how-note">Autopilot preserves, indexes, and uploads on a loop. Reclaim joins that loop only after you explicitly enable it, and Sealkeep notifies you when disk is reclaimed or something needs a decision — never for routine sealing, which would only train you to ignore it.</p><div class="belt"><div><span class="ck">Setup</span><div class="b-v">Two commands</div></div><div><span class="ck">Recovery phrase</span><div class="b-v">24 words, shown once</div></div><div><span class="ck">Local originals removed</span><div class="b-v amber">Opt-in · after verification</div></div><div><span class="ck">Transcript bytes we receive</span><div class="b-v amber">Zero</div></div></div></section><section class="center" id="get"><span class="kicker">What you get</span><h2>Six months of reasoning,<br>still searchable</h2><div class="bento"><div class="b-card span2"><div class="b-split"><div><h3>Find the session by what you said in it</h3><p>Six weeks later you remember the bug, not the folder. The content index is sealed with the same machinery as your archives and synced beside them — so every machine of the vault searches your whole history, and your agents search it too, over MCP.</p><div class="foot">metadata instantly · full text on any machine holding your phrase</div></div><div class="b-term">$ sealkeep search "stripe webhook" --content
11
+ <div class="row"><span><b>08424eb6</b> …the stripe webhook was firing twice…</span><span class="dim">12 Aug</span></div><div class="row"><span><b>a91c40f7</b> …switched the webhook to the idempotency key…</span><span class="dim">4 Aug</span></div><div class="row"><span><b>3f7c02d1</b> …stripe webhook signing secret rotated…</span><span class="dim">27 Jul</span></div></div></div></div><div class="b-card glow span2"><div class="b-split"><div><span class="ck" style="color:var(--live)">The failure we had to prevent</span><h3>Archiving must never cost you a resume</h3><p>For supported top-level (non-subagent) Codex rollouts, reclaim leaves a valid three-line transcript at the same native path and session ID. Unsupported and subagent Codex rollouts stay on disk. Resume from an eligible pointer starts immediately; Codex can search preserved memory through the local Sealkeep MCP, while full byte restoration stays an explicit choice.</p></div><div class="b-term"><span class="dim">codex resume 08424eb6</span><i>✓</i> same session ID · lightweight pointer
12
+ called <b>sealkeep_search</b>("where did we stop?")
13
+ <span class="dim">full bytes: sealkeep recover &lt;archive-id&gt; --native --overwrite backup</span></div></div></div><div class="b-card"><h3>Actually frees local space</h3><p>Reclaim is off by default. When enabled, Sealkeep removes an unchanged local original only after its complete encrypted archive is verified remotely and covered by search.</p><div class="b-term"><i>✓</i> verified <b>9f2c1ab4</b> in storage
14
+ <span class="dim">eligible top-level Codex source → same-ID pointer</span><b>1.4 GB</b> reclaimed</div><p class="green">The remote archive stays encrypted, verified and explicitly restorable.</p></div><div class="b-card"><h3>Your bucket sees noise</h3><p>Every path segment — project, date, session — is an HMAC under a key derived from your phrase. No project names, no dates, no session ids.</p><div class="cipherbox" id="cipher" aria-hidden="true"></div><div class="foot">Prefer legible paths in your own bucket? One config flag.</div></div><div class="b-card"><h3>Watch a session live</h3><p>Share an ongoing session on a channel; teammates follow it read-only. Every delta is sealed on your machine before it moves, and followers verify a hash chain before a byte lands in their mirror.</p><div class="b-term">$ sealkeep bridge start s01.jsonl --channel review
15
+ sharing on <b>review</b> · 2 deltas · live
16
+ <span class="dim">follow with: sealkeep bridge watch review</span></div><div class="foot">sealed deltas · hash-chained · read-only for followers</div></div><div class="b-card"><h3>Restores on a machine that never saw it</h3><p>Each archive carries its own envelope, so a restore needs your phrase and an account. Not the original laptop, not a config file, not an index you forgot to copy.</p><div class="foot">hash checked after download<br>checked again after decryption</div></div><div class="b-card span2"><div class="b-split"><div><h3>Your agents know the vault is there</h3><p>One command registers a local MCP server with Claude Code and Codex, so the agent itself can search your sealed history and restore a session it needs.</p><div class="foot">all local · nothing new exposed to the network</div></div><div class="b-term">$ sealkeep mcp install
17
+ <i>✓</i> registered with claude · codex
18
+ <span class="dim">search · restore · status · retention · upload · prepare</span></div></div></div></div></section><div class="zk"><div class="wrap"><div class="cols"><div><span class="eyebrow" style="color:var(--live)">Zero knowledge, spelled out</span><h2>What the control plane can and cannot see</h2><p>It coordinates devices, quotas and upload permissions. Sealing happens before any of that, on your machine.</p><blockquote>Lose your recovery kit and every device you registered, and your archives are unreadable. Support cannot get them back. <strong>That is not a gap in the product — it is the product.</strong></blockquote></div><div class="holds"><span class="h">It holds</span><span class="h">It never receives</span><div>Account and device identifiers</div><div>Transcript content — no endpoint accepts it</div><div>Byte counts and timestamps</div><div>Archive keys — generated and wrapped on your machine</div><div>Checksums of sealed archives</div><div>Your recovery phrase — shown once, stored nowhere</div><div>Encrypted manifests it cannot read</div><div>Device private keys — only public halves are registered</div></div></div></div></div><section class="center" id="words"><span class="kicker">Your only key</span><h2>Twenty-four words,<br>not a base64 string</h2><p class="sub">Words survive bad handwriting, can be read aloud, and carry a checksum — so a misread word is caught and corrected rather than silently locking you out.</p><div class="kit"><div class="head"><span>Shown once · stored nowhere · never sent</span><b>sealkeep recovery kit</b></div><div class="words"><span><i>1</i>acorn</span><span><i>2</i>oxide</span><span><i>3</i>lynx</span><span><i>4</i>atom</span><span><i>5</i>voyage</span><span><i>6</i>harbor</span><span><i>7</i>pine</span><span><i>8</i>delta</span><span><i>9</i>mango</span><span><i>10</i>flint</span><span><i>11</i>east</span><span><i>12</i>clock</span><span><i>13</i>iris</span><span><i>14</i>brass</span><span><i>15</i>stork</span><span><i>16</i>maple</span><span><i>17</i>ridge</span><span><i>18</i>ember</span><span><i>19</i>quilt</span><span><i>20</i>fable</span><span><i>21</i>onyx</span><span><i>22</i>cedar</span><span><i>23</i>dune</span><span><i>24</i>cricket</span></div><p class="verify">Type it back with <code>sealkeep recovery verify</code> to prove your copy works. Case and spacing do not matter; a wrong word is named with suggestions — <em>Word 1 “acron” is not in the list. Did you mean acorn, arrow?</em></p></div></section><section class="center" id="plans"><span class="kicker">Pricing</span><h2>Whose bucket</h2><p class="sub">Not the same product with a different quota. On free, archives go to a bucket <strong style="color:var(--ink)">you own</strong> under your own credentials — we never hold them and cannot count them.</p><div class="pricing"><div class="price-card light"><div class="p-name">Your bucket</div><div class="p-price">$0 <small>forever · S3, R2, GCS, Drive, MinIO</small></div><p class="pick"><strong>Pick this if</strong> you have a cloud account and would rather hold your own data than trust anyone with it.</p><ul><li>Every personal-vault capability — sealing, verify, search, retention, MCP, restore</li><li>Guided setup: console links, filled-in commands, a least-privilege policy</li><li>Credentials go to your OS keychain, never a config file</li><li class="dash">You paste each IAM step yourself</li><li class="dash">No escrow — your recovery kit is the only copy of the key</li></ul><div class="cta"><a class="ghost" href="#docs">Read the storage setup</a></div></div><div class="price-card"><div class="p-name">Our bucket<span class="managed-chip">Managed</span></div><div class="p-price">$6 <small>/month · 100 GB, up to 2 TB</small></div><p class="pick"><strong>Pick this if</strong> you bought “it just works” — and would rather not be the only backup of your own key.</p><ul><li>No provider to name. Quotas and uploads are brokered for you.</li><li>A sealed copy of your key that survives losing the machine, openable only by your phrase</li><li>Storage meter, archive list and device revocation in the account panel</li><li>Your keys and your phrase still never leave your machine</li></ul><div class="packs"><span class="now">100 GB · $6</span><span>500 GB · $12</span><span>2 TB · $30</span></div><div class="cta"><a class="primary" href="#account">Create an account</a></div></div><div class="price-card slim"><div class="p-name">Pro Lite</div><div class="p-price">$2 <small>/month · your storage, our safety net</small></div><p class="pick"><strong>Pick this if</strong> you keep your own bucket or Google Drive — and want the parts of an account that save a bad day.</p><ul><li>Sealed key escrow — a copy we cannot open, your phrase can</li><li>Machines signed in: enroll a new laptop with one code, revoke a stolen one from the panel</li><li>Drive or bucket stays yours — we still hold no bytes</li></ul><div class="cta"><a class="ghost" href="#account">Add the safety net</a></div></div><div class="price-card slim"><div class="p-name">Team<span class="managed-chip">Cowork</span></div><div class="p-price">$9 <small>/seat · project-scoped team access</small></div><p class="pick"><strong>Pick this if</strong> one project's memory belongs to several people — and to their agents.</p><ul><li>Project-scoped members: a colleague opens what the team sealed, nothing else</li><li>Shared sessions require a managed project copy; managed storage is billed separately. Personal Drive and bucket routes stay private to their owner</li><li>Live session bridges, relayed through our plane — ciphertext in, ciphertext out</li><li>The round table: add and revoke members from the dashboard</li></ul><div class="cta"><a class="ghost" href="#account">Start a team</a></div></div></div><p class="price-note">Or skip storage entirely and keep everything local — Sealkeep still seals every archive, it just has nowhere to copy it.</p></section><section id="start"><div class="center"><span class="kicker">Getting started</span><h2>Four steps either way</h2><p class="sub">The phrase step is the one that matters.</p></div><div class="tabs"><button id="t-free" aria-selected="true">Free</button><button id="t-paid" aria-selected="false">Paid</button></div><div class="pane on" id="p-free"><ol class="steps"><li><h3>Install</h3><pre>npm install -g sealkeep</pre></li><li><h3>Choose free, pick a provider</h3><p>Three questions: provider, bucket, prefix.</p><pre>sealkeep start</pre></li><li><h3>Write down the phrase</h3><p>Twenty-four words, shown once, generated here and never sent anywhere.</p></li><li><h3>Create the bucket, then run it</h3><p>Sealkeep prints the console links, the commands, and a policy scoped to your prefix.</p><pre>sealkeep storage setup
19
+ sealkeep autopilot</pre></li></ol></div><div class="pane" id="p-paid"><ol class="steps"><li><h3>Install</h3><pre>npm install -g sealkeep</pre></li><li><h3>Create an account</h3><p>Below, or in the terminal. Nothing else to set up.</p><pre>sealkeep start</pre></li><li><h3>Write down the phrase</h3><p>Your keys are generated on your machine. We store a copy sealed with this phrase, which never reaches us.</p></li><li><h3>Let it run</h3><p>Your machine requests a signed URL and uploads directly. The bytes never pass through our servers.</p><pre>sealkeep autopilot
20
+ sealkeep cloud status</pre></li></ol></div></section><section class="center" id="honestly"><span class="kicker">Product &amp; assurance roadmap</span><h2>What ships now,<br>and what advances next</h2><p class="sub">Production capabilities today, with dated milestones for the next assurance gates.</p><div class="ship"><div><span class="name">Capture and sealing<span class="d">Durable queue, crash-safe, deduplicating, resumable streaming</span></span><span class="tag ok">Shipping</span></div><div><span class="name">Restore<span class="d">Byte-identical explicit restore; supported top-level (non-subagent) Codex sessions keep a same-ID pointer that searches memory over MCP, while unsupported and subagent sessions keep their full local original</span></span><span class="tag ok">Shipping</span></div><div><span class="name">Retention<span class="d">Opt-in deletion of unchanged local originals, only after remote verification and search coverage</span></span><span class="tag ok">Shipping</span></div><div><span class="name">Search<span class="d">Sealed content index, synced across your machines as ciphertext</span></span><span class="tag ok">Shipping</span></div><div><span class="name">Agent integration<span class="d">MCP server for Claude Code and Codex, session hooks, live bridge</span></span><span class="tag ok">Shipping</span></div><div><span class="name">Encryption format<span class="d">Published v2 format, deterministic vectors, and tamper suite; independent-review package in preparation</span></span><span class="tag warn">Audit prep · Q3 ’26</span></div><div><span class="name">Cloud providers<span class="d">R2 production-backed; GCS retirement and S3/Drive production hardening tracked in the Q3 2026 provider milestone</span></span><span class="tag warn">Hardening · Q3 ’26</span></div><div><span class="name">Signed releases<span class="d">npm provenance and signed manifests ship today; native OS signing is the next supply-chain milestone</span></span><span class="tag warn">Roadmap · Q3 ’26</span></div></div><p class="ship-note"><strong>Independent-review preparation is active.</strong> Encryption format v2 uses published authenticated-encryption primitives and ships with deterministic vectors plus byte-by-byte tamper coverage. The public assurance roadmap tracks external cryptographic review as the next gate; no completed third-party audit is claimed today.</p></section><section class="center" id="faq"><span class="kicker">Questions</span><h2>The ones that actually matter</h2><details class="faq" open><summary>What happens if I lose the 24 words?</summary><div>Your phrase is the permanent recovery path, and Google sign-in alone cannot decrypt anything. Paid accounts can also create five one-time backup codes; each can recover the sealed vault key once after every device is lost. Keep the printed Recovery Kit offline and verify it while this machine still works.</div></details><details class="faq"><summary>Can Sealkeep delete my sessions by accident?</summary><div>Automatic reclaim is off by default. If you enable it, Sealkeep removes only the exact unchanged local bytes represented by a complete verified remote archive, after the configured idle and grace periods and after confirming that archive is searchable. Supported top-level (non-subagent) Codex sessions keep a same-ID pointer; unsupported and subagent Codex sessions keep their full local original. The full archive remains explicitly restorable. Deleting the remote archive is a separate account action.</div></details><details class="faq"><summary>Will archiving break <code>--resume</code>?</summary><div>No. For supported top-level (non-subagent) Codex sessions, reclaim leaves a tiny valid transcript under the original session ID, so <code>codex resume &lt;session-id&gt;</code> still opens immediately and can call <code>sealkeep_search</code> for relevant memory. Unsupported and subagent Codex sessions keep their full local original. Full restoration is deliberately explicit—use the recovery command shown in that pointer—so startup never waits on a multi-gigabyte download.</div></details><details class="faq"><summary>Can you read my transcripts?</summary><div>No. Sessions are sealed on your machine before anything moves. Storage holds ciphertext, the control plane holds identifiers, counts and checksums, and no endpoint accepts transcript content. We hold no key that opens an archive.</div></details><details class="faq"><summary>Do I need a cloud account?</summary><div>No. The free plan writes to a bucket you already own — S3, R2, GCS, Drive, or anything S3-compatible. Or skip storage entirely and stay local: everything still gets sealed, it just has nowhere to copy.</div></details><details class="faq"><summary>Is the encryption audited?</summary><div>Not independently, yet — and we say so on this page rather than in a footnote. It is authenticated encryption on published primitives (ChaCha20-Poly1305 or AES-256-GCM), with deterministic test vectors, a tamper suite, and a written threat model in the repository.</div></details></section><div class="cta-final"><h2>Two commands.<br>Then nothing.</h2><p class="sub">Install it, write down twenty-four words, and stop choosing between your disk and your history.</p><div class="cta-row"><a class="primary" href="#start">Install Sealkeep</a><a class="ghost" href="#account">Create an account</a></div><p class="meta">No credit card for the free plan — it uses your own bucket</p></div></div></main><section class="enterprise" id="enterprise"><div class="wrap enterprise-wrap"><div class="enterprise-page-hero"><div class="enterprise-copy"><h1>Your code is in Git. Your AI engineering history isn’t.</h1><p>SealKeep preserves the work behind the code—decisions, commands, failed approaches, corrections, test results, and handoffs—in storage your company controls. You can run on-premises or in your cloud, with connected and fully isolated modes stated separately.</p><div class="cta-row"><a class="primary" href="mailto:security@spala.ai?subject=Sealkeep%20continuity%20pilot">Request a continuity pilot →</a><a class="ghost" href="mailto:security@spala.ai?subject=Sealkeep%20enterprise%20architecture%20review">Request an architecture review</a><p class="enterprise-contact">Email <a href="mailto:security@spala.ai">security@spala.ai</a> with your team size and deployment preference.</p></div></div><div class="enterprise-story" aria-label="Interactive Sealkeep enterprise examples"><div class="enterprise-story-frame"><div class="enterprise-story-preview" aria-hidden="true"><div class="enterprise-preview-tabs"><span>Knowledge stays</span><span>Team collaboration</span><span>On-prem isolation</span><span>Incidents</span></div><div class="enterprise-preview-title"><strong>Senior engineering decisions become company memory.</strong><small>Preparing live example</small></div><div class="enterprise-preview-flow"><article><b>Senior engineer</b><i></i><span>Decisions<br>Trade-offs<br>Implementation history</span></article><article><b>Company memory</b><i></i><span>Encrypted project history<br>Source attribution<br>Retention policy</span></article><article><b>Team continues</b><i></i><span>Faster onboarding<br>Trusted context<br>No single-person dependency</span></article></div></div><iframe id="enterprise-story" title="Sealkeep enterprise knowledge and deployment examples" src="visual/index.html?embed=1#enterprise-boundary" loading="eager"></iframe></div></div></div><section class="enterprise-loss-story" aria-labelledby="enterprise-loss-title"><div class="enterprise-loss-copy"><span class="kicker">The work Git cannot keep</span><h2 id="enterprise-loss-title">Your code survives in Git. The work that produced it often doesn’t.</h2><p>A developer spends three days with Claude Code:</p><ol><li><span>01</span>Debugging a production race condition</li><li><span>02</span>Discovering two failed approaches</li><li><span>03</span>Correcting the agent</li><li><span>04</span>Learning an undocumented system constraint</li><li><span>05</span>Arriving at the final fix</li></ol></div><div class="enterprise-loss-ledger" aria-label="What Git and SealKeep preserve"><div class="loss-git"><span>Git keeps</span><strong>The final diff</strong><code>1 file changed<br>8 insertions · 3 deletions</code><small>The end state. Not the path.</small></div><div class="loss-arrow" aria-hidden="true">→</div><div class="loss-sealkeep"><span>SealKeep preserves</span><strong>The work behind it</strong><ul><li>Decisions and reasoning</li><li>Commands and outputs</li><li>Failed approaches</li><li>Human corrections</li><li>Tests and results</li><li>Handoffs and transcript</li></ul></div></div><p class="enterprise-aha">SealKeep makes that engineering history a company-controlled project record.</p></section><div class="enterprise-ownership" aria-label="How engineering systems divide project knowledge"><div><strong>Git</strong><span>WHAT changed</span><p>Final diffs, commits, branches, and tags.</p></div><div><strong>Jira + Linear</strong><span>WHAT was requested</span><p>Intent, tickets, acceptance criteria, and status.</p></div><div><strong>Slack</strong><span>WHAT people discussed</span><p>Useful fragments, scattered across conversations.</p></div><div class="sealkeep-owner"><strong>SealKeep</strong><span>HOW engineer + agent arrived</span><p>Decisions, commands, dead ends, corrections, tests, and handoffs.</p></div></div><div class="enterprise-proof" aria-label="Enterprise deployment commitments"><div><span>01 // Key custody</span><strong>Keys stay on customer endpoints</strong><p>Recovery phrases and device private keys are not sent to the backend or to Spala.</p></div><div><span>02 // Data residency</span><strong>You select every data region</strong><p>Application, PostgreSQL, object storage, logs, and backups stay where you place them.</p></div><div><span>03 // Entitlement</span><strong>Offline licensing is available</strong><p>The isolated edition disables the network licence check or validates entitlement locally.</p></div><div><span>04 // Administration</span><strong>No standing provider access</strong><p>Support access is disabled by default and, if approved, can be time-bounded and logged.</p></div></div><div class="enterprise-section-head"><span class="kicker">Built for high-impact moments</span><h2>Company memory for the moments that cost the most.</h2><p>AI-assisted engineering work becomes durable company memory: searchable by authorised teammates and available as project-scoped context to their coding agents.</p></div><div class="enterprise-value-grid" aria-label="Enterprise AI engineering memory use cases"><article class="enterprise-value featured"><span class="value-num">01 // Employee + contractor offboarding</span><h3>When the engineer leaves, project knowledge stays.</h3><p>In shared Team projects, new archives are encrypted for every accepted project member. Removing a contributor does not remove access already held by remaining members, while personal vaults stay outside project membership.</p><span class="value-proof">Accepted member recipients · managed project copy · personal vault isolation</span></article><article class="enterprise-value featured"><span class="value-num">02 // Incident reconstruction</span><h3>Recover what the engineer and agent actually did.</h3><p><b>Production auth bug introduced Monday.</b> Git shows the code change. Relevant preserved history can show the original request, agent session, terminal commands, failed attempts, human correction, tests that ran, and transcript—without implying automatic commit provenance.</p><span class="value-proof">Local content search · project filters · activity history</span></article><article class="enterprise-value"><span class="value-num">03 // Onboarding + handoffs</span><h3>Continue where somebody else stopped.</h3><p>Give a new hire, on-call engineer, or replacement contractor the preserved context needed to continue: what was attempted, what changed, which constraints mattered, and where the previous session stopped.</p><span class="value-proof">Preserved handoffs · content search · exact native restore</span><div class="value-flow" aria-hidden="true"><i>Previous engineer</i><em>→</em><i>Next engineer</i></div></article><article class="enterprise-value"><span class="value-num">04 // Shared agent context</span><h3>Codex can reuse what Claude learned.</h3><p>Project-scoped memory lets invited teammates move useful historical context between supported coding agents and approved machines without silently retraining a model or broadening project access.</p><span class="value-proof">Project scope · member keys · Claude Code and Codex</span><div class="value-flow" aria-hidden="true"><i>Claude Code</i><em>→</em><i>Codex</i></div></article><article class="enterprise-value"><span class="value-num">05 // Senior → junior transfer</span><h3>Juniors inherit trusted project history.</h3><p>Authorised teammates can search the implementation patterns, decisions, failed approaches, and handoffs preserved by senior engineers, then use that context to move forward rather than rework the past.</p><span class="value-proof">Local MCP recall · project scope · revocable member access</span><div class="value-flow" aria-hidden="true"><i>Senior history</i><em>→</em><i>Junior + agent</i></div></article><article class="enterprise-value"><span class="value-num">06 // Secret control</span><h3>Control what becomes retained evidence.</h3><p>Run deterministic offline scanning before sealing. Known credential patterns and suspicious high-entropy values produce masked findings; high-confidence results can fail a gate without copying the secret into a report.</p><span class="value-proof">Offline scan · masked previews · JSON and exit codes</span><div class="value-flow" aria-hidden="true"><i>Raw context</i><em>→</em><i>Sealed memory</i></div></article></div><div class="enterprise-operations" aria-label="Local security automation interfaces"><div class="enterprise-operations-copy"><span class="kicker">Security operations</span><h3>Because preserved agent history is sensitive, security controls travel with it.</h3><p>Secret scanning supports knowledge retention by controlling what becomes preserved evidence. Investigation and control workflows stay inside the customer boundary.</p></div><div><div class="enterprise-interfaces"><div class="enterprise-interface"><code>sealkeep scan … --json</code><p><b>Pre-seal control:</b> masked findings and high-confidence exit status for scripts, CI gates, or customer monitoring.</p></div><div class="enterprise-interface"><code>sealkeep search … --content --json</code><p><b>Incident investigation:</b> search preserved history locally and keep retrieval scoped to the authorised project.</p></div><div class="enterprise-interface"><code>sealkeep_search via MCP</code><p><b>Agent continuity:</b> let an approved coding agent retrieve relevant project memory through its local SealKeep connection.</p></div><div class="enterprise-interface"><code>sealkeep audit --json</code><p><b>Operational evidence:</b> export the local record of what SealKeep did to files for customer-owned review workflows.</p></div><div class="enterprise-interface"><code>sealkeep autopilot status</code><p><b>Local posture:</b> check whether automatic preservation is configured on the current machine.</p></div></div><p class="enterprise-interface-note">Available today as local CLI JSON and MCP interfaces. Sealkeep does not require a hosted plaintext search API; customer-specific SIEM or REST connectors can be scoped for an isolated enterprise deployment.</p></div></div><div class="enterprise-section-head compact"><span class="kicker">Why the boundary matters</span><h2>Agent sessions contain more than chat.</h2><p>They can include source code, incident details, infrastructure names, customer records, credentials accidentally pasted into a prompt, and decisions that reveal how critical systems work. Encryption is necessary; control over where the system runs is a separate requirement.</p></div><div class="enterprise-needs"><article><span>Residency</span><h3>Keep operational data in the approved region</h3><p>Your organisation selects the runtime, database, object-store, log, backup, and administrator-access locations.</p></article><article><span>Custody</span><h3>A third party does not need the archive or its keys</h3><p>Sessions are encrypted before archival, and storage credentials remain within the customer’s configured secret boundary.</p></article><article><span>Network policy</span><h3>Support strict no-egress environments</h3><p>The fully isolated agreement replaces the runtime licence path with an offline entitlement and removes mandatory Spala dependencies.</p></article><article><span>Control</span><h3>Use your own IAM and operating procedures</h3><p>Your administrators govern access, TLS, monitoring, retention, backups, incident response, and patch deployment.</p></article></div><div class="enterprise-section-head compact"><span class="kicker">Deployment choice</span><h2>“Self-hosted” is not one boundary.</h2><p>The order form names the exact mode so a connected licence path is never mistaken for a fully isolated deployment.</p></div><div class="enterprise-modes" aria-label="Self-hosted deployment choices"><div class="enterprise-mode"><div class="mode-name"><i></i><h3>Connected self-hosted</h3></div><p>The application, PostgreSQL, logs, and archive storage remain customer-operated. The deployment may contact Spala for licence validation; that path and its transmitted fields must be documented for review.</p><ul><li>Customer infrastructure and selected regions</li><li>Customer database and object storage</li><li>Documented outbound licence path</li></ul></div><div class="enterprise-mode isolated"><div class="mode-name"><i></i><h3>Fully isolated self-hosted</h3></div><p>Delivered under a special enterprise agreement with an offline entitlement and no mandatory Spala API, database, object store, authentication, telemetry, or licence call during ordinary operation.</p><ul><li>No mandatory runtime egress to Spala</li><li>No standing HM Software administrator access</li><li>Customer-controlled support window, if requested</li></ul></div></div><div class="enterprise-responsibility"><div class="enterprise-section-head compact"><span class="kicker">Shared responsibility</span><h2>You operate the boundary. We deliver the product.</h2></div><div class="enterprise-responsibility-grid"><div><span>HM Software / Spala</span><ul><li>Exported application bundle and deployment documentation</li><li>Build, version, and integrity information</li><li>Security updates and agreed support</li><li>Disclosure of optional external dependencies</li></ul></div><div><span>Customer</span><ul><li>Runtime, network, PostgreSQL, storage, and TLS</li><li>Identity, access, secrets, regions, and retention</li><li>Monitoring, backups, restores, and patch timing</li><li>Endpoint, recovery-material, and recipient lifecycle</li></ul></div></div></div><div class="enterprise-review"><div class="enterprise-section-head compact"><span class="kicker">Security &amp; procurement review</span><h2>Bring architecture, legal, and operations into the same review.</h2><p>Inspect the public evidence before booking a call. Each artifact is presented as a published statement, public template, or open assurance item; customer-specific terms, regions, subprocessors, retention, support access, and deployment mode are completed in the agreement.</p></div><div class="enterprise-review-grid"><div><span>Contract · public template</span><b>Data Processing Agreement</b><small>Article 28 roles, instructions, assistance, deletion, and audit terms</small><a class="artifact-link" href="/trust.html#dpa">Review template →</a></div><div><span>Security · control statement</span><b>Technical and organisational measures</b><small>Encryption, identity, network, development, incident, and continuity controls</small><a class="artifact-link" href="/trust.html#toms">Review controls →</a></div><div><span>Architecture · published</span><b>Architecture and data flow</b><small>Components, operators, visibility, key custody, residency, and egress paths</small><a class="artifact-link" href="/trust.html#architecture">Inspect diagram →</a></div><div><span>Ownership · published</span><b>Deployment responsibility matrix</b><small>Clear ownership across product delivery, infrastructure, keys, support, and operations</small><a class="artifact-link" href="/trust.html#responsibility">Inspect matrix →</a></div><div><span>Vendors · verification open</span><b>Subprocessor register</b><small>Applicable to Spala-selected hosted services—not customer-selected infrastructure</small><a class="artifact-link" href="/trust.html#subprocessors">Inspect register →</a></div><div><span>Assurance · roadmap</span><b>Threat model, SBOM &amp; audit roadmap</b><small>Known limitations, release evidence, completed tests, and independent-review gates</small><a class="artifact-link" href="/trust.html#assurance">Inspect evidence →</a></div></div><div class="enterprise-assurance"><strong>Assurance stated plainly.</strong><p>Sealkeep does not claim a completed independent cryptographic audit today. The current Team model preserves access through remaining accepted project members; it does not yet claim a separate organization recovery recipient or transferable project-owner role. Test vectors, tamper tests, and deployment rehearsals support review but do not replace customer validation or legal advice.</p></div></div></div><div class="enterprise-docs"><p><strong>Prove continuity on one repository.</strong><span>Preserve one team’s Claude Code and Codex history for 14 days. Then test whether another engineer can find and continue work they did not personally participate in.</span></p><div><a class="primary" href="mailto:security@spala.ai?subject=Sealkeep%20continuity%20pilot">Run a continuity pilot →</a><a href="/trust.html">Open public Trust Center</a><a href="mailto:legal@spala.ai?subject=Sealkeep%20enterprise%20procurement%20pack">Request the procurement pack</a></div></div></section><section id="docs"><div class="wrap"><div class="docs-start"><h1>Start with your first session.</h1><p class="sub">Install Sealkeep, choose where your history lives, and connect your coding agent.</p>
21
+ <pre><code>npm install -g sealkeep
22
+ sealkeep start</code></pre>
23
+ <div class="docs-paths"><div><a href="#docs-storage">Personal storage</a><p>Set up your own bucket or Google Drive.</p></div><div><a href="#docs-team">Team setup</a><p>Connect an account and share a project.</p></div><div><a href="#enterprise">Enterprise deployment</a><p>Explore company-controlled and isolated deployments.</p></div></div></div><div id="docs-team" class="docs-start"><h2>Bring your team’s context together.</h2><p class="sub">Choose the Team plan during setup, sign in, and open Projects in your account to create a shared project and invite teammates.</p><a class="ghost" href="#account">Open your account →</a></div><div id="docs-commands"><span class="eyebrow" style="color:var(--live)">Reference</span><h2 style="font-size:clamp(2rem,5vw,2.9rem)">Commands</h2><p class="sub">Every command takes <code>--json</code>. Run <code>sealkeep help</code> for the full list.</p></div><div class="cmdtable"><div><code>sealkeep autopilot</code><p>Preserve, index and upload automatically; keep original sessions by default</p></div><div><code>sealkeep autopilot --reclaim</code><p>Explicitly allow this machine to reclaim eligible originals</p></div><div><code>sealkeep autopilot status · off</code><p>Check on it, or stop it — every archive stays</p></div><div><code>sealkeep quickstart</code><p>Same setup, no background service</p></div><div><code>sealkeep status · doctor</code><p>What is archived; whether this machine is healthy</p></div><div><code>sealkeep queue run</code><p>Encrypt everything waiting</p></div><div><code>sealkeep daemon</code><p>Watch, seal, index and upload; reclaim only when enabled</p></div><div><code>sealkeep search &lt;query&gt;</code><p>Metadata search; <code>--content</code> searches inside sessions</p></div><div><code>sealkeep recover &lt;id&gt; &lt;dest&gt;</code><p>Restore original bytes; <code>--native</code> puts them back where they came from</p></div><div><code>sealkeep recover &lt;filename&gt;</code><p>The file an agent's resume just complained about, back in place</p></div><div><code>sealkeep archive &lt;path&gt; --stream</code><p>Seal straight to the bucket as a chunk folder, resumable mid-upload</p></div><div><code>sealkeep verify</code><p>Fetch every durable archive back and check it against the hash recorded at upload</p></div><div><code>sealkeep bridge start · watch</code><p>Live-share an ongoing session on a channel; members follow a hash-verified mirror, read-only</p></div><div><code>sealkeep share &lt;id&gt;</code><p>A sealed bundle under a one-time passcode — the phrase never travels</p></div><div><code>sealkeep mcp install</code><p>Register the vault as a tool server with Claude Code and Codex</p></div><div><code>sealkeep retention apply</code><p>Dry run; <code>--confirm</code> moves sources to the trash</p></div><div><code>sealkeep recovery kit · verify · seal</code><p>Printable sheet, type-it-back check, and a sealed escrow copy</p></div><div><code>sealkeep ui · tui</code><p>The dashboard, in a browser or the terminal</p></div><div><code>sealkeep audit</code><p>Everything Sealkeep has done to your files</p></div></div><div class="arch"><div class="b-card"><h3 id="docs-storage">A bucket without the usual afternoon</h3><p>The step people give up on is cloud permissions. So Sealkeep writes it out — the console link, the commands with your own bucket and prefix already filled in, and a policy that grants the least it can.</p><div class="b-term">$ sealkeep storage setup --provider r2
24
+ <span class="dim">console link · bucket + prefix filled in · policy printed</span>
25
+ $ sealkeep storage credentials
26
+ <span class="dim">keys → OS keychain, never a config file</span></div><p style="margin-top:.9rem">For S3 that is an IAM policy scoped to <code>bucket/prefix/*</code> with no delete permission and no wildcard resource. For GCS, a service account with an IAM condition pinned to your prefix. Nothing runs automatically — you read each step and paste it yourself.</p><div class="foot">S3 · Cloudflare R2 · Google Cloud Storage · Google Drive · S3-compatible like MinIO<br>credentials → macOS Keychain, Secret Service, or Windows DPAPI</div></div><div class="b-card"><h3>Encryption, format v2</h3><p>A random per-archive key encrypts the session in chunks under ChaCha20-Poly1305 (or AES-256-GCM), and that key is wrapped separately for each recipient — your phrase, and each device you trust.</p><p>Every chunk is authenticated over its own position, the archive's identity, and the total chunk count — so reordering, truncation, splicing and suite downgrade are all detectable rather than silent.</p><div class="b-term">"VLA1" | len | envelopeJson | ciphertext</div><p class="caution">Not independently audited. Deterministic vectors and a full tamper suite; assumptions and limits are in the threat model.</p></div><div class="b-card"><h3>Autopilot and the keystore trade-off</h3><p>To unlock the vault without a human present, autopilot keeps your phrase in the OS keystore. Stated plainly: <strong>anything running as you can read it once you are logged in.</strong></p><div class="b-term">$ sealkeep autopilot --no-remember
27
+ <span class="dim"># supply the phrase per run instead of storing it</span></div><p style="margin-top:.9rem">Either way, the phrase you wrote down stays your disaster-recovery path. Prefer to drive it yourself? <code>sealkeep quickstart</code> does the same setup with no background service.</p></div><div class="b-card"><h3>Proving the archives are really there</h3><p>A backup nobody has tested is a hope. <code>sealkeep verify</code> fetches every durable archive back and checks it against the hash recorded at upload.</p><div class="b-term"><i>ok</i> <b>88de9eb4…</b> 3.9 KB
28
+ <i>ok</i> <b>847571ef…</b> 2.5 MB
29
+ <span style="color:var(--warn)">warn</span> roundtrip-178… listed, no object behind it</div><p style="margin-top:.9rem">It re-downloads rather than trusting a <code>HEAD</code> — an object that lists but cannot be read back is named, not counted.</p></div></div></div></section><section id="account"><div class="wrap"><h1 class="acct-intro">Your account</h1><p class="sub acct-intro">Sign in to manage storage, machines, recovery, and shared projects. New to SealKeep? Create an account here.</p><div class="acct-why acct-intro"><div class="why-card"><span class="ck">Free · $0 · no account</span><h3>Your personal vault in your bucket or Drive</h3><p>Personal sealing, verification, search, retention, MCP recall, and restore. S3, R2, GCS, Google Drive, MinIO. Your credentials, your custody. Shared team projects use managed project memory so every approved member can reach the same history.</p><span class="tailln">sealkeep start → choose free</span></div><div class="why-card"><span class="ck">Pro Lite · $2/mo</span><h3>Your storage, our safety net</h3><p>A sealed copy of your key in escrow — openable only by your phrase — and every machine signed in: enroll a new laptop with one code, revoke a stolen one from this panel.</p><span class="tailln">for people on their own bucket or gdrive</span></div><div class="why-card"><span class="ck">Managed · $6–$30/mo</span><h3>Our bucket, zero setup</h3><p>100 GB to 2 TB of managed storage for your ciphertext. Storage meter, archive list, and device revocation live in this panel once you sign in.</p><span class="tailln">quotas brokered · keys never leave your machine</span></div><div class="why-card"><span class="ck">Team · $9/seat</span><h3>Cowork through our relay</h3><p>Members scoped to a project open what the team sealed — nothing else. Live session bridges relay through our plane: ciphertext in, ciphertext out.</p><span class="tailln">one project · one memory · several people</span></div></div><p class="acct-loop acct-intro">The loop: create the account here → it hands you an enroll code → <code>sealkeep enroll &lt;code&gt;</code> on your machine, where the keys are made. This page then becomes your panel: storage, machines, members, revocation.</p><div class="tabs" id="auth-tabs"><button id="t-signup" aria-selected="true">Create account</button><button id="t-signin" aria-selected="false">Sign in</button></div><div class="notice soft" id="connect-auth-intro" style="display:none"><p><strong>A machine is waiting for your approval.</strong> Create an account or sign in here, then review exactly what is asking to connect.</p></div><form class="pane on" id="p-signup"><button class="google" type="button" id="g-up"><svg viewBox="0 0 48 48" aria-hidden="true"><path fill="#EA4335" d="M24 9.5c3.5 0 6.6 1.2 9 3.6l6.8-6.8C35.6 2.4 30.2 0 24 0 14.6 0 6.5 5.4 2.6 13.2l7.9 6.1C12.4 13.2 17.7 9.5 24 9.5z"/><path fill="#4285F4" d="M46.1 24.6c0-1.6-.1-3.1-.4-4.6H24v9.1h12.4c-.5 2.9-2.2 5.3-4.7 7l7.7 6c4.5-4.2 6.7-10.3 6.7-17.5z"/><path fill="#FBBC05" d="M10.5 28.7c-.5-1.4-.8-2.9-.8-4.7s.3-3.3.8-4.7l-7.9-6.1C1 16.3 0 20 0 24s1 7.7 2.6 10.8l7.9-6.1z"/><path fill="#34A853" d="M24 48c6.5 0 11.9-2.1 15.9-5.8l-7.7-6c-2.1 1.4-4.9 2.3-8.2 2.3-6.3 0-11.6-3.7-13.5-9.8l-7.9 6.1C6.5 42.6 14.6 48 24 48z"/></svg>
30
+ Continue with Google
31
+ </button><div class="or">or use a password</div><div class="row2"><div><label for="su-email">Email</label><input id="su-email" type="email" autocomplete="email" required></div><div><label for="su-label">Account name</label><input id="su-label" autocomplete="name" required></div></div><label for="su-pass">Password</label><input id="su-pass" type="password" autocomplete="new-password" placeholder="at least 12 characters" required minlength="12"><button class="primary" id="su-go">Create account</button><p class="msg" id="su-msg" role="status" aria-live="polite"></p></form><form class="pane" id="p-signin"><button class="google" type="button" id="g-in">
32
+ Continue with Google
33
+ </button><div class="or">or use a password</div><label for="si-email">Email</label><input id="si-email" type="email" autocomplete="email" required><label for="si-pass">Password</label><input id="si-pass" type="password" autocomplete="current-password" required><button class="primary" id="si-go">Sign in</button><p class="msg" id="si-msg" role="status" aria-live="polite"></p></form><div class="pane" id="p-account"><div class="ph" id="a-strip" tabindex="-1"><span class="name" id="a-label">—</span><button class="chip" id="a-plan" type="button" title="See your plan">—</button><span class="who" id="a-who">—</span></div><h1 class="acct-hero" id="a-hero"></h1><p class="msg" id="a-summary" style="margin:.4rem 0 0"></p><nav class="acct-nav" aria-label="Account sections"><a href="?view=overview#account" data-account-view="overview">Overview</a><a href="?view=projects#account" data-account-view="projects">Projects</a><a href="?view=storage#account" data-account-view="storage">Storage</a><a href="?view=machines#account" data-account-view="machines">Machines &amp; recovery</a><a href="?view=account#account" data-account-view="account">Account</a></nav><div class="card next" id="device-connect" data-account-section="overview machines" style="display:none"><span class="eyebrow">Connect a machine</span><h2 class="lead" id="device-connect-title">Review this request</h2><p id="device-connect-copy">Loading the machine details…</p><div class="inline" id="device-connect-actions" style="display:none"><button class="primary" id="device-connect-approve" type="button">Approve this machine</button><button class="ghost" id="device-connect-deny" type="button">Deny</button></div><p class="msg" id="device-connect-msg" role="status" aria-live="polite"></p><p class="why">Approval lets this machine use your account and encrypted storage. It does not give Sealkeep—or this browser—the key that opens an archive.</p></div><div class="card next" id="a-firstrun" data-account-section="overview machines" style="display:none"><span class="eyebrow">Set up</span><h2 class="lead">Connect your first machine</h2><p>Sealkeep runs on your machine — that is where your keys are made and where every session is sealed before it moves. Pair one machine and archiving carries on by itself.</p><div id="pair-home-first"></div></div><div class="notice soft" id="a-verify" data-account-section="overview account" style="display:none"><p><strong>Confirm your email.</strong> It is how we reach you if an archive fails to store. Nothing else is sent to it.</p><button class="primary" id="a-verify-go" type="button">Send the link</button><span class="saved" id="a-verify-msg" role="status" aria-live="polite"></span></div><div class="card" id="c-recovery" data-account-section="overview machines"><span id="anchor-recovery"></span><span class="eyebrow">Recovery key</span><h2 id="e-head">Loading…</h2><p id="e-text"></p><div class="card-remedy" id="e-actions" hidden><button class="primary" id="e-fix" type="button">Open creation steps</button><button class="ghost" id="e-retry" type="button" hidden>Try again</button><span class="msg" id="e-action-msg" role="status" aria-live="polite">Recovery material is created only in a local SealKeep dashboard.</span></div></div><div class="card" id="anchor-storage" data-account-section="overview storage"><span class="eyebrow">Storage</span><div id="a-store-free" style="display:none"><h2>You run the bucket</h2><p>Archives go to storage you own, under your own credentials. Sealkeep never holds them, so there is nothing to meter here.</p><p class="empty">Your machine reports what it has stored. Run <code>sealkeep status</code> there, or open its dashboard with <code>sealkeep ui</code>.</p></div><div id="a-store-paid" style="display:none"><h2>What you keep here</h2><div class="kv"><div class="lead"><span class="k">Stored</span><b id="a-used">—</b><span class="cap" id="a-used-cap">sealed before it arrived</span></div><div><span class="k">Included</span><b id="a-quota-b">—</b><span class="cap">in your plan</span></div><div><span class="k">Archives</span><b id="a-count">—</b><span class="cap">chunk folders count as one</span></div><div><span class="k">Machines</span><b id="a-machines">—</b><span class="cap">signing uploads</span></div><div><span class="k">Destinations</span><b id="a-dest">—</b><span class="cap">where new seals go</span></div></div><div class="bar"><i id="a-bar"></i></div><p class="msg" id="a-quota"></p><div class="card-remedy" id="a-quota-actions" hidden><button class="primary" id="a-quota-fix" type="button">Review storage options</button><span class="msg">Choose more managed capacity or route new archives to storage you own.</span></div></div></div><div class="card" id="c-targets" data-account-section="storage"><span id="anchor-routing"></span><span class="eyebrow">Routing</span><h2>Where new archives go</h2><p>Rules live on your account — every machine of the vault follows the same ones.</p><ul class="ruleslist"><li><b>The first available destination receives new archives.</b></li><li><b>A project pin overrides the ordinary order.</b></li><li><b>Full or capped targets pass to the next in the order.</b></li></ul><p class="msg" id="tg-msg">Loading…</p><div id="tg-wrap" style="display:none;overflow-x:auto"><table><thead><tr><th>Target</th><th>Where</th><th>Order</th><th>Cap (GB)</th><th>Project routing</th><th></th></tr></thead><tbody id="tg-body"></tbody></table></div><div class="inline" style="margin-top:.9rem"><button class="ghost" id="tg-add-drive" type="button">Add Google Drive</button><button class="ghost" id="tg-add-bucket" type="button">Add my own bucket</button><button class="primary" id="tg-save" type="button">Save routing</button><span class="saved" id="tg-save-msg" role="status" aria-live="polite"></span></div><p class="why" id="tg-note">These rules choose storage; they do not grant project access. Project and folder identities are detected on each Sealkeep machine and never inferred from a typed name here. Use the local dashboard's project picker to pin a stable project identity to a destination. Open <b>Projects</b> here only to manage a shared memory pool and its members.</p><p class="why">Google Drive is a personal-vault destination, not the shared-project memory plane. It holds whole archive objects and skips streaming; big under-pressure seals route to cloud or a bucket on their own. Shared team sessions always keep their managed project copy in Sealkeep Cloud. Adding Drive here sets the personal routing rule — the one-time consent happens on each machine.</p></div><div class="card" id="anchor-machines" data-account-section="machines"><span class="eyebrow">Machines</span><h2>Connected machines</h2><p>Each connected machine signs its own uploads. Revoking one stops it writing here; the archives it already stored are untouched.</p><div id="pair-top"></div><ul class="lines" id="d-list"></ul><div id="d-retired"></div><p class="empty" id="d-msg">Loading…</p><!-- One pairing block exists on the page. It leads the panel while no
34
+ machine is connected, and JS moves it back into this slot once one
35
+ is, so the same code is never printed in two places. --><div id="pair-home-more"><div class="pair" id="pair"><p class="side" id="pair-side" style="display:none">Pairing code</p><!-- Grouped and labelled, so the copy button is announced as
36
+ belonging to the code rather than as a bare "Copy". --><div class="pair-field" id="pair-shown" role="group" aria-labelledby="pair-side" style="display:none"><code class="pair-code" id="pair-code"></code><button class="primary" id="pair-copy" type="button">Copy</button></div><p class="fine" id="pair-note"></p><div class="inline" id="pair-ask"><button class="ghost" id="pair-go" type="button">Show a pairing code</button><span class="saved" id="pair-msg" role="status" aria-live="polite"></span></div><details class="alt" id="pair-alt" style="display:none"><summary>Set the machine up from the terminal instead</summary><p class="empty">The same code, typed as one command. Needs Node 22 or newer.</p><div class="pair-field"><pre id="pair-cli"></pre>
37
+ <button class="ghost" id="pair-cli-copy" type="button">Copy</button></div></details></div></div></div><div class="card projects-card" id="anchor-team" data-account-section="projects"><div class="projects-top"><div><span class="eyebrow">Project memory</span><h2>Your projects and the people inside them</h2><p>Create a private memory pool, then invite only the people who should read, write, and search that project.</p></div><div class="projects-privacy">Keys stay on your machines</div></div><div class="invite-inbox" id="team-incoming" aria-live="polite"></div><div class="projects-layout" id="projects-layout"><aside class="project-rail" aria-label="Projects"><div class="project-rail-head"><span>Projects</span><button class="project-add" id="team-project-new" type="button">+ New project</button></div><ul class="project-list" id="team-project-list"></ul><p class="project-empty-rail" id="team-project-empty">Loading your projects…</p></aside><main class="project-main" id="team-project-detail"></main><aside class="team-drawer" id="team-invite-drawer" hidden><div class="drawer-head"><div><h3>Invite a teammate</h3><p id="team-drawer-project">Project access</p></div><button class="drawer-close" id="team-drawer-close" type="button" aria-label="Close invitation panel">×</button></div><label for="team-invite-email">Their SealKeep account email</label><input id="team-invite-email" type="email" autocomplete="email" placeholder="ann@company.com"><button class="primary" id="team-invite-send" type="button">Send invitation</button><p class="drawer-note">They receive access only after accepting. Your connected machine prepares their encrypted project key automatically.</p><p class="msg" id="team-invite-msg" role="status" aria-live="polite"></p></aside></div></div><dialog class="project-dialog" id="team-project-dialog"><form method="dialog" id="team-project-form"><h3>Create a project</h3><p>Name it now. It stays private until you invite someone.</p><label for="team-project-name">Project name</label><input id="team-project-name" maxlength="120" autocomplete="off" placeholder="Checkout platform"><p class="msg" id="team-project-msg" role="status" aria-live="polite"></p><div class="dialog-actions"><button class="ghost" value="cancel" type="button" id="team-project-cancel">Cancel</button><button class="primary" value="default" type="submit" id="team-project-create">Create project</button></div></form></dialog><dialog class="project-dialog local-task-dialog" id="local-task-dialog"><form method="dialog"><span class="eyebrow">On your machine</span><h3 id="local-task-title">Continue in the local SealKeep dashboard</h3><p id="local-task-copy"></p><div class="local-task-place"><strong id="local-task-place-title">Where to go</strong><p id="local-task-place-copy"></p></div><a id="local-task-open" class="primary" hidden target="_blank" rel="noopener noreferrer">Open this computer’s SealKeep</a><p style="margin-top:.8rem">If this account opened from your local dashboard, return to that tab—or use Back if it replaced the page. The browser already knows its exact port.</p><details class="local-task-fallback"><summary>No SealKeep window open?</summary><p>Run this on that computer. SealKeep reuses its running dashboard or opens the correct fallback port automatically.</p><div class="local-task-command"><code id="local-task-command">sealkeep ui</code><button class="ghost" id="local-task-copy-command" type="button">Copy</button></div></details><div class="dialog-actions"><button class="primary" value="close" type="submit">Done</button></div></form></dialog><div class="card" id="anchor-archives" data-account-section="storage" style="display:none"><span class="eyebrow">Archives</span><h2>What is stored</h2><p id="s-intro">Names live on your machines — your storage holds sealed archives it cannot read, listed here by reference. Deleting one removes the stored copy; if your machine already reclaimed the source, this is the last copy.</p><p class="msg" id="s-msg">Loading…</p><div id="s-wrap" style="display:none;overflow-x:auto"><table><thead><tr><th>Archive</th><th>Size</th><th>State</th><th>Added</th><th></th></tr></thead><tbody id="s-body"></tbody></table><p class="msg" id="s-foot"></p></div></div><div class="card" id="a-pack-card" data-account-section="account"><span id="anchor-plan"></span><span class="eyebrow">Your package</span><h2 id="a-pack-current">—</h2><p class="msg" id="a-pack-context" style="margin:0 0 .8rem"></p><div class="packrow" id="a-packs"></div><p class="empty" id="a-plan-free-note"></p><p class="empty" id="a-pack-alt">Not storing with us? Your own bucket is free with the same encryption, and Pro Lite keeps key escrow and machine sign-in with no metered storage.</p><div class="kv" id="a-sub-facts" style="margin-top:1rem"><div><span class="k">Subscription</span><b id="a-sub-state">—</b></div><div><span class="k">Renews</span><b id="a-sub-end">—</b></div><div><span class="k">Billed through</span><b id="a-sub-prov">—</b></div></div><p class="empty" id="a-sub-note"></p><div class="card-remedy" id="a-sub-actions" hidden><a class="primary" href="mailto:hello@spala.ai?subject=SealKeep%20subscription%20support">Fix subscription</a><span class="msg">This opens a message from the account email so support can restore uploads.</span></div><p class="why" id="a-pack-note">Changing your package is handled by hand today. Email hello@spala.ai from this address and we will move you within one business day — your archives keep uploading throughout.</p></div><div class="card" id="account-upload-rules" data-account-section="account"><span class="eyebrow">What gets uploaded</span><h2>Your upload rules</h2><p class="why" style="margin:.1rem 0 .8rem">Saved to your account — every machine you connect follows these, the same way routing rules do.</p><p>Everything you do not choose here stays local and is never uploaded.</p><p class="empty" id="r-note" style="margin-bottom:.9rem">Enforced on your machine, before a session is read or sealed. Anything held back is never uploaded, so this is a rule about what leaves — not a filter we apply after it arrives.</p><div class="rules"><label class="choice" data-on="yes"><input type="radio" name="sync" value="all" checked><span><span class="t">Every session</span><span class="d">Archive all of it. The safest choice, and the one that uses the most storage.</span></span></label><label class="choice" data-on="no"><input type="radio" name="sync" value="recent"><span><span class="t">Only the most recent</span><span class="d">Keep a rolling window. Older sessions stay local and are never uploaded.</span><input class="n" id="r-last" type="number" min="1" max="999" value="20" aria-label="How many recent sessions"><span class="d" style="display:inline">sessions</span></span></label><label class="choice" data-on="no"><input type="radio" name="sync" value="settled"><span><span class="t">Only once they have settled</span><span class="d">Wait before uploading, so work you are still iterating on stays put.</span><input class="n" id="r-age" type="number" min="1" max="365" value="7" aria-label="Days before a session is uploaded"><span class="d" style="display:inline">days old</span></span></label><button class="primary" id="r-save" type="button" style="margin-top:1.1rem">Save rules</button><span class="saved" id="r-msg" role="status" aria-live="polite"></span></div></div><div class="card" id="account-activity" data-account-section="account"><span class="eyebrow">Activity</span><h2>What happened lately</h2><p>Every change made to this account, newest first.</p><ul class="lines" id="v-list"></ul><p class="empty" id="v-msg">Loading…</p></div><div class="card" id="account-profile" data-account-section="account"><span class="eyebrow">Account</span><h2>Name and email</h2><label for="a-name">Account name</label><div class="inline"><input id="a-name" maxlength="60" autocomplete="off"><button class="ghost" id="a-rename" type="button">Save name</button><span class="saved" id="a-rename-msg" role="status" aria-live="polite"></span></div><!-- The setup commands used to be printed here as well as in the
38
+ first-run card. There is now one place to connect a machine, and it
39
+ is the pairing block above. --><p class="empty" style="margin-top:1.4rem">Your keys are made on your machine, so this page holds nothing that opens an archive. Signing out here does not stop a connected machine from archiving.</p><button class="ghost" id="a-out" style="margin-top:1rem">Sign out</button></div></div></div></section><footer><div class="wrap" style="display:flex;gap:1.5rem;flex-wrap:wrap;align-items:center"><span class="logo">⌑ Sealkeep</span><code style="font-size:.78rem;color:var(--soft)">npm install -g sealkeep</code><span style="flex:1"></span><span class="r" style="margin:0">unaudited encryption format · stated plainly</span></div><div class="wrap" style="padding-top:1rem"><p style="font-size:.9rem;line-height:1.6;color:var(--soft)">Sealkeep is a product of HM Software LLC, the company behind Spala AI.</p><nav aria-label="Company" style="display:flex;gap:1.25rem;flex-wrap:wrap;margin-top:.75rem;font-size:.85rem"><a href="https://spala.ai/about/">About the company</a><a href="https://spala.ai/terms/">Company terms</a><a href="https://spala.ai/privacy/">Company privacy policy</a></nav></div></footer><script>
40
+ if (location.hostname === "shared.spala.ai" && location.pathname.startsWith("/p04946") && !location.pathname.startsWith('/p04946/api/__internal/frontend-hosting/uploads/')) {
41
+ location.replace(`https://sealkeep.spala.ai/${location.search}${location.hash}`);
42
+ }
43
+ const $ = (id) => document.getElementById(id);
44
+ const API = location.hostname === "shared.spala.ai"
45
+ ? "/p04946/api"
46
+ : "https://shared.spala.ai/p04946/api";
47
+ const TOKEN = "sealkeep.token";
48
+ const OWNER_KEY = "sealkeep.ownerKey";
49
+ const CONNECT_REQUEST = "sealkeep.connectRequest";
50
+ const connectHash = location.hash.startsWith("#connect=")
51
+ ? decodeURIComponent(location.hash.slice("#connect=".length))
52
+ : "";
53
+ if (/^[A-Za-z0-9._~-]{1,200}$/.test(connectHash)) sessionStorage.setItem(CONNECT_REQUEST, connectHash);
54
+ const pendingConnectRequest = () => sessionStorage.getItem(CONNECT_REQUEST) || "";
55
+ const keepOwnerKey = (key) => { if (key) sessionStorage.setItem(OWNER_KEY, key); };
56
+ const motionOK = () => !matchMedia("(prefers-reduced-motion: reduce)").matches;
57
+ (function heroStories() {
58
+ const frame = $("hero-story");
59
+ const title = $("hero-story-title");
60
+ const indexLabel = $("hero-story-index");
61
+ const controls = [...document.querySelectorAll(".hero-demo-progress button")];
62
+ if (!frame || !title || !indexLabel) return;
63
+ const stories = [
64
+ { mode: "memory", title: "Preserve your agent memories", hash: "#agent-memory" },
65
+ { mode: "continue", title: "Continue your work anywhere", hash: "#continue-anywhere" },
66
+ { mode: "team", title: "Team cooperation", hash: "#team-cooperation" }
67
+ ];
68
+ let current = 0;
69
+ let load = 0;
70
+ const fit = () => {
71
+ const available = frame.parentElement.clientWidth;
72
+ if (available <= 760) {
73
+ const height = stories[current].mode === "team" ? 1280 : 1020;
74
+ frame.style.width = "100%";
75
+ frame.style.height = `${height}px`;
76
+ frame.style.transform = "none";
77
+ frame.parentElement.style.height = `${height}px`;
78
+ return;
79
+ }
80
+ frame.style.width = "100%";
81
+ frame.style.height = "560px";
82
+ frame.style.transform = "none";
83
+ frame.parentElement.style.height = "560px";
84
+ };
85
+ const show = (next) => {
86
+ current = (next + stories.length) % stories.length;
87
+ const story = stories[current];
88
+ title.textContent = story.title;
89
+ indexLabel.textContent = `${String(current + 1).padStart(2, "0")} / 03`;
90
+ frame.title = story.title;
91
+ controls.forEach((button, index) => button.setAttribute("aria-current", String(index === current)));
92
+ frame.src = `visual/index.html?embed=1&cycle=${++load}${story.hash}`;
93
+ fit();
94
+ };
95
+ controls.forEach((button, index) => button.addEventListener("click", () => show(index)));
96
+ new ResizeObserver(fit).observe(frame.parentElement);
97
+ fit();
98
+ window.addEventListener("message", (event) => {
99
+ if (event.source !== frame.contentWindow || event.data?.type !== "sealkeep-story-complete"
100
+ || event.data.mode !== stories[current].mode) return;
101
+ show(current + 1);
102
+ });
103
+ })();
104
+ const params = new URLSearchParams(location.search);
105
+ const authCode = params.get("code");
106
+ const authStates = params.getAll("state");
107
+ const authState = authStates.length === 0 ? null : authStates.length === 1 ? authStates[0] : "";
108
+ const confirmToken = params.get("confirm");
109
+ const confirmAccount = params.get("account");
110
+ const PROJECT_DRAFT_NAME = "sealkeep.projectDraftName";
111
+ const GOOGLE_OAUTH_STATE = "sealkeep.googleOauthState";
112
+ const linkedProjectDraft = (params.get("newProject") || "").trim().slice(0, 120);
113
+ if (linkedProjectDraft) sessionStorage.setItem(PROJECT_DRAFT_NAME, linkedProjectDraft);
114
+ const savedToken = sessionStorage.getItem(TOKEN);
115
+ const returning = Boolean(authCode) || sessionStorage.getItem("sealkeep.returning") === "1";
116
+ const resuming = Boolean(authCode || (confirmToken && confirmAccount) || savedToken);
117
+ if (resuming) {
118
+ if ("scrollRestoration" in history) history.scrollRestoration = "manual";
119
+ document.body.classList.add("booting");
120
+ }
121
+ const bootSay = (text) => { $("boot-note").textContent = text; };
122
+ function enterPanel() {
123
+ window.scrollTo({ top: 0, behavior: "instant" });
124
+ $("a-strip").focus({ preventScroll: true });
125
+ }
126
+ function bootExit() {
127
+ document.body.classList.remove("booting");
128
+ if (document.body.classList.contains("panel")) enterPanel();
129
+ }
130
+ function bootDone(confirmed) {
131
+ if (!document.body.classList.contains("booting")) return Promise.resolve();
132
+ if (!confirmed) { bootExit(); return Promise.resolve(); }
133
+ bootSay("Signed in");
134
+ $("boot-track").dataset.done = "yes";
135
+ return new Promise((finished) => setTimeout(() => { bootExit(); finished(); }, motionOK() ? 460 : 0));
136
+ }
137
+ (function seal() {
138
+ const el = $("cipher");
139
+ const glyphs = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz0123456789+/";
140
+ const target = "vGx8Kq2mNbZ4tRw9YpLd3cHfA7sJeU1oQiT6vXn0MyBkR5aWzDgP4uCjE8hSlF2N"
141
+ + "kR9wQ7pAzXm3TbYvL6dHcJ1fUsN4oiGeMy0BW5aKgPjD8uCE2hSlFqT6vXn0Myx";
142
+ if (matchMedia("(prefers-reduced-motion: reduce)").matches) { el.textContent = target; return; }
143
+ let frame = 0;
144
+ const total = 26;
145
+ const tick = () => {
146
+ const settled = Math.floor((frame / total) * target.length);
147
+ let out = target.slice(0, settled);
148
+ for (let i = settled; i < target.length; i++) out += glyphs[(Math.random() * glyphs.length) | 0];
149
+ el.textContent = out;
150
+ if (frame++ < total) requestAnimationFrame(tick);
151
+ else el.textContent = target;
152
+ };
153
+ requestAnimationFrame(tick);
154
+ })();
155
+ function copies(buttonId, read) {
156
+ const button = $(buttonId);
157
+ button.addEventListener("click", async () => {
158
+ const label = button.dataset.label || button.textContent;
159
+ button.dataset.label = label;
160
+ try { await navigator.clipboard.writeText(read()); button.textContent = "Copied"; }
161
+ catch { button.textContent = "Copy failed"; }
162
+ setTimeout(() => { button.textContent = label; }, 1600);
163
+ });
164
+ }
165
+ copies("copy", () => $("cmd").textContent.trim());
166
+ copies("pair-copy", () => $("pair-code").textContent.trim());
167
+ copies("pair-cli-copy", () => $("pair-cli").textContent.trim());
168
+ copies("local-task-copy-command", () => $("local-task-command").textContent.trim());
169
+ function localTaskContent(kind, subject = "") {
170
+ if (kind === "recovery") return {
171
+ title: "Create the Recovery Kit locally",
172
+ copy: "Use a computer that already holds this vault key. This account page cannot receive the key or discover another computer's private dashboard address. If you opened it from your local dashboard, the button below returns there.",
173
+ place: "In the local SealKeep dashboard, open Settings. Under Backup Recovery Kit, choose Create Recovery Kit."
174
+ };
175
+ if (kind === "folders") return {
176
+ title: subject ? `Choose folders for ${subject}` : "Choose this project's folders",
177
+ copy: "Folder paths stay on that computer. They are not sent to this account page, and each teammate chooses their own paths.",
178
+ place: `In the local SealKeep dashboard, open Shared. Under Connected project memory, find ${subject || "the project"}, choose one or more work folders, then Save folder mapping.`
179
+ };
180
+ if (kind === "project-routing") return {
181
+ title: subject ? `Choose projects for ${subject}` : "Choose projects for this destination",
182
+ copy: "Project routing uses the stable identity detected from each checkout—not a typed display name. Those identities stay on your SealKeep machines, so this account page cannot safely create or change a project pin.",
183
+ place: `In the local SealKeep dashboard, open Settings. Under Storage, find ${subject || "the destination"}, choose + pin project, then select the detected project. SealKeep saves the stable project identity to your account so every machine follows the same route.`
184
+ };
185
+ if (kind === "project") return {
186
+ title: "Let a SealKeep machine finish the project",
187
+ copy: "The project name is already queued. An owner machine that holds this vault key creates and encrypts the project key automatically; the cloud cannot create or open it.",
188
+ place: "Leave SealKeep running on an owner's computer. You can close this page—the project appears here after that machine checks in."
189
+ };
190
+ return {
191
+ title: subject ? `Connect ${subject} locally` : "Connect storage locally",
192
+ copy: "Provider credentials stay on each computer and are never entered into this account page.",
193
+ place: `In the local SealKeep dashboard, open Settings, find ${subject || "the destination"}, then connect or test it on that machine.`
194
+ };
195
+ }
196
+ function rememberLocalDashboardReturn() {
197
+ try {
198
+ const incoming = new URL(location.href).searchParams.get('local_return');
199
+ const remembered = incoming || sessionStorage.getItem('sealkeep.localReturn');
200
+ if (!remembered) return null;
201
+ const local = new URL(remembered);
202
+ if (local.protocol !== 'http:' || !['127.0.0.1','localhost','[::1]'].includes(local.hostname) || local.username || local.password || !['/','/index.html'].includes(local.pathname) || local.search || local.hash) return null;
203
+ sessionStorage.setItem('sealkeep.localReturn', local.href);
204
+ return local;
205
+ } catch { return null; }
206
+ }
207
+ rememberLocalDashboardReturn();
208
+ function openLocalTask(kind, subject = "", projectKey = "") {
209
+ const task = localTaskContent(kind, subject);
210
+ $("local-task-title").textContent = task.title;
211
+ $("local-task-copy").textContent = (/Android|iPhone|iPad|iPod|Mobile/i.test(navigator.userAgent) ? "Continue on a connected computer. " : "") + task.copy;
212
+ $("local-task-place-copy").textContent = task.place;
213
+ const localLink = $("local-task-open");
214
+ localLink.hidden = true;
215
+ const local = rememberLocalDashboardReturn();
216
+ if (local) {
217
+ local.hash = kind === 'recovery' ? 'recovery-kit' : ['folders','project'].includes(kind) ? 'shared' : 'storage-targets';
218
+ if (kind === 'folders' && projectKey) local.hash = `shared?${new URLSearchParams({ project: projectKey })}`;
219
+ localLink.href = local.href; localLink.hidden = false;
220
+ }
221
+ $("local-task-dialog").showModal();
222
+ }
223
+ $("e-fix").addEventListener("click", () => openLocalTask("recovery"));
224
+ $("a-quota-fix").addEventListener("click", () => showAccountView("account"));
225
+ const startTabs = { free: [$("t-free"), $("p-free")], paid: [$("t-paid"), $("p-paid")] };
226
+ for (const [key, [tab]] of Object.entries(startTabs))
227
+ tab.addEventListener("click", () => {
228
+ for (const [other, [b, pane]] of Object.entries(startTabs)) {
229
+ b.setAttribute("aria-selected", String(other === key));
230
+ pane.classList.toggle("on", other === key);
231
+ }
232
+ });
233
+ const say = (el, text, cls) => { const n = $(el); n.textContent = text; n.className = "msg " + (cls || ""); };
234
+ const errText = (error) => {
235
+ let text = String((error && error.message) || error || "Something went wrong");
236
+ text = text.replace(/^\s*Error at step\s+[^:]{0,60}:\s*/i, "");
237
+ text = text.replace(/^\s*(Error|Exception|Failure)\s*:\s*/i, "");
238
+ if (text) text = text.charAt(0).toUpperCase() + text.slice(1);
239
+ return text.slice(0, 200);
240
+ };
241
+ const bytes = (n) => {
242
+ n = Number(n) || 0;
243
+ for (const [u, d] of [["GB", 1024 ** 3], ["MB", 1024 ** 2], ["KB", 1024]])
244
+ if (n >= d) return (n / d).toFixed(1) + " " + u;
245
+ return n + " B";
246
+ };
247
+ const ago = (iso) => {
248
+ const s = (Date.now() - new Date(iso).getTime()) / 1000;
249
+ if (!isFinite(s)) return "—";
250
+ for (const [n, u] of [[86400 * 30, "mo"], [86400, "d"], [3600, "h"], [60, "m"]])
251
+ if (s >= n) return Math.floor(s / n) + u + " ago";
252
+ return "just now";
253
+ };
254
+ async function api(path, { method = "GET", body, token } = {}) {
255
+ const headers = {};
256
+ if (body) headers["content-type"] = "application/json";
257
+ if (token) headers.authorization = "Bearer " + token;
258
+ let res;
259
+ try {
260
+ res = await fetch(API + path, { method, headers, body: body && JSON.stringify(body) });
261
+ } catch {
262
+ throw new Error("No answer from the service. Check your connection and try again.");
263
+ }
264
+ let data = null;
265
+ let parsed = true;
266
+ try { data = await res.json(); } catch { parsed = false; }
267
+ if (!res.ok) {
268
+ const message = parsed
269
+ ? String(data?.error || `${res.status} ${res.statusText}`)
270
+ : `The service did not answer (HTTP ${res.status}). Try again in a minute.`;
271
+ const error = new Error(message.slice(0, 200));
272
+ error.status = res.status;
273
+ throw error;
274
+ }
275
+ return data;
276
+ }
277
+ function showPane(which) {
278
+ const wasPanel = document.body.classList.contains("panel");
279
+ for (const [id, on] of [["p-signup", which === "signup"], ["p-signin", which === "signin"], ["p-account", which === "account"]])
280
+ $(id).classList.toggle("on", on);
281
+ document.body.classList.toggle("panel", which === "account");
282
+ $("auth-tabs").style.display = which === "account" ? "none" : "";
283
+ for (const [b, key] of [["t-signup", "signup"], ["t-signin", "signin"]])
284
+ $(b).setAttribute("aria-selected", String(which === key));
285
+ if (which === "account" && !wasPanel && !document.body.classList.contains("booting")) enterPanel();
286
+ }
287
+ async function loadDeviceConnect(token) {
288
+ const requestId = pendingConnectRequest();
289
+ if (!requestId) return;
290
+ on("connect-auth-intro", false);
291
+ on("device-connect", true);
292
+ on("device-connect-actions", false);
293
+ $("device-connect-title").textContent = "Review this request";
294
+ $("device-connect-copy").textContent = "Loading the machine details…";
295
+ $("device-connect-msg").textContent = "";
296
+ try {
297
+ const request = await api(`/v1/cloud/device-authorizations/${encodeURIComponent(requestId)}`, { token });
298
+ const agents = Array.isArray(request.agents) ? request.agents.filter((value) => typeof value === "string") : [];
299
+ const os = { macos: "macOS", linux: "Linux", windows: "Windows" }[request.os_family] || "device";
300
+ $("device-connect-title").textContent = request.label || "Unnamed machine";
301
+ $("device-connect-copy").textContent = `${os}${agents.length ? " · " + agents.join(" + ") : ""} wants to connect to this Sealkeep account.`;
302
+ if (request.state === "pending") {
303
+ on("device-connect-actions", true);
304
+ const expiry = request.expires_at && Number.isFinite(Date.parse(request.expires_at))
305
+ ? new Date(request.expires_at).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
306
+ : "";
307
+ $("device-connect-msg").textContent = expiry ? `Waiting for your decision · expires at ${expiry}` : "Waiting for your decision";
308
+ return;
309
+ }
310
+ sessionStorage.removeItem(CONNECT_REQUEST);
311
+ history.replaceState(null, "", location.pathname + location.search + "#account");
312
+ if (request.state === "approved") $("device-connect-msg").textContent = "Approved. The machine is finishing securely now.";
313
+ else if (request.state === "claimed") $("device-connect-msg").textContent = "Connected. You can close this page.";
314
+ else if (request.state === "denied") $("device-connect-msg").textContent = "Denied. This machine received no account access.";
315
+ else $("device-connect-msg").textContent = "This request expired. Start again from the machine.";
316
+ } catch (error) {
317
+ $("device-connect-copy").textContent = "This request could not be opened.";
318
+ $("device-connect-msg").textContent = errText(error);
319
+ }
320
+ }
321
+ async function decideDeviceConnect(decision) {
322
+ const requestId = pendingConnectRequest();
323
+ const token = sessionStorage.getItem(TOKEN);
324
+ if (!requestId || !token) return;
325
+ const approve = $("device-connect-approve"), deny = $("device-connect-deny");
326
+ approve.disabled = true; deny.disabled = true;
327
+ $("device-connect-msg").textContent = decision === "approve" ? "Approving…" : "Denying…";
328
+ try {
329
+ await api(`/v1/cloud/device-authorizations/${encodeURIComponent(requestId)}/${decision}`, { method: "POST", token });
330
+ on("device-connect-actions", false);
331
+ sessionStorage.removeItem(CONNECT_REQUEST);
332
+ history.replaceState(null, "", location.pathname + location.search + "#account");
333
+ $("device-connect-msg").textContent = decision === "approve"
334
+ ? "Approved. The machine is finishing securely now."
335
+ : "Denied. This machine received no account access.";
336
+ } catch (error) {
337
+ $("device-connect-msg").textContent = errText(error);
338
+ approve.disabled = false; deny.disabled = false;
339
+ }
340
+ }
341
+ $("device-connect-approve").addEventListener("click", () => decideDeviceConnect("approve"));
342
+ $("device-connect-deny").addEventListener("click", () => decideDeviceConnect("deny"));
343
+ function goTo(el) {
344
+ if (!el) return;
345
+ const start = window.scrollY;
346
+ const nav = document.querySelector(".acct-nav");
347
+ const clearance = (nav ? nav.getBoundingClientRect().height : 0) + 12;
348
+ const target = Math.max(0, el.getBoundingClientRect().top + start - clearance);
349
+ const smooth = motionOK();
350
+ window.scrollTo({ top: target, behavior: smooth ? "smooth" : "instant" });
351
+ if (!smooth) return;
352
+ setTimeout(() => {
353
+ if (Math.abs(window.scrollY - start) < 4 && Math.abs(target - start) > 24) {
354
+ window.scrollTo({ top: target, behavior: "instant" });
355
+ }
356
+ }, 320);
357
+ }
358
+ const ACCOUNT_VIEWS = new Set(["overview", "projects", "storage", "machines", "account"]);
359
+ let activeAccountView = "overview";
360
+ const requestedAccountView = () => {
361
+ const wanted = new URLSearchParams(location.search).get("view") || sessionStorage.getItem("sealkeep.returnView") || "overview";
362
+ return ACCOUNT_VIEWS.has(wanted) ? wanted : "overview";
363
+ };
364
+ function showAccountView(view, { writeHistory = true } = {}) {
365
+ const next = ACCOUNT_VIEWS.has(view) ? view : "overview";
366
+ activeAccountView = next;
367
+ for (const section of document.querySelectorAll("[data-account-section]")) {
368
+ section.hidden = !section.dataset.accountSection.split(/\s+/).includes(next);
369
+ }
370
+ for (const link of document.querySelectorAll(".acct-nav [data-account-view]")) {
371
+ link.setAttribute("aria-current", link.dataset.accountView === next ? "page" : "false");
372
+ }
373
+ if (writeHistory) history.replaceState(null, "", `${location.pathname}?view=${encodeURIComponent(next)}#account`);
374
+ window.scrollTo({ top: 0, behavior: "instant" });
375
+ }
376
+ for (const link of document.querySelectorAll(".acct-nav [data-account-view]")) {
377
+ link.addEventListener("click", (event) => {
378
+ event.preventDefault();
379
+ showAccountView(link.dataset.accountView);
380
+ });
381
+ }
382
+ function goAccount() {
383
+ if (document.body.classList.contains("panel")) { window.scrollTo(0, 0); return; }
384
+ $("account").scrollIntoView({ behavior: motionOK() ? "smooth" : "auto", block: "start" });
385
+ }
386
+ for (const link of document.querySelectorAll('a[href="#account"]'))
387
+ link.addEventListener("click", (event) => {
388
+ event.preventDefault();
389
+ if (location.hash !== "#account") location.hash = "account";
390
+ else goAccount();
391
+ });
392
+ addEventListener("hashchange", () => { if (location.hash === "#account") goAccount(); });
393
+ const on = (id, show) => { $(id).style.display = show ? "" : "none"; };
394
+ const day = (iso) => {
395
+ const d = new Date(iso);
396
+ return isFinite(d.getTime())
397
+ ? d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })
398
+ : "—";
399
+ };
400
+ function renderAccount(user) {
401
+ const paid = user.plan === "pro" || user.plan === "team";
402
+ $("a-label").textContent = user.label || "Your vault";
403
+ $("a-name").value = user.label || "";
404
+ const chipPack = PACKAGES.find((p) => p.id === packageIdFor(user));
405
+ $("a-plan").textContent = chipPack ? chipPack.name : "Custom";
406
+ $("a-plan").className = "chip" + (paid || user.plan === "pro_lite" ? " pro" : "");
407
+ $("a-plan").onclick = () => showAccountView("account");
408
+ $("a-who").textContent = [
409
+ user.email,
410
+ "joined " + day(user.created_at),
411
+ user.auth_provider === "google" ? "signed in with Google" : null,
412
+ user.state === "suspended" ? "SUSPENDED" : null
413
+ ].filter(Boolean).join(" · ");
414
+ on("a-verify", !user.email_verified_at);
415
+ lastPaid = paid;
416
+ applyStorageView();
417
+ on("anchor-archives", paid);
418
+ $("a-plan-free-note").textContent = paid
419
+ ? ""
420
+ : "You are on your own storage: unlimited archives, same encryption, your provider's bill. Managed storage means we run the bucket and meter what you store — we still hold no key that opens any of it.";
421
+ on("a-sub-facts", paid);
422
+ on("a-plan-free-note", !paid);
423
+ if (paid) {
424
+ const quota = Number(user.quota_bytes) || 0, used = Number(user.used_bytes) || 0;
425
+ $("a-used").textContent = bytes(used);
426
+ $("a-quota-b").textContent = quota ? bytes(quota) : "—";
427
+ $("a-bar").style.width = quota ? Math.min(100, (used / quota) * 100) + "%" : "0%";
428
+ $("a-quota").textContent = quota
429
+ ? bytes(used) + " of " + bytes(quota) + " used" + (used >= quota * 0.9 ? " — close to the limit" : "")
430
+ : "";
431
+ $("a-quota-actions").hidden = !(quota && used >= quota * 0.9);
432
+ const sub = user.subscription;
433
+ $("a-sub-state").textContent = sub ? sub.state : "not recorded";
434
+ $("a-sub-end").textContent = sub && sub.current_period_end ? day(sub.current_period_end) : "—";
435
+ $("a-sub-prov").textContent = sub && sub.provider ? sub.provider : "—";
436
+ $("a-sub-note").textContent = sub && sub.state === "active"
437
+ ? "To change or cancel, write to hello@spala.ai from this address."
438
+ : "We cannot see an active subscription for this account, so uploads are paused. This is ours to fix. Email hello@spala.ai from this address and we will sort it out — nothing already stored is affected, and your machine keeps sealing sessions locally in the meantime.";
439
+ $("a-sub-actions").hidden = Boolean(sub && sub.state === "active");
440
+ } else {
441
+ $("a-quota-actions").hidden = true;
442
+ $("a-sub-actions").hidden = true;
443
+ }
444
+ rulesFrom(user);
445
+ packsFrom(user);
446
+ showPane("account");
447
+ }
448
+ const ACTIONS = {
449
+ "sync_rules.update": "Changed what leaves your machine",
450
+ "device.revoke": "Revoked a machine",
451
+ "email.confirmation_sent": "Sent an email confirmation link",
452
+ "email.confirmed": "Confirmed the email address",
453
+ "auth.google": "Signed in with Google"
454
+ };
455
+ async function loadDevices(token) {
456
+ const list = $("d-list");
457
+ let deviceCount = 0;
458
+ list.replaceChildren();
459
+ try {
460
+ const { devices } = await api("/v1/cloud/devices", { token });
461
+ deviceCount = devices.length;
462
+ if (teamState.token !== token) return deviceCount;
463
+ const active = devices.filter((d) => d.state !== "revoked");
464
+ teamState.machineCount = active.length;
465
+ teamState.machinesLoaded = true;
466
+ if (teamState.loaded) renderTeamProjects();
467
+ if (!devices.length) {
468
+ $("d-msg").textContent = "No machines connected yet.";
469
+ return deviceCount;
470
+ }
471
+ $("d-msg").textContent = "";
472
+ const retired = devices.filter((d) => d.state === "revoked");
473
+ const foldHost = $("d-retired");
474
+ foldHost.replaceChildren();
475
+ if (retired.length) {
476
+ const fold = document.createElement("details");
477
+ const summary = document.createElement("summary");
478
+ summary.textContent = `${retired.length} revoked machine${retired.length === 1 ? "" : "s"} — kept as the record; existing archives untouched, no new leases`;
479
+ const inner = document.createElement("ul");
480
+ inner.className = "lines";
481
+ for (const d of retired) {
482
+ const li = document.createElement("li");
483
+ const name = document.createElement("span");
484
+ name.className = "grow";
485
+ name.textContent = d.label || "Unnamed machine";
486
+ const when = document.createElement("span");
487
+ when.className = "when";
488
+ when.textContent = "revoked " + ago(d.revoked_at);
489
+ li.append(name, when);
490
+ inner.append(li);
491
+ }
492
+ fold.append(summary, inner);
493
+ foldHost.append(fold);
494
+ }
495
+ for (const d of active) {
496
+ const li = document.createElement("li");
497
+ const name = document.createElement("span");
498
+ name.className = "grow";
499
+ name.textContent = d.label || "Unnamed machine";
500
+ const state = document.createElement("span");
501
+ const revoked = d.state === "revoked";
502
+ state.className = "state " + (revoked ? "pending" : "durable");
503
+ state.textContent = revoked ? "revoked" : "active";
504
+ const seen = document.createElement("span");
505
+ seen.className = "when";
506
+ seen.textContent = revoked ? "revoked " + ago(d.revoked_at) : "enrolled " + ago(d.created_at);
507
+ li.append(name, state, seen);
508
+ if (!revoked) {
509
+ const btn = document.createElement("button");
510
+ btn.className = "del"; btn.type = "button"; btn.textContent = "Revoke";
511
+ btn.addEventListener("click", async () => {
512
+ if (btn.dataset.armed !== "yes") {
513
+ btn.dataset.armed = "yes";
514
+ btn.textContent = "Revoke this machine?";
515
+ btn.title = "It blocks new leases; it does not reach into your bucket. Existing archives are untouched. This machine loses its right to delete now, and its sign-in lapses within 7 days — it cannot renew one without you.";
516
+ setTimeout(() => {
517
+ if (btn.dataset.armed === "yes") { btn.dataset.armed = "no"; btn.textContent = "Revoke"; }
518
+ }, 4000);
519
+ return;
520
+ }
521
+ btn.disabled = true; btn.textContent = "Revoking";
522
+ try {
523
+ await api("/v1/cloud/devices/" + encodeURIComponent(d.id) + "/revoke", { method: "POST", token });
524
+ await loadDevices(token);
525
+ await loadAudit(token);
526
+ } catch (error) {
527
+ $("d-msg").textContent = errText(error);
528
+ btn.disabled = false; btn.textContent = "Revoke";
529
+ }
530
+ });
531
+ li.append(btn);
532
+ }
533
+ list.append(li);
534
+ }
535
+ } catch (error) {
536
+ if (teamState.token !== token) return deviceCount;
537
+ teamState.machinesLoaded = false;
538
+ if (teamState.loaded) renderTeamProjects();
539
+ $("d-msg").textContent = errText(error);
540
+ }
541
+ return deviceCount;
542
+ }
543
+ async function loadAudit(token) {
544
+ const list = $("v-list");
545
+ list.replaceChildren();
546
+ try {
547
+ const { events } = await api("/v1/cloud/audit", { token });
548
+ if (!events.length) { $("v-msg").textContent = "Nothing has changed on this account yet."; return; }
549
+ $("v-msg").textContent = "";
550
+ for (const e of events) {
551
+ const li = document.createElement("li");
552
+ const what = document.createElement("span");
553
+ what.className = "grow";
554
+ what.textContent = ACTIONS[e.action] || e.action;
555
+ const when = document.createElement("span");
556
+ when.className = "when";
557
+ when.textContent = ago(e.at);
558
+ li.append(what, when);
559
+ if (e.outcome === "denied") {
560
+ const bad = document.createElement("span");
561
+ bad.className = "state pending";
562
+ bad.textContent = "did not complete";
563
+ li.append(bad);
564
+ }
565
+ list.append(li);
566
+ }
567
+ } catch (error) { $("v-msg").textContent = errText(error); }
568
+ }
569
+ let lastEscrowToken = "", lastEscrowAtRisk = false, escrowLoadGeneration = 0;
570
+ const recoveryRemedy = (state) => {
571
+ const actionable = state !== "held";
572
+ $("e-actions").hidden = !actionable;
573
+ $("e-fix").hidden = state === "error";
574
+ $("e-retry").hidden = state !== "error";
575
+ $("e-action-msg").textContent = state === "error"
576
+ ? "Retry the cloud status check. Nothing on your machine will be changed."
577
+ : "The key stays local; this shows the exact steps for the machine that holds it.";
578
+ };
579
+ $("e-retry").addEventListener("click", () => loadEscrow(lastEscrowToken, lastEscrowAtRisk));
580
+ async function loadEscrow(token, atRisk) {
581
+ if (!token || sessionStorage.getItem(TOKEN) !== token) return;
582
+ const generation = ++escrowLoadGeneration;
583
+ const accountGeneration = accountLoadGeneration;
584
+ const isCurrent = () => generation === escrowLoadGeneration
585
+ && accountGeneration === accountLoadGeneration && sessionStorage.getItem(TOKEN) === token;
586
+ lastEscrowToken = token; lastEscrowAtRisk = atRisk;
587
+ try {
588
+ const [rows, recoveryKit] = await Promise.all([
589
+ api("/v1/cloud/escrow", { token }),
590
+ api("/v1/cloud/recovery-kit/status", { token })
591
+ ]);
592
+ if (!isCurrent()) return;
593
+ const payload = rows && typeof rows === "object" && !Array.isArray(rows) && "escrow" in rows ? rows.escrow : rows;
594
+ if (payload != null && !Array.isArray(payload) && (typeof payload !== "object" || !("sealed" in payload))) {
595
+ throw new Error("The recovery backup status response was not recognized. Try checking again.");
596
+ }
597
+ const held = (Array.isArray(payload) ? payload : payload ? [payload] : []).filter(row => Boolean(row?.sealed));
598
+ const remaining = recoveryKit?.available ? Number(recoveryKit.remaining || 0) : 0;
599
+ const hasUsableCodes = remaining > 0;
600
+ if (held.length && hasUsableCodes) {
601
+ recoveryRemedy("held");
602
+ $("e-head").textContent = "Recovery Kit held";
603
+ $("e-text").textContent = `Stored ${ago(held[0].created_at)}. ${remaining} unused one-time backup code${remaining === 1 ? " remains" : "s remain"}, plus your permanent 24-word phrase. Every key stays encrypted before it reaches us.`;
604
+ } else if (held.length) {
605
+ recoveryRemedy("missing");
606
+ $("e-head").textContent = recoveryKit?.available ? "Backup codes used up" : "Add backup codes";
607
+ $("e-text").textContent = "A sealed phrase-protected key copy is held, but there is no unused one-time code. Create a Recovery Kit on a connected machine that holds this vault key so a fresh machine can recover after Google sign-in.";
608
+ $("e-fix").textContent = recoveryKit?.available ? "Open replacement steps" : "Open creation steps";
609
+ } else if (atRisk) {
610
+ recoveryRemedy("missing");
611
+ $("e-head").textContent = "No sealed copy yet";
612
+ $("e-text").textContent = "You have archives stored. If every machine holding your key is lost, those archives cannot be opened. Create a Recovery Kit from one of those machines now; SealKeep stores only an encrypted copy it cannot open.";
613
+ $("e-fix").textContent = "Open creation steps";
614
+ } else {
615
+ recoveryRemedy("missing");
616
+ $("e-head").textContent = "Store a sealed copy of your key";
617
+ $("e-text").textContent = "Your key stays on your connected machines. From one of them, generate a Backup Recovery Kit. It creates five one-time codes and stores only ciphertext here; we cannot open it.";
618
+ $("e-fix").textContent = "Open creation steps";
619
+ }
620
+ $("c-recovery").classList.toggle("warncard", !held.length && Boolean(atRisk));
621
+ } catch (error) {
622
+ if (!isCurrent()) return;
623
+ recoveryRemedy("error");
624
+ $("e-head").textContent = "Could not verify recovery backup status";
625
+ $("e-text").textContent = errText(error);
626
+ }
627
+ }
628
+ const FAMILY_REF = /^(.+)\.(chunk-\d{6}|envelope\.skmeta)$/;
629
+ function foldFamilies(rows) {
630
+ const families = new Map();
631
+ const plain = [];
632
+ for (const row of rows) {
633
+ const match = FAMILY_REF.exec(String(row.vault_ref));
634
+ if (!match) { plain.push(row); continue; }
635
+ const fam = families.get(match[1]) ?? { rows: [], chunkBytes: 0, chunks: 0, members: [], allDurable: true };
636
+ fam.rows.push(row);
637
+ fam.members.push(row.vault_ref);
638
+ if (match[2].startsWith("chunk-")) { fam.chunkBytes += Number(row.bytes) || 0; fam.chunks++; }
639
+ if (row.state !== "durable") fam.allDurable = false;
640
+ families.set(match[1], fam);
641
+ }
642
+ const folded = [...families.entries()].map(([id, fam]) => ({
643
+ ...fam.rows[0], vault_ref: id, bytes: fam.chunkBytes, chunked: true,
644
+ chunkCount: fam.chunks, members: fam.members, state: fam.allDurable ? "durable" : "pending"
645
+ }));
646
+ return [...plain, ...folded];
647
+ }
648
+ let targetRows = [];
649
+ let targetBaselineJson = null;
650
+ let targetBaselineUpdatedAt = null;
651
+ let targetBaselineLoaded = false;
652
+ let targetAccountToken = "";
653
+ let targetAccountGeneration = 0;
654
+ let targetLoadGeneration = 0;
655
+ let targetSaveGeneration = 0;
656
+ let accountLoadGeneration = 0;
657
+ let planIsPaid = false;
658
+ let liveCloudBytes = 0;
659
+ const providerLabel = (p) => ({ vaultline: "Sealkeep Cloud", gdrive: "Google Drive", r2: "Cloudflare R2", s3: "S3-compatible", b2: "Backblaze B2", gcs: "Google Cloud Storage" }[p] || p);
660
+ function setTargetRoutingAvailable(available) {
661
+ for (const id of ["tg-add-drive", "tg-add-bucket", "tg-save"]) $(id).disabled = !available;
662
+ for (const control of $("tg-body").querySelectorAll("button,input,select")) control.disabled = !available;
663
+ }
664
+ function resetTargetRoutingState(token = "") {
665
+ targetAccountToken = token;
666
+ targetAccountGeneration += 1;
667
+ targetLoadGeneration += 1;
668
+ targetSaveGeneration += 1;
669
+ targetRows = [];
670
+ targetBaselineJson = null;
671
+ targetBaselineUpdatedAt = null;
672
+ targetBaselineLoaded = false;
673
+ renderTargets();
674
+ setTargetRoutingAvailable(false);
675
+ }
676
+ function targetRoutingAccountIsCurrent(token, generation) {
677
+ return Boolean(token)
678
+ && token === targetAccountToken
679
+ && token === sessionStorage.getItem(TOKEN)
680
+ && generation === targetAccountGeneration;
681
+ }
682
+ function accountLoadIsCurrent(token, generation) {
683
+ return Boolean(token)
684
+ && token === sessionStorage.getItem(TOKEN)
685
+ && generation === accountLoadGeneration;
686
+ }
687
+ function targetConnectionSummary(target) {
688
+ const reports = Object.values(target.connectionHealth || {});
689
+ const fresh = reports.filter((report) => report && Date.now() - Date.parse(report.checkedAt || 0) < 3 * 60 * 1000);
690
+ const saved = fresh.filter((report) => report.connected === true).length;
691
+ const missing = fresh.filter((report) => report.connected === false).length;
692
+ const verified = fresh.filter((report) => report.connected === true && report.status === "credentials_verified" && Number.isFinite(Date.parse(report.verifiedAt || ""))).length;
693
+ if (!fresh.length) return { kind: "unknown", text: "Machine credential state not reported yet", action: "Open Settings" };
694
+ if (!saved) return { kind: "disconnected", text: `${missing} online machine${missing === 1 ? "" : "s"} need${missing === 1 ? "s" : ""} credentials`, action: "Add credentials" };
695
+ if (missing) return { kind: "partial", text: `${saved} have credentials · ${missing} need${missing === 1 ? "s" : ""} credentials`, action: "Manage credentials" };
696
+ return { kind: "connected", text: verified === saved
697
+ ? `Tested credentials saved on ${saved} online machine${saved === 1 ? "" : "s"}`
698
+ : `Credentials saved on ${saved} online machine${saved === 1 ? "" : "s"} · not a live reachability check`, action: "Manage credentials" };
699
+ }
700
+ function canonicalStorageEndpoint(value, label) {
701
+ let parsed;
702
+ try { parsed = new URL(value); } catch { throw new Error(`${label} needs a valid endpoint URL.`); }
703
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:") throw new Error("A storage endpoint must use https (or http for a loopback test service).");
704
+ if (parsed.username || parsed.password) throw new Error("A storage endpoint cannot contain credentials.");
705
+ if (parsed.pathname !== "/" || parsed.search || parsed.hash || value.includes("?") || value.includes("#")) {
706
+ throw new Error("A storage endpoint must be only an origin, without a path, query, or fragment.");
707
+ }
708
+ const host = parsed.hostname.toLowerCase().replace(/\.$/, "");
709
+ const loopback = host === "localhost" || host.endsWith(".localhost") || host === "[::1]" || host === "::1" || host.startsWith("127.");
710
+ if (parsed.protocol === "http:" && !loopback) throw new Error("A storage endpoint must use https unless it is a loopback test service.");
711
+ return parsed.origin;
712
+ }
713
+ function renderTargets() {
714
+ const body = $("tg-body");
715
+ body.replaceChildren();
716
+ if (!targetRows.length) {
717
+ $("tg-msg").className = "empty";
718
+ $("tg-msg").textContent = planIsPaid
719
+ ? "No rules yet — everything goes to Sealkeep cloud. Add a destination here, or set rules from a machine: sealkeep storage targets add …"
720
+ : "No rules yet — each machine seals to the bucket configured on it. Add a destination here to route by project or cap it.";
721
+ $("tg-wrap").style.display = "none";
722
+ setTargetRoutingAvailable(targetBaselineLoaded);
723
+ return;
724
+ }
725
+ $("tg-msg").textContent = "";
726
+ targetRows.sort((a, b) => (a.priority ?? 50) - (b.priority ?? 50));
727
+ for (const t of targetRows) {
728
+ const tr = document.createElement("tr");
729
+ const name = document.createElement("td");
730
+ const byo = ["r2", "s3", "b2", "gcs"].includes(t.provider);
731
+ if (t.__editable) {
732
+ const sel = document.createElement("select");
733
+ for (const [v, l] of [["r2", "Cloudflare R2"], ["s3", "S3-compatible"], ["b2", "Backblaze B2"], ["gcs", "Google Cloud Storage"]]) {
734
+ const o = document.createElement("option"); o.value = v; o.textContent = l; sel.append(o);
735
+ }
736
+ sel.value = t.provider;
737
+ sel.setAttribute("aria-label", "Provider");
738
+ sel.addEventListener("change", () => {
739
+ t.provider = sel.value;
740
+ delete t.connectionHealth;
741
+ if (t.provider !== "r2" && t.provider !== "s3") delete t.endpoint;
742
+ renderTargets();
743
+ });
744
+ name.append(sel);
745
+ } else name.textContent = providerLabel(t.provider);
746
+ const where = document.createElement("td");
747
+ if (t.__editable) {
748
+ const editor = document.createElement("div");
749
+ editor.className = "target-place-editor";
750
+ const field = (labelText, placeholder, value, change) => {
751
+ const label = document.createElement("label");
752
+ const caption = document.createElement("span"); caption.textContent = labelText;
753
+ const input = document.createElement("input"); input.placeholder = placeholder; input.value = value || "";
754
+ input.addEventListener("input", () => { change(input.value.trim()); delete t.connectionHealth; });
755
+ label.append(caption, input); editor.append(label); return input;
756
+ };
757
+ field("Bucket", "archive-bucket", t.bucket, (value) => { t.bucket = value; });
758
+ field("Prefix", "sealkeep", t.prefix, (value) => { t.prefix = value || undefined; });
759
+ if (t.provider === "s3" || t.provider === "b2") {
760
+ field("Region", t.provider === "b2" ? "us-west-004" : "us-east-1", t.region, (value) => { t.region = value || undefined; });
761
+ }
762
+ if (t.provider === "r2" || t.provider === "s3") {
763
+ const endpoint = field(t.provider === "r2" ? "Endpoint (required)" : "Custom endpoint (optional)", t.provider === "r2" ? "https://<account-id>.r2.cloudflarestorage.com" : "https://s3.example.com", t.endpoint, (value) => { t.endpoint = value || undefined; });
764
+ if (t.provider === "r2") endpoint.required = true;
765
+ }
766
+ where.append(editor);
767
+ } else {
768
+ const wc = document.createElement("code");
769
+ wc.textContent = t.provider === "vaultline" ? "managed"
770
+ : t.provider === "gdrive" ? "folder " + (t.bucket || "Sealkeep")
771
+ : [t.bucket, t.prefix].filter(Boolean).join("/") || "—";
772
+ where.append(wc);
773
+ if (byo) {
774
+ const fixed = document.createElement("span");
775
+ fixed.className = "cellsub";
776
+ fixed.textContent = "Destination identity is fixed — add a new route to change provider, bucket, region, prefix, or endpoint.";
777
+ where.append(fixed);
778
+ }
779
+ }
780
+ const held = document.createElement("span");
781
+ held.className = "cellsub";
782
+ if (t.provider === "vaultline") held.textContent = "currently stores " + bytes(liveCloudBytes);
783
+ else {
784
+ const health = targetConnectionSummary(t);
785
+ held.textContent = t.provider === "gdrive" ? `Personal vault · ${health.text}` : health.text;
786
+ held.classList.add("connection-" + health.kind);
787
+ const reconnect = document.createElement("button");
788
+ reconnect.type = "button";
789
+ reconnect.className = "target-reconnect";
790
+ reconnect.textContent = health.action;
791
+ reconnect.title = `Show where to connect ${providerLabel(t.provider)} on a SealKeep machine`;
792
+ reconnect.addEventListener("click", () => openLocalTask("storage", providerLabel(t.provider)));
793
+ held.append(document.createTextNode(" · "), reconnect);
794
+ }
795
+ if (t.enabled === false) {
796
+ held.prepend(document.createTextNode("Paused for new archives · existing copies remain available · "));
797
+ held.classList.add("connection-paused");
798
+ }
799
+ where.append(held);
800
+ const pr = document.createElement("td");
801
+ const at = targetRows.indexOf(t);
802
+ const rank = document.createElement("span");
803
+ rank.className = "cellsub";
804
+ const ordinal = `${at + 1}${["st", "nd", "rd"][at] ?? "th"}`;
805
+ rank.textContent = t.enabled === false
806
+ ? `${ordinal} in order — paused`
807
+ : at === 0 ? "1st — new seals go here" : ordinal;
808
+ const up = document.createElement("button"); up.type = "button"; up.className = "ghost"; up.textContent = "▲";
809
+ up.disabled = at === 0;
810
+ up.setAttribute("aria-label", "Move " + providerLabel(t.provider) + " earlier");
811
+ const down = document.createElement("button"); down.type = "button"; down.className = "ghost"; down.textContent = "▼";
812
+ down.disabled = at === targetRows.length - 1;
813
+ down.setAttribute("aria-label", "Move " + providerLabel(t.provider) + " later");
814
+ const swap = (delta) => {
815
+ const to = at + delta;
816
+ [targetRows[at], targetRows[to]] = [targetRows[to], targetRows[at]];
817
+ targetRows.forEach((row, index) => { row.priority = (index + 1) * 10; });
818
+ renderTargets();
819
+ say("tg-save-msg", "Order changed — not saved yet. Save routing makes it real on every machine.");
820
+ };
821
+ up.addEventListener("click", () => swap(-1));
822
+ down.addEventListener("click", () => swap(1));
823
+ pr.append(up, down, document.createTextNode(" "), rank);
824
+ const cap = document.createElement("td");
825
+ const capIn = document.createElement("input"); capIn.type = "number"; capIn.className = "n"; capIn.min = "1"; capIn.max = "99999"; capIn.placeholder = "none";
826
+ if (t.maxGb) capIn.value = t.maxGb;
827
+ capIn.setAttribute("aria-label", "Cap in GB for " + providerLabel(t.provider));
828
+ capIn.addEventListener("input", () => { t.maxGb = capIn.value ? Number(capIn.value) : undefined; });
829
+ cap.append(capIn);
830
+ const proj = document.createElement("td");
831
+ const stablePin = /^(?:g1|p1):[a-f0-9]{64}$/;
832
+ const pins = Array.isArray(t.projects) ? t.projects : [];
833
+ const stablePins = pins.filter((project) => stablePin.test(project));
834
+ const legacyPins = pins.length - stablePins.length;
835
+ const pinSummary = document.createElement("span");
836
+ pinSummary.className = "cellsub";
837
+ pinSummary.textContent = pins.length === 0
838
+ ? "Every personal project follows this route"
839
+ : legacyPins > 0
840
+ ? `${pins.length} existing pin${pins.length === 1 ? "" : "s"} · ${legacyPins} older name pin${legacyPins === 1 ? " needs" : "s need"} review`
841
+ : `${stablePins.length} project${stablePins.length === 1 ? "" : "s"} pinned by stable identity`;
842
+ const managePins = document.createElement("button");
843
+ managePins.type = "button";
844
+ managePins.className = "target-reconnect";
845
+ managePins.textContent = pins.length ? "Review on a machine" : "Choose on a machine";
846
+ managePins.title = `Use detected project identities for ${providerLabel(t.provider)}`;
847
+ managePins.addEventListener("click", () => openLocalTask("project-routing", providerLabel(t.provider)));
848
+ proj.append(pinSummary, document.createTextNode(" "), managePins);
849
+ const act = document.createElement("td");
850
+ const rm = document.createElement("button"); rm.className = "del"; rm.type = "button";
851
+ const removableDraft = t.__editable || t.__new;
852
+ rm.textContent = removableDraft ? "Remove" : t.enabled === false ? "Resume new archives" : "Pause new archives";
853
+ rm.addEventListener("click", () => {
854
+ if (removableDraft) {
855
+ targetRows = targetRows.filter((x) => x !== t);
856
+ renderTargets();
857
+ return;
858
+ }
859
+ t.enabled = t.enabled === false;
860
+ renderTargets();
861
+ say("tg-save-msg", t.enabled === false
862
+ ? `${providerLabel(t.provider)} will stop receiving new archives after you save. Its identity stays here so existing copies can still be restored or moved.`
863
+ : `${providerLabel(t.provider)} will receive eligible new archives again after you save.`);
864
+ });
865
+ act.append(rm);
866
+ tr.append(name, where, pr, cap, proj, act);
867
+ body.append(tr);
868
+ }
869
+ $("tg-wrap").style.display = "";
870
+ setTargetRoutingAvailable(targetBaselineLoaded);
871
+ }
872
+ async function loadTargets(token) {
873
+ if (!token || token !== targetAccountToken || token !== sessionStorage.getItem(TOKEN)) return false;
874
+ const accountGeneration = targetAccountGeneration;
875
+ const generation = ++targetLoadGeneration;
876
+ targetBaselineLoaded = false;
877
+ setTargetRoutingAvailable(false);
878
+ try {
879
+ const data = await api("/v1/cloud/storage-targets", { token });
880
+ if (generation !== targetLoadGeneration || !targetRoutingAccountIsCurrent(token, accountGeneration)) return false;
881
+ targetBaselineJson = typeof data.targets_json === "string" ? data.targets_json : null;
882
+ targetBaselineUpdatedAt = typeof data.updated_at === "string" ? data.updated_at : null;
883
+ try { targetRows = JSON.parse(targetBaselineJson || "[]"); } catch { targetRows = []; }
884
+ if (!Array.isArray(targetRows)) targetRows = [];
885
+ targetBaselineLoaded = true;
886
+ renderTargets();
887
+ return true;
888
+ } catch (error) {
889
+ if (generation !== targetLoadGeneration || !targetRoutingAccountIsCurrent(token, accountGeneration)) return false;
890
+ targetBaselineLoaded = false;
891
+ renderTargets();
892
+ say("tg-msg", `${errText(error)} The last loaded rows are read-only until the account reload succeeds.`, "err");
893
+ return false;
894
+ }
895
+ }
896
+ $("tg-add-bucket").addEventListener("click", () => {
897
+ if (!targetBaselineLoaded) return void say("tg-save-msg", "Wait for the account routing to load before editing it.", "err");
898
+ targetRows.push({ id: "byo-" + Math.random().toString(36).slice(2, 8), provider: "r2", priority: 5, __editable: true });
899
+ renderTargets();
900
+ say("tg-save-msg", "Fill in the destination and save. Then use Sealkeep Settings on each machine to test and store that machine's credentials.");
901
+ });
902
+ $("tg-add-drive").addEventListener("click", () => {
903
+ if (!targetBaselineLoaded) return void say("tg-save-msg", "Wait for the account routing to load before editing it.", "err");
904
+ if (targetRows.some((t) => t.provider === "gdrive")) { say("tg-save-msg", "Drive is already in the rules."); return; }
905
+ targetRows.push({ id: "gdrive-" + Math.random().toString(36).slice(2, 8), provider: "gdrive", bucket: "Sealkeep", maxGb: 50, priority: 10, __new: true });
906
+ renderTargets();
907
+ say("tg-save-msg", "Added for personal vault memory — save, then open Sealkeep Settings on each machine for the one-time consent. Shared projects continue using managed project memory.");
908
+ });
909
+ async function saveTargets() {
910
+ const token = sessionStorage.getItem(TOKEN);
911
+ if (!token) { say("tg-save-msg", "Sign in again to save.", "err"); return; }
912
+ if (!targetBaselineLoaded || token !== targetAccountToken) { say("tg-save-msg", "Reload this account's routing before saving.", "err"); return; }
913
+ const accountGeneration = targetAccountGeneration;
914
+ const saveGeneration = ++targetSaveGeneration;
915
+ const saveIsCurrent = () => saveGeneration === targetSaveGeneration
916
+ && targetRoutingAccountIsCurrent(token, accountGeneration);
917
+ $("tg-save").disabled = true;
918
+ try {
919
+ for (const target of targetRows) {
920
+ if (!["r2", "s3", "b2", "gcs"].includes(target.provider)) continue;
921
+ if (!String(target.bucket || "").trim()) throw new Error(`${providerLabel(target.provider)} needs a bucket name.`);
922
+ target.prefix = String(target.prefix || "sealkeep").replace(/^\/+|\/+$/g, "");
923
+ if (target.provider === "s3" && !target.region) target.region = "us-east-1";
924
+ if (target.provider === "b2" && !target.region) throw new Error("Backblaze B2 needs the region from its S3 endpoint, for example us-west-004.");
925
+ if (target.provider === "r2" && !target.endpoint) throw new Error("Cloudflare R2 needs its account endpoint.");
926
+ if (target.endpoint) {
927
+ target.endpoint = canonicalStorageEndpoint(target.endpoint, providerLabel(target.provider));
928
+ const endpointHost = new URL(target.endpoint).hostname.toLowerCase().replace(/\.$/, "");
929
+ if (target.provider === "r2" && !endpointHost.endsWith(".r2.cloudflarestorage.com")) {
930
+ throw new Error("Cloudflare R2 needs its account endpoint ending in .r2.cloudflarestorage.com.");
931
+ }
932
+ }
933
+ }
934
+ const clean = targetRows.map(({ __editable, __new, usedBytes, usedAt, ...t }) => t);
935
+ const nextJson = JSON.stringify(clean);
936
+ let saved;
937
+ if (targetBaselineJson === null) {
938
+ saved = await api("/v1/cloud/storage-targets", { method: "PUT", token, body: { targets_json: nextJson } });
939
+ } else {
940
+ saved = await api("/v1/cloud/storage-targets", {
941
+ method: "PATCH", token,
942
+ body: {
943
+ targets_json: nextJson,
944
+ expected_targets_json: targetBaselineJson,
945
+ expected_updated_at: targetBaselineUpdatedAt,
946
+ }
947
+ });
948
+ }
949
+ if (!saveIsCurrent()) return;
950
+ targetBaselineJson = nextJson;
951
+ targetBaselineUpdatedAt = typeof saved?.updated_at === "string" ? saved.updated_at : null;
952
+ targetRows = clean;
953
+ renderTargets();
954
+ say("tg-save-msg", "Saved. Every machine follows these on its next seal.");
955
+ } catch (error) {
956
+ if (!saveIsCurrent()) return;
957
+ if (error?.status === 409) {
958
+ const reloaded = await loadTargets(token);
959
+ if (!saveIsCurrent()) return;
960
+ say("tg-save-msg", reloaded
961
+ ? "Routing changed elsewhere. Your draft was not saved; the latest rules are loaded for review."
962
+ : "Routing changed elsewhere, and the latest rules could not be reloaded yet. Editing stays disabled until they load.", "err");
963
+ } else say("tg-save-msg", errText(error), "err");
964
+ }
965
+ if (!saveIsCurrent()) return;
966
+ $("tg-save").disabled = !targetBaselineLoaded;
967
+ }
968
+ $("tg-save").addEventListener("click", saveTargets);
969
+ async function loadStorage(token) {
970
+ try {
971
+ const rows = await api("/v1/cloud/archives", { token });
972
+ const body = $("s-body");
973
+ body.replaceChildren();
974
+ if (!rows.length) {
975
+ on("s-intro", false);
976
+ $("a-count").textContent = "0";
977
+ $("s-msg").className = "empty";
978
+ $("s-msg").textContent = "Nothing stored yet. Archives appear here on their own once a machine is connected.";
979
+ $("s-wrap").style.display = "none";
980
+ return;
981
+ }
982
+ on("s-intro", true);
983
+ for (const r of foldFamilies(rows)) {
984
+ const tr = document.createElement("tr");
985
+ const ref = document.createElement("td");
986
+ const code = document.createElement("code"); code.textContent = String(r.vault_ref).slice(0, 8); ref.append(code);
987
+ const size = document.createElement("td");
988
+ const sc = document.createElement("code");
989
+ sc.textContent = bytes(r.bytes);
990
+ size.append(sc);
991
+ if (r.chunked) {
992
+ const sub = document.createElement("span");
993
+ sub.className = "cellsub";
994
+ sub.textContent = r.chunkCount + " sealed chunks";
995
+ size.append(sub);
996
+ }
997
+ const st = document.createElement("td");
998
+ const s = document.createElement("span");
999
+ s.className = "state " + (r.state === "durable" ? "durable" : "pending");
1000
+ s.textContent = r.state === "durable" ? "stored" : "incomplete";
1001
+ st.append(s);
1002
+ const when = document.createElement("td"); when.className = "mono"; when.style.fontSize = ".78rem"; when.style.color = "var(--soft)"; when.textContent = ago(r.durable_at || r.created_at);
1003
+ const act = document.createElement("td");
1004
+ const btn = document.createElement("button");
1005
+ btn.className = "del"; btn.type = "button"; btn.textContent = "Delete";
1006
+ btn.addEventListener("click", async () => {
1007
+ if (btn.dataset.armed !== "yes") {
1008
+ btn.dataset.armed = "yes"; btn.textContent = "Confirm";
1009
+ setTimeout(() => { if (btn.dataset.armed === "yes") { btn.dataset.armed = "no"; btn.textContent = "Delete"; } }, 4000);
1010
+ return;
1011
+ }
1012
+ btn.disabled = true; btn.textContent = "Deleting";
1013
+ try {
1014
+ const refs = r.members ? [...r.members].sort((a, b) => a.endsWith(".envelope.skmeta") ? 1 : b.endsWith(".envelope.skmeta") ? -1 : 0) : [r.vault_ref];
1015
+ for (let i = 0; i < refs.length; i++) {
1016
+ if (refs.length > 1) btn.textContent = "Deleting " + (i + 1) + "/" + refs.length;
1017
+ await api("/v1/cloud/archives/" + encodeURIComponent(refs[i]), {
1018
+ method: "DELETE", token, body: { owner_key: sessionStorage.getItem(OWNER_KEY) || undefined }
1019
+ });
1020
+ }
1021
+ await loadAccount(token);
1022
+ } catch (error) {
1023
+ const stale = !sessionStorage.getItem(OWNER_KEY);
1024
+ say("s-msg", stale
1025
+ ? "Sign out and back in, then delete again — this session started before deletes were tied to a machine."
1026
+ : errText(error), "err");
1027
+ btn.disabled = false; btn.textContent = "Delete";
1028
+ }
1029
+ });
1030
+ act.append(btn);
1031
+ tr.append(ref, size, st, when, act);
1032
+ body.append(tr);
1033
+ }
1034
+ const folded = foldFamilies(rows);
1035
+ const stored = folded.filter((r) => r.state === "durable").length;
1036
+ say("s-msg", "");
1037
+ $("a-count").textContent = String(folded.length);
1038
+ $("s-foot").textContent = `${folded.length} archive${folded.length === 1 ? "" : "s"} · ${stored} fully stored`
1039
+ + (stored < folded.length ? ` · ${folded.length - stored} incomplete, not billed` : "");
1040
+ $("s-wrap").style.display = "";
1041
+ } catch (error) { say("s-msg", errText(error), "err"); }
1042
+ }
1043
+ const teamRows = (value, keys) => {
1044
+ if (Array.isArray(value)) return value;
1045
+ if (!value || typeof value !== "object") return [];
1046
+ for (const key of keys) {
1047
+ if (Array.isArray(value[key])) return value[key];
1048
+ if (value[key] && typeof value[key] === "object") return [value[key]];
1049
+ }
1050
+ return [];
1051
+ };
1052
+ const teamState = { token: "", selected: "", spaces: [], requests: [], incoming: [], outgoing: [], removals: [], members: new Map(), poll: 0, canCreate: false, draftOpened: false, loaded: false, machinesLoaded: false, machineCount: null, localDrafts: new Set() };
1053
+ const projectName = (item) => String(item?.project_name || "Untitled project");
1054
+ const normalizedProjectName = (value) => String(value || "").trim().toLocaleLowerCase();
1055
+ const matchingTeamProjectsByName = (name) => {
1056
+ const wanted = normalizedProjectName(name);
1057
+ if (!wanted) return [];
1058
+ return [
1059
+ ...teamState.spaces.map((item) => ({ ...item, uiId: `space:${item.space_key}` })),
1060
+ ...teamState.requests.filter((item) => item.state === "requested").map((item) => ({ ...item, uiId: `request:${item.id}` })),
1061
+ ].filter((item) => normalizedProjectName(projectName(item)) === wanted);
1062
+ };
1063
+ const initials = (value) => String(value || "?").split(/[@\s._-]+/).filter(Boolean).slice(0, 2).map((word) => word[0]).join("");
1064
+ function beginTeamAccount(token) {
1065
+ if (teamState.token === token) return;
1066
+ clearTimeout(teamState.poll);
1067
+ teamState.token = token;
1068
+ teamState.selected = "";
1069
+ teamState.spaces = [];
1070
+ teamState.requests = [];
1071
+ teamState.incoming = [];
1072
+ teamState.outgoing = [];
1073
+ teamState.removals = [];
1074
+ teamState.members = new Map();
1075
+ teamState.loaded = false;
1076
+ teamState.machinesLoaded = false;
1077
+ teamState.machineCount = null;
1078
+ teamState.draftOpened = false;
1079
+ teamState.localDrafts = new Set();
1080
+ $("team-incoming").replaceChildren();
1081
+ $("team-project-list").replaceChildren();
1082
+ $("team-project-detail").replaceChildren();
1083
+ $("team-project-empty").style.display = "";
1084
+ $("team-project-empty").textContent = token ? "Loading your projects…" : "Sign in to see your projects.";
1085
+ closeTeamDrawer();
1086
+ }
1087
+ function avatar(value) {
1088
+ const el = document.createElement("span"); el.className = "member-avatar"; el.textContent = initials(value); return el;
1089
+ }
1090
+ function closeTeamDrawer() {
1091
+ $("team-invite-drawer").hidden = true;
1092
+ $("projects-layout").classList.remove("drawer-open");
1093
+ $("team-invite-email").value = "";
1094
+ say("team-invite-msg", "");
1095
+ }
1096
+ function currentTeamProject() {
1097
+ return teamState.spaces.find((space) => `space:${space.space_key}` === teamState.selected)
1098
+ || teamState.requests.find((request) => `request:${request.id}` === teamState.selected) || null;
1099
+ }
1100
+ function makeAction(label, className, handler, disabled = false) {
1101
+ const button = document.createElement("button"); button.type = "button"; button.className = className; button.textContent = label; button.disabled = disabled; button.addEventListener("click", handler); return button;
1102
+ }
1103
+ function openProjectDialog(name = "") {
1104
+ $("team-project-name").value = name;
1105
+ say("team-project-msg", name ? "Your folder choice is saved on this machine. Create the project here and Sealkeep will connect it automatically." : "");
1106
+ $("team-project-dialog").showModal();
1107
+ setTimeout(() => { $("team-project-name").focus(); $("team-project-name").select(); }, 0);
1108
+ }
1109
+ function openLinkedProjectDraft() {
1110
+ if (teamState.draftOpened) return;
1111
+ const name = (sessionStorage.getItem(PROJECT_DRAFT_NAME) || "").trim();
1112
+ if (!name || !teamState.canCreate) return;
1113
+ teamState.draftOpened = true;
1114
+ const normalized = normalizedProjectName(name);
1115
+ teamState.localDrafts.add(normalized);
1116
+ const matches = matchingTeamProjectsByName(name);
1117
+ sessionStorage.removeItem(PROJECT_DRAFT_NAME);
1118
+ history.replaceState(null, "", `${location.pathname}?view=projects#account`);
1119
+ if (matches.length === 1) {
1120
+ teamState.selected = matches[0].uiId;
1121
+ renderTeamProjects();
1122
+ return;
1123
+ }
1124
+ openProjectDialog(name);
1125
+ if (matches.length > 1) say("team-project-msg", "More than one older project uses this name. Choose the exact project from the list instead of creating another one.", "err");
1126
+ }
1127
+ function renderIncoming() {
1128
+ const box = $("team-incoming"); box.replaceChildren();
1129
+ for (const invitation of teamState.incoming) {
1130
+ const ready = invitation.state === "ready" || Boolean(invitation.wrapped_key);
1131
+ const row = document.createElement("div"); row.className = "invite-card";
1132
+ const copy = document.createElement("div"); copy.className = "invite-copy";
1133
+ const title = document.createElement("strong"); title.textContent = projectName(invitation);
1134
+ const detail = document.createElement("span"); detail.textContent = `${invitation.inviter_email || "A teammate"} invited you · ${ready ? "encrypted access is ready" : "their machine is preparing encrypted access"}`;
1135
+ copy.append(title, detail);
1136
+ const actions = document.createElement("div"); actions.className = "invite-actions";
1137
+ actions.append(
1138
+ makeAction(ready ? "Accept" : "Preparing…", "primary", async (event) => {
1139
+ const button = event.currentTarget; button.disabled = true;
1140
+ try { await api(`/v1/cloud/team/invitations/${encodeURIComponent(invitation.id)}/accept`, { method: "POST", token: teamState.token, body: {} }); await loadTeamAccess(teamState.token); }
1141
+ catch (error) { button.disabled = !ready; detail.textContent = errText(error); }
1142
+ }, !ready),
1143
+ makeAction("Decline", "ghost", async (event) => {
1144
+ event.currentTarget.disabled = true;
1145
+ try { await api(`/v1/cloud/team/invitations/${encodeURIComponent(invitation.id)}/decline`, { method: "POST", token: teamState.token, body: {} }); await loadTeamAccess(teamState.token); }
1146
+ catch (error) { event.currentTarget.disabled = false; detail.textContent = errText(error); }
1147
+ }),
1148
+ );
1149
+ row.append(avatar(invitation.inviter_email), copy, actions); box.append(row);
1150
+ }
1151
+ }
1152
+ function renderTeamProjects() {
1153
+ const all = [
1154
+ ...teamState.spaces.map((item) => ({ ...item, uiId: `space:${item.space_key}`, pending: false })),
1155
+ ...teamState.requests.filter((item) => item.state === "requested").map((item) => ({ ...item, uiId: `request:${item.id}`, pending: true })),
1156
+ ].sort((a, b) => projectName(a).localeCompare(projectName(b)));
1157
+ if (!all.some((item) => item.uiId === teamState.selected)) teamState.selected = all[0]?.uiId || "";
1158
+ const list = $("team-project-list"); list.replaceChildren();
1159
+ $("team-project-new").disabled = !teamState.canCreate;
1160
+ $("team-project-new").title = teamState.canCreate ? "Create project memory" : "Creating projects requires the Team plan";
1161
+ for (const item of all) {
1162
+ const memberResult = item.pending ? [] : teamState.members.get(item.space_key);
1163
+ const members = Array.isArray(memberResult) ? memberResult : [];
1164
+ const li = document.createElement("li"); const button = document.createElement("button"); button.type = "button"; button.setAttribute("aria-current", String(item.uiId === teamState.selected));
1165
+ const mark = document.createElement("span"); mark.className = `project-mark${item.pending ? " pending" : ""}`;
1166
+ const label = document.createElement("span"); label.className = "project-label"; label.textContent = projectName(item);
1167
+ const count = document.createElement("span"); count.className = "project-count"; count.textContent = item.pending ? "finishing…" : memberResult === null ? "?" : `${members.length || 1} people`;
1168
+ button.setAttribute("aria-label", `${projectName(item)}, ${item.pending ? "finishing automatically when an owner machine checks in" : count.textContent}`);
1169
+ button.append(mark, label, count); button.addEventListener("click", () => { teamState.selected = item.uiId; closeTeamDrawer(); renderTeamProjects(); }); li.append(button); list.append(li);
1170
+ }
1171
+ $("team-project-empty").style.display = all.length ? "none" : "";
1172
+ if (!all.length) $("team-project-empty").textContent = teamState.canCreate
1173
+ ? "No project memory yet. Create one here—your machine handles the encryption automatically."
1174
+ : "No shared project memory yet. You can accept a Team invitation here; creating a project requires the Team plan.";
1175
+ renderTeamDetail();
1176
+ }
1177
+ function renderTeamDetail() {
1178
+ const detail = $("team-project-detail"); detail.replaceChildren();
1179
+ const selected = currentTeamProject();
1180
+ if (!selected) {
1181
+ const empty = document.createElement("div"); empty.className = "project-empty-state";
1182
+ const inner = document.createElement("div"); const glyph = document.createElement("div"); glyph.className = "cloud-glyph"; glyph.textContent = "⌁";
1183
+ const title = document.createElement("h3"); title.textContent = "Create your first project memory";
1184
+ const copy = document.createElement("p"); copy.textContent = "It starts private. Invite Ann, Bob, or anyone else only when this project should become shared.";
1185
+ const button = makeAction("Create project", "primary", () => openProjectDialog()); inner.append(glyph, title, copy, button); empty.append(inner); detail.append(empty); return;
1186
+ }
1187
+ const pending = Object.hasOwn(selected, "state") && selected.state === "requested" && !selected.space_key;
1188
+ const space = pending ? null : selected;
1189
+ const head = document.createElement("div"); head.className = "project-head";
1190
+ const copy = document.createElement("div"); const title = document.createElement("h3"); title.textContent = projectName(selected);
1191
+ const meta = document.createElement("div"); meta.className = "project-meta";
1192
+ const privacy = document.createElement("span"); privacy.textContent = space?.role === "owner" ? "Private by default" : "Shared with you";
1193
+ const key = document.createElement("span"); key.className = pending ? "" : "ready"; key.textContent = pending ? "Finishing automatically" : "● Project key ready"; meta.append(privacy, key); copy.append(title, meta); head.append(copy);
1194
+ if (space?.role === "owner" && teamState.canCreate) head.append(makeAction("Invite people", "primary", () => {
1195
+ const drawer = $("team-invite-drawer");
1196
+ $("team-drawer-project").textContent = `Access to ${projectName(space)}`; drawer.hidden = false; $("projects-layout").classList.add("drawer-open");
1197
+ setTimeout(() => {
1198
+ if (matchMedia("(max-width:70rem)").matches) drawer.scrollIntoView({ block: "start", behavior: motionOK() ? "smooth" : "instant" });
1199
+ $("team-invite-email").focus({ preventScroll: true });
1200
+ }, 0);
1201
+ }));
1202
+ detail.append(head);
1203
+ if (pending) {
1204
+ const wait = document.createElement("div"); wait.className = "project-wait"; const strong = document.createElement("strong"); strong.textContent = "Project name saved · finishing automatically";
1205
+ const note = document.createElement("span");
1206
+ const fromLocal = teamState.localDrafts.has(projectName(selected).trim().toLocaleLowerCase());
1207
+ const machineFact = !teamState.machinesLoaded
1208
+ ? "Checking which machines are registered to this account."
1209
+ : teamState.machineCount === 0
1210
+ ? "No machine is connected to this account yet."
1211
+ : `${teamState.machineCount} machine${teamState.machineCount === 1 ? " is" : "s are"} registered to the account. This cloud page does not receive live dashboard status, so it cannot claim this computer is online.`;
1212
+ note.textContent = `${fromLocal ? "The folder choice is already saved on the computer that sent you here. " : ""}A running owner machine creates and encrypts the project key; the cloud never receives it. ${machineFact}`;
1213
+ const actions = document.createElement("div"); actions.className = "project-wait-actions";
1214
+ const check = makeAction("Check again", "primary", async (event) => {
1215
+ const button = event.currentTarget; button.disabled = true; button.textContent = "Checking…";
1216
+ try { await loadTeamAccess(teamState.token); }
1217
+ finally { if (button.isConnected) { button.disabled = false; button.textContent = "Check again"; } }
1218
+ });
1219
+ const next = teamState.machinesLoaded && teamState.machineCount === 0
1220
+ ? makeAction("Connect a machine", "ghost", () => showAccountView("machines"))
1221
+ : makeAction("Show local steps", "ghost", () => openLocalTask("project", projectName(selected)));
1222
+ const cancel = makeAction("Cancel request", "ghost", async (event) => { event.currentTarget.disabled = true; try { await api(`/v1/cloud/team/project-requests/${encodeURIComponent(selected.id)}/cancel`, { method: "POST", token: teamState.token, body: {} }); teamState.selected = ""; await loadTeamAccess(teamState.token); } catch (error) { note.textContent = errText(error); event.currentTarget.disabled = false; } });
1223
+ actions.append(check, next, cancel); wait.append(strong, note, actions); detail.append(wait); return;
1224
+ }
1225
+ const connected = document.createElement("div"); connected.className = "project-wait";
1226
+ const connectedTitle = document.createElement("strong"); connectedTitle.textContent = "Connect work on each machine";
1227
+ const connectedCopy = document.createElement("span"); connectedCopy.textContent = "Shared memory is stored in SealKeep Cloud for all project members, separately from personal backup destinations such as Google Drive. Local work folders are not storage destinations: SealKeep detects folders from Codex and Claude sessions, and each teammate confirms which ones belong here. Different paths can use the same project; source files are not copied between teammates.";
1228
+ const connect = makeAction("Connect detected work folders", "primary", () => openLocalTask("folders", projectName(space), space.space_key));
1229
+ connected.append(connectedTitle, connectedCopy, connect); detail.append(connected);
1230
+ const memberResult = teamState.members.get(space.space_key);
1231
+ const members = Array.isArray(memberResult) ? memberResult : [];
1232
+ const outgoing = teamState.outgoing.filter((item) => item.space_key === space.space_key);
1233
+ const heading = document.createElement("div"); heading.className = "members-head"; const h4 = document.createElement("h4"); h4.textContent = "People with access"; const total = document.createElement("span"); total.textContent = memberResult === null ? "Could not verify people · retrying" : `${members.length || 1} active · ${outgoing.length} pending`; heading.append(h4, total); detail.append(heading);
1234
+ const list = document.createElement("ul"); list.className = "member-list";
1235
+ for (const member of members) {
1236
+ const owner = member.role === "owner"; const removing = teamState.removals.some((item) => item.space_key === space.space_key && Number(item.target_account_id) === Number(member.account_id) && item.state === "requested");
1237
+ const row = document.createElement("li"); row.className = "member-row"; const email = member.display_email || (owner ? "Project owner" : "Project member");
1238
+ const memberCopy = document.createElement("div"); memberCopy.className = "member-copy"; const strong = document.createElement("strong"); strong.textContent = email; const sub = document.createElement("span"); sub.textContent = removing ? "Access removal is being sealed on your machine" : owner ? "Owns this project memory" : "Can read, write, and search this project"; memberCopy.append(strong, sub);
1239
+ const role = document.createElement("span"); role.className = `member-role${owner ? " owner" : ""}`; role.textContent = removing ? "Removing…" : owner ? "Owner" : "Member"; row.append(avatar(email), memberCopy, role);
1240
+ if (space.role === "owner" && !owner && !removing) row.append(makeAction("Remove", "ghost", async (event) => { if (!confirm(`Remove ${email} from ${projectName(space)}? Their future access ends after your machine rotates the project key.`)) return; event.currentTarget.disabled = true; try { await api(`/v1/cloud/team/spaces/${encodeURIComponent(space.space_key)}/members/${encodeURIComponent(member.account_id)}/removal-requests`, { method: "POST", token: teamState.token, body: {} }); await loadTeamAccess(teamState.token); } catch (error) { sub.textContent = errText(error); event.currentTarget.disabled = false; } }));
1241
+ list.append(row);
1242
+ }
1243
+ for (const invitation of outgoing) {
1244
+ const row = document.createElement("li"); row.className = "member-row"; const email = invitation.invitee_email || "Invited teammate"; const memberCopy = document.createElement("div"); memberCopy.className = "member-copy"; const strong = document.createElement("strong"); strong.textContent = email; const sub = document.createElement("span"); sub.textContent = invitation.state === "ready" || invitation.wrapped_key ? "Invitation sent · awaiting acceptance" : "Preparing encrypted access on your machine"; memberCopy.append(strong, sub); const role = document.createElement("span"); role.className = "member-role"; role.textContent = "Invited"; row.append(avatar(email), memberCopy, role, makeAction("Cancel", "ghost", async (event) => { event.currentTarget.disabled = true; try { await api(`/v1/cloud/team/invitations/${encodeURIComponent(invitation.id)}/cancel`, { method: "POST", token: teamState.token, body: {} }); await loadTeamAccess(teamState.token); } catch (error) { sub.textContent = errText(error); event.currentTarget.disabled = false; } })); list.append(row);
1245
+ }
1246
+ if (!members.length) { const row = document.createElement("li"); row.className = "member-row"; const copy = document.createElement("div"); copy.className = "member-copy"; const strong = document.createElement("strong"); strong.textContent = space.role === "owner" ? "You" : "Project membership"; const sub = document.createElement("span"); sub.textContent = space.role === "owner" ? "Owner · private until you invite someone" : "You have project-scoped access"; copy.append(strong, sub); row.append(avatar("You"), copy); list.append(row); }
1247
+ detail.append(list);
1248
+ }
1249
+ async function loadTeamAccess(token) {
1250
+ beginTeamAccount(token);
1251
+ try {
1252
+ const [spacesData, requestData, incomingData, outgoingData, removalData] = await Promise.all([
1253
+ api("/v1/cloud/team/spaces", { token }), api("/v1/cloud/team/project-requests", { token }), api("/v1/cloud/team/invitations", { token }), api("/v1/cloud/team/invitations/outgoing", { token }), api("/v1/cloud/team/access-requests/outgoing", { token }),
1254
+ ]);
1255
+ if (teamState.token !== token) return;
1256
+ teamState.spaces = teamRows(spacesData, ["spaces", "memberships", "items", "data"]); teamState.requests = teamRows(requestData, ["requests", "items", "data"]); teamState.incoming = teamRows(incomingData, ["invitations", "items", "data"]); teamState.outgoing = teamRows(outgoingData, ["invitations", "items", "data"]); teamState.removals = teamRows(removalData, ["requests", "items", "data"]);
1257
+ const members = await Promise.all(teamState.spaces.map(async (space) => {
1258
+ try { const result = await api(`/v1/cloud/team/spaces/${encodeURIComponent(space.space_key)}/members`, { token }); return [space.space_key, teamRows(result, ["members", "memberships", "items", "data"])]; }
1259
+ catch { return [space.space_key, null]; }
1260
+ }));
1261
+ if (teamState.token !== token) return;
1262
+ teamState.members = new Map(members);
1263
+ teamState.loaded = true;
1264
+ renderIncoming(); renderTeamProjects();
1265
+ openLinkedProjectDraft();
1266
+ clearTimeout(teamState.poll); const unsettled = teamState.requests.some((item) => item.state === "requested") || teamState.incoming.some((item) => item.state !== "ready" && !item.wrapped_key) || teamState.outgoing.some((item) => item.state !== "ready" && !item.wrapped_key) || teamState.removals.some((item) => item.state === "requested");
1267
+ if (unsettled) teamState.poll = setTimeout(() => { if (sessionStorage.getItem(TOKEN) === token) loadTeamAccess(token); }, 5000);
1268
+ } catch (error) {
1269
+ if (teamState.token !== token) return;
1270
+ $("team-project-empty").style.display = "";
1271
+ $("team-project-empty").textContent = errText(error);
1272
+ }
1273
+ }
1274
+ $("team-project-new").addEventListener("click", () => openProjectDialog());
1275
+ $("team-project-cancel").addEventListener("click", () => $("team-project-dialog").close());
1276
+ $("team-project-form").addEventListener("submit", async (event) => {
1277
+ event.preventDefault(); const name = $("team-project-name").value.trim(); if (!name) return void say("team-project-msg", "Enter a project name.", "err");
1278
+ const matches = matchingTeamProjectsByName(name);
1279
+ if (matches.length === 1) {
1280
+ teamState.selected = matches[0].uiId;
1281
+ $("team-project-dialog").close();
1282
+ renderTeamProjects();
1283
+ return;
1284
+ }
1285
+ if (matches.length > 1) return void say("team-project-msg", "More than one older project uses this name. Choose the exact project from the list instead of creating another one.", "err");
1286
+ const button = $("team-project-create"); button.disabled = true; button.textContent = "Creating…";
1287
+ try {
1288
+ const created = await api("/v1/cloud/team/project-requests", { method: "POST", token: teamState.token, body: { project_name: name } });
1289
+ const row = teamRows(created, ["request", "item", "data"])[0] || created;
1290
+ teamState.requests = [...teamState.requests.filter((item) => item.id !== row.id), { project_name: name, state: "requested", ...row }];
1291
+ teamState.selected = `request:${row.id}`;
1292
+ $("team-project-dialog").close();
1293
+ renderTeamProjects();
1294
+ clearTimeout(teamState.poll); teamState.poll = setTimeout(() => loadTeamAccess(teamState.token), 1000);
1295
+ }
1296
+ catch (error) { say("team-project-msg", errText(error), "err"); }
1297
+ finally { button.disabled = false; button.textContent = "Create project"; }
1298
+ });
1299
+ $("team-drawer-close").addEventListener("click", closeTeamDrawer);
1300
+ $("team-invite-email").addEventListener("keydown", (event) => {
1301
+ if (event.key !== "Enter") return;
1302
+ event.preventDefault();
1303
+ $("team-invite-send").click();
1304
+ });
1305
+ $("team-invite-send").addEventListener("click", async () => {
1306
+ const space = currentTeamProject(); const email = $("team-invite-email").value.trim(); if (!space?.space_key || space.role !== "owner" || !email) return void say("team-invite-msg", "Enter the SealKeep account email to invite.", "err");
1307
+ const button = $("team-invite-send"); button.disabled = true; button.textContent = "Sending…";
1308
+ try { await api(`/v1/cloud/team/spaces/${encodeURIComponent(space.space_key)}/invitations`, { method: "POST", token: teamState.token, body: { email } }); $("team-invite-email").value = ""; say("team-invite-msg", "Invitation created. Your machine will seal access automatically.", "ok"); await loadTeamAccess(teamState.token); }
1309
+ catch (error) { say("team-invite-msg", errText(error), "err"); }
1310
+ finally { button.disabled = false; button.textContent = "Send invitation"; }
1311
+ });
1312
+ async function loadAccount(token) {
1313
+ const generation = ++accountLoadGeneration;
1314
+ const isCurrent = () => accountLoadIsCurrent(token, generation);
1315
+ if (!isCurrent()) return null;
1316
+ let user;
1317
+ try { user = await api("/me", { token }); }
1318
+ catch {
1319
+ if (!isCurrent()) return null;
1320
+ sessionStorage.removeItem(TOKEN);
1321
+ sessionStorage.removeItem(OWNER_KEY);
1322
+ accountLoadGeneration += 1;
1323
+ resetTargetRoutingState("");
1324
+ return false;
1325
+ }
1326
+ if (!isCurrent()) return null;
1327
+ if (targetAccountToken !== token) resetTargetRoutingState(token);
1328
+ beginTeamAccount(token);
1329
+ planIsPaid = user.plan === "pro" || user.plan === "team";
1330
+ liveCloudBytes = Number(user.used_bytes) || 0;
1331
+ teamState.canCreate = user.plan === "team";
1332
+ renderAccount(user);
1333
+ await loadDeviceConnect(token);
1334
+ if (!isCurrent()) return null;
1335
+ if (user.plan === "pro" || user.plan === "team") { $("a-count").textContent = "—"; loadStorage(token); }
1336
+ await loadTargets(token);
1337
+ if (!isCurrent()) return null;
1338
+ loadAudit(token);
1339
+ loadTeamAccess(token);
1340
+ const devices = await loadDevices(token);
1341
+ if (!isCurrent()) return null;
1342
+ $("a-machines").textContent = String(devices);
1343
+ $("a-dest").textContent = String(Math.max(targetRows.length, 1));
1344
+ const destNames = targetRows.slice()
1345
+ .sort((a, b) => (a.priority ?? 10) - (b.priority ?? 10))
1346
+ .map((t) => providerLabel(t.provider) + (t.maxGb ? " ≤" + t.maxGb + " GB" : ""));
1347
+ const hasDrive = targetRows.some((t) => t.provider === "gdrive");
1348
+ const ownBucketTargets = targetRows.filter((t) => t.provider !== "gdrive" && t.provider !== "vaultline");
1349
+ const paidPlan = planIsPaid;
1350
+ const parts = [];
1351
+ if (paidPlan) parts.push(bytes(Number(user.used_bytes) || 0) + " in Sealkeep cloud");
1352
+ if (hasDrive) {
1353
+ const driveHealth = targetConnectionSummary(targetRows.find((t) => t.provider === "gdrive"));
1354
+ parts.push(driveHealth.kind === "connected"
1355
+ ? "Google Drive personal backup ready"
1356
+ : driveHealth.kind === "partial"
1357
+ ? "Google Drive personal backup needs sign-in on some machines"
1358
+ : driveHealth.kind === "disconnected"
1359
+ ? "Google Drive personal backup sign-in missing"
1360
+ : "Google Drive personal backup configured — machine sign-in not reported");
1361
+ }
1362
+ if (ownBucketTargets.length) {
1363
+ const bucketHealth = ownBucketTargets.map(targetConnectionSummary);
1364
+ const bucketLabel = ownBucketTargets.length === 1 ? "your bucket" : "your buckets";
1365
+ if (bucketHealth.every((health) => health.kind === "connected")) {
1366
+ parts.push(`${bucketLabel}: credentials saved`);
1367
+ } else if (bucketHealth.some((health) => health.kind === "disconnected" || health.kind === "partial")) {
1368
+ parts.push(`${bucketLabel} need${ownBucketTargets.length === 1 ? "s" : ""} credentials on some machines`);
1369
+ } else {
1370
+ parts.push(`${bucketLabel} configured — machine credentials not reported`);
1371
+ }
1372
+ }
1373
+ $("a-hero").textContent = parts.length ? parts.join(" · ")
1374
+ : "Your archives live in storage you run";
1375
+ $("a-summary").textContent =
1376
+ (devices ? devices + " machine" + (devices === 1 ? "" : "s") + " enrolled" : "No machine enrolled yet")
1377
+ + " · new archives go to "
1378
+ + (destNames.length ? destNames.join(", then ")
1379
+ : paidPlan ? "Sealkeep Cloud" : "the bucket each machine is configured with");
1380
+ const nothingYet = devices === 0 && Number(user.used_bytes) === 0;
1381
+ on("a-firstrun", nothingYet);
1382
+ (nothingYet ? $("pair-home-first") : $("pair-home-more")).append($("pair"));
1383
+ $("pair-side").textContent = nothingYet ? "Your pairing code" : "Add another machine";
1384
+ if (devices === 0)
1385
+ $("d-msg").textContent = nothingYet
1386
+ ? "No machines connected yet. Use the pairing code at the top of this page."
1387
+ : "No machines connected yet.";
1388
+ if (nothingYet && !pairShown) mintCode(true);
1389
+ loadEscrow(token, Number(user.used_bytes) > 0);
1390
+ showAccountView(requestedAccountView(), { writeHistory: false });
1391
+ sessionStorage.removeItem("sealkeep.returnView");
1392
+ return true;
1393
+ }
1394
+ $("a-rename").addEventListener("click", async () => {
1395
+ const token = sessionStorage.getItem(TOKEN);
1396
+ const label = $("a-name").value.trim();
1397
+ if (!label) return void ($("a-rename-msg").textContent = "Give the account a name.");
1398
+ $("a-rename").disabled = true;
1399
+ $("a-rename-msg").textContent = "Saving";
1400
+ try {
1401
+ const saved = await api("/account", { method: "PATCH", token, body: { label } });
1402
+ $("a-label").textContent = saved.label;
1403
+ $("a-rename-msg").textContent = "Saved";
1404
+ } catch (error) { $("a-rename-msg").textContent = errText(error); }
1405
+ finally {
1406
+ $("a-rename").disabled = false;
1407
+ setTimeout(() => { $("a-rename-msg").textContent = ""; }, 5000);
1408
+ }
1409
+ });
1410
+ $("g-in").prepend($("g-up").querySelector("svg").cloneNode(true));
1411
+ let lastPaid = false;
1412
+ function applyStorageView() {
1413
+ on("a-store-free", !lastPaid);
1414
+ on("a-store-paid", lastPaid);
1415
+ }
1416
+ let pairShown = false;
1417
+ function showCode(code, email) {
1418
+ const field = $("pair-code");
1419
+ field.textContent = code;
1420
+ field.dataset.long = code.length > 16 ? "yes" : "no";
1421
+ $("pair-cli").textContent = "npm install -g sealkeep\nsealkeep enroll " + code;
1422
+ $("pair-note").textContent = "Single use. On the new machine, run the command below — that is the whole setup.";
1423
+ on("pair-shown", true);
1424
+ on("pair-alt", true);
1425
+ on("pair-ask", false);
1426
+ on("pair-side", true);
1427
+ pairShown = true;
1428
+ }
1429
+ async function mintCode(auto) {
1430
+ const token = sessionStorage.getItem(TOKEN);
1431
+ $("pair-go").disabled = true;
1432
+ $("pair-msg").textContent = auto ? "" : "Making a code";
1433
+ try {
1434
+ const made = await api("/account/cli-token", { method: "POST", token });
1435
+ showCode(made.token, made.email);
1436
+ $("pair-msg").textContent = "";
1437
+ } catch (error) {
1438
+ $("pair-msg").textContent = errText(error);
1439
+ on("pair-ask", true);
1440
+ } finally { $("pair-go").disabled = false; }
1441
+ }
1442
+ $("pair-go").addEventListener("click", () => mintCode(false));
1443
+ $("a-verify-go").addEventListener("click", async () => {
1444
+ const token = sessionStorage.getItem(TOKEN);
1445
+ $("a-verify-go").disabled = true;
1446
+ $("a-verify-msg").textContent = "Sending";
1447
+ try {
1448
+ const result = await api("/account/send-confirmation", { method: "POST", token });
1449
+ $("a-verify-msg").textContent = result.sent
1450
+ ? "Sent. Check your inbox."
1451
+ : "Email delivery is not switched on yet — nothing was sent.";
1452
+ } catch (error) { $("a-verify-msg").textContent = errText(error); }
1453
+ finally { $("a-verify-go").disabled = false; }
1454
+ });
1455
+ $("p-signup").addEventListener("submit", async (event) => {
1456
+ event.preventDefault();
1457
+ const email = $("su-email").value.trim(), label = $("su-label").value.trim(), password = $("su-pass").value;
1458
+ if (password.length < 12) return say("su-msg", "Use at least 12 characters.", "err");
1459
+ $("su-go").disabled = true; say("su-msg", "Creating your account");
1460
+ try {
1461
+ await api("/signup", { method: "POST", body: { email, password, label } });
1462
+ const { token, owner_key } = await api("/login", { method: "POST", body: { email, password } });
1463
+ sessionStorage.setItem(TOKEN, token);
1464
+ keepOwnerKey(owner_key);
1465
+ say("su-msg", "");
1466
+ await loadAccount(token);
1467
+ } catch (error) {
1468
+ say("su-msg", /exists|unique|duplicate/i.test(error.message)
1469
+ ? "That email is already registered. Sign in instead."
1470
+ : errText(error), "err");
1471
+ } finally { $("su-go").disabled = false; }
1472
+ });
1473
+ $("p-signin").addEventListener("submit", async (event) => {
1474
+ event.preventDefault();
1475
+ $("si-go").disabled = true; say("si-msg", "Signing in");
1476
+ try {
1477
+ const { token, owner_key } = await api("/login", { method: "POST", body: { email: $("si-email").value.trim(), password: $("si-pass").value } });
1478
+ sessionStorage.setItem(TOKEN, token);
1479
+ keepOwnerKey(owner_key);
1480
+ say("si-msg", "");
1481
+ await loadAccount(token);
1482
+ } catch {
1483
+ say("si-msg", "Email or password is not correct.", "err");
1484
+ } finally { $("si-go").disabled = false; }
1485
+ });
1486
+ $("a-out").addEventListener("click", () => {
1487
+ accountLoadGeneration += 1;
1488
+ sessionStorage.removeItem(TOKEN);
1489
+ sessionStorage.removeItem(OWNER_KEY);
1490
+ sessionStorage.removeItem(PROJECT_DRAFT_NAME);
1491
+ beginTeamAccount("");
1492
+ resetTargetRoutingState("");
1493
+ showPane("signin");
1494
+ say("si-msg", "Signed out. Archiving on your connected machines carries on.");
1495
+ goAccount();
1496
+ });
1497
+ for (const [b, key] of [["t-signup", "signup"], ["t-signin", "signin"]])
1498
+ $(b).addEventListener("click", () => showPane(key));
1499
+ function openAuthFromHash() {
1500
+ const wanted = { "#account": "signin", "#signin": "signin", "#signup": "signup" }[location.hash];
1501
+ if (!wanted && !pendingConnectRequest()) return;
1502
+ if (sessionStorage.getItem(TOKEN)) { goAccount(); return; }
1503
+ on("connect-auth-intro", Boolean(pendingConnectRequest()));
1504
+ showPane(wanted || "signin");
1505
+ goAccount();
1506
+ }
1507
+ window.addEventListener("hashchange", openAuthFromHash);
1508
+ function googleAuthorizeState(url, responseState, baseUrl = location.href) {
1509
+ const parsed = new URL(url, baseUrl);
1510
+ const responseHasState = responseState !== undefined && responseState !== null;
1511
+ const urlStates = parsed.searchParams.getAll("state");
1512
+ const urlHasState = urlStates.length > 0;
1513
+ if (!responseHasState && !urlHasState) return { ok: true, state: null };
1514
+ const ok = typeof responseState === "string" && responseState.length > 0
1515
+ && urlStates.length === 1 && urlStates[0] === responseState;
1516
+ return { ok, state: ok ? responseState : null };
1517
+ }
1518
+ function googleCallbackState(expectedState, returnedState, legacyStarted) {
1519
+ const expectedHasState = expectedState !== undefined && expectedState !== null;
1520
+ const returnedHasState = returnedState !== undefined && returnedState !== null;
1521
+ if (!expectedHasState && !returnedHasState) return { ok: legacyStarted === true, state: null };
1522
+ const ok = typeof expectedState === "string" && expectedState.length > 0
1523
+ && typeof returnedState === "string" && returnedState.length > 0
1524
+ && returnedState === expectedState;
1525
+ return { ok, state: ok ? returnedState : null };
1526
+ }
1527
+ async function google(msgId) {
1528
+ say(msgId, "Opening Google");
1529
+ try {
1530
+ const { url, state } = await api("/google-auth/authorize");
1531
+ if (!url) throw new Error("Google sign-in could not start safely. Try again.");
1532
+ const verified = googleAuthorizeState(url, state);
1533
+ if (!verified.ok) throw new Error("Google sign-in could not start safely. Try again.");
1534
+ if (verified.state === null) sessionStorage.removeItem(GOOGLE_OAUTH_STATE);
1535
+ else sessionStorage.setItem(GOOGLE_OAUTH_STATE, verified.state);
1536
+ sessionStorage.setItem("sealkeep.returning", "1");
1537
+ sessionStorage.setItem("sealkeep.returnView", requestedAccountView());
1538
+ location.assign(url);
1539
+ } catch (error) {
1540
+ sessionStorage.removeItem(GOOGLE_OAUTH_STATE);
1541
+ say(msgId, /404|not found/i.test(error.message)
1542
+ ? "Google sign-in is not switched on yet. Use a password for now."
1543
+ : errText(error), "err");
1544
+ }
1545
+ }
1546
+ $("g-up").addEventListener("click", () => google("su-msg"));
1547
+ $("g-in").addEventListener("click", () => google("si-msg"));
1548
+ async function backToSignIn(message, bad) {
1549
+ await bootDone(false);
1550
+ showPane("signin");
1551
+ say("si-msg", message, bad ? "err" : "");
1552
+ goAccount();
1553
+ }
1554
+ async function finishGoogle(code, returnedState) {
1555
+ const returnView = requestedAccountView();
1556
+ history.replaceState(null, "", `${location.pathname}?view=${encodeURIComponent(returnView)}#account`);
1557
+ const expectedState = sessionStorage.getItem(GOOGLE_OAUTH_STATE);
1558
+ const legacyStarted = sessionStorage.getItem("sealkeep.returning") === "1";
1559
+ sessionStorage.removeItem(GOOGLE_OAUTH_STATE);
1560
+ sessionStorage.removeItem("sealkeep.returning");
1561
+ bootSay("Signing you in");
1562
+ const verified = googleCallbackState(expectedState, returnedState, legacyStarted);
1563
+ if (!verified.ok) {
1564
+ return void await backToSignIn("That Google sign-in could not be verified. Start again with the button below.", true);
1565
+ }
1566
+ try {
1567
+ const body = verified.state === null ? { code } : { code, state: verified.state };
1568
+ const { token, owner_key } = await api("/google-auth/callback", { method: "POST", body });
1569
+ sessionStorage.setItem(TOKEN, token);
1570
+ keepOwnerKey(owner_key);
1571
+ say("si-msg", "");
1572
+ const opened = await loadAccount(token);
1573
+ if (opened === true) return void await bootDone(true);
1574
+ if (opened === null) return;
1575
+ await backToSignIn("That sign-in did not finish. Try it again below.", true);
1576
+ } catch (error) {
1577
+ await backToSignIn(/execution failed/i.test(error.message)
1578
+ ? "That Google sign-in did not complete. Start again with the button below."
1579
+ : errText(error), true);
1580
+ }
1581
+ }
1582
+ const choices = [...document.querySelectorAll(".choice")];
1583
+ const paintChoices = () => {
1584
+ for (const c of choices) c.dataset.on = c.querySelector("input[type=radio]").checked ? "yes" : "no";
1585
+ };
1586
+ for (const c of choices) c.querySelector("input[type=radio]").addEventListener("change", paintChoices);
1587
+ paintChoices();
1588
+ const PACKAGES = [
1589
+ { id: "free", name: "Your own bucket", gb: 0, monthly: 0, managed: false, seats: 0, blurb: "Your provider, your region, your bill. We never hold the bytes." },
1590
+ { id: "pro_lite", name: "Pro Lite", gb: 0, monthly: 2, managed: false, seats: 3, blurb: "Your storage, our safety net: a sealed copy of your key in escrow, and every machine signed in. We still hold no bytes." },
1591
+ { id: "gb100", name: "100 GB", gb: 100, monthly: 6, managed: true, seats: 10, blurb: "Years of transcripts from one machine." },
1592
+ { id: "gb500", name: "500 GB", gb: 500, monthly: 12, managed: true, seats: 10, blurb: "A whole team's history." },
1593
+ { id: "gb2000", name: "2 TB", gb: 2000, monthly: 30, managed: true, seats: 10, blurb: "Every agent on every machine, kept indefinitely." },
1594
+ { id: "team", name: "Team", gb: 0, monthly: 9, perSeat: true, managed: false, blurb: "Per seat. Project-scoped members, live bridges relayed through our plane, revocation across every machine. Storage priced separately. Personal backups can use your own bucket; shared project memory uses Sealkeep Cloud." }
1595
+ ];
1596
+ const packageIdFor = (user) => {
1597
+ if (user.plan === "team") return "team";
1598
+ if (user.plan === "pro_lite") return "pro_lite";
1599
+ if (user.plan !== "pro") return "free";
1600
+ const gb = Math.round((Number(user.quota_bytes) || 0) / 1073741824);
1601
+ const match = PACKAGES.find((p) => p.managed && p.gb === gb);
1602
+ return match ? match.id : "custom";
1603
+ };
1604
+ const packsFrom = (user) => {
1605
+ const current = packageIdFor(user);
1606
+ const cur = PACKAGES.find((p) => p.id === current);
1607
+ const seats = Number(user.seats) || 0;
1608
+ $("a-pack-current").textContent = cur
1609
+ ? (cur.perSeat
1610
+ ? `${cur.name} — $${cur.monthly} per seat${seats > 0 ? ` · ${seats} seat${seats === 1 ? "" : "s"}` : ""}`
1611
+ : cur.managed ? `${cur.name} — $${cur.monthly} a month` : `${cur.name} — free`)
1612
+ : `Custom allocation`;
1613
+ $("a-pack-context").textContent = cur ? cur.blurb : "";
1614
+ const host = $("a-packs");
1615
+ while (host.firstChild) host.removeChild(host.firstChild);
1616
+ for (const pack of PACKAGES) {
1617
+ const chip = document.createElement(pack.id === current ? "span" : "a");
1618
+ chip.className = "packchip" + (pack.id === current ? " now" : "");
1619
+ const rate = pack.perSeat ? `$${pack.monthly} a seat, monthly` : pack.monthly ? `$${pack.monthly} a month` : "free";
1620
+ if (pack.id !== current) {
1621
+ chip.href = "mailto:hello@spala.ai?subject=" + encodeURIComponent(`Move me to ${pack.name}`)
1622
+ + "&body=" + encodeURIComponent(`Please move my account to ${pack.name} (${rate}).`);
1623
+ chip.title = `Ask us to move you to ${pack.name}`;
1624
+ }
1625
+ const name = document.createElement("b"); name.textContent = pack.name;
1626
+ const price = document.createElement("span");
1627
+ price.textContent = pack.perSeat ? `$${pack.monthly}/seat` : pack.monthly ? `$${pack.monthly}/mo` : "$0";
1628
+ chip.append(name, price);
1629
+ if (pack.id === current) {
1630
+ const tag = document.createElement("i"); tag.textContent = "your package"; chip.append(tag);
1631
+ }
1632
+ host.append(chip);
1633
+ }
1634
+ if (current === "custom") {
1635
+ const gb = Math.round((Number(user.quota_bytes) || 0) / 1073741824);
1636
+ $("a-pack-note").textContent = `You are on a custom allocation of ${gb} GB, not one of the standard packages.`;
1637
+ }
1638
+ };
1639
+ const rulesFrom = (user) => {
1640
+ const mode = user.sync_mode || "all";
1641
+ const radio = document.querySelector(`input[name=sync][value="${mode}"]`) || choices[0].querySelector("input");
1642
+ radio.checked = true;
1643
+ if (user.sync_keep_last) $("r-last").value = user.sync_keep_last;
1644
+ if (user.sync_min_age_days) $("r-age").value = user.sync_min_age_days;
1645
+ paintChoices();
1646
+ };
1647
+ $("r-save").addEventListener("click", async () => {
1648
+ const token = sessionStorage.getItem(TOKEN);
1649
+ const mode = document.querySelector("input[name=sync]:checked").value;
1650
+ $("r-save").disabled = true;
1651
+ $("r-msg").textContent = "Saving";
1652
+ try {
1653
+ await api("/v1/cloud/sync-rules", {
1654
+ method: "POST", token,
1655
+ body: {
1656
+ sync_mode: mode,
1657
+ sync_keep_last: Number($("r-last").value) || 20,
1658
+ sync_min_age_days: Number($("r-age").value) || 7
1659
+ }
1660
+ });
1661
+ $("r-msg").textContent = "Saved to your account.";
1662
+ } catch (error) {
1663
+ $("r-msg").textContent = /404|not found/i.test(error.message)
1664
+ ? "Rules are not switched on yet — nothing was changed."
1665
+ : errText(error);
1666
+ } finally {
1667
+ $("r-save").disabled = false;
1668
+ setTimeout(() => { $("r-msg").textContent = ""; }, 6000);
1669
+ }
1670
+ });
1671
+ async function finishConfirm(accountId, token) {
1672
+ history.replaceState(null, "", location.pathname + "#account");
1673
+ const signedIn = sessionStorage.getItem(TOKEN);
1674
+ try {
1675
+ await api("/confirm-email", { method: "POST", body: { account_id: accountId, token } });
1676
+ const opened = signedIn ? await loadAccount(signedIn) : false;
1677
+ if (opened === true) {
1678
+ await bootDone(returning);
1679
+ $("a-verify-msg").textContent = "Email confirmed.";
1680
+ return;
1681
+ }
1682
+ if (opened === null) return;
1683
+ await backToSignIn("Email confirmed. Sign in to continue.");
1684
+ } catch (error) {
1685
+ const opened = signedIn ? await loadAccount(signedIn) : false;
1686
+ if (opened === true) {
1687
+ await bootDone(returning);
1688
+ $("a-verify-msg").textContent = errText(error);
1689
+ return;
1690
+ }
1691
+ if (opened === null) return;
1692
+ await backToSignIn(errText(error), true);
1693
+ }
1694
+ }
1695
+ (async function start() {
1696
+ if (authCode) return void finishGoogle(authCode, authState);
1697
+ if (confirmToken && confirmAccount) return void finishConfirm(confirmAccount, confirmToken);
1698
+ if (savedToken) {
1699
+ const opened = await loadAccount(savedToken);
1700
+ if (opened === null) return;
1701
+ await bootDone(returning && opened);
1702
+ sessionStorage.removeItem("sealkeep.returning");
1703
+ if (!opened) return void await backToSignIn("That session has expired. Sign in again.");
1704
+ if (location.hash === "#account") requestAnimationFrame(goAccount);
1705
+ return;
1706
+ }
1707
+ await bootDone(false);
1708
+ if (location.hash === "#account") requestAnimationFrame(goAccount);
1709
+ requestAnimationFrame(openAuthFromHash);
1710
+ })();
1711
+ </script><script>
1712
+ // Product / Enterprise / Docs / Account share one document. #account keeps
1713
+ // its own long-standing flow; this only decides which surface is on screen,
1714
+ // fits the enterprise story, and lights the matching tab.
1715
+ (function () {
1716
+ const pills = { home: document.getElementById("pill-home"), enterprise: document.getElementById("pill-enterprise"), docs: document.getElementById("pill-docs"), account: document.getElementById("pill-account") };
1717
+ const enterpriseFrame = document.getElementById("enterprise-story");
1718
+ let enterpriseWasActive = false;
1719
+ const light = (which) => {
1720
+ for (const [key, pill] of Object.entries(pills)) pill.classList.toggle("on", key === which);
1721
+ };
1722
+ const controlEnterpriseStory = (active, reset = false) => {
1723
+ if (!enterpriseFrame?.contentWindow) return;
1724
+ enterpriseFrame.contentWindow.postMessage({ type: "sealkeep-story-control", active, reset }, location.origin);
1725
+ };
1726
+ const syncEnterpriseStory = () => {
1727
+ const active = document.body.classList.contains("enterprise-page");
1728
+ controlEnterpriseStory(active, false);
1729
+ };
1730
+ const enterpriseStoryReady = () => {
1731
+ enterpriseFrame.parentElement.classList.add("is-ready");
1732
+ syncEnterpriseStory();
1733
+ };
1734
+ if (enterpriseFrame) {
1735
+ enterpriseFrame.addEventListener("load", syncEnterpriseStory);
1736
+ addEventListener("message", (event) => {
1737
+ if (event.source !== enterpriseFrame.contentWindow || event.origin !== location.origin) return;
1738
+ if (event.data?.type === "sealkeep-story-ready") enterpriseStoryReady();
1739
+ if (event.data?.type === "sealkeep-story-size" && event.data.mode === "enterprise") {
1740
+ const height = Number(event.data.height);
1741
+ if (!Number.isFinite(height) || height < 200 || height > 10000) return;
1742
+ enterpriseFrame.style.height = `${Math.ceil(height)}px`;
1743
+ enterpriseFrame.parentElement.style.height = `${Math.ceil(height)}px`;
1744
+ }
1745
+ });
1746
+ try {
1747
+ if (enterpriseFrame.contentDocument?.readyState === "complete") queueMicrotask(syncEnterpriseStory);
1748
+ } catch {}
1749
+ }
1750
+ const fitEnterpriseStory = () => {
1751
+ if (!enterpriseFrame || !document.body.classList.contains("enterprise-page")) return;
1752
+ const holder = enterpriseFrame.parentElement;
1753
+ const available = holder.clientWidth;
1754
+ if (!available) return;
1755
+ enterpriseFrame.style.width = "100%";
1756
+ enterpriseFrame.style.transform = "none";
1757
+ };
1758
+ const route = () => {
1759
+ const h = location.hash;
1760
+ const panel = document.body.classList.contains("panel");
1761
+ const docs = !panel && (h === "#docs" || h.startsWith("#docs-"));
1762
+ const enterprise = !panel && h === "#enterprise";
1763
+ const account = !panel && (h === "#account" || h === "#signin" || h === "#signup" || h.startsWith("#connect="));
1764
+ document.body.classList.toggle("docs", docs);
1765
+ document.body.classList.toggle("enterprise-page", enterprise);
1766
+ document.body.classList.toggle("account-page", account);
1767
+ document.title = enterprise ? "Sealkeep Enterprise — keep the work behind the code" : docs ? "Sealkeep Docs — setup and command reference" : account || panel ? "Your account — Sealkeep" : "Sealkeep — your agent history, sealed";
1768
+ if (!panel && ["#home", "#enterprise", "#docs", "#account", "#signin", "#signup"].includes(h)) {
1769
+ requestAnimationFrame(() => requestAnimationFrame(() => window.scrollTo({top:0,behavior:"instant"})));
1770
+ }
1771
+ const enteringEnterprise = enterprise && !enterpriseWasActive;
1772
+ enterpriseWasActive = enterprise;
1773
+ controlEnterpriseStory(enterprise, enteringEnterprise);
1774
+ if (panel) { light("account"); return; }
1775
+ if (enterprise) { window.scrollTo(0, 0); light("enterprise"); requestAnimationFrame(fitEnterpriseStory); return; }
1776
+ if (docs) {
1777
+ light("docs");
1778
+ requestAnimationFrame(() => {
1779
+ if (h === "#docs") window.scrollTo({top:0,behavior:"instant"});
1780
+ else document.getElementById(h.slice(1))?.scrollIntoView({behavior:"instant",block:"start"});
1781
+ });
1782
+ return;
1783
+ }
1784
+ if (account) { window.scrollTo(0, 0); light("account"); return; }
1785
+ light("home");
1786
+ };
1787
+ addEventListener("hashchange", route);
1788
+ addEventListener("load", route, {once:true});
1789
+ addEventListener("resize", fitEnterpriseStory);
1790
+ // The panel class flips outside any hash change (sign-in, sign-out), so
1791
+ // watch it rather than duplicating that logic here.
1792
+ new MutationObserver(route).observe(document.body, { attributes: true, attributeFilter: ["class"] });
1793
+ for (const link of document.querySelectorAll('.navbar a[href="#home"],.navbar a[href="#how"],.navbar a[href="#get"]')) {
1794
+ link.addEventListener("click", (event) => {
1795
+ event.preventDefault();
1796
+ const hash = link.getAttribute("href");
1797
+ if (location.hash !== hash) history.pushState(null, "", hash);
1798
+ route();
1799
+ // Dedicated surfaces hide Product sections. Let routing expose the
1800
+ // target before measuring it, then perform the anchor movement.
1801
+ requestAnimationFrame(() => requestAnimationFrame(() => {
1802
+ if (hash === "#home") window.scrollTo(0, 0);
1803
+ else document.querySelector(hash)?.scrollIntoView({ behavior: motionOK() ? "smooth" : "auto", block: "start" });
1804
+ }));
1805
+ });
1806
+ }
1807
+ route();
1808
+ })();
1905
1809
  </script>