sealkeep 0.5.1 → 0.5.3
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.
- package/CHANGELOG.md +19 -0
- package/dist/site/index.html +360 -155
- package/dist/site.zip +0 -0
- package/dist/src/chunk-store.js +6 -0
- package/dist/src/local-api.js +12 -4
- package/dist/src/storage-targets.d.ts +3 -0
- package/dist/src/storage-targets.js +19 -0
- package/dist/src/types.d.ts +3 -0
- package/dist/src/upload.js +4 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,25 @@
|
|
|
3
3
|
Notable changes, by published version. Sealkeep is pre-1.0: minor versions
|
|
4
4
|
may change behavior, and say so here when they do.
|
|
5
5
|
|
|
6
|
+
## 0.5.3 — 2026-08-19
|
|
7
|
+
|
|
8
|
+
The account now knows what every destination holds. Machines stamp
|
|
9
|
+
per-target usage (Drive, your bucket, cloud) into the routing rules they
|
|
10
|
+
already sync, so the panel's new headline reads like the truth:
|
|
11
|
+
"3.7 GB in Sealkeep cloud · 56 MB in Google Drive". The panel also got
|
|
12
|
+
its polish pass: status headline first, routing rules as three short
|
|
13
|
+
bullets, per-row "holds X", compact editors, your current package leads
|
|
14
|
+
with the tiers as chips, and Drive shows as "folder <name>".
|
|
15
|
+
|
|
16
|
+
## 0.5.2 — 2026-08-19
|
|
17
|
+
|
|
18
|
+
- "Manage in cloud" now opens sealkeep.spala.ai — the panel URL was still
|
|
19
|
+
derived from the API origin, which the rename left behind. Self-hosted
|
|
20
|
+
planes keep deriving from their own origin.
|
|
21
|
+
- The cloud panel folds chunk families into one archive per row, edits the
|
|
22
|
+
account's storage routing (priorities, caps, project pins, Google Drive),
|
|
23
|
+
and gained a for-companies section.
|
|
24
|
+
|
|
6
25
|
## 0.5.1 — 2026-08-19
|
|
7
26
|
|
|
8
27
|
The first release that lives entirely at its new addresses.
|
package/dist/site/index.html
CHANGED
|
@@ -172,7 +172,7 @@
|
|
|
172
172
|
|
|
173
173
|
.ph{display:flex;flex-wrap:wrap;align-items:center;gap:.5rem .9rem;background:var(--card);
|
|
174
174
|
border:1px solid var(--rule);border-radius:4px;padding:1.2rem 1.3rem;margin-bottom:1.3rem}
|
|
175
|
-
.ph .name{font-size:1.
|
|
175
|
+
.ph .name{font-size:1.15rem;font-weight:650;letter-spacing:-.02em}
|
|
176
176
|
.ph .who{font-family:var(--mono);font-size:.75rem;color:var(--soft);flex:1 1 100%}
|
|
177
177
|
/* The strip takes focus when the panel opens so a screen reader is told whose
|
|
178
178
|
account this is. It is not in the tab order, so nobody can arrive here by
|
|
@@ -193,11 +193,14 @@
|
|
|
193
193
|
.warncard{border-color:var(--warn)}
|
|
194
194
|
.warncard .eyebrow{color:var(--warn)}
|
|
195
195
|
|
|
196
|
-
.kv{display:grid;gap
|
|
196
|
+
.kv{display:grid;gap:.75rem;grid-template-columns:repeat(auto-fit,minmax(9rem,1fr))}
|
|
197
|
+
.kv>div{border:1px solid var(--rule);border-radius:4px;padding:.9rem 1rem}
|
|
198
|
+
.kv .cap{display:block;font-size:.78rem;color:var(--soft);margin-top:.25rem}
|
|
197
199
|
.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:
|
|
199
|
-
.
|
|
200
|
-
.bar
|
|
200
|
+
.kv b{font-family:var(--mono);font-size:1rem;font-weight:500;color:var(--soft);letter-spacing:-.02em}
|
|
201
|
+
.kv .lead b{font-size:2.2rem;font-weight:700;color:var(--ink);letter-spacing:-.04em;line-height:1.1}
|
|
202
|
+
.bar{height:8px;background:var(--rule);border-radius:4px;margin:1.05rem 0 .4rem;overflow:hidden}
|
|
203
|
+
.bar i{display:block;height:100%;width:0;min-width:6px;background:var(--live);transition:width .5s ease}
|
|
201
204
|
|
|
202
205
|
table{width:100%;border-collapse:collapse;font-size:.9rem;min-width:31rem}
|
|
203
206
|
th{text-align:left;font-family:var(--mono);font-size:.72rem;color:var(--soft);font-weight:400;
|
|
@@ -205,7 +208,29 @@
|
|
|
205
208
|
td{padding:.68rem .65rem;border-bottom:1px solid var(--rule)}
|
|
206
209
|
tr:last-child td{border-bottom:0}
|
|
207
210
|
td code{font-size:.86em;color:var(--soft)}
|
|
208
|
-
.state{font-family:var(--mono);font-size:.
|
|
211
|
+
.state{font-family:var(--mono);font-size:.72rem;border:1px solid currentColor;border-radius:3px;padding:.18rem .55rem;white-space:nowrap}
|
|
212
|
+
.choice .t,.choice .d{font-family:var(--sans)}
|
|
213
|
+
.cellsub{display:block;font-family:var(--sans);font-size:.75rem;color:var(--soft);margin-top:.15rem}
|
|
214
|
+
.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)}
|
|
215
|
+
.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:.28rem .8rem}
|
|
216
|
+
.acct-nav a:hover{color:var(--ink);border-color:var(--ink)}
|
|
217
|
+
.acct-hero{font-family:var(--mono);font-size:1.55rem;font-weight:700;letter-spacing:-.03em;margin:.9rem 0 0;line-height:1.3}
|
|
218
|
+
.ruleslist{margin:.3rem 0 .9rem;padding-left:1.1rem}
|
|
219
|
+
.ruleslist li{margin:.25rem 0;color:var(--soft)}
|
|
220
|
+
.ruleslist b{color:var(--ink);font-weight:600}
|
|
221
|
+
#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)}
|
|
222
|
+
#tg-body input[type=number]{width:4.2rem}
|
|
223
|
+
#tg-body input:focus,#tg-body select:focus{border-color:var(--live);outline:none}
|
|
224
|
+
#tg-body tr:hover td{background:color-mix(in srgb, var(--rule) 22%, transparent)}
|
|
225
|
+
#tg-save-msg{display:block;margin-top:.6rem}
|
|
226
|
+
.packrow{display:flex;gap:.6rem;flex-wrap:wrap;margin:.4rem 0 .9rem}
|
|
227
|
+
.packchip{display:flex;flex-direction:column;gap:.1rem;border:1px solid var(--rule);border-radius:6px;padding:.6rem .95rem;min-width:6.4rem}
|
|
228
|
+
.packchip b{font-family:var(--mono);font-size:.95rem}
|
|
229
|
+
.packchip span{font-size:.78rem;color:var(--soft)}
|
|
230
|
+
.packchip.now{border-color:var(--live)}
|
|
231
|
+
.packchip.now i{font-style:normal;font-family:var(--mono);font-size:.66rem;color:var(--live);letter-spacing:.03em}
|
|
232
|
+
#p-account .card h3{font-size:1.02rem;margin:.15rem 0 .5rem}
|
|
233
|
+
[id^="anchor-"]{scroll-margin-top:3.4rem}
|
|
209
234
|
.state.durable{color:var(--live)}
|
|
210
235
|
.state.pending{color:var(--soft);opacity:.75}
|
|
211
236
|
.del{background:none;color:var(--soft);border:1px solid var(--rule);padding:.3rem .7rem;
|
|
@@ -386,6 +411,40 @@
|
|
|
386
411
|
|
|
387
412
|
</section>
|
|
388
413
|
|
|
414
|
+
<section id="teams"><div class="wrap">
|
|
415
|
+
<h2>For teams and companies</h2>
|
|
416
|
+
<p class="sub">Agent transcripts are source code, prompts, and pasted secrets. Treat them like it.</p>
|
|
417
|
+
|
|
418
|
+
<div class="row-item duo">
|
|
419
|
+
<div class="human">
|
|
420
|
+
<span class="mark">Custody</span>
|
|
421
|
+
<h3>Your data never changes hands</h3>
|
|
422
|
+
<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>
|
|
423
|
+
</div>
|
|
424
|
+
<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>
|
|
425
|
+
</div>
|
|
426
|
+
|
|
427
|
+
<div class="row-item duo">
|
|
428
|
+
<div class="human">
|
|
429
|
+
<span class="mark">Audit</span>
|
|
430
|
+
<h3>Every action, written down</h3>
|
|
431
|
+
<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>
|
|
432
|
+
</div>
|
|
433
|
+
<div class="machine"><div class="ev">$ sealkeep audit<br>sealed 9f2c1ab4 · uploaded · verified<br>restored by device macbook · 12 Aug</div></div>
|
|
434
|
+
</div>
|
|
435
|
+
|
|
436
|
+
<div class="row-item duo">
|
|
437
|
+
<div class="human">
|
|
438
|
+
<span class="mark">Share</span>
|
|
439
|
+
<h3>Hand a session over, not the vault</h3>
|
|
440
|
+
<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>
|
|
441
|
+
</div>
|
|
442
|
+
<div class="machine"><div class="ev">$ sealkeep share 9f2c1ab4<br>bundle sealed · passcode shown once<br>opens without your phrase</div></div>
|
|
443
|
+
</div>
|
|
444
|
+
|
|
445
|
+
<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>
|
|
446
|
+
</div></section>
|
|
447
|
+
|
|
389
448
|
<section id="plans">
|
|
390
449
|
<h2>Two ways to run it</h2>
|
|
391
450
|
<p class="sub">Same encryption either way. The only difference is which side of the line the bucket sits on.</p>
|
|
@@ -522,9 +581,19 @@ sealkeep open 08424eb6… ./session.jsonl \
|
|
|
522
581
|
<div class="pane" id="p-account">
|
|
523
582
|
<div class="ph" id="a-strip" tabindex="-1">
|
|
524
583
|
<span class="name" id="a-label">—</span>
|
|
525
|
-
<
|
|
584
|
+
<button class="chip" id="a-plan" type="button" title="See your plan">—</button>
|
|
526
585
|
<span class="who" id="a-who">—</span>
|
|
527
586
|
</div>
|
|
587
|
+
<p class="acct-hero" id="a-hero"></p>
|
|
588
|
+
<p class="msg" id="a-summary" style="margin:.4rem 0 0"></p>
|
|
589
|
+
<nav class="acct-nav" aria-label="Account sections">
|
|
590
|
+
<a href="#anchor-storage">Storage</a>
|
|
591
|
+
<a href="#anchor-routing">Routing</a>
|
|
592
|
+
<a href="#anchor-machines">Machines</a>
|
|
593
|
+
<a href="#anchor-archives" id="nav-archives">Archives</a>
|
|
594
|
+
<a href="#anchor-plan">Plan</a>
|
|
595
|
+
<a href="#anchor-recovery">Recovery</a>
|
|
596
|
+
</nav>
|
|
528
597
|
|
|
529
598
|
<div class="card next" id="a-firstrun" style="display:none">
|
|
530
599
|
<span class="eyebrow">Set up</span>
|
|
@@ -539,7 +608,7 @@ sealkeep open 08424eb6… ./session.jsonl \
|
|
|
539
608
|
<span class="saved" id="a-verify-msg" role="status" aria-live="polite"></span>
|
|
540
609
|
</div>
|
|
541
610
|
|
|
542
|
-
<div class="card">
|
|
611
|
+
<div class="card" id="anchor-storage">
|
|
543
612
|
<span class="eyebrow">Storage</span>
|
|
544
613
|
<div id="a-store-free" style="display:none">
|
|
545
614
|
<h3>You run the bucket</h3>
|
|
@@ -547,39 +616,103 @@ sealkeep open 08424eb6… ./session.jsonl \
|
|
|
547
616
|
<p class="empty">Your machine reports what it has stored. Run <code>sealkeep status</code> to see it.</p>
|
|
548
617
|
</div>
|
|
549
618
|
<div id="a-store-paid" style="display:none">
|
|
619
|
+
<h3>What you keep here</h3>
|
|
550
620
|
<div class="kv">
|
|
551
|
-
<div><span class="k">Stored</span><b id="a-used">—</b></div>
|
|
552
|
-
<div><span class="k">Included</span><b id="a-quota-b">—</b></div>
|
|
553
|
-
<div><span class="k">Archives</span><b id="a-count">—</b></div>
|
|
621
|
+
<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>
|
|
622
|
+
<div><span class="k">Included</span><b id="a-quota-b">—</b><span class="cap">in your plan</span></div>
|
|
623
|
+
<div><span class="k">Archives</span><b id="a-count">—</b><span class="cap">chunk folders count as one</span></div>
|
|
624
|
+
<div><span class="k">Machines</span><b id="a-machines">—</b><span class="cap">signing uploads</span></div>
|
|
625
|
+
<div><span class="k">Destinations</span><b id="a-dest">—</b><span class="cap">where new seals go</span></div>
|
|
554
626
|
</div>
|
|
555
627
|
<div class="bar"><i id="a-bar"></i></div>
|
|
556
628
|
<p class="msg" id="a-quota"></p>
|
|
557
629
|
</div>
|
|
558
630
|
</div>
|
|
559
631
|
|
|
560
|
-
<div class="card" id="c-
|
|
632
|
+
<div class="card" id="c-targets"><span id="anchor-routing"></span>
|
|
633
|
+
<span class="eyebrow">Routing</span>
|
|
634
|
+
<h3>Where new archives go</h3>
|
|
635
|
+
<p>Rules live on your account — every machine of the vault follows the same ones.</p>
|
|
636
|
+
<ul class="ruleslist">
|
|
637
|
+
<li><b>Lowest priority number wins.</b></li>
|
|
638
|
+
<li><b>A pinned project always goes to its target.</b></li>
|
|
639
|
+
<li><b>Full or capped targets pass to the next</b> — Sealkeep cloud is the fallback.</li>
|
|
640
|
+
</ul>
|
|
641
|
+
<p class="msg" id="tg-msg">Loading…</p>
|
|
642
|
+
<div id="tg-wrap" style="display:none;overflow-x:auto">
|
|
643
|
+
<table>
|
|
644
|
+
<thead><tr><th>Target</th><th>Where</th><th>Priority</th><th>Cap (GB)</th><th>Only these projects</th><th></th></tr></thead>
|
|
645
|
+
<tbody id="tg-body"></tbody>
|
|
646
|
+
</table>
|
|
647
|
+
</div>
|
|
648
|
+
<div class="inline" style="margin-top:.9rem">
|
|
649
|
+
<button class="ghost" id="tg-add-drive" type="button">Add Google Drive</button>
|
|
650
|
+
<button class="ghost" id="tg-add-bucket" type="button">Add my own bucket</button>
|
|
651
|
+
<button class="primary" id="tg-save" type="button">Save routing</button>
|
|
652
|
+
<span class="saved" id="tg-save-msg" role="status" aria-live="polite"></span>
|
|
653
|
+
</div>
|
|
654
|
+
<p class="why" id="tg-note">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>
|
|
655
|
+
</div>
|
|
656
|
+
|
|
657
|
+
<div class="card" id="anchor-machines">
|
|
658
|
+
<span class="eyebrow">Machines</span>
|
|
659
|
+
<h3>Connected machines</h3>
|
|
660
|
+
<p>Each connected machine signs its own uploads. Revoking one stops it writing here; the archives it already stored are untouched.</p>
|
|
661
|
+
<ul class="lines" id="d-list"></ul>
|
|
662
|
+
<p class="empty" id="d-msg">Loading…</p>
|
|
663
|
+
|
|
664
|
+
<!-- One pairing block exists on the page. It leads the panel while no
|
|
665
|
+
machine is connected, and JS moves it back into this slot once one
|
|
666
|
+
is, so the same code is never printed in two places. -->
|
|
667
|
+
<div id="pair-home-more">
|
|
668
|
+
<div class="pair" id="pair">
|
|
669
|
+
<p class="side" id="pair-side" style="display:none">Pairing code</p>
|
|
670
|
+
<!-- Grouped and labelled, so the copy button is announced as
|
|
671
|
+
belonging to the code rather than as a bare "Copy". -->
|
|
672
|
+
<div class="pair-field" id="pair-shown" role="group" aria-labelledby="pair-side" style="display:none">
|
|
673
|
+
<code class="pair-code" id="pair-code"></code>
|
|
674
|
+
<button class="primary" id="pair-copy" type="button">Copy</button>
|
|
675
|
+
</div>
|
|
676
|
+
<p class="fine" id="pair-note"></p>
|
|
677
|
+
<div class="inline" id="pair-ask">
|
|
678
|
+
<button class="ghost" id="pair-go" type="button">Show a pairing code</button>
|
|
679
|
+
<span class="saved" id="pair-msg" role="status" aria-live="polite"></span>
|
|
680
|
+
</div>
|
|
681
|
+
<details class="alt" id="pair-alt" style="display:none">
|
|
682
|
+
<summary>Set the machine up from the terminal instead</summary>
|
|
683
|
+
<p class="empty">The same code, typed as one command. Needs Node 22 or newer.</p>
|
|
684
|
+
<div class="pair-field"><pre id="pair-cli"></pre>
|
|
685
|
+
<button class="ghost" id="pair-cli-copy" type="button">Copy</button>
|
|
686
|
+
</div>
|
|
687
|
+
</details>
|
|
688
|
+
</div>
|
|
689
|
+
</div>
|
|
690
|
+
</div>
|
|
691
|
+
|
|
692
|
+
<div class="card" id="anchor-archives" style="display:none">
|
|
561
693
|
<span class="eyebrow">Archives</span>
|
|
562
|
-
<
|
|
694
|
+
<h3>What is stored</h3>
|
|
695
|
+
<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>
|
|
563
696
|
<p class="msg" id="s-msg">Loading…</p>
|
|
564
697
|
<div id="s-wrap" style="display:none;overflow-x:auto">
|
|
565
698
|
<table>
|
|
566
|
-
<thead><tr><th>
|
|
699
|
+
<thead><tr><th>Archive</th><th>Contents</th><th>State</th><th>Added</th><th></th></tr></thead>
|
|
567
700
|
<tbody id="s-body"></tbody>
|
|
568
701
|
</table>
|
|
569
702
|
<p class="msg" id="s-foot"></p>
|
|
570
703
|
</div>
|
|
571
704
|
</div>
|
|
572
705
|
|
|
573
|
-
<div class="card">
|
|
706
|
+
<div class="card" id="c-plan"><span id="anchor-plan"></span>
|
|
574
707
|
<span class="eyebrow">Plan</span>
|
|
575
708
|
<div id="a-plan-free" style="display:none">
|
|
576
709
|
<h3>Free — your bucket, your bill</h3>
|
|
577
710
|
<p>Unlimited archives with the same encryption. You supply the storage credentials and pay your provider directly.</p>
|
|
578
|
-
<p class="empty">Want us to run storage instead?
|
|
711
|
+
<p class="empty">Want us to run storage instead? Pricing is in Your package, below. Write to <span id="a-sales">hello@spala.ai</span> from this address and we will switch it on.</p>
|
|
579
712
|
</div>
|
|
580
713
|
<div id="a-plan-paid" style="display:none">
|
|
581
714
|
<h3>Managed storage</h3>
|
|
582
|
-
<p>We run the bucket and meter what you store. We still hold no key that opens any of it.</p>
|
|
715
|
+
<p>We run the bucket and meter what you store. We still hold no key that opens any of it — the only difference from your own bucket is who pays the storage bill and who can delete it.</p>
|
|
583
716
|
<div class="kv" style="margin-top:1rem">
|
|
584
717
|
<div><span class="k">Subscription</span><b id="a-sub-state">—</b></div>
|
|
585
718
|
<div><span class="k">Renews</span><b id="a-sub-end">—</b></div>
|
|
@@ -591,13 +724,16 @@ sealkeep open 08424eb6… ./session.jsonl \
|
|
|
591
724
|
|
|
592
725
|
<div class="card" id="a-pack-card">
|
|
593
726
|
<span class="eyebrow">Your package</span>
|
|
594
|
-
<
|
|
595
|
-
<
|
|
727
|
+
<h3 id="a-pack-current">—</h3>
|
|
728
|
+
<p class="msg" id="a-pack-context" style="margin:0 0 .8rem"></p>
|
|
729
|
+
<div class="packrow" id="a-packs"></div>
|
|
730
|
+
<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>
|
|
596
731
|
<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>
|
|
597
732
|
</div>
|
|
598
733
|
|
|
599
734
|
<div class="card">
|
|
600
735
|
<span class="eyebrow">What leaves this machine</span>
|
|
736
|
+
<h3>Your upload rules</h3>
|
|
601
737
|
<p>Everything you do not choose here stays local and is never uploaded.</p>
|
|
602
738
|
<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>
|
|
603
739
|
<div class="rules">
|
|
@@ -624,75 +760,7 @@ sealkeep open 08424eb6… ./session.jsonl \
|
|
|
624
760
|
</div>
|
|
625
761
|
</div>
|
|
626
762
|
|
|
627
|
-
<div class="card">
|
|
628
|
-
<span class="eyebrow">Where your archives live</span>
|
|
629
|
-
<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>
|
|
630
|
-
<div class="rules">
|
|
631
|
-
<label class="choice" data-on="yes">
|
|
632
|
-
<input type="radio" name="stor" value="managed" checked>
|
|
633
|
-
<span><span class="t">Let Sealkeep hold it</span>
|
|
634
|
-
<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>
|
|
635
|
-
</label>
|
|
636
|
-
<label class="choice" data-on="no">
|
|
637
|
-
<input type="radio" name="stor" value="byo">
|
|
638
|
-
<span><span class="t">Use my own bucket</span>
|
|
639
|
-
<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>
|
|
640
|
-
<div class="row2" style="margin-top:.7rem">
|
|
641
|
-
<div><label for="st-prov">Provider</label>
|
|
642
|
-
<select id="st-prov">
|
|
643
|
-
<option value="r2">Cloudflare R2 — no egress fees</option>
|
|
644
|
-
<option value="s3">Amazon S3 or any S3-compatible</option>
|
|
645
|
-
<option value="gcs">Google Cloud Storage</option>
|
|
646
|
-
</select></div>
|
|
647
|
-
<div><label for="st-bucket">Bucket</label><input id="st-bucket" placeholder="my-sealkeep-archives"></div>
|
|
648
|
-
</div>
|
|
649
|
-
<div class="row2">
|
|
650
|
-
<div><label for="st-region">Region</label><input id="st-region" placeholder="auto"></div>
|
|
651
|
-
<div><label for="st-prefix">Prefix</label><input id="st-prefix" placeholder="sealkeep/"></div>
|
|
652
|
-
</div>
|
|
653
|
-
</span>
|
|
654
|
-
</label>
|
|
655
|
-
<button class="primary" id="st-save" type="button" style="margin-top:1.1rem">Save destination</button>
|
|
656
|
-
<span class="saved" id="st-msg" role="status" aria-live="polite"></span>
|
|
657
|
-
</div>
|
|
658
|
-
<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>
|
|
659
|
-
</div>
|
|
660
|
-
|
|
661
|
-
<div class="card">
|
|
662
|
-
<span class="eyebrow">Machines</span>
|
|
663
|
-
<p>Each connected machine signs its own uploads. Revoking one stops it writing here; the archives it already stored are untouched.</p>
|
|
664
|
-
<ul class="lines" id="d-list"></ul>
|
|
665
|
-
<p class="empty" id="d-msg">Loading…</p>
|
|
666
|
-
|
|
667
|
-
<!-- One pairing block exists on the page. It leads the panel while no
|
|
668
|
-
machine is connected, and JS moves it back into this slot once one
|
|
669
|
-
is, so the same code is never printed in two places. -->
|
|
670
|
-
<div id="pair-home-more">
|
|
671
|
-
<div class="pair" id="pair">
|
|
672
|
-
<p class="side" id="pair-side" style="display:none">Pairing code</p>
|
|
673
|
-
<!-- Grouped and labelled, so the copy button is announced as
|
|
674
|
-
belonging to the code rather than as a bare "Copy". -->
|
|
675
|
-
<div class="pair-field" id="pair-shown" role="group" aria-labelledby="pair-side" style="display:none">
|
|
676
|
-
<code class="pair-code" id="pair-code"></code>
|
|
677
|
-
<button class="primary" id="pair-copy" type="button">Copy</button>
|
|
678
|
-
</div>
|
|
679
|
-
<p class="fine" id="pair-note"></p>
|
|
680
|
-
<div class="inline" id="pair-ask">
|
|
681
|
-
<button class="ghost" id="pair-go" type="button">Show a pairing code</button>
|
|
682
|
-
<span class="saved" id="pair-msg" role="status" aria-live="polite"></span>
|
|
683
|
-
</div>
|
|
684
|
-
<details class="alt" id="pair-alt" style="display:none">
|
|
685
|
-
<summary>Set the machine up from the terminal instead</summary>
|
|
686
|
-
<p class="empty">The same code, typed as one command. Needs Node 22 or newer.</p>
|
|
687
|
-
<div class="pair-field"><pre id="pair-cli"></pre>
|
|
688
|
-
<button class="ghost" id="pair-cli-copy" type="button">Copy</button>
|
|
689
|
-
</div>
|
|
690
|
-
</details>
|
|
691
|
-
</div>
|
|
692
|
-
</div>
|
|
693
|
-
</div>
|
|
694
|
-
|
|
695
|
-
<div class="card" id="c-recovery">
|
|
763
|
+
<div class="card" id="c-recovery"><span id="anchor-recovery"></span>
|
|
696
764
|
<span class="eyebrow">Recovery key</span>
|
|
697
765
|
<h3 id="e-head">Loading…</h3>
|
|
698
766
|
<p id="e-text"></p>
|
|
@@ -700,6 +768,7 @@ sealkeep open 08424eb6… ./session.jsonl \
|
|
|
700
768
|
|
|
701
769
|
<div class="card">
|
|
702
770
|
<span class="eyebrow">Activity</span>
|
|
771
|
+
<h3>What happened lately</h3>
|
|
703
772
|
<p>Every change made to this account, newest first.</p>
|
|
704
773
|
<ul class="lines" id="v-list"></ul>
|
|
705
774
|
<p class="empty" id="v-msg">Loading…</p>
|
|
@@ -707,6 +776,7 @@ sealkeep open 08424eb6… ./session.jsonl \
|
|
|
707
776
|
|
|
708
777
|
<div class="card">
|
|
709
778
|
<span class="eyebrow">Account</span>
|
|
779
|
+
<h3>Name and email</h3>
|
|
710
780
|
<label for="a-name">Account name</label>
|
|
711
781
|
<div class="inline">
|
|
712
782
|
<input id="a-name" maxlength="60" autocomplete="off">
|
|
@@ -924,6 +994,7 @@ sealkeep open 08424eb6… ./session.jsonl \
|
|
|
924
994
|
$("a-name").value = user.label || "";
|
|
925
995
|
$("a-plan").textContent = paid ? "Managed storage" : "Free";
|
|
926
996
|
$("a-plan").className = "chip" + (paid ? " pro" : "");
|
|
997
|
+
$("a-plan").onclick = () => $("c-plan").scrollIntoView({ behavior: "smooth", block: "start" });
|
|
927
998
|
$("a-who").textContent = [
|
|
928
999
|
user.email,
|
|
929
1000
|
"joined " + day(user.created_at),
|
|
@@ -940,7 +1011,7 @@ sealkeep open 08424eb6… ./session.jsonl \
|
|
|
940
1011
|
// destination arrives; the plan is only the opening guess.
|
|
941
1012
|
lastPaid = paid;
|
|
942
1013
|
applyStorageView();
|
|
943
|
-
on("
|
|
1014
|
+
on("anchor-archives", paid); on("nav-archives", paid);
|
|
944
1015
|
on("a-plan-free", !paid);
|
|
945
1016
|
on("a-plan-paid", paid);
|
|
946
1017
|
|
|
@@ -1095,6 +1166,145 @@ sealkeep open 08424eb6… ./session.jsonl \
|
|
|
1095
1166
|
}
|
|
1096
1167
|
}
|
|
1097
1168
|
|
|
1169
|
+
// Managed chunk folders arrive as one row per stored object —
|
|
1170
|
+
// <archive>.chunk-000001 … plus <archive>.envelope.vlmeta. A listing is
|
|
1171
|
+
// about archives, so fold each family into one row: the archive id, the
|
|
1172
|
+
// summed chunk bytes, and durable only when every member is durable.
|
|
1173
|
+
// Mirrors collapseCloudRows in the CLI, so both surfaces tell one story.
|
|
1174
|
+
const FAMILY_REF = /^(.+)\.(chunk-\d{6}|envelope\.vlmeta)$/;
|
|
1175
|
+
function foldFamilies(rows) {
|
|
1176
|
+
const families = new Map();
|
|
1177
|
+
const plain = [];
|
|
1178
|
+
for (const row of rows) {
|
|
1179
|
+
const match = FAMILY_REF.exec(String(row.vault_ref));
|
|
1180
|
+
if (!match) { plain.push(row); continue; }
|
|
1181
|
+
const fam = families.get(match[1]) ?? { rows: [], chunkBytes: 0, chunks: 0, members: [], allDurable: true };
|
|
1182
|
+
fam.rows.push(row);
|
|
1183
|
+
fam.members.push(row.vault_ref);
|
|
1184
|
+
if (match[2].startsWith("chunk-")) { fam.chunkBytes += Number(row.bytes) || 0; fam.chunks++; }
|
|
1185
|
+
if (row.state !== "durable") fam.allDurable = false;
|
|
1186
|
+
families.set(match[1], fam);
|
|
1187
|
+
}
|
|
1188
|
+
const folded = [...families.entries()].map(([id, fam]) => ({
|
|
1189
|
+
...fam.rows[0], vault_ref: id, bytes: fam.chunkBytes, chunked: true,
|
|
1190
|
+
chunkCount: fam.chunks, members: fam.members, state: fam.allDurable ? "durable" : "pending"
|
|
1191
|
+
}));
|
|
1192
|
+
return [...plain, ...folded];
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
/* ------------------------------------------------ routing (storage targets) */
|
|
1196
|
+
// The same rules the CLI syncs: GET/PUT /v1/cloud/storage-targets carries
|
|
1197
|
+
// {targets_json} — a JSON string of [{id, provider, bucket, prefix, maxGb,
|
|
1198
|
+
// projects, priority}]. Editing here lands on every machine of the vault,
|
|
1199
|
+
// because resolveTargets is cloud-first.
|
|
1200
|
+
let targetRows = [];
|
|
1201
|
+
const providerLabel = (p) => ({ vaultline: "Sealkeep cloud", gdrive: "Google Drive", r2: "Cloudflare R2", s3: "S3", gcs: "Google Cloud" }[p] || p);
|
|
1202
|
+
function renderTargets() {
|
|
1203
|
+
const body = $("tg-body");
|
|
1204
|
+
body.replaceChildren();
|
|
1205
|
+
if (!targetRows.length) {
|
|
1206
|
+
$("tg-msg").className = "empty";
|
|
1207
|
+
$("tg-msg").textContent = "No rules yet — everything goes to Sealkeep cloud. Add your Drive or set rules from a machine: sealkeep storage targets add …";
|
|
1208
|
+
$("tg-wrap").style.display = "none";
|
|
1209
|
+
return;
|
|
1210
|
+
}
|
|
1211
|
+
$("tg-msg").textContent = "";
|
|
1212
|
+
for (const t of targetRows) {
|
|
1213
|
+
const tr = document.createElement("tr");
|
|
1214
|
+
const name = document.createElement("td");
|
|
1215
|
+
if (t.__editable) {
|
|
1216
|
+
const sel = document.createElement("select");
|
|
1217
|
+
for (const [v, l] of [["r2", "Cloudflare R2"], ["s3", "S3-compatible"], ["gcs", "Google Cloud"]]) {
|
|
1218
|
+
const o = document.createElement("option"); o.value = v; o.textContent = l; sel.append(o);
|
|
1219
|
+
}
|
|
1220
|
+
sel.value = t.provider;
|
|
1221
|
+
sel.setAttribute("aria-label", "Provider");
|
|
1222
|
+
sel.addEventListener("change", () => { t.provider = sel.value; });
|
|
1223
|
+
name.append(sel);
|
|
1224
|
+
} else name.textContent = providerLabel(t.provider);
|
|
1225
|
+
const where = document.createElement("td");
|
|
1226
|
+
if (t.__editable) {
|
|
1227
|
+
const b = document.createElement("input"); b.placeholder = "bucket"; b.style.width = "8rem"; b.value = t.bucket || "";
|
|
1228
|
+
b.setAttribute("aria-label", "Bucket name");
|
|
1229
|
+
b.addEventListener("input", () => { t.bucket = b.value.trim(); });
|
|
1230
|
+
const p = document.createElement("input"); p.placeholder = "prefix/"; p.style.width = "6rem"; p.value = t.prefix || "";
|
|
1231
|
+
p.setAttribute("aria-label", "Prefix");
|
|
1232
|
+
p.addEventListener("input", () => { t.prefix = p.value.trim() || undefined; });
|
|
1233
|
+
where.append(b, document.createTextNode(" "), p);
|
|
1234
|
+
} else {
|
|
1235
|
+
const wc = document.createElement("code");
|
|
1236
|
+
wc.textContent = t.provider === "vaultline" ? "managed"
|
|
1237
|
+
: t.provider === "gdrive" ? "folder " + (t.bucket || "Sealkeep")
|
|
1238
|
+
: [t.bucket, t.prefix].filter(Boolean).join("/") || "—";
|
|
1239
|
+
where.append(wc);
|
|
1240
|
+
if (t.usedBytes) {
|
|
1241
|
+
const held = document.createElement("span");
|
|
1242
|
+
held.className = "cellsub";
|
|
1243
|
+
held.textContent = "holds " + bytes(t.usedBytes);
|
|
1244
|
+
where.append(held);
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
const pr = document.createElement("td");
|
|
1248
|
+
const prIn = document.createElement("input"); prIn.type = "number"; prIn.className = "n"; prIn.min = "0"; prIn.max = "99";
|
|
1249
|
+
prIn.value = t.priority ?? (t.provider === "vaultline" ? 0 : 10);
|
|
1250
|
+
prIn.setAttribute("aria-label", "Priority for " + providerLabel(t.provider));
|
|
1251
|
+
prIn.addEventListener("input", () => { t.priority = Number(prIn.value); });
|
|
1252
|
+
pr.append(prIn);
|
|
1253
|
+
const cap = document.createElement("td");
|
|
1254
|
+
const capIn = document.createElement("input"); capIn.type = "number"; capIn.className = "n"; capIn.min = "1"; capIn.max = "99999"; capIn.placeholder = "none";
|
|
1255
|
+
if (t.maxGb) capIn.value = t.maxGb;
|
|
1256
|
+
capIn.setAttribute("aria-label", "Cap in GB for " + providerLabel(t.provider));
|
|
1257
|
+
capIn.addEventListener("input", () => { t.maxGb = capIn.value ? Number(capIn.value) : undefined; });
|
|
1258
|
+
cap.append(capIn);
|
|
1259
|
+
const proj = document.createElement("td");
|
|
1260
|
+
const projIn = document.createElement("input"); projIn.placeholder = "all projects"; projIn.style.minWidth = "9rem";
|
|
1261
|
+
projIn.value = (t.projects || []).join(", ");
|
|
1262
|
+
projIn.setAttribute("aria-label", "Projects pinned to " + providerLabel(t.provider));
|
|
1263
|
+
projIn.addEventListener("input", () => {
|
|
1264
|
+
const list = projIn.value.split(",").map((x) => x.trim()).filter(Boolean);
|
|
1265
|
+
if (list.length) t.projects = list; else delete t.projects;
|
|
1266
|
+
});
|
|
1267
|
+
proj.append(projIn);
|
|
1268
|
+
const act = document.createElement("td");
|
|
1269
|
+
const rm = document.createElement("button"); rm.className = "del"; rm.type = "button"; rm.textContent = "Remove";
|
|
1270
|
+
rm.addEventListener("click", () => { targetRows = targetRows.filter((x) => x !== t); renderTargets(); });
|
|
1271
|
+
act.append(rm);
|
|
1272
|
+
tr.append(name, where, pr, cap, proj, act);
|
|
1273
|
+
body.append(tr);
|
|
1274
|
+
}
|
|
1275
|
+
$("tg-wrap").style.display = "";
|
|
1276
|
+
}
|
|
1277
|
+
async function loadTargets(token) {
|
|
1278
|
+
try {
|
|
1279
|
+
const data = await api("/v1/cloud/storage-targets", { token });
|
|
1280
|
+
try { targetRows = JSON.parse(data.targets_json || "[]"); } catch { targetRows = []; }
|
|
1281
|
+
if (!Array.isArray(targetRows)) targetRows = [];
|
|
1282
|
+
renderTargets();
|
|
1283
|
+
} catch (error) { say("tg-msg", errText(error), "err"); }
|
|
1284
|
+
}
|
|
1285
|
+
$("tg-add-bucket").addEventListener("click", () => {
|
|
1286
|
+
targetRows.push({ id: "byo-" + Math.random().toString(36).slice(2, 8), provider: "r2", priority: 5, __editable: true });
|
|
1287
|
+
renderTargets();
|
|
1288
|
+
say("tg-save-msg", "Fill bucket and prefix, save — then put the access keys on your machine: sealkeep storage credentials set");
|
|
1289
|
+
});
|
|
1290
|
+
$("tg-add-drive").addEventListener("click", () => {
|
|
1291
|
+
if (targetRows.some((t) => t.provider === "gdrive")) { say("tg-save-msg", "Drive is already in the rules."); return; }
|
|
1292
|
+
targetRows.push({ id: "gdrive-" + Math.random().toString(36).slice(2, 8), provider: "gdrive", bucket: "Sealkeep", maxGb: 50, priority: 10 });
|
|
1293
|
+
renderTargets();
|
|
1294
|
+
say("tg-save-msg", "Added — save, then run the one-time consent on your machine.");
|
|
1295
|
+
});
|
|
1296
|
+
$("tg-save").addEventListener("click", async () => {
|
|
1297
|
+
const token = sessionStorage.getItem(TOKEN);
|
|
1298
|
+
if (!token) { say("tg-save-msg", "Sign in again to save.", "err"); return; }
|
|
1299
|
+
$("tg-save").disabled = true;
|
|
1300
|
+
try {
|
|
1301
|
+
const clean = targetRows.map(({ __editable, ...t }) => t);
|
|
1302
|
+
await api("/v1/cloud/storage-targets", { method: "PUT", token, body: { targets_json: JSON.stringify(clean) } });
|
|
1303
|
+
say("tg-save-msg", "Saved. Every machine follows these on its next seal.");
|
|
1304
|
+
} catch (error) { say("tg-save-msg", errText(error), "err"); }
|
|
1305
|
+
$("tg-save").disabled = false;
|
|
1306
|
+
});
|
|
1307
|
+
|
|
1098
1308
|
async function loadStorage(token) {
|
|
1099
1309
|
try {
|
|
1100
1310
|
const rows = await api("/v1/cloud/archives", { token });
|
|
@@ -1112,18 +1322,27 @@ sealkeep open 08424eb6… ./session.jsonl \
|
|
|
1112
1322
|
}
|
|
1113
1323
|
on("s-intro", true);
|
|
1114
1324
|
|
|
1115
|
-
for (const r of rows) {
|
|
1325
|
+
for (const r of foldFamilies(rows)) {
|
|
1116
1326
|
const tr = document.createElement("tr");
|
|
1117
1327
|
// textContent throughout: an archive ref is data, never markup.
|
|
1118
1328
|
const ref = document.createElement("td");
|
|
1119
1329
|
const code = document.createElement("code"); code.textContent = String(r.vault_ref).slice(0, 8); ref.append(code);
|
|
1120
|
-
const size = document.createElement("td");
|
|
1330
|
+
const size = document.createElement("td");
|
|
1331
|
+
const sc = document.createElement("code");
|
|
1332
|
+
sc.textContent = bytes(r.bytes);
|
|
1333
|
+
size.append(sc);
|
|
1334
|
+
if (r.chunked) {
|
|
1335
|
+
const sub = document.createElement("span");
|
|
1336
|
+
sub.className = "cellsub";
|
|
1337
|
+
sub.textContent = r.chunkCount + " sealed chunks";
|
|
1338
|
+
size.append(sub);
|
|
1339
|
+
}
|
|
1121
1340
|
const st = document.createElement("td");
|
|
1122
1341
|
const s = document.createElement("span");
|
|
1123
1342
|
s.className = "state " + (r.state === "durable" ? "durable" : "pending");
|
|
1124
1343
|
s.textContent = r.state === "durable" ? "stored" : "incomplete";
|
|
1125
1344
|
st.append(s);
|
|
1126
|
-
const when = document.createElement("td"); when.textContent = ago(r.durable_at || r.created_at);
|
|
1345
|
+
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);
|
|
1127
1346
|
const act = document.createElement("td");
|
|
1128
1347
|
const btn = document.createElement("button");
|
|
1129
1348
|
btn.className = "del"; btn.type = "button"; btn.textContent = "Delete";
|
|
@@ -1137,7 +1356,14 @@ sealkeep open 08424eb6… ./session.jsonl \
|
|
|
1137
1356
|
}
|
|
1138
1357
|
btn.disabled = true; btn.textContent = "Deleting";
|
|
1139
1358
|
try {
|
|
1140
|
-
|
|
1359
|
+
// A chunked archive is many stored objects; removing it means
|
|
1360
|
+
// removing every member, envelope last so a crash leaves an
|
|
1361
|
+
// openable family rather than an orphaned envelope.
|
|
1362
|
+
const refs = r.members ? [...r.members].sort((a, b) => a.endsWith(".envelope.vlmeta") ? 1 : b.endsWith(".envelope.vlmeta") ? -1 : 0) : [r.vault_ref];
|
|
1363
|
+
for (let i = 0; i < refs.length; i++) {
|
|
1364
|
+
if (refs.length > 1) btn.textContent = "Deleting " + (i + 1) + "/" + refs.length;
|
|
1365
|
+
await api("/v1/cloud/archives/" + encodeURIComponent(refs[i]), { method: "DELETE", token });
|
|
1366
|
+
}
|
|
1141
1367
|
await loadAccount(token);
|
|
1142
1368
|
} catch (error) { say("s-msg", errText(error), "err"); btn.disabled = false; btn.textContent = "Delete"; }
|
|
1143
1369
|
});
|
|
@@ -1146,11 +1372,13 @@ sealkeep open 08424eb6… ./session.jsonl \
|
|
|
1146
1372
|
body.append(tr);
|
|
1147
1373
|
}
|
|
1148
1374
|
|
|
1149
|
-
|
|
1375
|
+
// Counts describe archives, not stored objects — a chunk family is one.
|
|
1376
|
+
const folded = foldFamilies(rows);
|
|
1377
|
+
const stored = folded.filter((r) => r.state === "durable").length;
|
|
1150
1378
|
say("s-msg", "");
|
|
1151
|
-
$("a-count").textContent = String(
|
|
1152
|
-
$("s-foot").textContent = `${
|
|
1153
|
-
+ (stored <
|
|
1379
|
+
$("a-count").textContent = String(folded.length);
|
|
1380
|
+
$("s-foot").textContent = `${folded.length} archive${folded.length === 1 ? "" : "s"} · ${stored} fully stored`
|
|
1381
|
+
+ (stored < folded.length ? ` · ${folded.length - stored} incomplete, not billed` : "");
|
|
1154
1382
|
$("s-wrap").style.display = "";
|
|
1155
1383
|
} catch (error) { say("s-msg", errText(error), "err"); }
|
|
1156
1384
|
}
|
|
@@ -1163,13 +1391,28 @@ sealkeep open 08424eb6… ./session.jsonl \
|
|
|
1163
1391
|
renderAccount(user);
|
|
1164
1392
|
// Each section fails on its own. One dead panel must not blank the rest.
|
|
1165
1393
|
if (user.plan === "pro") { $("a-count").textContent = "—"; loadStorage(token); }
|
|
1394
|
+
await loadTargets(token);
|
|
1166
1395
|
loadAudit(token);
|
|
1167
|
-
loadStorage2(token);
|
|
1168
1396
|
const devices = await loadDevices(token);
|
|
1169
1397
|
|
|
1170
1398
|
// A brand-new account has exactly one useful next step. It leads the panel,
|
|
1171
1399
|
// and it leads with the code rather than with a terminal — a paying
|
|
1172
1400
|
// customer should not have to be a terminal user to finish setting up.
|
|
1401
|
+
// The glance line: what is connected, where seals go — from data already fetched.
|
|
1402
|
+
$("a-machines").textContent = String(devices);
|
|
1403
|
+
$("a-dest").textContent = String(Math.max(targetRows.length, 1));
|
|
1404
|
+
const destNames = (targetRows.length ? targetRows : [{ provider: "vaultline" }])
|
|
1405
|
+
.slice().sort((a, b) => (a.priority ?? 10) - (b.priority ?? 10))
|
|
1406
|
+
.map((t) => providerLabel(t.provider) + (t.maxGb ? " ≤" + t.maxGb + " GB" : ""));
|
|
1407
|
+
const driveUsed = targetRows.filter((t) => t.provider === "gdrive" && t.usedBytes)
|
|
1408
|
+
.reduce((n, t) => n + t.usedBytes, 0);
|
|
1409
|
+
const byoUsed = targetRows.filter((t) => t.provider !== "gdrive" && t.provider !== "vaultline" && t.usedBytes)
|
|
1410
|
+
.reduce((n, t) => n + t.usedBytes, 0);
|
|
1411
|
+
$("a-hero").textContent = bytes(Number(user.used_bytes) || 0) + " in Sealkeep cloud"
|
|
1412
|
+
+ (driveUsed ? " · " + bytes(driveUsed) + " in Google Drive" : "")
|
|
1413
|
+
+ (byoUsed ? " · " + bytes(byoUsed) + " in your bucket" : "");
|
|
1414
|
+
$("a-summary").textContent =
|
|
1415
|
+
devices + " machine" + (devices === 1 ? "" : "s") + " connected · new archives go to " + destNames.join(", then ");
|
|
1173
1416
|
const nothingYet = devices === 0 && Number(user.used_bytes) === 0;
|
|
1174
1417
|
on("a-firstrun", nothingYet);
|
|
1175
1418
|
$(nothingYet ? "pair-home-first" : "pair-home-more").append($("pair"));
|
|
@@ -1211,53 +1454,14 @@ sealkeep open 08424eb6… ./session.jsonl \
|
|
|
1211
1454
|
// half the bytes, and still no innerHTML anywhere on the page.
|
|
1212
1455
|
$("g-in").prepend($("g-up").querySelector("svg").cloneNode(true));
|
|
1213
1456
|
|
|
1214
|
-
|
|
1457
|
+
// The destination choice lives in Routing; this card only switches between
|
|
1458
|
+
// "we meter it" (paid) and "your machine reports it" (free).
|
|
1215
1459
|
let lastPaid = false;
|
|
1216
|
-
let lastStorageMode = null;
|
|
1217
1460
|
function applyStorageView() {
|
|
1218
|
-
|
|
1219
|
-
on("a-store-
|
|
1220
|
-
on("a-store-paid", managed);
|
|
1221
|
-
}
|
|
1222
|
-
|
|
1223
|
-
const paintStorage = () => {
|
|
1224
|
-
for (const input of storChoices) input.closest(".choice").dataset.on = input.checked ? "yes" : "no";
|
|
1225
|
-
};
|
|
1226
|
-
for (const input of storChoices) input.addEventListener("change", paintStorage);
|
|
1227
|
-
|
|
1228
|
-
async function loadStorage2(token) {
|
|
1229
|
-
try {
|
|
1230
|
-
const where = await api("/v1/cloud/storage", { token });
|
|
1231
|
-
const pick = storChoices.find((input) => input.value === where.mode) || storChoices[0];
|
|
1232
|
-
pick.checked = true;
|
|
1233
|
-
if (where.provider && where.provider !== "vaultline") $("st-prov").value = where.provider;
|
|
1234
|
-
$("st-bucket").value = where.bucket && where.mode === "byo" ? where.bucket : "";
|
|
1235
|
-
$("st-region").value = where.region || "";
|
|
1236
|
-
$("st-prefix").value = where.mode === "byo" ? (where.prefix || "") : "";
|
|
1237
|
-
lastStorageMode = where.mode;
|
|
1238
|
-
applyStorageView();
|
|
1239
|
-
paintStorage();
|
|
1240
|
-
} catch { /* the card keeps its defaults */ }
|
|
1461
|
+
on("a-store-free", !lastPaid);
|
|
1462
|
+
on("a-store-paid", lastPaid);
|
|
1241
1463
|
}
|
|
1242
1464
|
|
|
1243
|
-
$("st-save").addEventListener("click", async () => {
|
|
1244
|
-
const token = sessionStorage.getItem(TOKEN);
|
|
1245
|
-
const mode = (storChoices.find((input) => input.checked) || {}).value || "managed";
|
|
1246
|
-
$("st-save").disabled = true;
|
|
1247
|
-
$("st-msg").textContent = "Saving";
|
|
1248
|
-
try {
|
|
1249
|
-
const saved = await api("/v1/cloud/storage", { method: "PUT", token, body: mode === "byo"
|
|
1250
|
-
? { mode, provider: $("st-prov").value, bucket: $("st-bucket").value.trim(), region: $("st-region").value.trim(), prefix: $("st-prefix").value.trim() }
|
|
1251
|
-
: { mode } });
|
|
1252
|
-
lastStorageMode = saved.mode;
|
|
1253
|
-
applyStorageView();
|
|
1254
|
-
$("st-msg").textContent = saved.mode === "byo"
|
|
1255
|
-
? "Saved. Run sealkeep storage credentials set on the machine that uploads — the keys stay there."
|
|
1256
|
-
: "Saved. Nothing else to configure.";
|
|
1257
|
-
} catch (error) { $("st-msg").textContent = errText(error); }
|
|
1258
|
-
finally { $("st-save").disabled = false; }
|
|
1259
|
-
});
|
|
1260
|
-
|
|
1261
1465
|
/* -------------------------------------------------------------- pairing
|
|
1262
1466
|
An account created through Google has no password anyone knows, so a
|
|
1263
1467
|
one-time code is the only way a machine can ever sign in. That makes this
|
|
@@ -1456,22 +1660,23 @@ sealkeep open 08424eb6… ./session.jsonl \
|
|
|
1456
1660
|
|
|
1457
1661
|
const packsFrom = (user) => {
|
|
1458
1662
|
const current = packageIdFor(user);
|
|
1663
|
+
const cur = PACKAGES.find((p) => p.id === current);
|
|
1664
|
+
$("a-pack-current").textContent = cur
|
|
1665
|
+
? (cur.managed ? `${cur.name} — $${cur.monthly} a month` : `${cur.name} — free`)
|
|
1666
|
+
: `Custom allocation`;
|
|
1667
|
+
$("a-pack-context").textContent = cur ? cur.blurb : "";
|
|
1459
1668
|
const host = $("a-packs");
|
|
1460
1669
|
while (host.firstChild) host.removeChild(host.firstChild);
|
|
1461
|
-
for (const pack of PACKAGES) {
|
|
1462
|
-
const
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
const
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
desc.textContent = pack.id === current ? `${pack.blurb} This is your package.` : pack.blurb;
|
|
1472
|
-
wrap.append(title, desc);
|
|
1473
|
-
row.append(wrap);
|
|
1474
|
-
host.append(row);
|
|
1670
|
+
for (const pack of PACKAGES.filter((p) => p.managed)) {
|
|
1671
|
+
const chip = document.createElement("span");
|
|
1672
|
+
chip.className = "packchip" + (pack.id === current ? " now" : "");
|
|
1673
|
+
const name = document.createElement("b"); name.textContent = pack.name;
|
|
1674
|
+
const price = document.createElement("span"); price.textContent = `$${pack.monthly}/mo`;
|
|
1675
|
+
chip.append(name, price);
|
|
1676
|
+
if (pack.id === current) {
|
|
1677
|
+
const tag = document.createElement("i"); tag.textContent = "your package"; chip.append(tag);
|
|
1678
|
+
}
|
|
1679
|
+
host.append(chip);
|
|
1475
1680
|
}
|
|
1476
1681
|
if (current === "custom") {
|
|
1477
1682
|
const gb = Math.round((Number(user.quota_bytes) || 0) / 1073741824);
|
package/dist/site.zip
CHANGED
|
Binary file
|
package/dist/src/chunk-store.js
CHANGED
|
@@ -407,6 +407,12 @@ async function runChunkSeal(ctx, args) {
|
|
|
407
407
|
indexed = true;
|
|
408
408
|
}
|
|
409
409
|
catch { /* the next `index build` re-indexes this archive */ }
|
|
410
|
+
// The account learns what each destination holds from the machines that
|
|
411
|
+
// write to them — refresh the stamps now that this archive is recorded.
|
|
412
|
+
{
|
|
413
|
+
const { pushTargetUsage } = await import("./storage-targets.js");
|
|
414
|
+
void pushTargetUsage(ctx.dataDir);
|
|
415
|
+
}
|
|
410
416
|
return { record, folder: args.folder, storedBytes: sealed.storedBytes, chunkCount: envelope.chunks.length, reusedChunks: args.reusedChunks, heldAtMostBytes, indexed };
|
|
411
417
|
}
|
|
412
418
|
/**
|
package/dist/src/local-api.js
CHANGED
|
@@ -645,11 +645,19 @@ async function cloudPlanAndQuota(dataDir) {
|
|
|
645
645
|
const { account, source } = await resolveAccount(dataDir);
|
|
646
646
|
return { plan: account.plan, quota: account.quota, email: account.email, source };
|
|
647
647
|
}
|
|
648
|
-
/**
|
|
648
|
+
/**
|
|
649
|
+
* The account panel lives on the product domain, which is no longer the API's
|
|
650
|
+
* origin — deriving it from the cloud URL sent people to the old address after
|
|
651
|
+
* the rename. A self-hosted plane (VAULTLINE_CLOUD_URL set) still derives from
|
|
652
|
+
* its own origin, because there is no sealkeep.spala.ai panel for it.
|
|
653
|
+
*/
|
|
649
654
|
async function panelUrl() {
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
655
|
+
if (process.env.VAULTLINE_CLOUD_URL) {
|
|
656
|
+
const origin = process.env.VAULTLINE_CLOUD_URL.replace(/\/+$/, "").replace(/\/api$/, "");
|
|
657
|
+
return `${origin}/#account`;
|
|
658
|
+
}
|
|
659
|
+
const { PRODUCT_URL } = await import("./branding.js");
|
|
660
|
+
return `${PRODUCT_URL}/#account`;
|
|
653
661
|
}
|
|
654
662
|
/**
|
|
655
663
|
* Everything the settings screen shows about the account, and none of it
|
|
@@ -19,6 +19,9 @@ export type StorageTarget = StorageTargetConfig;
|
|
|
19
19
|
*/
|
|
20
20
|
export declare function resolveTargets(dataDir: string): Promise<StorageTarget[]>;
|
|
21
21
|
/** Pushes the rules to the account so every other machine syncs them. Advisory: offline edits still apply locally. */
|
|
22
|
+
/** Re-pushes the current rules with fresh usage stamps — machines are the only
|
|
23
|
+
* ones who can see Drive and BYO bytes, so the account learns them from here. */
|
|
24
|
+
export declare function pushTargetUsage(dataDir: string): Promise<void>;
|
|
22
25
|
export declare function pushTargetsToCloud(dataDir: string, targets: StorageTarget[]): Promise<boolean>;
|
|
23
26
|
/** Bytes each target currently holds, from the records: targetId when stamped, provider+bucket for older records. */
|
|
24
27
|
export declare function targetUsage(dataDir: string, targets: StorageTarget[]): Promise<Map<string, number>>;
|
|
@@ -59,8 +59,27 @@ async function pullTargetsFromCloud(dataDir) {
|
|
|
59
59
|
}
|
|
60
60
|
}
|
|
61
61
|
/** Pushes the rules to the account so every other machine syncs them. Advisory: offline edits still apply locally. */
|
|
62
|
+
/** Re-pushes the current rules with fresh usage stamps — machines are the only
|
|
63
|
+
* ones who can see Drive and BYO bytes, so the account learns them from here. */
|
|
64
|
+
export async function pushTargetUsage(dataDir) {
|
|
65
|
+
try {
|
|
66
|
+
const targets = await resolveTargets(dataDir);
|
|
67
|
+
if (!targets.length)
|
|
68
|
+
return;
|
|
69
|
+
await pushTargetsToCloud(dataDir, targets);
|
|
70
|
+
}
|
|
71
|
+
catch { /* advisory */ }
|
|
72
|
+
}
|
|
62
73
|
export async function pushTargetsToCloud(dataDir, targets) {
|
|
63
74
|
try {
|
|
75
|
+
const usage = await targetUsage(dataDir, targets).catch(() => null);
|
|
76
|
+
if (usage) {
|
|
77
|
+
const now = new Date().toISOString();
|
|
78
|
+
targets = targets.map((t) => {
|
|
79
|
+
const used = usage.get(t.id);
|
|
80
|
+
return used && used > 0 ? { ...t, usedBytes: used, usedAt: now } : t;
|
|
81
|
+
});
|
|
82
|
+
}
|
|
64
83
|
const { cloudToken, DEFAULT_CLOUD_URL } = await import("./cloud.js");
|
|
65
84
|
const token = await cloudToken(dataDir);
|
|
66
85
|
const base = (process.env.VAULTLINE_CLOUD_URL ?? DEFAULT_CLOUD_URL).replace(/\/+$/, "");
|
package/dist/src/types.d.ts
CHANGED
|
@@ -133,6 +133,9 @@ export type StorageTargetConfig = {
|
|
|
133
133
|
maxGb?: number;
|
|
134
134
|
projects?: string[];
|
|
135
135
|
priority?: number;
|
|
136
|
+
/** Stamped by machines when they push: bytes this target holds, per their records. */
|
|
137
|
+
usedBytes?: number;
|
|
138
|
+
usedAt?: string;
|
|
136
139
|
};
|
|
137
140
|
export declare function isV2(record: ArchiveRecord): record is ArchiveRecordV2;
|
|
138
141
|
export type VaultConfig = {
|
package/dist/src/upload.js
CHANGED
|
@@ -59,6 +59,10 @@ export async function uploadArchive(dataDir, archiveId, options = {}) {
|
|
|
59
59
|
const next = { ...record, remote };
|
|
60
60
|
await writeRecord(config.storage.root, next);
|
|
61
61
|
await recordAudit(dataDir, "upload.verify", "allowed", { archiveId, objectKey: lease.objectKey, bytes: ciphertext.length, provider: lease.provider });
|
|
62
|
+
{
|
|
63
|
+
const { pushTargetUsage } = await import("./storage-targets.js");
|
|
64
|
+
void pushTargetUsage(dataDir);
|
|
65
|
+
}
|
|
62
66
|
return { archiveId, objectKey: lease.objectKey, bytes: ciphertext.length, checksum: localChecksum, verified: true, provider: lease.provider };
|
|
63
67
|
}
|
|
64
68
|
/** Uploads every archive that has no verified remote object yet. */
|
package/package.json
CHANGED