sealkeep 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (180) hide show
  1. package/ARCHITECTURE.md +201 -0
  2. package/CHANGELOG.md +218 -0
  3. package/CONTROL_PLANE.md +86 -0
  4. package/LICENSE +34 -0
  5. package/README.md +249 -0
  6. package/THIRD_PARTY.md +22 -0
  7. package/THREAT_MODEL.md +107 -0
  8. package/dist/packages/vaultline-crypto/src/aead.d.ts +12 -0
  9. package/dist/packages/vaultline-crypto/src/aead.js +24 -0
  10. package/dist/packages/vaultline-crypto/src/chunk-access.d.ts +39 -0
  11. package/dist/packages/vaultline-crypto/src/chunk-access.js +93 -0
  12. package/dist/packages/vaultline-crypto/src/envelope.d.ts +71 -0
  13. package/dist/packages/vaultline-crypto/src/envelope.js +188 -0
  14. package/dist/packages/vaultline-crypto/src/format.d.ts +106 -0
  15. package/dist/packages/vaultline-crypto/src/format.js +43 -0
  16. package/dist/packages/vaultline-crypto/src/index.d.ts +5 -0
  17. package/dist/packages/vaultline-crypto/src/index.js +5 -0
  18. package/dist/packages/vaultline-crypto/src/recipients.d.ts +42 -0
  19. package/dist/packages/vaultline-crypto/src/recipients.js +129 -0
  20. package/dist/packages/vaultline-crypto/src/sha256-stream.d.ts +41 -0
  21. package/dist/packages/vaultline-crypto/src/sha256-stream.js +206 -0
  22. package/dist/packages/vaultline-crypto/src/stream.d.ts +139 -0
  23. package/dist/packages/vaultline-crypto/src/stream.js +477 -0
  24. package/dist/site/index.html +1542 -0
  25. package/dist/site.zip +0 -0
  26. package/dist/src/activity.d.ts +22 -0
  27. package/dist/src/activity.js +52 -0
  28. package/dist/src/adapters.d.ts +212 -0
  29. package/dist/src/adapters.js +533 -0
  30. package/dist/src/audit.d.ts +24 -0
  31. package/dist/src/audit.js +41 -0
  32. package/dist/src/autopilot.d.ts +77 -0
  33. package/dist/src/autopilot.js +148 -0
  34. package/dist/src/bip39-wordlist.d.ts +15 -0
  35. package/dist/src/bip39-wordlist.js +272 -0
  36. package/dist/src/branding.d.ts +31 -0
  37. package/dist/src/branding.js +31 -0
  38. package/dist/src/chunk-store.d.ts +142 -0
  39. package/dist/src/chunk-store.js +502 -0
  40. package/dist/src/cli.d.ts +2 -0
  41. package/dist/src/cli.js +2035 -0
  42. package/dist/src/cloud.d.ts +434 -0
  43. package/dist/src/cloud.js +851 -0
  44. package/dist/src/control-plane/auth.d.ts +62 -0
  45. package/dist/src/control-plane/auth.js +123 -0
  46. package/dist/src/control-plane/server.d.ts +31 -0
  47. package/dist/src/control-plane/server.js +263 -0
  48. package/dist/src/control-plane/store.d.ts +101 -0
  49. package/dist/src/control-plane/store.js +82 -0
  50. package/dist/src/control-plane-cli.d.ts +2 -0
  51. package/dist/src/control-plane-cli.js +37 -0
  52. package/dist/src/control-plane-server.d.ts +10 -0
  53. package/dist/src/control-plane-server.js +11 -0
  54. package/dist/src/control-plane.d.ts +78 -0
  55. package/dist/src/control-plane.js +61 -0
  56. package/dist/src/crypto.d.ts +56 -0
  57. package/dist/src/crypto.js +132 -0
  58. package/dist/src/daemon.d.ts +52 -0
  59. package/dist/src/daemon.js +142 -0
  60. package/dist/src/dashboard-cli.d.ts +2 -0
  61. package/dist/src/dashboard-cli.js +20 -0
  62. package/dist/src/disk.d.ts +110 -0
  63. package/dist/src/disk.js +169 -0
  64. package/dist/src/doctor.d.ts +11 -0
  65. package/dist/src/doctor.js +198 -0
  66. package/dist/src/enroll.d.ts +27 -0
  67. package/dist/src/enroll.js +136 -0
  68. package/dist/src/errors.d.ts +26 -0
  69. package/dist/src/errors.js +23 -0
  70. package/dist/src/heartbeat.d.ts +89 -0
  71. package/dist/src/heartbeat.js +120 -0
  72. package/dist/src/index-sync.d.ts +53 -0
  73. package/dist/src/index-sync.js +147 -0
  74. package/dist/src/leakscan.d.ts +48 -0
  75. package/dist/src/leakscan.js +222 -0
  76. package/dist/src/local-api.d.ts +132 -0
  77. package/dist/src/local-api.js +1757 -0
  78. package/dist/src/managed-chunks.d.ts +55 -0
  79. package/dist/src/managed-chunks.js +108 -0
  80. package/dist/src/mcp-install.d.ts +52 -0
  81. package/dist/src/mcp-install.js +140 -0
  82. package/dist/src/mcp.d.ts +1 -0
  83. package/dist/src/mcp.js +59 -0
  84. package/dist/src/migrate.d.ts +35 -0
  85. package/dist/src/migrate.js +88 -0
  86. package/dist/src/mnemonic.d.ts +60 -0
  87. package/dist/src/mnemonic.js +134 -0
  88. package/dist/src/net.d.ts +2 -0
  89. package/dist/src/net.js +16 -0
  90. package/dist/src/notify.d.ts +46 -0
  91. package/dist/src/notify.js +84 -0
  92. package/dist/src/offload.d.ts +117 -0
  93. package/dist/src/offload.js +331 -0
  94. package/dist/src/onboarding.d.ts +10 -0
  95. package/dist/src/onboarding.js +44 -0
  96. package/dist/src/packages.d.ts +126 -0
  97. package/dist/src/packages.js +114 -0
  98. package/dist/src/passkey.d.ts +26 -0
  99. package/dist/src/passkey.js +54 -0
  100. package/dist/src/password-lock.d.ts +19 -0
  101. package/dist/src/password-lock.js +156 -0
  102. package/dist/src/paths.d.ts +9 -0
  103. package/dist/src/paths.js +24 -0
  104. package/dist/src/providers/gcs.d.ts +133 -0
  105. package/dist/src/providers/gcs.js +235 -0
  106. package/dist/src/providers/gdrive.d.ts +156 -0
  107. package/dist/src/providers/gdrive.js +335 -0
  108. package/dist/src/providers/index.d.ts +45 -0
  109. package/dist/src/providers/index.js +74 -0
  110. package/dist/src/providers/s3.d.ts +174 -0
  111. package/dist/src/providers/s3.js +345 -0
  112. package/dist/src/providers/sigv4.d.ts +78 -0
  113. package/dist/src/providers/sigv4.js +112 -0
  114. package/dist/src/queue.d.ts +185 -0
  115. package/dist/src/queue.js +286 -0
  116. package/dist/src/recovery.d.ts +40 -0
  117. package/dist/src/recovery.js +132 -0
  118. package/dist/src/rehydrate.d.ts +43 -0
  119. package/dist/src/rehydrate.js +66 -0
  120. package/dist/src/restore.d.ts +34 -0
  121. package/dist/src/restore.js +80 -0
  122. package/dist/src/retention.d.ts +251 -0
  123. package/dist/src/retention.js +446 -0
  124. package/dist/src/rotate.d.ts +47 -0
  125. package/dist/src/rotate.js +95 -0
  126. package/dist/src/search.d.ts +147 -0
  127. package/dist/src/search.js +677 -0
  128. package/dist/src/secrets.d.ts +86 -0
  129. package/dist/src/secrets.js +220 -0
  130. package/dist/src/service.d.ts +73 -0
  131. package/dist/src/service.js +197 -0
  132. package/dist/src/share.d.ts +34 -0
  133. package/dist/src/share.js +68 -0
  134. package/dist/src/spool.d.ts +97 -0
  135. package/dist/src/spool.js +213 -0
  136. package/dist/src/start-tui.d.ts +17 -0
  137. package/dist/src/start-tui.js +113 -0
  138. package/dist/src/start.d.ts +75 -0
  139. package/dist/src/start.js +101 -0
  140. package/dist/src/storage-setup.d.ts +49 -0
  141. package/dist/src/storage-setup.js +222 -0
  142. package/dist/src/storage-targets.d.ts +40 -0
  143. package/dist/src/storage-targets.js +147 -0
  144. package/dist/src/stream-to-cloud.d.ts +76 -0
  145. package/dist/src/stream-to-cloud.js +820 -0
  146. package/dist/src/sync-rules.d.ts +85 -0
  147. package/dist/src/sync-rules.js +125 -0
  148. package/dist/src/trash.d.ts +15 -0
  149. package/dist/src/trash.js +63 -0
  150. package/dist/src/tui.d.ts +18 -0
  151. package/dist/src/tui.js +179 -0
  152. package/dist/src/types.d.ts +191 -0
  153. package/dist/src/types.js +3 -0
  154. package/dist/src/ui-server.d.ts +187 -0
  155. package/dist/src/ui-server.js +293 -0
  156. package/dist/src/ui.d.ts +41 -0
  157. package/dist/src/ui.js +102 -0
  158. package/dist/src/update.d.ts +30 -0
  159. package/dist/src/update.js +56 -0
  160. package/dist/src/upload.d.ts +46 -0
  161. package/dist/src/upload.js +80 -0
  162. package/dist/src/vault.d.ts +208 -0
  163. package/dist/src/vault.js +812 -0
  164. package/dist/src/watcher.d.ts +34 -0
  165. package/dist/src/watcher.js +121 -0
  166. package/dist/src/worker.d.ts +52 -0
  167. package/dist/src/worker.js +190 -0
  168. package/package.json +65 -0
  169. package/web/app.js +1372 -0
  170. package/web/index.html +476 -0
  171. package/web/rail.js +308 -0
  172. package/web/retention.html +17 -0
  173. package/web/rules-view.js +249 -0
  174. package/web/sessions-view.js +448 -0
  175. package/web/sessions.html +17 -0
  176. package/web/setup-api.js +181 -0
  177. package/web/setup-logic.js +394 -0
  178. package/web/setup.html +419 -0
  179. package/web/setup.js +697 -0
  180. package/web/style.css +990 -0
@@ -0,0 +1,1542 @@
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
+ --paper:#f4f4f3; --card:#fbfbfa; --ink:#111316; --soft:#5e646c; --rule:#dadcde;
26
+ --seal:#101a3d; --seal-ink:#96a6e0; --live:#2a3fb0; --warn:#8c3220; --warn-bg:#f4e6e2;
27
+ --sans:-apple-system,BlinkMacSystemFont,"Segoe UI",Inter,system-ui,"Helvetica Neue",sans-serif;
28
+ --mono:ui-monospace,SFMono-Regular,"SF Mono",Menlo,"DejaVu Sans Mono",Consolas,monospace;
29
+ --gut:2.6rem;
30
+ }
31
+ @media(prefers-color-scheme:dark){:root{
32
+ --paper:#0d0f11; --card:#14171a; --ink:#e7e9ec; --soft:#8d949c; --rule:#24282d;
33
+ --seal:#111c44; --seal-ink:#a2b2ea; --live:#8195f2; --warn:#d97a62; --warn-bg:#2b1a16;
34
+ }}
35
+ *{box-sizing:border-box}
36
+ html{scroll-behavior:smooth}
37
+ @media(prefers-reduced-motion:reduce){html{scroll-behavior:auto}*{animation:none!important;transition:none!important}}
38
+ body{margin:0;background:var(--paper);color:var(--ink);
39
+ font:400 1rem/1.62 var(--sans);-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility}
40
+ .wrap{max-width:64rem;margin:0 auto;padding:0 1.5rem}
41
+ a{color:var(--live)}
42
+ :focus-visible{outline:2px solid var(--live);outline-offset:3px}
43
+ code,pre,.mono{font-family:var(--mono);font-variant-ligatures:none}
44
+
45
+ /* Scale carries the voice; the face is deliberately neutral so the mono
46
+ columns are the only thing with an accent. */
47
+ h1{font-size:clamp(2.55rem,7.4vw,4.5rem);font-weight:800;line-height:1.0;
48
+ letter-spacing:-.042em;margin:0 0 1.2rem}
49
+ h2{font-size:clamp(1.55rem,3.4vw,2.15rem);font-weight:700;letter-spacing:-.03em;margin:0 0 .5rem}
50
+ h3{font-size:1.02rem;font-weight:650;letter-spacing:-.012em;margin:0 0 .35rem}
51
+ p{margin:0 0 1rem}
52
+
53
+ /* A label is machine-side, so it is mono and it is small. */
54
+ .mark,.eyebrow{display:block;font-family:var(--mono);font-size:.72rem;color:var(--live);
55
+ margin-bottom:.8rem;letter-spacing:.02em}
56
+ .mark::before,.eyebrow::before{content:"";display:inline-block;width:1.4rem;height:1px;
57
+ background:currentColor;vertical-align:.3em;margin-right:.55rem}
58
+
59
+ nav{display:flex;align-items:center;gap:1.4rem;padding:1.5rem 0;font-size:.86rem}
60
+ nav .logo{font-weight:750;letter-spacing:-.03em;font-size:1rem}
61
+ nav .sp{flex:1}
62
+ nav a{color:var(--soft);text-decoration:none}
63
+ nav a:hover{color:var(--ink)}
64
+
65
+ /* ------------------------------------------------------------- the spine
66
+ One rule, drawn once on the container, so it never breaks at a section
67
+ boundary. Below the breakpoint it retires to the left edge and the two
68
+ sides stack in reading order. */
69
+ .spined{position:relative}
70
+ .duo>.human{padding-bottom:1.6rem}
71
+ @media(min-width:58rem){
72
+ .spined::before{content:"";position:absolute;top:0;bottom:0;left:50%;width:1px;background:var(--rule)}
73
+ .duo{display:grid;grid-template-columns:1fr 1fr;align-items:start}
74
+ .duo>.human{padding:0 var(--gut) 0 0}
75
+ .duo>.machine{padding:0 0 0 var(--gut)}
76
+ .duo.tight{align-items:stretch}
77
+ }
78
+ @media(max-width:57.99rem){
79
+ .spined{padding-left:1.1rem}
80
+ .spined::before{content:"";position:absolute;top:0;bottom:0;left:0;width:1px;background:var(--rule)}
81
+ }
82
+
83
+ header{padding:2.6rem 0 3.4rem}
84
+ .lede{font-size:1.14rem;color:var(--soft);max-width:34ch;margin:0 0 2rem}
85
+ .cmd{display:flex;gap:.5rem;flex-wrap:wrap;max-width:30rem}
86
+ .cmd code{flex:1 1 15rem;background:var(--card);border:1px solid var(--rule);border-radius:3px;
87
+ padding:.72rem .85rem;font-size:.86rem;white-space:nowrap;overflow-x:auto}
88
+ button{font:inherit;cursor:pointer;border:1px solid transparent;border-radius:3px}
89
+ .primary{background:var(--ink);color:var(--paper);padding:.72rem 1.2rem;font-size:.9rem;font-weight:650;letter-spacing:-.01em}
90
+ .primary:hover{opacity:.86}
91
+ .primary:disabled{opacity:.45;cursor:default}
92
+ .ghost{background:none;border:1px solid var(--rule);color:var(--ink);padding:.62rem 1.1rem;font-size:.88rem}
93
+ .ghost:hover{border-color:var(--ink)}
94
+ .fine{font-family:var(--mono);font-size:.76rem;color:var(--soft);margin-top:.9rem}
95
+
96
+ /* The machine side of the hero: one session, read out and then sealed. */
97
+ .side{font-family:var(--mono);font-size:.7rem;color:var(--soft);margin:0 0 .7rem;letter-spacing:.02em}
98
+ .plain{font-family:var(--mono);font-size:.82rem;line-height:1.8;white-space:pre-wrap;word-break:break-word;
99
+ margin:0 0 1.1rem;color:var(--ink)}
100
+ .plain b{font-weight:400;color:var(--live)}
101
+ .cipher{font-family:var(--mono);font-size:.8rem;line-height:1.8;background:var(--seal);color:var(--seal-ink);
102
+ word-break:break-all;padding:.95rem 1rem;border-radius:3px;margin:0}
103
+ .seal-foot{font-size:.84rem;color:var(--soft);margin:.9rem 0 0}
104
+
105
+ section{padding:3.4rem 0;border-top:1px solid var(--rule)}
106
+ .sub{color:var(--soft);max-width:46ch;margin:0 0 2rem;font-size:1.02rem}
107
+ .row-item{padding:1.6rem 0;border-top:1px solid var(--rule)}
108
+ .row-item:first-of-type{border-top:0;padding-top:.4rem}
109
+ .row-item p{color:var(--soft);margin:0;font-size:.96rem}
110
+ .ev{font-family:var(--mono);font-size:.8rem;color:var(--soft);background:var(--card);
111
+ border:1px solid var(--rule);border-radius:3px;padding:.8rem .9rem;line-height:1.75;word-break:break-word}
112
+ .ev b{font-weight:400;color:var(--live)}
113
+
114
+ /* Free is your side of the line, managed is ours. The tiers sit where they
115
+ belong rather than in an arbitrary order. */
116
+ .tier{background:var(--card);border:1px solid var(--rule);border-radius:4px;padding:1.5rem;height:100%}
117
+ .tier.paid{border-color:var(--live)}
118
+ .tier .who{font-family:var(--mono);font-size:.72rem;color:var(--soft)}
119
+ .tier .price{font-size:1.6rem;font-weight:750;letter-spacing:-.03em;margin:.4rem 0 .1rem}
120
+ .tier .price small{font-family:var(--sans);font-size:.86rem;font-weight:400;color:var(--soft);letter-spacing:0}
121
+ .tier ul{list-style:none;padding:0;margin:1.1rem 0 0;font-size:.93rem}
122
+ .tier li{padding:.45rem 0 .45rem 1.4rem;position:relative;color:var(--soft);border-top:1px solid var(--rule)}
123
+ .tier li:first-child{border-top:0}
124
+ .tier li::before{content:"+";position:absolute;left:0;color:var(--live);font-family:var(--mono);font-size:.85rem}
125
+ .tier li.cost::before{content:"−";color:var(--warn)}
126
+
127
+ .tabs{display:flex;margin-bottom:1.6rem;border-bottom:1px solid var(--rule)}
128
+ .tabs button{background:none;color:var(--soft);border:0;border-radius:0;border-bottom:2px solid transparent;
129
+ padding:.6rem 1rem;font-size:.88rem}
130
+ .tabs button[aria-selected=true]{color:var(--ink);font-weight:650;border-bottom-color:var(--live)}
131
+ .pane{display:none}.pane.on{display:block}
132
+
133
+ ol.steps{counter-reset:s;list-style:none;padding:0;margin:0}
134
+ ol.steps li{counter-increment:s;position:relative;padding:0 0 1.6rem 2.7rem}
135
+ ol.steps li::before{content:counter(s,decimal-leading-zero);position:absolute;left:0;top:.16rem;
136
+ font-family:var(--mono);font-size:.76rem;color:var(--live);font-weight:600}
137
+ ol.steps p{margin:.2rem 0 .55rem;color:var(--soft);font-size:.95rem}
138
+ pre{background:var(--card);border:1px solid var(--rule);border-radius:3px;padding:.85rem .9rem;
139
+ overflow-x:auto;font-size:.82rem;line-height:1.7;margin:.5rem 0 0}
140
+
141
+ .phrase{background:var(--seal);color:#eaedf9;border-radius:4px;padding:1.2rem 1.3rem;margin:2rem 0 0}
142
+ .phrase strong{font-weight:700;font-size:1.04rem;letter-spacing:-.018em}
143
+ .phrase p{margin:.4rem 0 0;font-size:.94rem;color:var(--seal-ink)}
144
+
145
+ label{display:block;font-family:var(--mono);font-size:.72rem;color:var(--soft);margin:1rem 0 .35rem}
146
+ input{width:100%;padding:.66rem .75rem;border:1px solid var(--rule);border-radius:3px;
147
+ background:var(--card);color:var(--ink);font:inherit;font-size:.95rem}
148
+ input:focus-visible{border-color:var(--live)}
149
+ .row2{display:grid;gap:1rem;grid-template-columns:1fr}
150
+ @media(min-width:34rem){.row2{grid-template-columns:1fr 1fr}}
151
+ form button{margin-top:1.3rem}
152
+ .msg{font-family:var(--mono);font-size:.78rem;margin:.8rem 0 0;min-height:1.2em;color:var(--soft)}
153
+ .msg.err{color:var(--warn)}
154
+ .saved{font-family:var(--mono);font-size:.76rem;color:var(--live)}
155
+
156
+ .google{display:flex;align-items:center;justify-content:center;gap:.6rem;width:100%;
157
+ background:var(--card);border:1px solid var(--rule);color:var(--ink);padding:.7rem 1rem;font-size:.92rem;font-weight:650}
158
+ .google:hover{border-color:var(--ink)}
159
+ .google svg{width:17px;height:17px;flex:none}
160
+ .or{display:flex;align-items:center;gap:.85rem;margin:1.3rem 0;color:var(--soft);
161
+ font-family:var(--mono);font-size:.72rem}
162
+ .or::before,.or::after{content:"";height:1px;background:var(--rule);flex:1}
163
+
164
+ /* ------------------------------------------------------- control panel */
165
+ body.panel header,
166
+ body.panel section:not(#account),
167
+ body.panel .acct-intro,
168
+ body.panel nav .guest{display:none}
169
+ body.panel #account{border-top:0;padding-top:1.6rem}
170
+ body.panel .spined::before{display:none}
171
+ body.panel .spined{padding-left:0}
172
+
173
+ .ph{display:flex;flex-wrap:wrap;align-items:center;gap:.5rem .9rem;background:var(--card);
174
+ border:1px solid var(--rule);border-radius:4px;padding:1.2rem 1.3rem;margin-bottom:1.3rem}
175
+ .ph .name{font-size:1.32rem;font-weight:750;letter-spacing:-.03em}
176
+ .ph .who{font-family:var(--mono);font-size:.75rem;color:var(--soft);flex:1 1 100%}
177
+ /* The strip takes focus when the panel opens so a screen reader is told whose
178
+ account this is. It is not in the tab order, so nobody can arrive here by
179
+ keyboard and be left without a ring — and a ring drawn on arrival would
180
+ compete with the one card that is meant to be asking for attention. */
181
+ .ph:focus,.ph:focus-visible{outline:none}
182
+ .chip{font-family:var(--mono);font-size:.7rem;border:1px solid var(--rule);border-radius:3px;
183
+ color:var(--soft);padding:.2rem .55rem}
184
+ .chip.pro{border-color:var(--live);color:var(--live);font-weight:600}
185
+ .card{background:var(--card);border:1px solid var(--rule);border-radius:4px;padding:1.35rem;margin-bottom:.9rem}
186
+ .card>p{color:var(--soft);font-size:.94rem;margin:0 0 .3rem;max-width:60ch}
187
+ .empty{color:var(--soft);font-size:.9rem;margin:.5rem 0 0;max-width:60ch}
188
+ .notice{background:var(--warn-bg);border:1px solid var(--warn);border-radius:4px;padding:1rem 1.2rem;
189
+ margin-bottom:.9rem;display:flex;flex-wrap:wrap;gap:.75rem;align-items:center}
190
+ .notice.soft{background:var(--card);border-color:var(--rule)}
191
+ .notice p{margin:0;flex:1 1 17rem;font-size:.94rem}
192
+ .notice strong{font-weight:700}
193
+ .warncard{border-color:var(--warn)}
194
+ .warncard .eyebrow{color:var(--warn)}
195
+
196
+ .kv{display:grid;gap:1.2rem;grid-template-columns:repeat(auto-fit,minmax(8rem,1fr))}
197
+ .kv .k{display:block;font-family:var(--mono);font-size:.72rem;color:var(--soft);margin-bottom:.2rem}
198
+ .kv b{font-family:var(--mono);font-size:1.24rem;font-weight:600;letter-spacing:-.03em}
199
+ .bar{height:3px;background:var(--rule);border-radius:2px;margin:1.05rem 0 .4rem;overflow:hidden}
200
+ .bar i{display:block;height:100%;width:0;background:var(--live);transition:width .5s ease}
201
+
202
+ table{width:100%;border-collapse:collapse;font-size:.9rem;min-width:31rem}
203
+ th{text-align:left;font-family:var(--mono);font-size:.72rem;color:var(--soft);font-weight:400;
204
+ padding:.65rem;border-bottom:1px solid var(--rule)}
205
+ td{padding:.68rem .65rem;border-bottom:1px solid var(--rule)}
206
+ tr:last-child td{border-bottom:0}
207
+ td code{font-size:.86em;color:var(--soft)}
208
+ .state{font-family:var(--mono);font-size:.74rem}
209
+ .state.durable{color:var(--live)}
210
+ .state.pending{color:var(--soft);opacity:.75}
211
+ .del{background:none;color:var(--soft);border:1px solid var(--rule);padding:.3rem .7rem;
212
+ font-family:var(--mono);font-size:.74rem}
213
+ .del:hover{border-color:var(--warn);color:var(--warn)}
214
+ .del[data-armed=yes]{background:var(--warn);color:var(--card);border-color:var(--warn)}
215
+
216
+ .lines{list-style:none;margin:.55rem 0 0;padding:0;font-size:.92rem}
217
+ .lines li{display:flex;flex-wrap:wrap;gap:.45rem .85rem;align-items:center;padding:.68rem 0;border-top:1px solid var(--rule)}
218
+ .lines li:first-child{border-top:0}
219
+ .lines .grow{flex:1 1 11rem}
220
+ .lines .when{font-family:var(--mono);font-size:.75rem;color:var(--soft)}
221
+ .inline{display:flex;gap:.6rem;flex-wrap:wrap;align-items:center;margin-top:.4rem}
222
+ .inline input{flex:1 1 13rem;width:auto}
223
+
224
+ select{width:100%;padding:.66rem .7rem;border:1px solid var(--rule);border-radius:3px;
225
+ background:var(--card);color:var(--ink);font:inherit;font-size:.95rem}
226
+ .why{border-left:2px solid var(--live);padding:.15rem 0 .15rem .8rem;margin:.6rem 0 0;
227
+ color:var(--soft);font-size:.86rem}
228
+ .rules{border:1px solid var(--rule);border-radius:4px;padding:1.2rem;margin-top:.8rem}
229
+ .choice{display:flex;gap:.75rem;align-items:flex-start;padding:.72rem 0;border-top:1px solid var(--rule);cursor:pointer}
230
+ .choice:first-of-type{border-top:0;padding-top:0}
231
+ .choice input{width:auto;margin:.22rem 0 0;accent-color:var(--live);flex:none}
232
+ .choice .t{font-size:.95rem;font-weight:650}
233
+ .choice .d{color:var(--soft);font-size:.88rem;margin-top:.15rem;display:block}
234
+ .choice .n{width:4.4rem;padding:.3rem .45rem;font-family:var(--mono);font-size:.85rem;margin:.4rem .5rem 0 0;display:inline-block}
235
+ .choice[data-on=no] .d,.choice[data-on=no] .n{opacity:.45}
236
+
237
+ /* ---------------------------------------------------- first run, pairing
238
+ A new account is not a broken account. The card that leads the panel is
239
+ the next step, so it borrows the accent already used for "interactive or
240
+ currently true" (.tier.paid, .chip.pro) rather than the warning colour.
241
+ --warn stays rationed to the one case it is worth: inaction losing data. */
242
+ .card.next{border-color:var(--live);padding:1.6rem 1.5rem}
243
+ .card.next .eyebrow{color:var(--live)}
244
+ h3.lead{font-size:1.24rem;font-weight:750;letter-spacing:-.028em;margin:0 0 .5rem}
245
+
246
+ /* The pairing code is the most important string in the panel, so it is the
247
+ largest type in it — nothing else here passes 1.32rem. Mono because it is
248
+ machine material, and never re-chunked or prettified: what is on screen is
249
+ byte-for-byte what Copy writes and what the machine expects back. */
250
+ .pair{margin-top:1.25rem}
251
+ .pair-field{display:flex;gap:.5rem;flex-wrap:wrap;align-items:stretch;max-width:34rem}
252
+ .pair-field pre{flex:1 1 17rem;margin:0}
253
+ .pair-code{flex:1 1 15rem;display:flex;align-items:center;background:var(--paper);
254
+ border:1px solid var(--live);border-radius:3px;padding:.68rem .9rem;color:var(--ink);
255
+ font-family:var(--mono);font-size:clamp(1.3rem,4.4vw,1.8rem);font-weight:600;
256
+ letter-spacing:.12em;line-height:1.24;word-break:break-all}
257
+ /* An opaque token is still a code. It drops to a readable size rather than
258
+ wrapping into a wall, and keeps the same field. */
259
+ .pair-code[data-long=yes]{font-size:clamp(.94rem,2.8vw,1.1rem);letter-spacing:.05em;font-weight:500}
260
+ .pair-field .primary,.pair-field .ghost{flex:0 0 auto}
261
+ .pair .fine{margin-top:.75rem;max-width:66ch}
262
+ .alt{margin-top:1.15rem;border-top:1px solid var(--rule);padding-top:.9rem}
263
+ .alt summary{font-family:var(--mono);font-size:.76rem;color:var(--soft);cursor:pointer;list-style:none}
264
+ .alt summary::-webkit-details-marker{display:none}
265
+ .alt summary::before{content:"+ ";color:var(--live)}
266
+ .alt[open] summary::before{content:"− "}
267
+ .alt summary:hover{color:var(--ink)}
268
+ .alt p{margin:.7rem 0 .55rem}
269
+
270
+ /* --------------------------------------------------------- coming back
271
+ Returning from Google is a return, not a fresh arrival. The page says so
272
+ before the network does, so the moment never reads as a failure. One
273
+ hairline crossing one track — the spine, laid on its side. */
274
+ .boot{display:none;min-height:74vh;flex-direction:column;align-items:flex-start;
275
+ justify-content:center;max-width:64rem;margin:0 auto;padding:0 1.5rem}
276
+ body.booting>.boot{display:flex}
277
+ body.booting>.wrap,body.booting>section,body.booting>footer{display:none}
278
+ .boot .logo{font-weight:750;letter-spacing:-.03em;font-size:1rem}
279
+ .boot .track{width:min(21rem,100%);height:1px;background:var(--rule);margin-top:1.7rem;overflow:hidden}
280
+ .boot .track i{display:block;height:100%;width:34%;background:var(--live);
281
+ animation:cross 1.1s ease-in-out infinite}
282
+ .boot .track[data-done=yes] i{width:100%;animation:none;transform:none}
283
+ .boot p{font-family:var(--mono);font-size:.78rem;color:var(--soft);margin:.95rem 0 0}
284
+ @keyframes cross{from{transform:translateX(-105%)}to{transform:translateX(325%)}}
285
+ /* Held back a beat, so a session that restores quickly shows plain paper
286
+ rather than a flash of text nobody had time to read. */
287
+ .boot>*{opacity:0;animation:boot-in .3s ease .24s forwards}
288
+ @keyframes boot-in{to{opacity:1}}
289
+ @media(prefers-reduced-motion:reduce){
290
+ .boot>*{opacity:1;animation:none}
291
+ .boot .track i{width:100%;transform:none}
292
+ }
293
+
294
+ footer{border-top:1px solid var(--rule);padding:2.4rem 0 3.6rem;color:var(--soft);font-size:.9rem}
295
+ footer .r{font-family:var(--mono);font-size:.74rem;margin-top:.6rem}
296
+ </style>
297
+
298
+ <div class="boot" id="boot">
299
+ <span class="logo">⌑ Sealkeep</span>
300
+ <div class="track" id="boot-track"><i></i></div>
301
+ <p id="boot-note" role="status" aria-live="polite">Signing you in</p>
302
+ </div>
303
+
304
+ <div class="wrap">
305
+ <nav>
306
+ <span class="logo">⌑ Sealkeep</span>
307
+ <span class="sp"></span>
308
+ <a class="guest" href="#plans">Plans</a>
309
+ <a class="guest" href="#start">Start</a>
310
+ <a href="#account">Account</a>
311
+ </nav>
312
+
313
+ <div class="spined">
314
+
315
+ <header>
316
+ <h1>Your agent history, sealed.</h1>
317
+ <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>
318
+
319
+ <div class="duo">
320
+ <div class="human">
321
+ <div class="cmd">
322
+ <code id="cmd">npm install -g sealkeep</code>
323
+ <button class="primary" id="copy">Copy</button>
324
+ </div>
325
+ <p class="fine">Node 22+ · macOS, Linux, Windows · then <code>sealkeep start</code></p>
326
+ <p class="side" style="margin-top:2rem">on this machine · claude · 14:22</p>
327
+ <div class="plain"><b>user</b> deploy the payment service
328
+ <b>assistant</b> checking the stripe webhook config</div>
329
+ </div>
330
+ <div class="machine">
331
+ <p class="side">in storage · 14:22</p>
332
+ <div class="cipher" id="cipher" aria-hidden="true"></div>
333
+ <p class="seal-foot">Nothing readable crosses this line. Not to us, not to anyone holding the disk.</p>
334
+ </div>
335
+ </div>
336
+ </header>
337
+
338
+ <section>
339
+ <h2>What it does</h2>
340
+ <p class="sub">Three things, and it does them without ever seeing your work.</p>
341
+
342
+ <div class="row-item duo">
343
+ <div class="human">
344
+ <span class="mark">Search</span>
345
+ <h3>Find the session, not the file</h3>
346
+ <p>Search by what you said in it. Metadata instantly; full text on any machine holding your phrase.</p>
347
+ </div>
348
+ <div class="machine"><div class="ev">$ sealkeep search "stripe webhook"<br><b>3 sessions</b> · 12 Aug, 4 Aug, 27 Jul</div></div>
349
+ </div>
350
+
351
+ <div class="row-item duo">
352
+ <div class="human">
353
+ <span class="mark">Reclaim</span>
354
+ <h3>Get the disk back</h3>
355
+ <p>Sources move to the trash only once the archive is verified in storage — never <code>unlink</code>, never early.</p>
356
+ </div>
357
+ <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>
358
+ </div>
359
+
360
+ <div class="row-item duo">
361
+ <div class="human">
362
+ <span class="mark">Seal</span>
363
+ <h3>Encrypted before it moves</h3>
364
+ <p>Sealed on your machine. Storage holds ciphertext, and we hold no key that opens it.</p>
365
+ </div>
366
+ <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>
367
+ </div>
368
+ </section>
369
+
370
+ <section id="plans">
371
+ <h2>Two ways to run it</h2>
372
+ <p class="sub">Same encryption either way. The only difference is which side of the line the bucket sits on.</p>
373
+ <div class="duo tight">
374
+ <div class="human"><div class="tier">
375
+ <div class="who">Free</div>
376
+ <div class="price">$0 <small>· your bucket</small></div>
377
+ <ul>
378
+ <li>Encryption, archiving, search, retention</li>
379
+ <li>Guided setup with a least-privilege policy</li>
380
+ <li class="cost">You create the bucket and its credential</li>
381
+ <li class="cost">The setup time is yours</li>
382
+ </ul>
383
+ </div></div>
384
+ <div class="machine"><div class="tier paid">
385
+ <div class="who">Paid</div>
386
+ <div class="price">from $6<small>/mo · you pick the size</small></div>
387
+ <ul>
388
+ <li>100 GB $6 · 500 GB $12 · 2 TB $30 per month</li>
389
+ <li>Nothing to configure — no IAM, no keys, no console</li>
390
+ <li>Your machine uploads straight to storage</li>
391
+ <li>A sealed backup of your key, opened only by your phrase</li>
392
+ <li>Usage and quota in one command</li>
393
+ </ul>
394
+ </div></div>
395
+ </div>
396
+ </section>
397
+
398
+ <section id="start">
399
+ <h2>Getting started</h2>
400
+ <p class="sub">Four steps either way. The phrase step is the one that matters.</p>
401
+ <div class="tabs">
402
+ <button id="t-free" aria-selected="true">Free</button>
403
+ <button id="t-paid" aria-selected="false">Paid</button>
404
+ </div>
405
+
406
+ <div class="pane on" id="p-free">
407
+ <ol class="steps">
408
+ <li><h3>Install</h3><pre>npm install -g sealkeep</pre></li>
409
+ <li><h3>Choose free, pick a provider</h3><p>Three questions: provider, bucket, prefix.</p><pre>sealkeep start</pre></li>
410
+ <li><h3>Write down the phrase</h3><p>Twenty-four words, shown once, generated here and never sent anywhere.</p></li>
411
+ <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
412
+ sealkeep autopilot</pre></li>
413
+ </ol>
414
+ </div>
415
+
416
+ <div class="pane" id="p-paid">
417
+ <ol class="steps">
418
+ <li><h3>Install</h3><pre>npm install -g sealkeep</pre></li>
419
+ <li><h3>Create an account</h3><p>Below, or in the terminal. Nothing else to set up.</p><pre>sealkeep start</pre></li>
420
+ <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>
421
+ <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
422
+ sealkeep cloud status</pre></li>
423
+ </ol>
424
+ </div>
425
+
426
+ <div class="phrase">
427
+ <strong>The phrase is the only key.</strong>
428
+ <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>
429
+ </div>
430
+ </section>
431
+
432
+ <section id="restore" class="spined">
433
+ <h2>Getting one back</h2>
434
+ <p class="sub">The part most backup tools make you find out about during the emergency.</p>
435
+ <div class="duo">
436
+ <div class="human">
437
+ <h3>Two things, not three</h3>
438
+ <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>
439
+ <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>
440
+ <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>
441
+ </div>
442
+ <div class="machine">
443
+ <pre>npm install -g sealkeep
444
+ sealkeep cloud login --email you@company.com
445
+ sealkeep cloud archives
446
+
447
+ ref archived size
448
+ 08424eb6… 2 hours ago 5.3 KB
449
+ a91c40f7… yesterday 2.5 MB
450
+
451
+ sealkeep open 08424eb6… ./session.jsonl \
452
+ --recovery-phrase "…"
453
+
454
+ ok Opened 5.3 KB from managed storage
455
+ to ./session.jsonl</pre>
456
+ <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>
457
+ </div>
458
+ </div>
459
+ </section>
460
+
461
+ </div>
462
+ </div>
463
+
464
+ <section id="account"><div class="wrap">
465
+ <h2 class="acct-intro">Your account</h2>
466
+ <p class="sub acct-intro">Create one here, then finish on your machine — that is where your keys are made.</p>
467
+
468
+ <div class="tabs" id="auth-tabs">
469
+ <button id="t-signup" aria-selected="true">Create account</button>
470
+ <button id="t-signin" aria-selected="false">Sign in</button>
471
+ </div>
472
+
473
+ <form class="pane on" id="p-signup">
474
+ <button class="google" type="button" id="g-up">
475
+ <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>
476
+ Continue with Google
477
+ </button>
478
+ <div class="or">or use a password</div>
479
+ <div class="row2">
480
+ <div><label for="su-email">Email</label><input id="su-email" type="email" autocomplete="email" required></div>
481
+ <div><label for="su-label">Account name</label><input id="su-label" autocomplete="name" required></div>
482
+ </div>
483
+ <label for="su-pass">Password</label>
484
+ <input id="su-pass" type="password" autocomplete="new-password" placeholder="at least 12 characters" required minlength="12">
485
+ <button class="primary" id="su-go">Create account</button>
486
+ <p class="msg" id="su-msg" role="status" aria-live="polite"></p>
487
+ </form>
488
+
489
+ <form class="pane" id="p-signin">
490
+ <button class="google" type="button" id="g-in">
491
+
492
+ Continue with Google
493
+ </button>
494
+ <div class="or">or use a password</div>
495
+ <label for="si-email">Email</label>
496
+ <input id="si-email" type="email" autocomplete="email" required>
497
+ <label for="si-pass">Password</label>
498
+ <input id="si-pass" type="password" autocomplete="current-password" required>
499
+ <button class="primary" id="si-go">Sign in</button>
500
+ <p class="msg" id="si-msg" role="status" aria-live="polite"></p>
501
+ </form>
502
+
503
+ <div class="pane" id="p-account">
504
+ <div class="ph" id="a-strip" tabindex="-1">
505
+ <span class="name" id="a-label">—</span>
506
+ <span class="chip" id="a-plan">—</span>
507
+ <span class="who" id="a-who">—</span>
508
+ </div>
509
+
510
+ <div class="card next" id="a-firstrun" style="display:none">
511
+ <span class="eyebrow">Set up</span>
512
+ <h3 class="lead">Connect your first machine</h3>
513
+ <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>
514
+ <div id="pair-home-first"></div>
515
+ </div>
516
+
517
+ <div class="notice soft" id="a-verify" style="display:none">
518
+ <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>
519
+ <button class="primary" id="a-verify-go" type="button">Send the link</button>
520
+ <span class="saved" id="a-verify-msg" role="status" aria-live="polite"></span>
521
+ </div>
522
+
523
+ <div class="card">
524
+ <span class="eyebrow">Storage</span>
525
+ <div id="a-store-free" style="display:none">
526
+ <h3>You run the bucket</h3>
527
+ <p>Archives go to storage you own, under your own credentials. Sealkeep never holds them, so there is nothing to meter here.</p>
528
+ <p class="empty">Your machine reports what it has stored. Run <code>sealkeep status</code> to see it.</p>
529
+ </div>
530
+ <div id="a-store-paid" style="display:none">
531
+ <div class="kv">
532
+ <div><span class="k">Stored</span><b id="a-used">—</b></div>
533
+ <div><span class="k">Included</span><b id="a-quota-b">—</b></div>
534
+ <div><span class="k">Archives</span><b id="a-count">—</b></div>
535
+ </div>
536
+ <div class="bar"><i id="a-bar"></i></div>
537
+ <p class="msg" id="a-quota"></p>
538
+ </div>
539
+ </div>
540
+
541
+ <div class="card" id="c-archives" style="display:none">
542
+ <span class="eyebrow">Archives</span>
543
+ <p id="s-intro">Deleting one here removes the stored copy. If your machine already reclaimed the source, this is the last copy.</p>
544
+ <p class="msg" id="s-msg">Loading…</p>
545
+ <div id="s-wrap" style="display:none;overflow-x:auto">
546
+ <table>
547
+ <thead><tr><th>Ref</th><th>Size</th><th>State</th><th>Added</th><th></th></tr></thead>
548
+ <tbody id="s-body"></tbody>
549
+ </table>
550
+ <p class="msg" id="s-foot"></p>
551
+ </div>
552
+ </div>
553
+
554
+ <div class="card">
555
+ <span class="eyebrow">Plan</span>
556
+ <div id="a-plan-free" style="display:none">
557
+ <h3>Free — your bucket, your bill</h3>
558
+ <p>Unlimited archives with the same encryption. You supply the storage credentials and pay your provider directly.</p>
559
+ <p class="empty">Want us to run storage instead? Managed storage is $6 a month for 100 GB, $12 for 500 GB, or $30 for 2 TB. Write to <span id="a-sales">hello@spala.ai</span> from this address and we will switch it on.</p>
560
+ </div>
561
+ <div id="a-plan-paid" style="display:none">
562
+ <h3>Managed storage</h3>
563
+ <p>We run the bucket and meter what you store. We still hold no key that opens any of it.</p>
564
+ <div class="kv" style="margin-top:1rem">
565
+ <div><span class="k">Subscription</span><b id="a-sub-state">—</b></div>
566
+ <div><span class="k">Renews</span><b id="a-sub-end">—</b></div>
567
+ <div><span class="k">Billed through</span><b id="a-sub-prov">—</b></div>
568
+ </div>
569
+ <p class="empty" id="a-sub-note"></p>
570
+ </div>
571
+ </div>
572
+
573
+ <div class="card" id="a-pack-card">
574
+ <span class="eyebrow">Your package</span>
575
+ <p>A package is a number of gigabytes. Which provider holds the ciphertext is our problem — we pick the cheapest that has free egress, and can move between them without you doing anything, because an archive carries everything needed to open it.</p>
576
+ <div class="rules" id="a-packs"></div>
577
+ <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>
578
+ </div>
579
+
580
+ <div class="card">
581
+ <span class="eyebrow">What leaves this machine</span>
582
+ <p>Everything you do not choose here stays local and is never uploaded.</p>
583
+ <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>
584
+ <div class="rules">
585
+ <label class="choice" data-on="yes">
586
+ <input type="radio" name="sync" value="all" checked>
587
+ <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>
588
+ </label>
589
+ <label class="choice" data-on="no">
590
+ <input type="radio" name="sync" value="recent">
591
+ <span><span class="t">Only the most recent</span>
592
+ <span class="d">Keep a rolling window. Older sessions stay local and are never uploaded.</span>
593
+ <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>
594
+ </span>
595
+ </label>
596
+ <label class="choice" data-on="no">
597
+ <input type="radio" name="sync" value="settled">
598
+ <span><span class="t">Only once they have settled</span>
599
+ <span class="d">Wait before uploading, so work you are still iterating on stays put.</span>
600
+ <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>
601
+ </span>
602
+ </label>
603
+ <button class="primary" id="r-save" type="button" style="margin-top:1.1rem">Save rules</button>
604
+ <span class="saved" id="r-msg" role="status" aria-live="polite"></span>
605
+ </div>
606
+ </div>
607
+
608
+ <div class="card">
609
+ <span class="eyebrow">Where your archives live</span>
610
+ <p>Archives are encrypted on your machine before anything moves, on both options. This only decides who owns the bucket the ciphertext lands in.</p>
611
+ <div class="rules">
612
+ <label class="choice" data-on="yes">
613
+ <input type="radio" name="stor" value="managed" checked>
614
+ <span><span class="t">Let Sealkeep hold it</span>
615
+ <span class="d">Pick how many gigabytes and stop thinking about it. No bucket to create, no IAM policy, no access keys, no console. We choose the provider and absorb the storage bill inside your plan.</span></span>
616
+ </label>
617
+ <label class="choice" data-on="no">
618
+ <input type="radio" name="stor" value="byo">
619
+ <span><span class="t">Use my own bucket</span>
620
+ <span class="d">Your storage account, your bill, your retention rules. Sealkeep records the destination and nothing else — your access keys stay on your machine and are never sent here.</span>
621
+ <div class="row2" style="margin-top:.7rem">
622
+ <div><label for="st-prov">Provider</label>
623
+ <select id="st-prov">
624
+ <option value="r2">Cloudflare R2 — no egress fees</option>
625
+ <option value="s3">Amazon S3 or any S3-compatible</option>
626
+ <option value="gcs">Google Cloud Storage</option>
627
+ </select></div>
628
+ <div><label for="st-bucket">Bucket</label><input id="st-bucket" placeholder="my-sealkeep-archives"></div>
629
+ </div>
630
+ <div class="row2">
631
+ <div><label for="st-region">Region</label><input id="st-region" placeholder="auto"></div>
632
+ <div><label for="st-prefix">Prefix</label><input id="st-prefix" placeholder="sealkeep/"></div>
633
+ </div>
634
+ </span>
635
+ </label>
636
+ <button class="primary" id="st-save" type="button" style="margin-top:1.1rem">Save destination</button>
637
+ <span class="saved" id="st-msg" role="status" aria-live="polite"></span>
638
+ </div>
639
+ <p class="why">Either way we hold no key that opens an archive. The difference is only who pays the storage bill and who can delete the bucket.</p>
640
+ </div>
641
+
642
+ <div class="card">
643
+ <span class="eyebrow">Machines</span>
644
+ <p>Each connected machine signs its own uploads. Revoking one stops it writing here; the archives it already stored are untouched.</p>
645
+ <ul class="lines" id="d-list"></ul>
646
+ <p class="empty" id="d-msg">Loading…</p>
647
+
648
+ <!-- One pairing block exists on the page. It leads the panel while no
649
+ machine is connected, and JS moves it back into this slot once one
650
+ is, so the same code is never printed in two places. -->
651
+ <div id="pair-home-more">
652
+ <div class="pair" id="pair">
653
+ <p class="side" id="pair-side" style="display:none">Pairing code</p>
654
+ <!-- Grouped and labelled, so the copy button is announced as
655
+ belonging to the code rather than as a bare "Copy". -->
656
+ <div class="pair-field" id="pair-shown" role="group" aria-labelledby="pair-side" style="display:none">
657
+ <code class="pair-code" id="pair-code"></code>
658
+ <button class="primary" id="pair-copy" type="button">Copy</button>
659
+ </div>
660
+ <p class="fine" id="pair-note"></p>
661
+ <div class="inline" id="pair-ask">
662
+ <button class="ghost" id="pair-go" type="button">Show a pairing code</button>
663
+ <span class="saved" id="pair-msg" role="status" aria-live="polite"></span>
664
+ </div>
665
+ <details class="alt" id="pair-alt" style="display:none">
666
+ <summary>Set the machine up from the terminal instead</summary>
667
+ <p class="empty">The same code, typed as one command. Needs Node 22 or newer.</p>
668
+ <div class="pair-field"><pre id="pair-cli"></pre>
669
+ <button class="ghost" id="pair-cli-copy" type="button">Copy</button>
670
+ </div>
671
+ </details>
672
+ </div>
673
+ </div>
674
+ </div>
675
+
676
+ <div class="card" id="c-recovery">
677
+ <span class="eyebrow">Recovery key</span>
678
+ <h3 id="e-head">Loading…</h3>
679
+ <p id="e-text"></p>
680
+ </div>
681
+
682
+ <div class="card">
683
+ <span class="eyebrow">Activity</span>
684
+ <p>Every change made to this account, newest first.</p>
685
+ <ul class="lines" id="v-list"></ul>
686
+ <p class="empty" id="v-msg">Loading…</p>
687
+ </div>
688
+
689
+ <div class="card">
690
+ <span class="eyebrow">Account</span>
691
+ <label for="a-name">Account name</label>
692
+ <div class="inline">
693
+ <input id="a-name" maxlength="60" autocomplete="off">
694
+ <button class="ghost" id="a-rename" type="button">Save name</button>
695
+ <span class="saved" id="a-rename-msg" role="status" aria-live="polite"></span>
696
+ </div>
697
+
698
+ <!-- The setup commands used to be printed here as well as in the
699
+ first-run card. There is now one place to connect a machine, and it
700
+ is the pairing block above. -->
701
+ <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>
702
+ <button class="ghost" id="a-out" style="margin-top:1rem">Sign out</button>
703
+ </div>
704
+ </div>
705
+ </div></section>
706
+
707
+ <footer><div class="wrap">
708
+ Encryption happens on your machine on both plans.
709
+ <div class="r">Sealkeep · sealed local-first archive</div>
710
+ </div></footer>
711
+
712
+ <script>
713
+ const $ = (id) => document.getElementById(id);
714
+ const API = "/p04946/api";
715
+ // sessionStorage, not localStorage: a bearer token should die with the tab
716
+ // rather than rest on disk.
717
+ const TOKEN = "vaultline.token";
718
+ const motionOK = () => !matchMedia("(prefers-reduced-motion: reduce)").matches;
719
+
720
+ /* ------------------------------------------------------------- returning
721
+ Coming back from Google used to drop the reader at the top of the
722
+ marketing page while an unseen fetch decided their fate — indistinguishable
723
+ from a sign-in that failed. Three things were racing: the browser restoring
724
+ a scroll position, the browser's own anchor scroll, and an async sign-in
725
+ that deletes the entire page underneath both of them. The page now claims
726
+ the moment up front and routes the hash itself once the outcome is known. */
727
+ const params = new URLSearchParams(location.search);
728
+ const authCode = params.get("code");
729
+ const confirmToken = params.get("confirm");
730
+ const confirmAccount = params.get("account");
731
+ const savedToken = sessionStorage.getItem(TOKEN);
732
+ const returning = Boolean(authCode) || sessionStorage.getItem("vaultline.returning") === "1";
733
+ // Only take the wheel when the layout is about to change under it. A plain
734
+ // read of the marketing page keeps the browser's own scroll restoration.
735
+ const resuming = Boolean(authCode || (confirmToken && confirmAccount) || savedToken);
736
+ if (resuming) {
737
+ if ("scrollRestoration" in history) history.scrollRestoration = "manual";
738
+ document.body.classList.add("booting");
739
+ }
740
+
741
+ const bootSay = (text) => { $("boot-note").textContent = text; };
742
+
743
+ // Landing in the panel is the same act however you got there: put the top of
744
+ // the panel at the top of the window, and put the reading position on the
745
+ // identity strip so a screen reader is told whose account this is.
746
+ function enterPanel() {
747
+ window.scrollTo(0, 0);
748
+ $("a-strip").focus({ preventScroll: true });
749
+ }
750
+ function bootExit() {
751
+ document.body.classList.remove("booting");
752
+ // The panel was rendered while it was still hidden, so nothing could take
753
+ // focus or scroll until now.
754
+ if (document.body.classList.contains("panel")) enterPanel();
755
+ }
756
+ // Confirmed exits hold one beat on "Signed in" so the return has an ending
757
+ // rather than just a disappearance. A silently resumed session skips it.
758
+ function bootDone(confirmed) {
759
+ if (!document.body.classList.contains("booting")) return Promise.resolve();
760
+ if (!confirmed) { bootExit(); return Promise.resolve(); }
761
+ bootSay("Signed in");
762
+ $("boot-track").dataset.done = "yes";
763
+ return new Promise((finished) => setTimeout(() => { bootExit(); finished(); }, motionOK() ? 460 : 0));
764
+ }
765
+
766
+ /* --- signature: the seal settles once, then stays still --- */
767
+ (function seal() {
768
+ const el = $("cipher");
769
+ const glyphs = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz0123456789+/";
770
+ const target = "vGx8Kq2mNbZ4tRw9YpLd3cHfA7sJeU1oQiT6vXn0MyBkR5aWzDgP4uCjE8hSlF2N"
771
+ + "kR9wQ7pAzXm3TbYvL6dHcJ1fUsN4oiGeMy0BW5aKgPjD8uCE2hSlFqT6vXn0Myx";
772
+ // The column wraps in CSS now, so the text is one unbroken run.
773
+ if (matchMedia("(prefers-reduced-motion: reduce)").matches) { el.textContent = target; return; }
774
+ let frame = 0;
775
+ const total = 26;
776
+ const tick = () => {
777
+ // Settle left to right, so it reads as the line being sealed as it
778
+ // crosses the boundary rather than as decoration.
779
+ const settled = Math.floor((frame / total) * target.length);
780
+ let out = target.slice(0, settled);
781
+ for (let i = settled; i < target.length; i++) out += glyphs[(Math.random() * glyphs.length) | 0];
782
+ el.textContent = out;
783
+ if (frame++ < total) requestAnimationFrame(tick);
784
+ else el.textContent = target;
785
+ };
786
+ requestAnimationFrame(tick);
787
+ })();
788
+
789
+ // One copy control, used everywhere a string is meant to be taken away. The
790
+ // pairing code is the string this whole panel exists to hand over, so it gets
791
+ // the same affordance the hero already gives an install command.
792
+ function copies(buttonId, read) {
793
+ const button = $(buttonId);
794
+ button.addEventListener("click", async () => {
795
+ const label = button.dataset.label || button.textContent;
796
+ button.dataset.label = label;
797
+ try { await navigator.clipboard.writeText(read()); button.textContent = "Copied"; }
798
+ catch { button.textContent = "Copy failed"; }
799
+ setTimeout(() => { button.textContent = label; }, 1600);
800
+ });
801
+ }
802
+ copies("copy", () => $("cmd").textContent.trim());
803
+ copies("pair-copy", () => $("pair-code").textContent.trim());
804
+ copies("pair-cli-copy", () => $("pair-cli").textContent.trim());
805
+
806
+ const startTabs = { free: [$("t-free"), $("p-free")], paid: [$("t-paid"), $("p-paid")] };
807
+ for (const [key, [tab]] of Object.entries(startTabs))
808
+ tab.addEventListener("click", () => {
809
+ for (const [other, [b, pane]] of Object.entries(startTabs)) {
810
+ b.setAttribute("aria-selected", String(other === key));
811
+ pane.classList.toggle("on", other === key);
812
+ }
813
+ });
814
+
815
+ /* ---------------------------------------------------------------- account */
816
+ const say = (el, text, cls) => { const n = $(el); n.textContent = text; n.className = "msg " + (cls || ""); };
817
+ /* Every error a catch renders into the page goes through this: the first 200
818
+ characters are a message, anything past that is a payload nobody meant to
819
+ put on screen. */
820
+ const errText = (error) => String((error && error.message) || error || "Something went wrong").slice(0, 200);
821
+ const bytes = (n) => {
822
+ n = Number(n) || 0;
823
+ for (const [u, d] of [["GB", 1024 ** 3], ["MB", 1024 ** 2], ["KB", 1024]])
824
+ if (n >= d) return (n / d).toFixed(1) + " " + u;
825
+ return n + " B";
826
+ };
827
+ const ago = (iso) => {
828
+ const s = (Date.now() - new Date(iso).getTime()) / 1000;
829
+ if (!isFinite(s)) return "—";
830
+ for (const [n, u] of [[86400 * 30, "mo"], [86400, "d"], [3600, "h"], [60, "m"]])
831
+ if (s >= n) return Math.floor(s / n) + u + " ago";
832
+ return "just now";
833
+ };
834
+
835
+ async function api(path, { method = "GET", body, token } = {}) {
836
+ const headers = {};
837
+ if (body) headers["content-type"] = "application/json";
838
+ if (token) headers.authorization = "Bearer " + token;
839
+ const res = await fetch(API + path, { method, headers, body: body && JSON.stringify(body) });
840
+ // A gateway answering for a dead service sends an HTML error page, not
841
+ // JSON. That markup used to ride error.message straight into spans like
842
+ // pair-msg — raw <html> on screen. When the body does not parse, the
843
+ // status code is the only fact in it worth repeating; no raw body text
844
+ // ever becomes a message.
845
+ let data = null;
846
+ let parsed = true;
847
+ try { data = await res.json(); } catch { parsed = false; }
848
+ if (!res.ok) {
849
+ const message = parsed
850
+ ? String(data?.error || `${res.status} ${res.statusText}`)
851
+ : `The service did not answer (HTTP ${res.status}). Try again in a minute.`;
852
+ throw new Error(message.slice(0, 200));
853
+ }
854
+ return data;
855
+ }
856
+
857
+ function showPane(which) {
858
+ const wasPanel = document.body.classList.contains("panel");
859
+ for (const [id, on] of [["p-signup", which === "signup"], ["p-signin", which === "signin"], ["p-account", which === "account"]])
860
+ $(id).classList.toggle("on", on);
861
+ // Signed in, the panel is the page. A customer checking their storage
862
+ // should not have to scroll past the pitch that sold them.
863
+ document.body.classList.toggle("panel", which === "account");
864
+ $("auth-tabs").style.display = which === "account" ? "none" : "";
865
+ for (const [b, key] of [["t-signup", "signup"], ["t-signin", "signin"]])
866
+ $(b).setAttribute("aria-selected", String(which === key));
867
+ // Only on the way in, and only once the panel is actually on screen. A
868
+ // refresh after deleting an archive should leave the reader where they
869
+ // were rather than throwing them back to the top.
870
+ if (which === "account" && !wasPanel && !document.body.classList.contains("booting")) enterPanel();
871
+ }
872
+
873
+ /* "Account" is a promise the page has to keep on the first click, not the
874
+ second. Routing it here rather than leaving it to the native anchor means
875
+ exactly one thing is scrolling, and it runs after the state that decides
876
+ where the account actually is. */
877
+ function goAccount() {
878
+ if (document.body.classList.contains("panel")) { window.scrollTo(0, 0); return; }
879
+ $("account").scrollIntoView({ behavior: motionOK() ? "smooth" : "auto", block: "start" });
880
+ }
881
+ for (const link of document.querySelectorAll('a[href="#account"]'))
882
+ link.addEventListener("click", (event) => {
883
+ event.preventDefault();
884
+ if (location.hash !== "#account") history.pushState(null, "", "#account");
885
+ goAccount();
886
+ });
887
+ addEventListener("hashchange", () => { if (location.hash === "#account") goAccount(); });
888
+
889
+ const on = (id, show) => { $(id).style.display = show ? "" : "none"; };
890
+ const day = (iso) => {
891
+ const d = new Date(iso);
892
+ return isFinite(d.getTime())
893
+ ? d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" })
894
+ : "—";
895
+ };
896
+
897
+ function renderAccount(user) {
898
+ // The free plan writes to the customer's own bucket, so we hold nothing and
899
+ // meter nothing. Showing a quota bar there would be a meter that never moves.
900
+ const paid = user.plan === "pro";
901
+
902
+ $("a-label").textContent = user.label || "Your vault";
903
+ $("a-name").value = user.label || "";
904
+ $("a-plan").textContent = paid ? "Managed storage" : "Free";
905
+ $("a-plan").className = "chip" + (paid ? " pro" : "");
906
+ $("a-who").textContent = [
907
+ user.email,
908
+ "joined " + day(user.created_at),
909
+ user.auth_provider === "google" ? "signed in with Google" : null,
910
+ user.state === "suspended" ? "SUSPENDED" : null
911
+ ].filter(Boolean).join(" · ");
912
+
913
+ on("a-verify", !user.email_verified_at);
914
+
915
+ // Plan and storage destination are independent: a paid account may choose
916
+ // its own bucket. Metering it against a quota we do not hold would be a
917
+ // bar that never moves for bytes we never see, so the card follows where
918
+ // the archives actually go. loadStorage2 corrects this once the saved
919
+ // destination arrives; the plan is only the opening guess.
920
+ lastPaid = paid;
921
+ applyStorageView();
922
+ on("c-archives", paid);
923
+ on("a-plan-free", !paid);
924
+ on("a-plan-paid", paid);
925
+
926
+ if (paid) {
927
+ const quota = Number(user.quota_bytes) || 0, used = Number(user.used_bytes) || 0;
928
+ $("a-used").textContent = bytes(used);
929
+ $("a-quota-b").textContent = quota ? bytes(quota) : "—";
930
+ $("a-bar").style.width = quota ? Math.min(100, (used / quota) * 100) + "%" : "0%";
931
+ $("a-quota").textContent = quota
932
+ ? bytes(used) + " of " + bytes(quota) + " used" + (used >= quota * 0.9 ? " — close to the limit" : "")
933
+ : "";
934
+
935
+ const sub = user.subscription;
936
+ $("a-sub-state").textContent = sub ? sub.state : "not recorded";
937
+ $("a-sub-end").textContent = sub && sub.current_period_end ? day(sub.current_period_end) : "—";
938
+ $("a-sub-prov").textContent = sub && sub.provider ? sub.provider : "—";
939
+ $("a-sub-note").textContent = sub && sub.state === "active"
940
+ ? "To change or cancel, write to hello@spala.ai from this address."
941
+ : "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.";
942
+ }
943
+
944
+ rulesFrom(user);
945
+ packsFrom(user);
946
+ showPane("account");
947
+ }
948
+
949
+ const ACTIONS = {
950
+ "sync_rules.update": "Changed what leaves your machine",
951
+ "device.revoke": "Revoked a machine",
952
+ "email.confirmation_sent": "Sent an email confirmation link",
953
+ "email.confirmed": "Confirmed the email address",
954
+ "auth.google": "Signed in with Google"
955
+ };
956
+
957
+ async function loadDevices(token) {
958
+ const list = $("d-list");
959
+ let deviceCount = 0;
960
+ list.replaceChildren();
961
+ try {
962
+ const { devices } = await api("/v1/cloud/devices", { token });
963
+ deviceCount = devices.length;
964
+ if (!devices.length) {
965
+ // The instruction lives with the code, not here. loadAccount points at
966
+ // it when the pairing block has moved to the top of the panel.
967
+ $("d-msg").textContent = "No machines connected yet.";
968
+ return deviceCount;
969
+ }
970
+ $("d-msg").textContent = "";
971
+ for (const d of devices) {
972
+ const li = document.createElement("li");
973
+ const name = document.createElement("span");
974
+ name.className = "grow";
975
+ name.textContent = d.label || "Unnamed machine";
976
+ const state = document.createElement("span");
977
+ const revoked = d.state === "revoked";
978
+ state.className = "state " + (revoked ? "pending" : "durable");
979
+ state.textContent = revoked ? "revoked" : "active";
980
+ const seen = document.createElement("span");
981
+ seen.className = "when";
982
+ seen.textContent = revoked ? "revoked " + ago(d.revoked_at) : "enrolled " + ago(d.created_at);
983
+ li.append(name, state, seen);
984
+
985
+ if (!revoked) {
986
+ const btn = document.createElement("button");
987
+ btn.className = "del"; btn.type = "button"; btn.textContent = "Revoke";
988
+ btn.addEventListener("click", async () => {
989
+ // Two-step, like archive deletion: revoking locks a machine out of
990
+ // its own backups, so a stray click must not do it.
991
+ if (btn.dataset.armed !== "yes") {
992
+ btn.dataset.armed = "yes"; btn.textContent = "Confirm";
993
+ setTimeout(() => {
994
+ if (btn.dataset.armed === "yes") { btn.dataset.armed = "no"; btn.textContent = "Revoke"; }
995
+ }, 4000);
996
+ return;
997
+ }
998
+ btn.disabled = true; btn.textContent = "Revoking";
999
+ try {
1000
+ await api("/v1/cloud/devices/" + encodeURIComponent(d.id) + "/revoke", { method: "POST", token });
1001
+ await loadDevices(token);
1002
+ await loadAudit(token);
1003
+ } catch (error) {
1004
+ $("d-msg").textContent = errText(error);
1005
+ btn.disabled = false; btn.textContent = "Revoke";
1006
+ }
1007
+ });
1008
+ li.append(btn);
1009
+ }
1010
+ list.append(li);
1011
+ }
1012
+ } catch (error) { $("d-msg").textContent = errText(error); }
1013
+ return deviceCount;
1014
+ }
1015
+
1016
+ async function loadAudit(token) {
1017
+ const list = $("v-list");
1018
+ list.replaceChildren();
1019
+ try {
1020
+ const { events } = await api("/v1/cloud/audit", { token });
1021
+ if (!events.length) { $("v-msg").textContent = "Nothing has changed on this account yet."; return; }
1022
+ $("v-msg").textContent = "";
1023
+ for (const e of events) {
1024
+ const li = document.createElement("li");
1025
+ const what = document.createElement("span");
1026
+ what.className = "grow";
1027
+ what.textContent = ACTIONS[e.action] || e.action;
1028
+ const when = document.createElement("span");
1029
+ when.className = "when";
1030
+ when.textContent = ago(e.at);
1031
+ li.append(what, when);
1032
+ if (e.outcome === "denied") {
1033
+ const bad = document.createElement("span");
1034
+ bad.className = "state pending";
1035
+ bad.textContent = "did not complete";
1036
+ li.append(bad);
1037
+ }
1038
+ list.append(li);
1039
+ }
1040
+ } catch (error) { $("v-msg").textContent = errText(error); }
1041
+ }
1042
+
1043
+ // atRisk is the whole design of this card. --warn is spent only where doing
1044
+ // nothing loses data: an account with no archives has nothing to lose yet, so
1045
+ // a missing sealed copy there is a step not reached, not a fault. The moment
1046
+ // archives exist it becomes the real warning it always claimed to be.
1047
+ async function loadEscrow(token, atRisk) {
1048
+ try {
1049
+ const rows = await api("/v1/cloud/escrow", { token });
1050
+ const held = Array.isArray(rows) ? rows : (rows && rows.escrow) || [];
1051
+ if (held.length) {
1052
+ $("e-head").textContent = "Sealed copy held";
1053
+ $("e-text").textContent = "Stored " + ago(held[0].created_at)
1054
+ + ". It opens only with your 24-word phrase, which never reaches us.";
1055
+ } else if (atRisk) {
1056
+ $("e-head").textContent = "No sealed copy yet";
1057
+ // Sealing needs the key, and the key is on the machine — there is no
1058
+ // web action that could do this, so the command is named. It is set as
1059
+ // a command rather than as prose, like every other command on the page.
1060
+ const cmd = document.createElement("code");
1061
+ cmd.textContent = "sealkeep recovery seal";
1062
+ $("e-text").replaceChildren(
1063
+ document.createTextNode("You have archives stored. If the machine holding your key is lost, every one of them is lost with it. Run "),
1064
+ cmd,
1065
+ document.createTextNode(" to store a copy we cannot open."));
1066
+ } else {
1067
+ $("e-head").textContent = "Nothing to seal yet";
1068
+ $("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.";
1069
+ }
1070
+ $("c-recovery").classList.toggle("warncard", !held.length && Boolean(atRisk));
1071
+ } catch (error) {
1072
+ $("e-head").textContent = "Could not read the recovery key";
1073
+ $("e-text").textContent = errText(error);
1074
+ }
1075
+ }
1076
+
1077
+ async function loadStorage(token) {
1078
+ try {
1079
+ const rows = await api("/v1/cloud/archives", { token });
1080
+ const body = $("s-body");
1081
+ body.replaceChildren();
1082
+ if (!rows.length) {
1083
+ // A caution about deleting archives is noise when there are none, and
1084
+ // an empty state is prose, so it is set in prose. .msg is mono because
1085
+ // it reports what a machine did.
1086
+ on("s-intro", false);
1087
+ $("s-msg").className = "empty";
1088
+ $("s-msg").textContent = "Nothing stored yet. Archives appear here on their own once a machine is connected.";
1089
+ $("s-wrap").style.display = "none";
1090
+ return;
1091
+ }
1092
+ on("s-intro", true);
1093
+
1094
+ for (const r of rows) {
1095
+ const tr = document.createElement("tr");
1096
+ // textContent throughout: an archive ref is data, never markup.
1097
+ const ref = document.createElement("td");
1098
+ const code = document.createElement("code"); code.textContent = String(r.vault_ref).slice(0, 8); ref.append(code);
1099
+ const size = document.createElement("td"); size.textContent = bytes(r.bytes);
1100
+ const st = document.createElement("td");
1101
+ const s = document.createElement("span");
1102
+ s.className = "state " + (r.state === "durable" ? "durable" : "pending");
1103
+ s.textContent = r.state === "durable" ? "stored" : "incomplete";
1104
+ st.append(s);
1105
+ const when = document.createElement("td"); when.textContent = ago(r.durable_at || r.created_at);
1106
+ const act = document.createElement("td");
1107
+ const btn = document.createElement("button");
1108
+ btn.className = "del"; btn.type = "button"; btn.textContent = "Delete";
1109
+ btn.addEventListener("click", async () => {
1110
+ // Two-step: an archive is the last copy once the source is reclaimed,
1111
+ // so one stray click must not remove it.
1112
+ if (btn.dataset.armed !== "yes") {
1113
+ btn.dataset.armed = "yes"; btn.textContent = "Confirm";
1114
+ setTimeout(() => { if (btn.dataset.armed === "yes") { btn.dataset.armed = "no"; btn.textContent = "Delete"; } }, 4000);
1115
+ return;
1116
+ }
1117
+ btn.disabled = true; btn.textContent = "Deleting";
1118
+ try {
1119
+ await api("/v1/cloud/archives/" + encodeURIComponent(r.vault_ref), { method: "DELETE", token });
1120
+ await loadAccount(token);
1121
+ } catch (error) { say("s-msg", errText(error), "err"); btn.disabled = false; btn.textContent = "Delete"; }
1122
+ });
1123
+ act.append(btn);
1124
+ tr.append(ref, size, st, when, act);
1125
+ body.append(tr);
1126
+ }
1127
+
1128
+ const stored = rows.filter((r) => r.state === "durable").length;
1129
+ say("s-msg", "");
1130
+ $("a-count").textContent = String(rows.length);
1131
+ $("s-foot").textContent = `${rows.length} archive${rows.length === 1 ? "" : "s"} · ${stored} fully stored`
1132
+ + (stored < rows.length ? ` · ${rows.length - stored} incomplete, not billed` : "");
1133
+ $("s-wrap").style.display = "";
1134
+ } catch (error) { say("s-msg", errText(error), "err"); }
1135
+ }
1136
+
1137
+ async function loadAccount(token) {
1138
+ let user;
1139
+ try { user = await api("/me", { token }); }
1140
+ catch { sessionStorage.removeItem(TOKEN); return false; }
1141
+
1142
+ renderAccount(user);
1143
+ // Each section fails on its own. One dead panel must not blank the rest.
1144
+ if (user.plan === "pro") { $("a-count").textContent = "—"; loadStorage(token); }
1145
+ loadAudit(token);
1146
+ loadStorage2(token);
1147
+ const devices = await loadDevices(token);
1148
+
1149
+ // A brand-new account has exactly one useful next step. It leads the panel,
1150
+ // and it leads with the code rather than with a terminal — a paying
1151
+ // customer should not have to be a terminal user to finish setting up.
1152
+ const nothingYet = devices === 0 && Number(user.used_bytes) === 0;
1153
+ on("a-firstrun", nothingYet);
1154
+ $(nothingYet ? "pair-home-first" : "pair-home-more").append($("pair"));
1155
+ $("pair-side").textContent = nothingYet ? "Your pairing code" : "Pairing code";
1156
+ if (devices === 0)
1157
+ $("d-msg").textContent = nothingYet
1158
+ ? "No machines connected yet. Use the pairing code at the top of this page."
1159
+ : "No machines connected yet.";
1160
+ // On first run there is one reason to be here, so the code is ready rather
1161
+ // than hidden behind a click. Anywhere else, adding a machine stays a
1162
+ // deliberate act — codes are single use and should not be minted by
1163
+ // accident on every visit.
1164
+ if (nothingYet && !pairShown) mintCode(true);
1165
+
1166
+ // Escrow last, because how loud it should be depends on whether anything
1167
+ // is stored yet.
1168
+ loadEscrow(token, !nothingYet);
1169
+ return true;
1170
+ }
1171
+
1172
+ $("a-rename").addEventListener("click", async () => {
1173
+ const token = sessionStorage.getItem(TOKEN);
1174
+ const label = $("a-name").value.trim();
1175
+ if (!label) return void ($("a-rename-msg").textContent = "Give the account a name.");
1176
+ $("a-rename").disabled = true;
1177
+ $("a-rename-msg").textContent = "Saving";
1178
+ try {
1179
+ const saved = await api("/account", { method: "PATCH", token, body: { label } });
1180
+ $("a-label").textContent = saved.label;
1181
+ $("a-rename-msg").textContent = "Saved";
1182
+ } catch (error) { $("a-rename-msg").textContent = errText(error); }
1183
+ finally {
1184
+ $("a-rename").disabled = false;
1185
+ setTimeout(() => { $("a-rename-msg").textContent = ""; }, 5000);
1186
+ }
1187
+ });
1188
+
1189
+ // One Google mark in the markup, cloned into the second button. Same node,
1190
+ // half the bytes, and still no innerHTML anywhere on the page.
1191
+ $("g-in").prepend($("g-up").querySelector("svg").cloneNode(true));
1192
+
1193
+ const storChoices = [...document.querySelectorAll("input[name=stor]")];
1194
+ let lastPaid = false;
1195
+ let lastStorageMode = null;
1196
+ function applyStorageView() {
1197
+ const managed = lastStorageMode ? lastStorageMode !== "byo" : lastPaid;
1198
+ on("a-store-free", !managed);
1199
+ on("a-store-paid", managed);
1200
+ }
1201
+
1202
+ const paintStorage = () => {
1203
+ for (const input of storChoices) input.closest(".choice").dataset.on = input.checked ? "yes" : "no";
1204
+ };
1205
+ for (const input of storChoices) input.addEventListener("change", paintStorage);
1206
+
1207
+ async function loadStorage2(token) {
1208
+ try {
1209
+ const where = await api("/v1/cloud/storage", { token });
1210
+ const pick = storChoices.find((input) => input.value === where.mode) || storChoices[0];
1211
+ pick.checked = true;
1212
+ if (where.provider && where.provider !== "vaultline") $("st-prov").value = where.provider;
1213
+ $("st-bucket").value = where.bucket && where.mode === "byo" ? where.bucket : "";
1214
+ $("st-region").value = where.region || "";
1215
+ $("st-prefix").value = where.mode === "byo" ? (where.prefix || "") : "";
1216
+ lastStorageMode = where.mode;
1217
+ applyStorageView();
1218
+ paintStorage();
1219
+ } catch { /* the card keeps its defaults */ }
1220
+ }
1221
+
1222
+ $("st-save").addEventListener("click", async () => {
1223
+ const token = sessionStorage.getItem(TOKEN);
1224
+ const mode = (storChoices.find((input) => input.checked) || {}).value || "managed";
1225
+ $("st-save").disabled = true;
1226
+ $("st-msg").textContent = "Saving";
1227
+ try {
1228
+ const saved = await api("/v1/cloud/storage", { method: "PUT", token, body: mode === "byo"
1229
+ ? { mode, provider: $("st-prov").value, bucket: $("st-bucket").value.trim(), region: $("st-region").value.trim(), prefix: $("st-prefix").value.trim() }
1230
+ : { mode } });
1231
+ lastStorageMode = saved.mode;
1232
+ applyStorageView();
1233
+ $("st-msg").textContent = saved.mode === "byo"
1234
+ ? "Saved. Run sealkeep storage credentials set on the machine that uploads — the keys stay there."
1235
+ : "Saved. Nothing else to configure.";
1236
+ } catch (error) { $("st-msg").textContent = errText(error); }
1237
+ finally { $("st-save").disabled = false; }
1238
+ });
1239
+
1240
+ /* -------------------------------------------------------------- pairing
1241
+ An account created through Google has no password anyone knows, so a
1242
+ one-time code is the only way a machine can ever sign in. That makes this
1243
+ code the single most important string on the page, and it used to be the
1244
+ least supported one: behind a click, in small type, with no way to copy it.
1245
+ It is now the largest thing in the panel and it copies in one press. */
1246
+ let pairShown = false;
1247
+
1248
+ function showCode(code, email) {
1249
+ const field = $("pair-code");
1250
+ // Never regrouped, spaced or prettified. What is on screen is byte for byte
1251
+ // what Copy writes and what the machine will accept back.
1252
+ field.textContent = code;
1253
+ field.dataset.long = code.length > 16 ? "yes" : "no";
1254
+ $("pair-cli").textContent = "sealkeep cloud login \\\n --email " + email + " \\\n --code " + code;
1255
+ $("pair-note").textContent = "Single use. Enter it on the machine you want to archive from.";
1256
+ on("pair-shown", true);
1257
+ on("pair-alt", true);
1258
+ on("pair-ask", false);
1259
+ // A label with nothing under it is not a label. It arrives with the code.
1260
+ on("pair-side", true);
1261
+ pairShown = true;
1262
+ }
1263
+
1264
+ async function mintCode(auto) {
1265
+ const token = sessionStorage.getItem(TOKEN);
1266
+ $("pair-go").disabled = true;
1267
+ $("pair-msg").textContent = auto ? "" : "Making a code";
1268
+ try {
1269
+ const made = await api("/account/cli-token", { method: "POST", token });
1270
+ showCode(made.token, made.email);
1271
+ $("pair-msg").textContent = "";
1272
+ } catch (error) {
1273
+ // A failed mint must leave the button behind, not a dead card.
1274
+ $("pair-msg").textContent = errText(error);
1275
+ on("pair-ask", true);
1276
+ } finally { $("pair-go").disabled = false; }
1277
+ }
1278
+
1279
+ $("pair-go").addEventListener("click", () => mintCode(false));
1280
+
1281
+ $("a-verify-go").addEventListener("click", async () => {
1282
+ const token = sessionStorage.getItem(TOKEN);
1283
+ $("a-verify-go").disabled = true;
1284
+ $("a-verify-msg").textContent = "Sending";
1285
+ try {
1286
+ const result = await api("/account/send-confirmation", { method: "POST", token });
1287
+ // The endpoint reports what the mail provider actually did, so this
1288
+ // never claims a message was sent when it was refused.
1289
+ $("a-verify-msg").textContent = result.sent
1290
+ ? "Sent. Check your inbox."
1291
+ : "Email delivery is not switched on yet — nothing was sent.";
1292
+ } catch (error) { $("a-verify-msg").textContent = errText(error); }
1293
+ finally { $("a-verify-go").disabled = false; }
1294
+ });
1295
+
1296
+ $("p-signup").addEventListener("submit", async (event) => {
1297
+ event.preventDefault();
1298
+ const email = $("su-email").value.trim(), label = $("su-label").value.trim(), password = $("su-pass").value;
1299
+ if (password.length < 12) return say("su-msg", "Use at least 12 characters.", "err");
1300
+ $("su-go").disabled = true; say("su-msg", "Creating your account");
1301
+ try {
1302
+ await api("/signup", { method: "POST", body: { email, password, label } });
1303
+ const { token } = await api("/login", { method: "POST", body: { email, password } });
1304
+ sessionStorage.setItem(TOKEN, token);
1305
+ say("su-msg", "");
1306
+ await loadAccount(token);
1307
+ } catch (error) {
1308
+ say("su-msg", /exists|unique|duplicate/i.test(error.message)
1309
+ ? "That email is already registered. Sign in instead."
1310
+ : errText(error), "err");
1311
+ } finally { $("su-go").disabled = false; }
1312
+ });
1313
+
1314
+ $("p-signin").addEventListener("submit", async (event) => {
1315
+ event.preventDefault();
1316
+ $("si-go").disabled = true; say("si-msg", "Signing in");
1317
+ try {
1318
+ const { token } = await api("/login", { method: "POST", body: { email: $("si-email").value.trim(), password: $("si-pass").value } });
1319
+ sessionStorage.setItem(TOKEN, token);
1320
+ say("si-msg", "");
1321
+ await loadAccount(token);
1322
+ } catch {
1323
+ // Never distinguish "no such account" from "wrong password" — that
1324
+ // difference tells an attacker which addresses are registered.
1325
+ say("si-msg", "Email or password is not correct.", "err");
1326
+ } finally { $("si-go").disabled = false; }
1327
+ });
1328
+
1329
+ $("a-out").addEventListener("click", () => {
1330
+ sessionStorage.removeItem(TOKEN);
1331
+ showPane("signin");
1332
+ say("si-msg", "Signed out. Archiving on your connected machines carries on.");
1333
+ goAccount();
1334
+ });
1335
+ for (const [b, key] of [["t-signup", "signup"], ["t-signin", "signin"]])
1336
+ $(b).addEventListener("click", () => showPane(key));
1337
+
1338
+ /**
1339
+ * #signin and #signup land on the right tab, not merely the right section.
1340
+ *
1341
+ * The installer sends people here mid-setup — they have chosen managed
1342
+ * storage and need an account before they can go on. Dropping them on the
1343
+ * page and letting them find the tab is one more thing to get wrong at the
1344
+ * exact moment they are furthest from what they came to do, and a returning
1345
+ * customer sent to "Create account" reads it as being asked to sign up twice.
1346
+ */
1347
+ function openAuthFromHash() {
1348
+ const wanted = { "#signin": "signin", "#signup": "signup" }[location.hash];
1349
+ if (!wanted) return;
1350
+ // Never over a signed-in panel: they already have what the link was for.
1351
+ if (sessionStorage.getItem(TOKEN)) { goAccount(); return; }
1352
+ showPane(wanted);
1353
+ goAccount();
1354
+ }
1355
+ window.addEventListener("hashchange", openAuthFromHash);
1356
+
1357
+ /* ----------------------------------------------------------- google auth */
1358
+ // Authorization-code flow: the browser only ever carries a short-lived code,
1359
+ // and GOOGLE_CLIENT_SECRET stays on the server where it belongs.
1360
+ async function google(msgId) {
1361
+ say(msgId, "Opening Google");
1362
+ try {
1363
+ const { url } = await api("/google-auth/authorize");
1364
+ if (!url) throw new Error("Google sign-in is not configured yet.");
1365
+ sessionStorage.setItem("vaultline.returning", "1");
1366
+ location.assign(url);
1367
+ } catch (error) {
1368
+ say(msgId, /404|not found/i.test(error.message)
1369
+ ? "Google sign-in is not switched on yet. Use a password for now."
1370
+ : errText(error), "err");
1371
+ }
1372
+ }
1373
+ $("g-up").addEventListener("click", () => google("su-msg"));
1374
+ $("g-in").addEventListener("click", () => google("si-msg"));
1375
+
1376
+ // A failure that happens off-screen reads as no failure at all. Put the
1377
+ // reason next to the retry, and put both in front of the reader.
1378
+ async function backToSignIn(message, bad) {
1379
+ await bootDone(false);
1380
+ showPane("signin");
1381
+ say("si-msg", message, bad ? "err" : "");
1382
+ goAccount();
1383
+ }
1384
+
1385
+ async function finishGoogle(code) {
1386
+ // Strip the code from the address bar before anything else, so it is not
1387
+ // left in history or leaked by a Referer header.
1388
+ history.replaceState(null, "", location.pathname + "#account");
1389
+ sessionStorage.removeItem("vaultline.returning");
1390
+ bootSay("Signing you in");
1391
+ try {
1392
+ const { token } = await api("/google-auth/callback", { method: "POST", body: { code } });
1393
+ sessionStorage.setItem(TOKEN, token);
1394
+ say("si-msg", "");
1395
+ // loadAccount reports whether the token actually opened the account. A
1396
+ // sign-in that half worked must not exit through the confirmed path.
1397
+ if (await loadAccount(token)) return void await bootDone(true);
1398
+ await backToSignIn("That sign-in did not finish. Try it again below.", true);
1399
+ } catch (error) {
1400
+ // A code that Google rejects surfaces as an opaque execution failure,
1401
+ // which tells the reader nothing. The recovery is the same either way:
1402
+ // start again from the button.
1403
+ await backToSignIn(/execution failed/i.test(error.message)
1404
+ ? "That Google sign-in did not complete. Start again with the button below."
1405
+ : errText(error), true);
1406
+ }
1407
+ }
1408
+
1409
+ /* ----------------------------------------------------------- sync rules */
1410
+ const choices = [...document.querySelectorAll(".choice")];
1411
+ const paintChoices = () => {
1412
+ for (const c of choices) c.dataset.on = c.querySelector("input[type=radio]").checked ? "yes" : "no";
1413
+ };
1414
+ for (const c of choices) c.querySelector("input[type=radio]").addEventListener("change", paintChoices);
1415
+ paintChoices();
1416
+
1417
+ // Kept in step with src/packages.ts. A package is gigabytes and a price; the
1418
+ // provider is deliberately absent, because which one holds the bytes is ours
1419
+ // to choose and may change without the customer being involved.
1420
+ const PACKAGES = [
1421
+ { id: "free", name: "Your own bucket", gb: 0, monthly: 0, managed: false, blurb: "Your provider, your region, your bill. We never hold the bytes." },
1422
+ { 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." },
1423
+ { id: "gb100", name: "100 GB", gb: 100, monthly: 6, managed: true, blurb: "Years of transcripts from one machine." },
1424
+ { id: "gb500", name: "500 GB", gb: 500, monthly: 12, managed: true, blurb: "A whole team's history." },
1425
+ { id: "gb2000", name: "2 TB", gb: 2000, monthly: 30, managed: true, blurb: "Every agent on every machine, kept indefinitely." }
1426
+ ];
1427
+
1428
+ const packageIdFor = (user) => {
1429
+ if (user.plan === "pro_lite") return "pro_lite";
1430
+ if (user.plan !== "pro") return "free";
1431
+ const gb = Math.round((Number(user.quota_bytes) || 0) / 1073741824);
1432
+ const match = PACKAGES.find((p) => p.managed && p.gb === gb);
1433
+ return match ? match.id : "custom";
1434
+ };
1435
+
1436
+ const packsFrom = (user) => {
1437
+ const current = packageIdFor(user);
1438
+ const host = $("a-packs");
1439
+ while (host.firstChild) host.removeChild(host.firstChild);
1440
+ for (const pack of PACKAGES) {
1441
+ const row = document.createElement("div");
1442
+ row.className = "choice";
1443
+ row.dataset.on = pack.id === current ? "yes" : "no";
1444
+ const wrap = document.createElement("span");
1445
+ const title = document.createElement("span");
1446
+ title.className = "t";
1447
+ title.textContent = pack.managed ? `${pack.name} — $${pack.monthly}/mo` : `${pack.name} — free`;
1448
+ const desc = document.createElement("span");
1449
+ desc.className = "d";
1450
+ desc.textContent = pack.id === current ? `${pack.blurb} This is your package.` : pack.blurb;
1451
+ wrap.append(title, desc);
1452
+ row.append(wrap);
1453
+ host.append(row);
1454
+ }
1455
+ if (current === "custom") {
1456
+ const gb = Math.round((Number(user.quota_bytes) || 0) / 1073741824);
1457
+ $("a-pack-note").textContent = `You are on a custom allocation of ${gb} GB, not one of the standard packages.`;
1458
+ }
1459
+ };
1460
+
1461
+ const rulesFrom = (user) => {
1462
+ const mode = user.sync_mode || "all";
1463
+ const radio = document.querySelector(`input[name=sync][value="${mode}"]`) || choices[0].querySelector("input");
1464
+ radio.checked = true;
1465
+ if (user.sync_keep_last) $("r-last").value = user.sync_keep_last;
1466
+ if (user.sync_min_age_days) $("r-age").value = user.sync_min_age_days;
1467
+ paintChoices();
1468
+ };
1469
+
1470
+ $("r-save").addEventListener("click", async () => {
1471
+ const token = sessionStorage.getItem(TOKEN);
1472
+ const mode = document.querySelector("input[name=sync]:checked").value;
1473
+ $("r-save").disabled = true;
1474
+ $("r-msg").textContent = "Saving";
1475
+ try {
1476
+ await api("/v1/cloud/sync-rules", {
1477
+ method: "POST", token,
1478
+ body: {
1479
+ sync_mode: mode,
1480
+ sync_keep_last: Number($("r-last").value) || 20,
1481
+ sync_min_age_days: Number($("r-age").value) || 7
1482
+ }
1483
+ });
1484
+ $("r-msg").textContent = "Saved to your account.";
1485
+ } catch (error) {
1486
+ $("r-msg").textContent = /404|not found/i.test(error.message)
1487
+ ? "Rules are not switched on yet — nothing was changed."
1488
+ : errText(error);
1489
+ } finally {
1490
+ $("r-save").disabled = false;
1491
+ setTimeout(() => { $("r-msg").textContent = ""; }, 6000);
1492
+ }
1493
+ });
1494
+
1495
+ /* ------------------------------------------------------ email confirmation */
1496
+ async function finishConfirm(accountId, token) {
1497
+ history.replaceState(null, "", location.pathname + "#account");
1498
+ const signedIn = sessionStorage.getItem(TOKEN);
1499
+ try {
1500
+ await api("/confirm-email", { method: "POST", body: { account_id: accountId, token } });
1501
+ if (signedIn && await loadAccount(signedIn)) {
1502
+ await bootDone(returning);
1503
+ $("a-verify-msg").textContent = "Email confirmed.";
1504
+ return;
1505
+ }
1506
+ await backToSignIn("Email confirmed. Sign in to continue.");
1507
+ } catch (error) {
1508
+ if (signedIn && await loadAccount(signedIn)) {
1509
+ await bootDone(returning);
1510
+ $("a-verify-msg").textContent = errText(error);
1511
+ return;
1512
+ }
1513
+ await backToSignIn(errText(error), true);
1514
+ }
1515
+ }
1516
+
1517
+ /* ------------------------------------------------------------------ start
1518
+ One entry point, and the hash is routed only once the outcome is settled.
1519
+ Everything that follows a return — where the reader lands, what they are
1520
+ told, and whether the marketing page is even in the way — is decided here
1521
+ rather than left to whichever of three scroll mechanisms fired last. */
1522
+ (async function start() {
1523
+ if (authCode) return void finishGoogle(authCode);
1524
+ if (confirmToken && confirmAccount) return void finishConfirm(confirmAccount, confirmToken);
1525
+ if (savedToken) {
1526
+ const opened = await loadAccount(savedToken);
1527
+ // A resumed session is not an event, so it exits without the beat.
1528
+ // Coming back from a provider is, so it keeps it.
1529
+ await bootDone(returning && opened);
1530
+ sessionStorage.removeItem("vaultline.returning");
1531
+ if (!opened) return void await backToSignIn("That session has expired. Sign in again.");
1532
+ if (location.hash === "#account") requestAnimationFrame(goAccount);
1533
+ return;
1534
+ }
1535
+ await bootDone(false);
1536
+ if (location.hash === "#account") requestAnimationFrame(goAccount);
1537
+ // A link that names a tab opens that tab, on arrival as well as on a later
1538
+ // hash change. Someone sent here mid-install should land where they were
1539
+ // sent, not on the section and a guess.
1540
+ requestAnimationFrame(openAuthFromHash);
1541
+ })();
1542
+ </script>