sealkeep 0.5.1 → 0.5.2

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,15 @@
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.2 — 2026-08-19
7
+
8
+ - "Manage in cloud" now opens sealkeep.spala.ai — the panel URL was still
9
+ derived from the API origin, which the rename left behind. Self-hosted
10
+ planes keep deriving from their own origin.
11
+ - The cloud panel folds chunk families into one archive per row, edits the
12
+ account's storage routing (priorities, caps, project pins, Google Drive),
13
+ and gained a for-companies section.
14
+
6
15
  ## 0.5.1 — 2026-08-19
7
16
 
8
17
  The first release that lives entirely at its new addresses.
@@ -386,6 +386,40 @@
386
386
 
387
387
  </section>
388
388
 
389
+ <section id="teams"><div class="wrap">
390
+ <h2>For teams and companies</h2>
391
+ <p class="sub">Agent transcripts are source code, prompts, and pasted secrets. Treat them like it.</p>
392
+
393
+ <div class="row-item duo">
394
+ <div class="human">
395
+ <span class="mark">Custody</span>
396
+ <h3>Your data never changes hands</h3>
397
+ <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>
398
+ </div>
399
+ <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>
400
+ </div>
401
+
402
+ <div class="row-item duo">
403
+ <div class="human">
404
+ <span class="mark">Audit</span>
405
+ <h3>Every action, written down</h3>
406
+ <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>
407
+ </div>
408
+ <div class="machine"><div class="ev">$ sealkeep audit<br>sealed 9f2c1ab4 · uploaded · verified<br>restored by device macbook · 12 Aug</div></div>
409
+ </div>
410
+
411
+ <div class="row-item duo">
412
+ <div class="human">
413
+ <span class="mark">Share</span>
414
+ <h3>Hand a session over, not the vault</h3>
415
+ <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>
416
+ </div>
417
+ <div class="machine"><div class="ev">$ sealkeep share 9f2c1ab4<br>bundle sealed · passcode shown once<br>opens without your phrase</div></div>
418
+ </div>
419
+
420
+ <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>
421
+ </div></section>
422
+
389
423
  <section id="plans">
390
424
  <h2>Two ways to run it</h2>
391
425
  <p class="sub">Same encryption either way. The only difference is which side of the line the bucket sits on.</p>
@@ -522,7 +556,7 @@ sealkeep open 08424eb6… ./session.jsonl \
522
556
  <div class="pane" id="p-account">
523
557
  <div class="ph" id="a-strip" tabindex="-1">
524
558
  <span class="name" id="a-label">—</span>
525
- <span class="chip" id="a-plan">—</span>
559
+ <button class="chip" id="a-plan" type="button" title="See your plan">—</button>
526
560
  <span class="who" id="a-who">—</span>
527
561
  </div>
528
562
 
@@ -559,18 +593,18 @@ sealkeep open 08424eb6… ./session.jsonl \
559
593
 
560
594
  <div class="card" id="c-archives" style="display:none">
561
595
  <span class="eyebrow">Archives</span>
562
- <p id="s-intro">Deleting one here removes the stored copy. If your machine already reclaimed the source, this is the last copy.</p>
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>
563
597
  <p class="msg" id="s-msg">Loading…</p>
564
598
  <div id="s-wrap" style="display:none;overflow-x:auto">
565
599
  <table>
566
- <thead><tr><th>Ref</th><th>Size</th><th>State</th><th>Added</th><th></th></tr></thead>
600
+ <thead><tr><th>Archive</th><th>Contents</th><th>State</th><th>Added</th><th></th></tr></thead>
567
601
  <tbody id="s-body"></tbody>
568
602
  </table>
569
603
  <p class="msg" id="s-foot"></p>
570
604
  </div>
571
605
  </div>
572
606
 
573
- <div class="card">
607
+ <div class="card" id="c-plan">
574
608
  <span class="eyebrow">Plan</span>
575
609
  <div id="a-plan-free" style="display:none">
576
610
  <h3>Free — your bucket, your bill</h3>
@@ -658,6 +692,25 @@ sealkeep open 08424eb6… ./session.jsonl \
658
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>
659
693
  </div>
660
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
+
661
714
  <div class="card">
662
715
  <span class="eyebrow">Machines</span>
663
716
  <p>Each connected machine signs its own uploads. Revoking one stops it writing here; the archives it already stored are untouched.</p>
@@ -924,6 +977,7 @@ sealkeep open 08424eb6… ./session.jsonl \
924
977
  $("a-name").value = user.label || "";
925
978
  $("a-plan").textContent = paid ? "Managed storage" : "Free";
926
979
  $("a-plan").className = "chip" + (paid ? " pro" : "");
980
+ $("a-plan").onclick = () => $("c-plan").scrollIntoView({ behavior: "smooth", block: "start" });
927
981
  $("a-who").textContent = [
928
982
  user.email,
929
983
  "joined " + day(user.created_at),
@@ -1095,6 +1149,109 @@ sealkeep open 08424eb6… ./session.jsonl \
1095
1149
  }
1096
1150
  }
1097
1151
 
1152
+ // Managed chunk folders arrive as one row per stored object —
1153
+ // <archive>.chunk-000001 … plus <archive>.envelope.vlmeta. A listing is
1154
+ // about archives, so fold each family into one row: the archive id, the
1155
+ // summed chunk bytes, and durable only when every member is durable.
1156
+ // Mirrors collapseCloudRows in the CLI, so both surfaces tell one story.
1157
+ const FAMILY_REF = /^(.+)\.(chunk-\d{6}|envelope\.vlmeta)$/;
1158
+ function foldFamilies(rows) {
1159
+ const families = new Map();
1160
+ const plain = [];
1161
+ for (const row of rows) {
1162
+ const match = FAMILY_REF.exec(String(row.vault_ref));
1163
+ if (!match) { plain.push(row); continue; }
1164
+ const fam = families.get(match[1]) ?? { rows: [], chunkBytes: 0, chunks: 0, members: [], allDurable: true };
1165
+ fam.rows.push(row);
1166
+ fam.members.push(row.vault_ref);
1167
+ if (match[2].startsWith("chunk-")) { fam.chunkBytes += Number(row.bytes) || 0; fam.chunks++; }
1168
+ if (row.state !== "durable") fam.allDurable = false;
1169
+ families.set(match[1], fam);
1170
+ }
1171
+ const folded = [...families.entries()].map(([id, fam]) => ({
1172
+ ...fam.rows[0], vault_ref: id, bytes: fam.chunkBytes, chunked: true,
1173
+ chunkCount: fam.chunks, members: fam.members, state: fam.allDurable ? "durable" : "pending"
1174
+ }));
1175
+ return [...plain, ...folded];
1176
+ }
1177
+
1178
+ /* ------------------------------------------------ routing (storage targets) */
1179
+ // The same rules the CLI syncs: GET/PUT /v1/cloud/storage-targets carries
1180
+ // {targets_json} — a JSON string of [{id, provider, bucket, prefix, maxGb,
1181
+ // projects, priority}]. Editing here lands on every machine of the vault,
1182
+ // because resolveTargets is cloud-first.
1183
+ let targetRows = [];
1184
+ const providerLabel = (p) => ({ vaultline: "Sealkeep cloud", gdrive: "Google Drive", r2: "Cloudflare R2", s3: "S3", gcs: "Google Cloud" }[p] || p);
1185
+ function renderTargets() {
1186
+ const body = $("tg-body");
1187
+ body.replaceChildren();
1188
+ if (!targetRows.length) {
1189
+ $("tg-msg").className = "empty";
1190
+ $("tg-msg").textContent = "No rules yet — everything goes to Sealkeep cloud. Add your Drive or set rules from a machine: sealkeep storage targets add …";
1191
+ $("tg-wrap").style.display = "none";
1192
+ return;
1193
+ }
1194
+ $("tg-msg").textContent = "";
1195
+ for (const t of targetRows) {
1196
+ const tr = document.createElement("tr");
1197
+ const name = document.createElement("td"); name.textContent = providerLabel(t.provider);
1198
+ 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);
1200
+ const pr = document.createElement("td");
1201
+ const prIn = document.createElement("input"); prIn.type = "number"; prIn.className = "n"; prIn.min = "0"; prIn.max = "99";
1202
+ prIn.value = t.priority ?? (t.provider === "vaultline" ? 0 : 10);
1203
+ prIn.setAttribute("aria-label", "Priority for " + providerLabel(t.provider));
1204
+ prIn.addEventListener("input", () => { t.priority = Number(prIn.value); });
1205
+ pr.append(prIn);
1206
+ const cap = document.createElement("td");
1207
+ const capIn = document.createElement("input"); capIn.type = "number"; capIn.className = "n"; capIn.min = "1"; capIn.max = "99999"; capIn.placeholder = "none";
1208
+ if (t.maxGb) capIn.value = t.maxGb;
1209
+ capIn.setAttribute("aria-label", "Cap in GB for " + providerLabel(t.provider));
1210
+ capIn.addEventListener("input", () => { t.maxGb = capIn.value ? Number(capIn.value) : undefined; });
1211
+ cap.append(capIn);
1212
+ const proj = document.createElement("td");
1213
+ const projIn = document.createElement("input"); projIn.placeholder = "all projects"; projIn.style.minWidth = "9rem";
1214
+ projIn.value = (t.projects || []).join(", ");
1215
+ projIn.setAttribute("aria-label", "Projects pinned to " + providerLabel(t.provider));
1216
+ projIn.addEventListener("input", () => {
1217
+ const list = projIn.value.split(",").map((x) => x.trim()).filter(Boolean);
1218
+ if (list.length) t.projects = list; else delete t.projects;
1219
+ });
1220
+ proj.append(projIn);
1221
+ const act = document.createElement("td");
1222
+ const rm = document.createElement("button"); rm.className = "del"; rm.type = "button"; rm.textContent = "Remove";
1223
+ rm.addEventListener("click", () => { targetRows = targetRows.filter((x) => x !== t); renderTargets(); });
1224
+ act.append(rm);
1225
+ tr.append(name, where, pr, cap, proj, act);
1226
+ body.append(tr);
1227
+ }
1228
+ $("tg-wrap").style.display = "";
1229
+ }
1230
+ async function loadTargets(token) {
1231
+ try {
1232
+ const data = await api("/v1/cloud/storage-targets", { token });
1233
+ try { targetRows = JSON.parse(data.targets_json || "[]"); } catch { targetRows = []; }
1234
+ if (!Array.isArray(targetRows)) targetRows = [];
1235
+ renderTargets();
1236
+ } catch (error) { say("tg-msg", errText(error), "err"); }
1237
+ }
1238
+ $("tg-add-drive").addEventListener("click", () => {
1239
+ if (targetRows.some((t) => t.provider === "gdrive")) { say("tg-save-msg", "Drive is already in the rules."); return; }
1240
+ targetRows.push({ id: "gdrive-" + Math.random().toString(36).slice(2, 8), provider: "gdrive", bucket: "Sealkeep", maxGb: 50, priority: 10 });
1241
+ renderTargets();
1242
+ say("tg-save-msg", "Added — save, then run the one-time consent on your machine.");
1243
+ });
1244
+ $("tg-save").addEventListener("click", async () => {
1245
+ const token = sessionStorage.getItem(TOKEN);
1246
+ if (!token) { say("tg-save-msg", "Sign in again to save.", "err"); return; }
1247
+ $("tg-save").disabled = true;
1248
+ try {
1249
+ await api("/v1/cloud/storage-targets", { method: "PUT", token, body: { targets_json: JSON.stringify(targetRows) } });
1250
+ say("tg-save-msg", "Saved. Every machine follows these on its next seal.");
1251
+ } catch (error) { say("tg-save-msg", errText(error), "err"); }
1252
+ $("tg-save").disabled = false;
1253
+ });
1254
+
1098
1255
  async function loadStorage(token) {
1099
1256
  try {
1100
1257
  const rows = await api("/v1/cloud/archives", { token });
@@ -1112,12 +1269,13 @@ sealkeep open 08424eb6… ./session.jsonl \
1112
1269
  }
1113
1270
  on("s-intro", true);
1114
1271
 
1115
- for (const r of rows) {
1272
+ for (const r of foldFamilies(rows)) {
1116
1273
  const tr = document.createElement("tr");
1117
1274
  // textContent throughout: an archive ref is data, never markup.
1118
1275
  const ref = document.createElement("td");
1119
1276
  const code = document.createElement("code"); code.textContent = String(r.vault_ref).slice(0, 8); ref.append(code);
1120
- const size = document.createElement("td"); size.textContent = bytes(r.bytes);
1277
+ const size = document.createElement("td");
1278
+ size.textContent = r.chunked ? bytes(r.bytes) + " in " + r.chunkCount + " sealed chunks" : bytes(r.bytes);
1121
1279
  const st = document.createElement("td");
1122
1280
  const s = document.createElement("span");
1123
1281
  s.className = "state " + (r.state === "durable" ? "durable" : "pending");
@@ -1137,7 +1295,14 @@ sealkeep open 08424eb6… ./session.jsonl \
1137
1295
  }
1138
1296
  btn.disabled = true; btn.textContent = "Deleting";
1139
1297
  try {
1140
- await api("/v1/cloud/archives/" + encodeURIComponent(r.vault_ref), { method: "DELETE", token });
1298
+ // A chunked archive is many stored objects; removing it means
1299
+ // removing every member, envelope last so a crash leaves an
1300
+ // openable family rather than an orphaned envelope.
1301
+ const refs = r.members ? [...r.members].sort((a, b) => a.endsWith(".envelope.vlmeta") ? 1 : b.endsWith(".envelope.vlmeta") ? -1 : 0) : [r.vault_ref];
1302
+ for (let i = 0; i < refs.length; i++) {
1303
+ if (refs.length > 1) btn.textContent = "Deleting " + (i + 1) + "/" + refs.length;
1304
+ await api("/v1/cloud/archives/" + encodeURIComponent(refs[i]), { method: "DELETE", token });
1305
+ }
1141
1306
  await loadAccount(token);
1142
1307
  } catch (error) { say("s-msg", errText(error), "err"); btn.disabled = false; btn.textContent = "Delete"; }
1143
1308
  });
@@ -1163,6 +1328,7 @@ sealkeep open 08424eb6… ./session.jsonl \
1163
1328
  renderAccount(user);
1164
1329
  // Each section fails on its own. One dead panel must not blank the rest.
1165
1330
  if (user.plan === "pro") { $("a-count").textContent = "—"; loadStorage(token); }
1331
+ loadTargets(token);
1166
1332
  loadAudit(token);
1167
1333
  loadStorage2(token);
1168
1334
  const devices = await loadDevices(token);
package/dist/site.zip CHANGED
Binary file
@@ -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
- /** The account panel lives at the cloud API's origin with `/api` swapped for `#account`. */
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
- const { DEFAULT_CLOUD_URL } = await import("./cloud.js");
651
- const origin = (process.env.VAULTLINE_CLOUD_URL ?? DEFAULT_CLOUD_URL).replace(/\/+$/, "").replace(/\/api$/, "");
652
- return `${origin}/#account`;
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sealkeep",
3
- "version": "0.5.1",
3
+ "version": "0.5.2",
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",