sealkeep 0.5.2 → 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 CHANGED
@@ -3,6 +3,16 @@
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
+
6
16
  ## 0.5.2 — 2026-08-19
7
17
 
8
18
  - "Manage in cloud" now opens sealkeep.spala.ai — the panel URL was still
@@ -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.32rem;font-weight:750;letter-spacing:-.03em}
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:1.2rem;grid-template-columns:repeat(auto-fit,minmax(8rem,1fr))}
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: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}
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:.74rem}
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;
@@ -559,6 +584,16 @@ sealkeep open 08424eb6… ./session.jsonl \
559
584
  <button class="chip" id="a-plan" type="button" title="See your plan">—</button>
560
585
  <span class="who" id="a-who">—</span>
561
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>
562
597
 
563
598
  <div class="card next" id="a-firstrun" style="display:none">
564
599
  <span class="eyebrow">Set up</span>
@@ -573,7 +608,7 @@ sealkeep open 08424eb6… ./session.jsonl \
573
608
  <span class="saved" id="a-verify-msg" role="status" aria-live="polite"></span>
574
609
  </div>
575
610
 
576
- <div class="card">
611
+ <div class="card" id="anchor-storage">
577
612
  <span class="eyebrow">Storage</span>
578
613
  <div id="a-store-free" style="display:none">
579
614
  <h3>You run the bucket</h3>
@@ -581,19 +616,83 @@ sealkeep open 08424eb6… ./session.jsonl \
581
616
  <p class="empty">Your machine reports what it has stored. Run <code>sealkeep status</code> to see it.</p>
582
617
  </div>
583
618
  <div id="a-store-paid" style="display:none">
619
+ <h3>What you keep here</h3>
584
620
  <div class="kv">
585
- <div><span class="k">Stored</span><b id="a-used">—</b></div>
586
- <div><span class="k">Included</span><b id="a-quota-b">—</b></div>
587
- <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>
588
626
  </div>
589
627
  <div class="bar"><i id="a-bar"></i></div>
590
628
  <p class="msg" id="a-quota"></p>
591
629
  </div>
592
630
  </div>
593
631
 
594
- <div class="card" id="c-archives" style="display:none">
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">
595
693
  <span class="eyebrow">Archives</span>
596
- <p id="s-intro">Names live on your machines — the cloud 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>
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>
597
696
  <p class="msg" id="s-msg">Loading…</p>
598
697
  <div id="s-wrap" style="display:none;overflow-x:auto">
599
698
  <table>
@@ -604,16 +703,16 @@ sealkeep open 08424eb6… ./session.jsonl \
604
703
  </div>
605
704
  </div>
606
705
 
607
- <div class="card" id="c-plan">
706
+ <div class="card" id="c-plan"><span id="anchor-plan"></span>
608
707
  <span class="eyebrow">Plan</span>
609
708
  <div id="a-plan-free" style="display:none">
610
709
  <h3>Free — your bucket, your bill</h3>
611
710
  <p>Unlimited archives with the same encryption. You supply the storage credentials and pay your provider directly.</p>
612
- <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>
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>
613
712
  </div>
614
713
  <div id="a-plan-paid" style="display:none">
615
714
  <h3>Managed storage</h3>
616
- <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>
617
716
  <div class="kv" style="margin-top:1rem">
618
717
  <div><span class="k">Subscription</span><b id="a-sub-state">—</b></div>
619
718
  <div><span class="k">Renews</span><b id="a-sub-end">—</b></div>
@@ -625,13 +724,16 @@ sealkeep open 08424eb6… ./session.jsonl \
625
724
 
626
725
  <div class="card" id="a-pack-card">
627
726
  <span class="eyebrow">Your package</span>
628
- <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>
629
- <div class="rules" id="a-packs"></div>
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>
630
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>
631
732
  </div>
632
733
 
633
734
  <div class="card">
634
735
  <span class="eyebrow">What leaves this machine</span>
736
+ <h3>Your upload rules</h3>
635
737
  <p>Everything you do not choose here stays local and is never uploaded.</p>
636
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>
637
739
  <div class="rules">
@@ -658,94 +760,7 @@ sealkeep open 08424eb6… ./session.jsonl \
658
760
  </div>
659
761
  </div>
660
762
 
661
- <div class="card">
662
- <span class="eyebrow">Where your archives live</span>
663
- <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>
664
- <div class="rules">
665
- <label class="choice" data-on="yes">
666
- <input type="radio" name="stor" value="managed" checked>
667
- <span><span class="t">Let Sealkeep hold it</span>
668
- <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>
669
- </label>
670
- <label class="choice" data-on="no">
671
- <input type="radio" name="stor" value="byo">
672
- <span><span class="t">Use my own bucket</span>
673
- <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>
674
- <div class="row2" style="margin-top:.7rem">
675
- <div><label for="st-prov">Provider</label>
676
- <select id="st-prov">
677
- <option value="r2">Cloudflare R2 — no egress fees</option>
678
- <option value="s3">Amazon S3 or any S3-compatible</option>
679
- <option value="gcs">Google Cloud Storage</option>
680
- </select></div>
681
- <div><label for="st-bucket">Bucket</label><input id="st-bucket" placeholder="my-sealkeep-archives"></div>
682
- </div>
683
- <div class="row2">
684
- <div><label for="st-region">Region</label><input id="st-region" placeholder="auto"></div>
685
- <div><label for="st-prefix">Prefix</label><input id="st-prefix" placeholder="sealkeep/"></div>
686
- </div>
687
- </span>
688
- </label>
689
- <button class="primary" id="st-save" type="button" style="margin-top:1.1rem">Save destination</button>
690
- <span class="saved" id="st-msg" role="status" aria-live="polite"></span>
691
- </div>
692
- <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>
693
- </div>
694
-
695
- <div class="card" id="c-targets">
696
- <span class="eyebrow">Routing</span>
697
- <h3>Where new archives go</h3>
698
- <p>Rules live on your account, so every machine of the vault follows the same ones. Lowest priority number wins; a project pinned to a target always goes there; a target at its cap passes to the next. Managed cloud is the fallback when nothing else claims an archive.</p>
699
- <p class="msg" id="tg-msg">Loading…</p>
700
- <div id="tg-wrap" style="display:none;overflow-x:auto">
701
- <table>
702
- <thead><tr><th>Target</th><th>Where</th><th>Priority</th><th>Cap (GB)</th><th>Only these projects</th><th></th></tr></thead>
703
- <tbody id="tg-body"></tbody>
704
- </table>
705
- </div>
706
- <div class="inline" style="margin-top:.9rem">
707
- <button class="ghost" id="tg-add-drive" type="button">Add Google Drive</button>
708
- <button class="primary" id="tg-save" type="button">Save routing</button>
709
- <span class="saved" id="tg-save-msg" role="status" aria-live="polite"></span>
710
- </div>
711
- <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>
712
- </div>
713
-
714
- <div class="card">
715
- <span class="eyebrow">Machines</span>
716
- <p>Each connected machine signs its own uploads. Revoking one stops it writing here; the archives it already stored are untouched.</p>
717
- <ul class="lines" id="d-list"></ul>
718
- <p class="empty" id="d-msg">Loading…</p>
719
-
720
- <!-- One pairing block exists on the page. It leads the panel while no
721
- machine is connected, and JS moves it back into this slot once one
722
- is, so the same code is never printed in two places. -->
723
- <div id="pair-home-more">
724
- <div class="pair" id="pair">
725
- <p class="side" id="pair-side" style="display:none">Pairing code</p>
726
- <!-- Grouped and labelled, so the copy button is announced as
727
- belonging to the code rather than as a bare "Copy". -->
728
- <div class="pair-field" id="pair-shown" role="group" aria-labelledby="pair-side" style="display:none">
729
- <code class="pair-code" id="pair-code"></code>
730
- <button class="primary" id="pair-copy" type="button">Copy</button>
731
- </div>
732
- <p class="fine" id="pair-note"></p>
733
- <div class="inline" id="pair-ask">
734
- <button class="ghost" id="pair-go" type="button">Show a pairing code</button>
735
- <span class="saved" id="pair-msg" role="status" aria-live="polite"></span>
736
- </div>
737
- <details class="alt" id="pair-alt" style="display:none">
738
- <summary>Set the machine up from the terminal instead</summary>
739
- <p class="empty">The same code, typed as one command. Needs Node 22 or newer.</p>
740
- <div class="pair-field"><pre id="pair-cli"></pre>
741
- <button class="ghost" id="pair-cli-copy" type="button">Copy</button>
742
- </div>
743
- </details>
744
- </div>
745
- </div>
746
- </div>
747
-
748
- <div class="card" id="c-recovery">
763
+ <div class="card" id="c-recovery"><span id="anchor-recovery"></span>
749
764
  <span class="eyebrow">Recovery key</span>
750
765
  <h3 id="e-head">Loading…</h3>
751
766
  <p id="e-text"></p>
@@ -753,6 +768,7 @@ sealkeep open 08424eb6… ./session.jsonl \
753
768
 
754
769
  <div class="card">
755
770
  <span class="eyebrow">Activity</span>
771
+ <h3>What happened lately</h3>
756
772
  <p>Every change made to this account, newest first.</p>
757
773
  <ul class="lines" id="v-list"></ul>
758
774
  <p class="empty" id="v-msg">Loading…</p>
@@ -760,6 +776,7 @@ sealkeep open 08424eb6… ./session.jsonl \
760
776
 
761
777
  <div class="card">
762
778
  <span class="eyebrow">Account</span>
779
+ <h3>Name and email</h3>
763
780
  <label for="a-name">Account name</label>
764
781
  <div class="inline">
765
782
  <input id="a-name" maxlength="60" autocomplete="off">
@@ -994,7 +1011,7 @@ sealkeep open 08424eb6… ./session.jsonl \
994
1011
  // destination arrives; the plan is only the opening guess.
995
1012
  lastPaid = paid;
996
1013
  applyStorageView();
997
- on("c-archives", paid);
1014
+ on("anchor-archives", paid); on("nav-archives", paid);
998
1015
  on("a-plan-free", !paid);
999
1016
  on("a-plan-paid", paid);
1000
1017
 
@@ -1194,9 +1211,39 @@ sealkeep open 08424eb6… ./session.jsonl \
1194
1211
  $("tg-msg").textContent = "";
1195
1212
  for (const t of targetRows) {
1196
1213
  const tr = document.createElement("tr");
1197
- const name = document.createElement("td"); name.textContent = providerLabel(t.provider);
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);
1198
1225
  const where = document.createElement("td");
1199
- const wc = document.createElement("code"); wc.textContent = t.provider === "vaultline" ? "managed" : [t.bucket, t.prefix].filter(Boolean).join("/") || "—"; where.append(wc);
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
+ }
1200
1247
  const pr = document.createElement("td");
1201
1248
  const prIn = document.createElement("input"); prIn.type = "number"; prIn.className = "n"; prIn.min = "0"; prIn.max = "99";
1202
1249
  prIn.value = t.priority ?? (t.provider === "vaultline" ? 0 : 10);
@@ -1235,6 +1282,11 @@ sealkeep open 08424eb6… ./session.jsonl \
1235
1282
  renderTargets();
1236
1283
  } catch (error) { say("tg-msg", errText(error), "err"); }
1237
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
+ });
1238
1290
  $("tg-add-drive").addEventListener("click", () => {
1239
1291
  if (targetRows.some((t) => t.provider === "gdrive")) { say("tg-save-msg", "Drive is already in the rules."); return; }
1240
1292
  targetRows.push({ id: "gdrive-" + Math.random().toString(36).slice(2, 8), provider: "gdrive", bucket: "Sealkeep", maxGb: 50, priority: 10 });
@@ -1246,7 +1298,8 @@ sealkeep open 08424eb6… ./session.jsonl \
1246
1298
  if (!token) { say("tg-save-msg", "Sign in again to save.", "err"); return; }
1247
1299
  $("tg-save").disabled = true;
1248
1300
  try {
1249
- await api("/v1/cloud/storage-targets", { method: "PUT", token, body: { targets_json: JSON.stringify(targetRows) } });
1301
+ const clean = targetRows.map(({ __editable, ...t }) => t);
1302
+ await api("/v1/cloud/storage-targets", { method: "PUT", token, body: { targets_json: JSON.stringify(clean) } });
1250
1303
  say("tg-save-msg", "Saved. Every machine follows these on its next seal.");
1251
1304
  } catch (error) { say("tg-save-msg", errText(error), "err"); }
1252
1305
  $("tg-save").disabled = false;
@@ -1275,13 +1328,21 @@ sealkeep open 08424eb6… ./session.jsonl \
1275
1328
  const ref = document.createElement("td");
1276
1329
  const code = document.createElement("code"); code.textContent = String(r.vault_ref).slice(0, 8); ref.append(code);
1277
1330
  const size = document.createElement("td");
1278
- size.textContent = r.chunked ? bytes(r.bytes) + " in " + r.chunkCount + " sealed chunks" : bytes(r.bytes);
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
+ }
1279
1340
  const st = document.createElement("td");
1280
1341
  const s = document.createElement("span");
1281
1342
  s.className = "state " + (r.state === "durable" ? "durable" : "pending");
1282
1343
  s.textContent = r.state === "durable" ? "stored" : "incomplete";
1283
1344
  st.append(s);
1284
- 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);
1285
1346
  const act = document.createElement("td");
1286
1347
  const btn = document.createElement("button");
1287
1348
  btn.className = "del"; btn.type = "button"; btn.textContent = "Delete";
@@ -1311,11 +1372,13 @@ sealkeep open 08424eb6… ./session.jsonl \
1311
1372
  body.append(tr);
1312
1373
  }
1313
1374
 
1314
- const stored = rows.filter((r) => r.state === "durable").length;
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;
1315
1378
  say("s-msg", "");
1316
- $("a-count").textContent = String(rows.length);
1317
- $("s-foot").textContent = `${rows.length} archive${rows.length === 1 ? "" : "s"} · ${stored} fully stored`
1318
- + (stored < rows.length ? ` · ${rows.length - stored} incomplete, not billed` : "");
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` : "");
1319
1382
  $("s-wrap").style.display = "";
1320
1383
  } catch (error) { say("s-msg", errText(error), "err"); }
1321
1384
  }
@@ -1328,14 +1391,28 @@ sealkeep open 08424eb6… ./session.jsonl \
1328
1391
  renderAccount(user);
1329
1392
  // Each section fails on its own. One dead panel must not blank the rest.
1330
1393
  if (user.plan === "pro") { $("a-count").textContent = "—"; loadStorage(token); }
1331
- loadTargets(token);
1394
+ await loadTargets(token);
1332
1395
  loadAudit(token);
1333
- loadStorage2(token);
1334
1396
  const devices = await loadDevices(token);
1335
1397
 
1336
1398
  // A brand-new account has exactly one useful next step. It leads the panel,
1337
1399
  // and it leads with the code rather than with a terminal — a paying
1338
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 ");
1339
1416
  const nothingYet = devices === 0 && Number(user.used_bytes) === 0;
1340
1417
  on("a-firstrun", nothingYet);
1341
1418
  $(nothingYet ? "pair-home-first" : "pair-home-more").append($("pair"));
@@ -1377,53 +1454,14 @@ sealkeep open 08424eb6… ./session.jsonl \
1377
1454
  // half the bytes, and still no innerHTML anywhere on the page.
1378
1455
  $("g-in").prepend($("g-up").querySelector("svg").cloneNode(true));
1379
1456
 
1380
- const storChoices = [...document.querySelectorAll("input[name=stor]")];
1457
+ // The destination choice lives in Routing; this card only switches between
1458
+ // "we meter it" (paid) and "your machine reports it" (free).
1381
1459
  let lastPaid = false;
1382
- let lastStorageMode = null;
1383
1460
  function applyStorageView() {
1384
- const managed = lastStorageMode ? lastStorageMode !== "byo" : lastPaid;
1385
- on("a-store-free", !managed);
1386
- on("a-store-paid", managed);
1461
+ on("a-store-free", !lastPaid);
1462
+ on("a-store-paid", lastPaid);
1387
1463
  }
1388
1464
 
1389
- const paintStorage = () => {
1390
- for (const input of storChoices) input.closest(".choice").dataset.on = input.checked ? "yes" : "no";
1391
- };
1392
- for (const input of storChoices) input.addEventListener("change", paintStorage);
1393
-
1394
- async function loadStorage2(token) {
1395
- try {
1396
- const where = await api("/v1/cloud/storage", { token });
1397
- const pick = storChoices.find((input) => input.value === where.mode) || storChoices[0];
1398
- pick.checked = true;
1399
- if (where.provider && where.provider !== "vaultline") $("st-prov").value = where.provider;
1400
- $("st-bucket").value = where.bucket && where.mode === "byo" ? where.bucket : "";
1401
- $("st-region").value = where.region || "";
1402
- $("st-prefix").value = where.mode === "byo" ? (where.prefix || "") : "";
1403
- lastStorageMode = where.mode;
1404
- applyStorageView();
1405
- paintStorage();
1406
- } catch { /* the card keeps its defaults */ }
1407
- }
1408
-
1409
- $("st-save").addEventListener("click", async () => {
1410
- const token = sessionStorage.getItem(TOKEN);
1411
- const mode = (storChoices.find((input) => input.checked) || {}).value || "managed";
1412
- $("st-save").disabled = true;
1413
- $("st-msg").textContent = "Saving";
1414
- try {
1415
- const saved = await api("/v1/cloud/storage", { method: "PUT", token, body: mode === "byo"
1416
- ? { mode, provider: $("st-prov").value, bucket: $("st-bucket").value.trim(), region: $("st-region").value.trim(), prefix: $("st-prefix").value.trim() }
1417
- : { mode } });
1418
- lastStorageMode = saved.mode;
1419
- applyStorageView();
1420
- $("st-msg").textContent = saved.mode === "byo"
1421
- ? "Saved. Run sealkeep storage credentials set on the machine that uploads — the keys stay there."
1422
- : "Saved. Nothing else to configure.";
1423
- } catch (error) { $("st-msg").textContent = errText(error); }
1424
- finally { $("st-save").disabled = false; }
1425
- });
1426
-
1427
1465
  /* -------------------------------------------------------------- pairing
1428
1466
  An account created through Google has no password anyone knows, so a
1429
1467
  one-time code is the only way a machine can ever sign in. That makes this
@@ -1622,22 +1660,23 @@ sealkeep open 08424eb6… ./session.jsonl \
1622
1660
 
1623
1661
  const packsFrom = (user) => {
1624
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 : "";
1625
1668
  const host = $("a-packs");
1626
1669
  while (host.firstChild) host.removeChild(host.firstChild);
1627
- for (const pack of PACKAGES) {
1628
- const row = document.createElement("div");
1629
- row.className = "choice";
1630
- row.dataset.on = pack.id === current ? "yes" : "no";
1631
- const wrap = document.createElement("span");
1632
- const title = document.createElement("span");
1633
- title.className = "t";
1634
- title.textContent = pack.managed ? `${pack.name} — $${pack.monthly}/mo` : `${pack.name} — free`;
1635
- const desc = document.createElement("span");
1636
- desc.className = "d";
1637
- desc.textContent = pack.id === current ? `${pack.blurb} This is your package.` : pack.blurb;
1638
- wrap.append(title, desc);
1639
- row.append(wrap);
1640
- 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);
1641
1680
  }
1642
1681
  if (current === "custom") {
1643
1682
  const gb = Math.round((Number(user.quota_bytes) || 0) / 1073741824);
package/dist/site.zip CHANGED
Binary file
@@ -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
  /**
@@ -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(/\/+$/, "");
@@ -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 = {
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sealkeep",
3
- "version": "0.5.2",
3
+ "version": "0.5.3",
4
4
  "type": "module",
5
5
  "description": "Sealkeep by SPALA AI \u2014 your AI coding-agent history, sealed, searchable, and shared across your machines.",
6
6
  "license": "SEE LICENSE IN LICENSE",