sealkeep 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +201 -0
- package/CHANGELOG.md +218 -0
- package/CONTROL_PLANE.md +86 -0
- package/LICENSE +34 -0
- package/README.md +249 -0
- package/THIRD_PARTY.md +22 -0
- package/THREAT_MODEL.md +107 -0
- package/dist/packages/vaultline-crypto/src/aead.d.ts +12 -0
- package/dist/packages/vaultline-crypto/src/aead.js +24 -0
- package/dist/packages/vaultline-crypto/src/chunk-access.d.ts +39 -0
- package/dist/packages/vaultline-crypto/src/chunk-access.js +93 -0
- package/dist/packages/vaultline-crypto/src/envelope.d.ts +71 -0
- package/dist/packages/vaultline-crypto/src/envelope.js +188 -0
- package/dist/packages/vaultline-crypto/src/format.d.ts +106 -0
- package/dist/packages/vaultline-crypto/src/format.js +43 -0
- package/dist/packages/vaultline-crypto/src/index.d.ts +5 -0
- package/dist/packages/vaultline-crypto/src/index.js +5 -0
- package/dist/packages/vaultline-crypto/src/recipients.d.ts +42 -0
- package/dist/packages/vaultline-crypto/src/recipients.js +129 -0
- package/dist/packages/vaultline-crypto/src/sha256-stream.d.ts +41 -0
- package/dist/packages/vaultline-crypto/src/sha256-stream.js +206 -0
- package/dist/packages/vaultline-crypto/src/stream.d.ts +139 -0
- package/dist/packages/vaultline-crypto/src/stream.js +477 -0
- package/dist/site/index.html +1542 -0
- package/dist/site.zip +0 -0
- package/dist/src/activity.d.ts +22 -0
- package/dist/src/activity.js +52 -0
- package/dist/src/adapters.d.ts +212 -0
- package/dist/src/adapters.js +533 -0
- package/dist/src/audit.d.ts +24 -0
- package/dist/src/audit.js +41 -0
- package/dist/src/autopilot.d.ts +77 -0
- package/dist/src/autopilot.js +148 -0
- package/dist/src/bip39-wordlist.d.ts +15 -0
- package/dist/src/bip39-wordlist.js +272 -0
- package/dist/src/branding.d.ts +31 -0
- package/dist/src/branding.js +31 -0
- package/dist/src/chunk-store.d.ts +142 -0
- package/dist/src/chunk-store.js +502 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +2035 -0
- package/dist/src/cloud.d.ts +434 -0
- package/dist/src/cloud.js +851 -0
- package/dist/src/control-plane/auth.d.ts +62 -0
- package/dist/src/control-plane/auth.js +123 -0
- package/dist/src/control-plane/server.d.ts +31 -0
- package/dist/src/control-plane/server.js +263 -0
- package/dist/src/control-plane/store.d.ts +101 -0
- package/dist/src/control-plane/store.js +82 -0
- package/dist/src/control-plane-cli.d.ts +2 -0
- package/dist/src/control-plane-cli.js +37 -0
- package/dist/src/control-plane-server.d.ts +10 -0
- package/dist/src/control-plane-server.js +11 -0
- package/dist/src/control-plane.d.ts +78 -0
- package/dist/src/control-plane.js +61 -0
- package/dist/src/crypto.d.ts +56 -0
- package/dist/src/crypto.js +132 -0
- package/dist/src/daemon.d.ts +52 -0
- package/dist/src/daemon.js +142 -0
- package/dist/src/dashboard-cli.d.ts +2 -0
- package/dist/src/dashboard-cli.js +20 -0
- package/dist/src/disk.d.ts +110 -0
- package/dist/src/disk.js +169 -0
- package/dist/src/doctor.d.ts +11 -0
- package/dist/src/doctor.js +198 -0
- package/dist/src/enroll.d.ts +27 -0
- package/dist/src/enroll.js +136 -0
- package/dist/src/errors.d.ts +26 -0
- package/dist/src/errors.js +23 -0
- package/dist/src/heartbeat.d.ts +89 -0
- package/dist/src/heartbeat.js +120 -0
- package/dist/src/index-sync.d.ts +53 -0
- package/dist/src/index-sync.js +147 -0
- package/dist/src/leakscan.d.ts +48 -0
- package/dist/src/leakscan.js +222 -0
- package/dist/src/local-api.d.ts +132 -0
- package/dist/src/local-api.js +1757 -0
- package/dist/src/managed-chunks.d.ts +55 -0
- package/dist/src/managed-chunks.js +108 -0
- package/dist/src/mcp-install.d.ts +52 -0
- package/dist/src/mcp-install.js +140 -0
- package/dist/src/mcp.d.ts +1 -0
- package/dist/src/mcp.js +59 -0
- package/dist/src/migrate.d.ts +35 -0
- package/dist/src/migrate.js +88 -0
- package/dist/src/mnemonic.d.ts +60 -0
- package/dist/src/mnemonic.js +134 -0
- package/dist/src/net.d.ts +2 -0
- package/dist/src/net.js +16 -0
- package/dist/src/notify.d.ts +46 -0
- package/dist/src/notify.js +84 -0
- package/dist/src/offload.d.ts +117 -0
- package/dist/src/offload.js +331 -0
- package/dist/src/onboarding.d.ts +10 -0
- package/dist/src/onboarding.js +44 -0
- package/dist/src/packages.d.ts +126 -0
- package/dist/src/packages.js +114 -0
- package/dist/src/passkey.d.ts +26 -0
- package/dist/src/passkey.js +54 -0
- package/dist/src/password-lock.d.ts +19 -0
- package/dist/src/password-lock.js +156 -0
- package/dist/src/paths.d.ts +9 -0
- package/dist/src/paths.js +24 -0
- package/dist/src/providers/gcs.d.ts +133 -0
- package/dist/src/providers/gcs.js +235 -0
- package/dist/src/providers/gdrive.d.ts +156 -0
- package/dist/src/providers/gdrive.js +335 -0
- package/dist/src/providers/index.d.ts +45 -0
- package/dist/src/providers/index.js +74 -0
- package/dist/src/providers/s3.d.ts +174 -0
- package/dist/src/providers/s3.js +345 -0
- package/dist/src/providers/sigv4.d.ts +78 -0
- package/dist/src/providers/sigv4.js +112 -0
- package/dist/src/queue.d.ts +185 -0
- package/dist/src/queue.js +286 -0
- package/dist/src/recovery.d.ts +40 -0
- package/dist/src/recovery.js +132 -0
- package/dist/src/rehydrate.d.ts +43 -0
- package/dist/src/rehydrate.js +66 -0
- package/dist/src/restore.d.ts +34 -0
- package/dist/src/restore.js +80 -0
- package/dist/src/retention.d.ts +251 -0
- package/dist/src/retention.js +446 -0
- package/dist/src/rotate.d.ts +47 -0
- package/dist/src/rotate.js +95 -0
- package/dist/src/search.d.ts +147 -0
- package/dist/src/search.js +677 -0
- package/dist/src/secrets.d.ts +86 -0
- package/dist/src/secrets.js +220 -0
- package/dist/src/service.d.ts +73 -0
- package/dist/src/service.js +197 -0
- package/dist/src/share.d.ts +34 -0
- package/dist/src/share.js +68 -0
- package/dist/src/spool.d.ts +97 -0
- package/dist/src/spool.js +213 -0
- package/dist/src/start-tui.d.ts +17 -0
- package/dist/src/start-tui.js +113 -0
- package/dist/src/start.d.ts +75 -0
- package/dist/src/start.js +101 -0
- package/dist/src/storage-setup.d.ts +49 -0
- package/dist/src/storage-setup.js +222 -0
- package/dist/src/storage-targets.d.ts +40 -0
- package/dist/src/storage-targets.js +147 -0
- package/dist/src/stream-to-cloud.d.ts +76 -0
- package/dist/src/stream-to-cloud.js +820 -0
- package/dist/src/sync-rules.d.ts +85 -0
- package/dist/src/sync-rules.js +125 -0
- package/dist/src/trash.d.ts +15 -0
- package/dist/src/trash.js +63 -0
- package/dist/src/tui.d.ts +18 -0
- package/dist/src/tui.js +179 -0
- package/dist/src/types.d.ts +191 -0
- package/dist/src/types.js +3 -0
- package/dist/src/ui-server.d.ts +187 -0
- package/dist/src/ui-server.js +293 -0
- package/dist/src/ui.d.ts +41 -0
- package/dist/src/ui.js +102 -0
- package/dist/src/update.d.ts +30 -0
- package/dist/src/update.js +56 -0
- package/dist/src/upload.d.ts +46 -0
- package/dist/src/upload.js +80 -0
- package/dist/src/vault.d.ts +208 -0
- package/dist/src/vault.js +812 -0
- package/dist/src/watcher.d.ts +34 -0
- package/dist/src/watcher.js +121 -0
- package/dist/src/worker.d.ts +52 -0
- package/dist/src/worker.js +190 -0
- package/package.json +65 -0
- package/web/app.js +1372 -0
- package/web/index.html +476 -0
- package/web/rail.js +308 -0
- package/web/retention.html +17 -0
- package/web/rules-view.js +249 -0
- package/web/sessions-view.js +448 -0
- package/web/sessions.html +17 -0
- package/web/setup-api.js +181 -0
- package/web/setup-logic.js +394 -0
- package/web/setup.html +419 -0
- package/web/setup.js +697 -0
- package/web/style.css +990 -0
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Memory pool, as a dashboard view.
|
|
3
|
+
*
|
|
4
|
+
* This was sessions.html — its own page, which meant every trip between
|
|
5
|
+
* "pick what to archive" and anywhere else was a full load: white flash,
|
|
6
|
+
* rail redrawn, list refetched, scroll lost. Nothing about the page needed
|
|
7
|
+
* a page. It is now a view in the one panel: entering it the first time
|
|
8
|
+
* loads the list; entering it again just shows the table that is already
|
|
9
|
+
* there, refreshing the data quietly underneath.
|
|
10
|
+
*
|
|
11
|
+
* The code is the page's script, moved. It shares `api` and `token` with
|
|
12
|
+
* app.js (classic scripts, one scope) and keeps its own small formatters —
|
|
13
|
+
* shadowed locals beat a refactor of working code. Everything it touches
|
|
14
|
+
* lives inside #view-sessions except the chip queries, which are scoped to
|
|
15
|
+
* this view's #chips container so no future chip elsewhere gets rewired.
|
|
16
|
+
*/
|
|
17
|
+
(() => {
|
|
18
|
+
const $ = (id) => document.getElementById(id);
|
|
19
|
+
|
|
20
|
+
const bytes = (value) => {
|
|
21
|
+
if (!Number.isFinite(value) || value <= 0) return "0 B";
|
|
22
|
+
const units = ["B", "KB", "MB", "GB", "TB"];
|
|
23
|
+
let size = value, unit = 0;
|
|
24
|
+
while (size >= 1024 && unit < units.length - 1) { size /= 1024; unit += 1; }
|
|
25
|
+
return `${size < 10 && unit > 0 ? size.toFixed(1) : Math.round(size)} ${units[unit]}`;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const when = (iso) => {
|
|
29
|
+
const days = Math.floor((Date.now() - Date.parse(iso)) / 86400000);
|
|
30
|
+
if (!Number.isFinite(days)) return "";
|
|
31
|
+
if (days <= 0) return "today";
|
|
32
|
+
if (days === 1) return "yesterday";
|
|
33
|
+
if (days < 60) return `${days}d ago`;
|
|
34
|
+
return `${Math.floor(days / 30)}mo ago`;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
let sessions = [];
|
|
38
|
+
const chosen = new Set();
|
|
39
|
+
|
|
40
|
+
const DAY = 86400000;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The rows that survive the current filters, in the chosen order.
|
|
44
|
+
*
|
|
45
|
+
* Selection deliberately persists across filter changes: someone narrowing to
|
|
46
|
+
* "over 1 GB", ticking four, then switching to "untouched 90 days" has not
|
|
47
|
+
* changed their mind about the four. Clearing a filter must not silently
|
|
48
|
+
* discard work.
|
|
49
|
+
*/
|
|
50
|
+
/** The active chip in a group, or "" for none. */
|
|
51
|
+
const chip = (group) => $("chips").querySelector(`.chip.on[data-group="${group}"]`)?.dataset.value ?? "";
|
|
52
|
+
|
|
53
|
+
function visible() {
|
|
54
|
+
const needle = $("f-search").value.trim().toLowerCase();
|
|
55
|
+
const agent = chip("agent");
|
|
56
|
+
const state = chip("state");
|
|
57
|
+
const project = chip("project");
|
|
58
|
+
const minBytes = Number(chip("size")) || 0;
|
|
59
|
+
const idleDays = Number(chip("age")) || 0;
|
|
60
|
+
const now = Date.now();
|
|
61
|
+
|
|
62
|
+
const rows = sessions.filter((row) => {
|
|
63
|
+
if (needle && !row.path.toLowerCase().includes(needle)) return false;
|
|
64
|
+
if (agent && row.agent !== agent) return false;
|
|
65
|
+
// "(none)" collects the sessions whose transcripts never said where they
|
|
66
|
+
// ran; any other project chip is a name to match exactly.
|
|
67
|
+
if (project === "(none)") { if (row.project) return false; }
|
|
68
|
+
else if (project && row.project !== project) return false;
|
|
69
|
+
if (state === "archived" && !row.archived) return false;
|
|
70
|
+
// A queued row is on its way into the vault: no decision is pending on
|
|
71
|
+
// it, so it is not "not archived yet" — it has its own chip. The three
|
|
72
|
+
// chips partition the table the same way the state column words it.
|
|
73
|
+
if (state === "unarchived" && (row.archived || row.queued)) return false;
|
|
74
|
+
if (state === "sealing" && !row.queued) return false;
|
|
75
|
+
// Cloud state: "stored" is a verified remote copy; "unstored" is anything
|
|
76
|
+
// that would be lost with this disk, sealed-but-local included.
|
|
77
|
+
if (state === "stored" && !row.stored) return false;
|
|
78
|
+
if (state === "unstored" && row.stored) return false;
|
|
79
|
+
if (row.bytes < minBytes) return false;
|
|
80
|
+
if (idleDays && (now - Date.parse(row.modifiedAt)) < idleDays * DAY) return false;
|
|
81
|
+
return true;
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const order = $("f-sort").value;
|
|
85
|
+
rows.sort((a, b) => order === "size"
|
|
86
|
+
? b.bytes - a.bytes
|
|
87
|
+
: order === "oldest"
|
|
88
|
+
? Date.parse(a.modifiedAt) - Date.parse(b.modifiedAt)
|
|
89
|
+
: Date.parse(b.modifiedAt) - Date.parse(a.modifiedAt));
|
|
90
|
+
return rows;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function refreshChoice() {
|
|
94
|
+
const total = sessions.filter((row) => chosen.has(row.path)).reduce((sum, row) => sum + row.bytes, 0);
|
|
95
|
+
$("chosen").textContent = chosen.size === 0 ? "Nothing selected." : `${chosen.size} selected · ${bytes(total)}`;
|
|
96
|
+
$("archive").disabled = chosen.size === 0;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
let draw = function () {
|
|
100
|
+
const body = $("rows");
|
|
101
|
+
const rows = visible();
|
|
102
|
+
body.textContent = "";
|
|
103
|
+
|
|
104
|
+
// What the filters are currently hiding, said out loud. A list that silently
|
|
105
|
+
// shows 12 of 697 looks like a list of 12.
|
|
106
|
+
const shownBytes = rows.reduce((sum, row) => sum + row.bytes, 0);
|
|
107
|
+
$("filter-count").textContent = sessions.length === 0
|
|
108
|
+
? ""
|
|
109
|
+
: `Showing ${rows.length} of ${sessions.length} sessions · ${bytes(shownBytes)}`;
|
|
110
|
+
|
|
111
|
+
if (rows.length === 0) {
|
|
112
|
+
const row = body.insertRow();
|
|
113
|
+
const cell = row.insertCell();
|
|
114
|
+
cell.colSpan = 6;
|
|
115
|
+
cell.className = "note";
|
|
116
|
+
cell.textContent = sessions.length === 0
|
|
117
|
+
? "No sessions found for the agents this build can archive."
|
|
118
|
+
: "No session matches these filters. Widen them, or clear them to see everything.";
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
for (const item of rows) {
|
|
122
|
+
const row = body.insertRow();
|
|
123
|
+
if (item.archived) row.className = "done";
|
|
124
|
+
|
|
125
|
+
const tick = row.insertCell();
|
|
126
|
+
const box = document.createElement("input");
|
|
127
|
+
box.type = "checkbox";
|
|
128
|
+
box.checked = chosen.has(item.path);
|
|
129
|
+
// An archive already covers this file at exactly this length, so sealing
|
|
130
|
+
// it again would write a duplicate of something we hold. A queued row is
|
|
131
|
+
// already on its way into the vault — re-selecting it makes no sense.
|
|
132
|
+
box.disabled = item.archived || item.queued;
|
|
133
|
+
box.onchange = () => { box.checked ? chosen.add(item.path) : chosen.delete(item.path); refreshChoice(); };
|
|
134
|
+
tick.append(box);
|
|
135
|
+
|
|
136
|
+
const size = row.insertCell(); size.className = "n"; size.textContent = bytes(item.bytes);
|
|
137
|
+
const age = row.insertCell(); age.className = "n"; age.textContent = when(item.modifiedAt);
|
|
138
|
+
// Its own column: the name answers "which repo", the folder under it
|
|
139
|
+
// answers "which one exactly". A Codex row's session column is a rollout
|
|
140
|
+
// filename that carries no location at all, so this column is the only
|
|
141
|
+
// place its whereabouts appear. Clicking a name applies that project's
|
|
142
|
+
// chip when one exists (only the largest projects earn chips).
|
|
143
|
+
const proj = row.insertCell(); proj.className = "proj-cell";
|
|
144
|
+
if (item.project) {
|
|
145
|
+
const name = document.createElement("span");
|
|
146
|
+
name.className = "name";
|
|
147
|
+
name.textContent = item.project;
|
|
148
|
+
const matching = $("chips").querySelector(`.chip[data-group="project"][data-value="${CSS.escape(item.project)}"]`);
|
|
149
|
+
if (matching) {
|
|
150
|
+
name.classList.add("filterable");
|
|
151
|
+
name.title = "Show only this project";
|
|
152
|
+
name.onclick = () => matching.click();
|
|
153
|
+
}
|
|
154
|
+
proj.append(name);
|
|
155
|
+
if (item.projectPath) {
|
|
156
|
+
const where = document.createElement("span");
|
|
157
|
+
where.className = "where";
|
|
158
|
+
where.textContent = item.projectPath.replace(/^\/(?:Users|home)\/[^/]+/, "~");
|
|
159
|
+
where.title = item.projectPath;
|
|
160
|
+
proj.append(where);
|
|
161
|
+
}
|
|
162
|
+
} else {
|
|
163
|
+
proj.textContent = "—";
|
|
164
|
+
}
|
|
165
|
+
const path = row.insertCell(); path.className = "path";
|
|
166
|
+
// The name, not the address. A person recognises "rollout-…f2.jsonl";
|
|
167
|
+
// nobody reads /Users/…/sessions/2026/08/11/ across 731 rows. The full
|
|
168
|
+
// path is one click away when it is actually needed.
|
|
169
|
+
const short = document.createElement("span");
|
|
170
|
+
short.className = "short";
|
|
171
|
+
short.textContent = item.path.split("/").pop();
|
|
172
|
+
short.title = `${item.path} — click for the full path`;
|
|
173
|
+
const full = document.createElement("span");
|
|
174
|
+
full.className = "full";
|
|
175
|
+
full.textContent = item.path;
|
|
176
|
+
full.title = "Click to fold the path away";
|
|
177
|
+
full.hidden = true;
|
|
178
|
+
short.onclick = () => { short.hidden = true; full.hidden = false; };
|
|
179
|
+
full.onclick = () => { full.hidden = true; short.hidden = false; };
|
|
180
|
+
path.append(short, full);
|
|
181
|
+
// The same words as the chips, and nothing that is not a state: the
|
|
182
|
+
// agent name used to stand here for unarchived rows, which meant the
|
|
183
|
+
// column answered a different question per row. "in your cloud" is
|
|
184
|
+
// the strongest true state — it implies archived — and matches the
|
|
185
|
+
// Pro chip that filters on it.
|
|
186
|
+
const state = row.insertCell();
|
|
187
|
+
if (item.stored) state.innerHTML = '<span class="ok">in your cloud</span>';
|
|
188
|
+
else if (item.archived) state.innerHTML = '<span class="ok">archived</span>';
|
|
189
|
+
else if (item.queued && item.held) {
|
|
190
|
+
// Queued but refused on its last attempt — today that means the disk
|
|
191
|
+
// guard stopped it. "sealing…" here was a lie the queue knew better
|
|
192
|
+
// than; the guard's own sentence rides on the tooltip.
|
|
193
|
+
const span = document.createElement("span");
|
|
194
|
+
span.className = "note";
|
|
195
|
+
span.textContent = "waiting for room";
|
|
196
|
+
span.title = item.held;
|
|
197
|
+
state.append(span);
|
|
198
|
+
}
|
|
199
|
+
else if (item.queued) state.innerHTML = `<span class="queued">sealing…${item.sealingPct !== null && item.sealingPct !== undefined ? ` ${item.sealingPct}%` : ""}</span>`;
|
|
200
|
+
else state.innerHTML = '<span class="note">not archived</span>';
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
// Selects what is on screen, not what exists. A tick-all that reached past the
|
|
205
|
+
// filters would be the single most expensive misunderstanding on this page:
|
|
206
|
+
// someone filters to four large sessions, ticks the header box, and archives
|
|
207
|
+
// six hundred.
|
|
208
|
+
// Only rows that can actually be sealed: not archived, not already queued.
|
|
209
|
+
const selectable = () => visible().filter((item) => !item.archived && !item.queued);
|
|
210
|
+
|
|
211
|
+
// The button says how many it would take, so it reads as a sentence: "Select
|
|
212
|
+
// all 13 shown (21 GB)". When some visible rows are already archived or in
|
|
213
|
+
// the queue, "all N shown" would disagree with the summary line above it —
|
|
214
|
+
// 579 shown, 22 GB, yet the button offering 529 and 456 MB — which reads as
|
|
215
|
+
// the page miscounting. The number it acts on is the still-archivable
|
|
216
|
+
// subset, so when the two differ the button says exactly that.
|
|
217
|
+
const originalDraw = draw;
|
|
218
|
+
draw = function () {
|
|
219
|
+
originalDraw();
|
|
220
|
+
const rows = selectable();
|
|
221
|
+
const shown = visible().length;
|
|
222
|
+
const total = rows.reduce((sum, row) => sum + row.bytes, 0);
|
|
223
|
+
$("select-shown").textContent = !rows.length ? "Nothing to select"
|
|
224
|
+
: rows.length === shown ? `Select all ${rows.length} shown (${bytes(total)})`
|
|
225
|
+
: `Select the ${rows.length} not yet archived (${bytes(total)})`;
|
|
226
|
+
$("select-shown").disabled = rows.length === 0;
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
// Shared by the static chips and the project chips built after load — a
|
|
230
|
+
// dynamic chip with different click behavior would be two filter languages
|
|
231
|
+
// on one row.
|
|
232
|
+
function wireChip(button) {
|
|
233
|
+
button.onclick = () => {
|
|
234
|
+
const on = button.classList.contains("on");
|
|
235
|
+
// One chip per group: choosing "older than 30 days" replaces "older than
|
|
236
|
+
// 7 days" rather than stacking two ages. Clicking the active chip clears
|
|
237
|
+
// the group.
|
|
238
|
+
for (const other of $("chips").querySelectorAll(`.chip[data-group="${button.dataset.group}"]`)) other.classList.remove("on");
|
|
239
|
+
if (!on) button.classList.add("on");
|
|
240
|
+
draw();
|
|
241
|
+
refreshChoice();
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Project chips cannot be static markup: which projects exist is a fact
|
|
247
|
+
* about this machine's sessions. Top eight by total bytes — the page is
|
|
248
|
+
* about disk, so the projects holding the most of it earn the chips — plus
|
|
249
|
+
* one for sessions whose transcripts never said where they ran. They sit
|
|
250
|
+
* before the age group so the row reads as what, then when, then how big.
|
|
251
|
+
*/
|
|
252
|
+
function buildProjectChips() {
|
|
253
|
+
if ($("chips").querySelector('.chip[data-group="project"]')) return;
|
|
254
|
+
const totals = new Map();
|
|
255
|
+
let unplaced = 0;
|
|
256
|
+
for (const row of sessions) {
|
|
257
|
+
if (row.project) totals.set(row.project, (totals.get(row.project) ?? 0) + row.bytes);
|
|
258
|
+
else unplaced += 1;
|
|
259
|
+
}
|
|
260
|
+
// A machine where no transcript names a project gets no dead chip row.
|
|
261
|
+
if (totals.size === 0) return;
|
|
262
|
+
const anchor = $("chips").querySelector('.chip[data-group="age"]');
|
|
263
|
+
const add = (node) => anchor.parentElement.insertBefore(node, anchor);
|
|
264
|
+
const make = (value, label, title) => {
|
|
265
|
+
const button = document.createElement("button");
|
|
266
|
+
button.type = "button";
|
|
267
|
+
button.className = "chip";
|
|
268
|
+
button.dataset.group = "project";
|
|
269
|
+
button.dataset.value = value;
|
|
270
|
+
button.textContent = label;
|
|
271
|
+
if (title) button.title = title;
|
|
272
|
+
wireChip(button);
|
|
273
|
+
add(button);
|
|
274
|
+
};
|
|
275
|
+
for (const [name] of [...totals.entries()].sort((a, b) => b[1] - a[1]).slice(0, 8)) make(name, name);
|
|
276
|
+
if (unplaced > 0) make("(none)", "No project", "Sessions whose transcripts do not say which project they belong to");
|
|
277
|
+
const sep = document.createElement("span");
|
|
278
|
+
sep.className = "chip-sep";
|
|
279
|
+
add(sep);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* What the plan is, and how much of it is left.
|
|
284
|
+
*
|
|
285
|
+
* A paying customer should be able to see what they are paying for somewhere
|
|
286
|
+
* other than a card statement. Free vaults get nothing here rather than an
|
|
287
|
+
* upsell: a bar reading "0 of 0" would be worse than silence.
|
|
288
|
+
*/
|
|
289
|
+
async function drawPlan() {
|
|
290
|
+
try {
|
|
291
|
+
const status = await api("/v1/status");
|
|
292
|
+
if (!status.plan || !status.quota) return;
|
|
293
|
+
// The cloud-state chips only mean something once the account has storage.
|
|
294
|
+
for (const proChip of $("chips").querySelectorAll(".pro-chip")) proChip.hidden = false;
|
|
295
|
+
const used = Number(status.quota.usedBytes) || 0;
|
|
296
|
+
const limit = Number(status.quota.quotaBytes) || 0;
|
|
297
|
+
const room = limit - used;
|
|
298
|
+
if (limit && room > 0) {
|
|
299
|
+
$("planbar").hidden = false;
|
|
300
|
+
$("plan-note").textContent = `Room for about ${bytes(room)} more in your cloud storage — roughly ${bytes(room / 0.7)} of sessions at this machine's compression.`;
|
|
301
|
+
}
|
|
302
|
+
} catch { /* a vault with no account simply shows nothing here */ }
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* The button queues; the machine seals. Sealing used to run inside this
|
|
307
|
+
* page's own loop, so refreshing mid-way silently abandoned everything after
|
|
308
|
+
* the current batch and the list kept offering the same rows for
|
|
309
|
+
* re-selection. Now one request queues the whole selection, the server's
|
|
310
|
+
* background drain does the work, and this page — or any later visit to it —
|
|
311
|
+
* just reads progress from the rows' `queued` flag. Refresh changes nothing.
|
|
312
|
+
*/
|
|
313
|
+
let pollTimer = null;
|
|
314
|
+
function stopPolling() { if (pollTimer) { clearInterval(pollTimer); pollTimer = null; } }
|
|
315
|
+
function startPolling() {
|
|
316
|
+
if (pollTimer) return;
|
|
317
|
+
pollTimer = setInterval(async () => {
|
|
318
|
+
try {
|
|
319
|
+
const data = await api("/v1/sessions");
|
|
320
|
+
sessions = data.sessions;
|
|
321
|
+
draw();
|
|
322
|
+
refreshChoice();
|
|
323
|
+
reportProgress();
|
|
324
|
+
} catch { /* one missed poll is fine; the next tick retries */ }
|
|
325
|
+
}, 3000);
|
|
326
|
+
}
|
|
327
|
+
function reportProgress() {
|
|
328
|
+
const queued = sessions.filter((row) => row.queued);
|
|
329
|
+
const waiting = queued.filter((row) => row.held).length;
|
|
330
|
+
const sealing = queued.length;
|
|
331
|
+
if (sealing > 0) {
|
|
332
|
+
$("result").textContent = waiting === sealing
|
|
333
|
+
? `${waiting} waiting for disk room — ${queued.find((row) => row.held).held} Free some space and they seal on their own.`
|
|
334
|
+
: `Sealing in the background — ${sealing - waiting} working${waiting ? `, ${waiting} waiting for room` : ""}. You can close or refresh this page; it carries on without it.`;
|
|
335
|
+
} else {
|
|
336
|
+
stopPolling();
|
|
337
|
+
$("result").textContent = "Everything queued is sealed. The originals are untouched — reclaim them from the retention page when you are ready.";
|
|
338
|
+
// The watching chip has nothing left to watch. Hand the person to the
|
|
339
|
+
// outcome — the rows they queued, now archived — instead of an empty
|
|
340
|
+
// table under "Sealing now".
|
|
341
|
+
const active = $("chips").querySelector('.chip[data-group="state"].on');
|
|
342
|
+
if (active?.dataset.value === "sealing") {
|
|
343
|
+
active.classList.remove("on");
|
|
344
|
+
$("chips").querySelector('.chip[data-group="state"][data-value="archived"]')?.classList.add("on");
|
|
345
|
+
draw();
|
|
346
|
+
refreshChoice();
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
async function load() {
|
|
352
|
+
try {
|
|
353
|
+
const data = await api("/v1/sessions");
|
|
354
|
+
sessions = data.sessions;
|
|
355
|
+
buildProjectChips();
|
|
356
|
+
// A selection can go stale across a refresh: anything now archived or
|
|
357
|
+
// already queued must not be re-submitted.
|
|
358
|
+
for (const row of sessions) if (row.archived || row.queued) chosen.delete(row.path);
|
|
359
|
+
const done = sessions.filter((item) => item.archived).length;
|
|
360
|
+
$("summary").textContent = `${bytes(data.totalBytes)} across ${sessions.length} sessions. `
|
|
361
|
+
+ `${bytes(data.unarchivedBytes)} in ${sessions.length - done} not yet archived.`;
|
|
362
|
+
draw();
|
|
363
|
+
refreshChoice();
|
|
364
|
+
// A background seal keeps going whether or not this view is open. If one
|
|
365
|
+
// is running, a fresh visit picks the progress line straight back up.
|
|
366
|
+
if (sessions.some((row) => row.queued)) { reportProgress(); startPolling(); }
|
|
367
|
+
} catch (error) {
|
|
368
|
+
$("summary").textContent = token
|
|
369
|
+
? `Could not read sessions: ${error.message}`
|
|
370
|
+
: "Connect the dashboard first so it can read this machine's sessions.";
|
|
371
|
+
$("rows").textContent = "";
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function wire() {
|
|
376
|
+
for (const button of $("chips").querySelectorAll(".chip")) wireChip(button);
|
|
377
|
+
$("f-search").addEventListener("input", () => { draw(); refreshChoice(); });
|
|
378
|
+
$("f-sort").addEventListener("change", () => { draw(); refreshChoice(); });
|
|
379
|
+
$("f-clear").onclick = () => {
|
|
380
|
+
for (const other of $("chips").querySelectorAll(".chip")) other.classList.remove("on");
|
|
381
|
+
$("chips").querySelector('.chip[data-value="unarchived"]').classList.add("on");
|
|
382
|
+
$("f-search").value = "";
|
|
383
|
+
$("f-sort").value = "size";
|
|
384
|
+
draw();
|
|
385
|
+
refreshChoice();
|
|
386
|
+
};
|
|
387
|
+
|
|
388
|
+
$("all").onchange = () => {
|
|
389
|
+
const rows = selectable();
|
|
390
|
+
if ($("all").checked) for (const item of rows) chosen.add(item.path);
|
|
391
|
+
else for (const item of rows) chosen.delete(item.path);
|
|
392
|
+
draw();
|
|
393
|
+
refreshChoice();
|
|
394
|
+
};
|
|
395
|
+
|
|
396
|
+
// "Select all shown" is the second click of the flow this view exists for:
|
|
397
|
+
// filter to what you mean, then take all of it. It reaches only what the
|
|
398
|
+
// filters left visible and only what is not already archived.
|
|
399
|
+
$("select-shown").onclick = () => {
|
|
400
|
+
for (const item of selectable()) chosen.add(item.path);
|
|
401
|
+
draw();
|
|
402
|
+
refreshChoice();
|
|
403
|
+
$("archive").focus();
|
|
404
|
+
};
|
|
405
|
+
|
|
406
|
+
$("archive").onclick = async () => {
|
|
407
|
+
const paths = [...chosen];
|
|
408
|
+
$("archive").disabled = true;
|
|
409
|
+
$("result").textContent = `Queueing ${paths.length} session${paths.length === 1 ? "" : "s"}…`;
|
|
410
|
+
try {
|
|
411
|
+
const outcome = await api("/v1/sessions/archive", { method: "POST", body: JSON.stringify({ paths }) });
|
|
412
|
+
const failures = outcome.results.filter((item) => !item.ok);
|
|
413
|
+
chosen.clear();
|
|
414
|
+
const data = await api("/v1/sessions");
|
|
415
|
+
sessions = data.sessions;
|
|
416
|
+
// The rows just queued belong to "Sealing now", not "Not archived yet" —
|
|
417
|
+
// staying on the old chip would show an empty table the moment the
|
|
418
|
+
// person acted. Follow the work: switch to the chip that watches it.
|
|
419
|
+
if (outcome.queued > 0) {
|
|
420
|
+
const sealingChip = $("chips").querySelector('.chip[data-group="state"][data-value="sealing"]');
|
|
421
|
+
for (const other of $("chips").querySelectorAll('.chip[data-group="state"]')) other.classList.remove("on");
|
|
422
|
+
sealingChip?.classList.add("on");
|
|
423
|
+
}
|
|
424
|
+
draw();
|
|
425
|
+
refreshChoice();
|
|
426
|
+
$("result").textContent = `Queued ${outcome.queued} of ${outcome.requested} — sealing runs in the background on this machine.`
|
|
427
|
+
+ (failures.length ? ` ${failures.length} could not be queued: ${failures[0].error}` : "");
|
|
428
|
+
startPolling();
|
|
429
|
+
} catch (error) {
|
|
430
|
+
$("result").textContent = `Nothing was queued: ${error.message}`;
|
|
431
|
+
$("archive").disabled = chosen.size === 0;
|
|
432
|
+
}
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
let initialised = false;
|
|
437
|
+
// Called by app.js when the view is entered. First entry wires and loads;
|
|
438
|
+
// every later entry shows the already-rendered table instantly and refreshes
|
|
439
|
+
// the data underneath it — no placeholder, no flash, no lost scroll.
|
|
440
|
+
window.initSessionsView = function () {
|
|
441
|
+
if (!initialised) {
|
|
442
|
+
initialised = true;
|
|
443
|
+
wire();
|
|
444
|
+
void drawPlan();
|
|
445
|
+
}
|
|
446
|
+
void load();
|
|
447
|
+
};
|
|
448
|
+
})();
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<title>Sealkeep — memory pool</title>
|
|
6
|
+
<meta name="referrer" content="no-referrer">
|
|
7
|
+
</head>
|
|
8
|
+
<body>
|
|
9
|
+
<!-- The Memory pool is a dashboard view now (index.html#sessions) — one page,
|
|
10
|
+
no reloads between views. This stub keeps old bookmarks and links working,
|
|
11
|
+
preserving a #token=… fragment when one is carried. -->
|
|
12
|
+
<script>
|
|
13
|
+
const h = location.hash.replace(/^#/, "");
|
|
14
|
+
location.replace(h.includes("token=") ? `./index.html#${h}` : "./index.html#sessions");
|
|
15
|
+
</script>
|
|
16
|
+
</body>
|
|
17
|
+
</html>
|
package/web/setup-api.js
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The only place the setup wizard talks to the machine.
|
|
3
|
+
*
|
|
4
|
+
* The endpoints in PRODUCT_ARCHITECTURE.md §7 are being built in parallel with
|
|
5
|
+
* this page, so every call is confined to this file: when they land, nothing
|
|
6
|
+
* above this layer changes, and until they land `missing()` is true and the
|
|
7
|
+
* page says so in words instead of rendering blank.
|
|
8
|
+
*
|
|
9
|
+
* Three rules are enforced here rather than trusted to callers:
|
|
10
|
+
*
|
|
11
|
+
* 1. Paths come from a fixed list. A caller cannot build a URL, so a bucket
|
|
12
|
+
* name, a secret key or a recovery phrase cannot end up in one — not in a
|
|
13
|
+
* query string, not in a path segment, not in the server's access log.
|
|
14
|
+
* 2. The phrase is returned by exactly one call, `createVault`, and there is
|
|
15
|
+
* no function here that reads it back. There is no endpoint to add one to.
|
|
16
|
+
* 3. Nothing is logged. No console call exists in this file, because a
|
|
17
|
+
* console line is a copy of a secret in a place nobody remembers to clear.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Every path this page may ever request. Nothing is concatenated onto them.
|
|
22
|
+
*
|
|
23
|
+
* The agents and hooks routes left with the screen that used them: the
|
|
24
|
+
* background service finds sessions by watching the agent folders itself, so
|
|
25
|
+
* setup no longer edits another program's configuration at all.
|
|
26
|
+
*/
|
|
27
|
+
export const ROUTES = {
|
|
28
|
+
state: "/v1/setup/state",
|
|
29
|
+
vault: "/v1/setup/vault",
|
|
30
|
+
unlockCheck: "/v1/setup/unlock-check",
|
|
31
|
+
storage: "/v1/setup/storage",
|
|
32
|
+
storageTest: "/v1/setup/storage/test",
|
|
33
|
+
enroll: "/v1/setup/enroll",
|
|
34
|
+
service: "/v1/setup/service"
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const TOKEN_KEY = "vaultline.localApiToken";
|
|
38
|
+
|
|
39
|
+
export class SetupError extends Error {
|
|
40
|
+
constructor(message, { status = 0, route = "", missing = false } = {}) {
|
|
41
|
+
super(message);
|
|
42
|
+
this.name = "SetupError";
|
|
43
|
+
this.status = status;
|
|
44
|
+
this.route = route;
|
|
45
|
+
/** True when this build of Sealkeep has no such endpoint yet. */
|
|
46
|
+
this.missing = missing;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
let token = "";
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The token arrives in the URL fragment, which browsers never send to a server,
|
|
54
|
+
* and is kept in sessionStorage — the same key and the same rules as the
|
|
55
|
+
* dashboard, so opening the dashboard after setup needs no second sign-in.
|
|
56
|
+
* The persistent half of web storage is never touched: it outlives the window,
|
|
57
|
+
* and a token that outlives the window is a token nobody remembers to revoke.
|
|
58
|
+
*/
|
|
59
|
+
export function readTokenFromLocation() {
|
|
60
|
+
if (typeof location === "undefined") return token;
|
|
61
|
+
const hash = new URLSearchParams(String(location.hash ?? "").replace(/^#/, ""));
|
|
62
|
+
const fromHash = hash.get("token");
|
|
63
|
+
if (fromHash) {
|
|
64
|
+
sessionStorage.setItem(TOKEN_KEY, fromHash);
|
|
65
|
+
if (typeof history !== "undefined") history.replaceState(null, "", location.pathname);
|
|
66
|
+
}
|
|
67
|
+
token = sessionStorage.getItem(TOKEN_KEY) ?? "";
|
|
68
|
+
return token;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Accepts a token from anywhere, and keeps it where the fragment path keeps
|
|
73
|
+
* one.
|
|
74
|
+
*
|
|
75
|
+
* Setting only the module variable meant a token pasted by a person was
|
|
76
|
+
* forgotten the moment anything called `readTokenFromLocation` again — which
|
|
77
|
+
* `boot` does first thing — so signing a second browser in appeared to do
|
|
78
|
+
* nothing at all. Session storage, not local: a token that outlives the window
|
|
79
|
+
* is a token nobody remembers to revoke.
|
|
80
|
+
*/
|
|
81
|
+
export function setToken(value) {
|
|
82
|
+
token = String(value ?? "");
|
|
83
|
+
if (typeof sessionStorage === "undefined") return;
|
|
84
|
+
if (token) sessionStorage.setItem(TOKEN_KEY, token);
|
|
85
|
+
else sessionStorage.removeItem(TOKEN_KEY);
|
|
86
|
+
}
|
|
87
|
+
export function hasToken() { return token.length > 0; }
|
|
88
|
+
|
|
89
|
+
async function request(route, { method = "GET", body } = {}) {
|
|
90
|
+
if (!Object.values(ROUTES).includes(route)) throw new SetupError(`Refusing to request ${route}: not a setup route.`, { route });
|
|
91
|
+
|
|
92
|
+
let response;
|
|
93
|
+
try {
|
|
94
|
+
response = await fetch(route, {
|
|
95
|
+
method,
|
|
96
|
+
headers: { "content-type": "application/json", accept: "application/json", authorization: `Bearer ${token}` },
|
|
97
|
+
body: body === undefined ? undefined : JSON.stringify(body)
|
|
98
|
+
});
|
|
99
|
+
} catch {
|
|
100
|
+
throw new SetupError("Sealkeep on this machine stopped answering. Is it still running in the terminal you started it from?", { route });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (response.status === 401 || response.status === 403) {
|
|
104
|
+
throw new SetupError("This page is not signed in to Sealkeep on this machine. Start it again with sealkeep ui, which opens the page already signed in.", { status: response.status, route });
|
|
105
|
+
}
|
|
106
|
+
if (response.status === 404) {
|
|
107
|
+
throw new SetupError(`This build of Sealkeep has no ${route} endpoint yet.`, { status: 404, route, missing: true });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const payload = await response.json().catch(() => ({}));
|
|
111
|
+
if (!response.ok) throw new SetupError(payload?.error?.message ?? `${response.status} ${response.statusText || "request failed"}`, { status: response.status, route });
|
|
112
|
+
return payload;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** What is already done on this machine, so a reopened page does not offer to redo it. */
|
|
116
|
+
export const fetchState = () => request(ROUTES.state);
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Creates the vault and returns the recovery phrase.
|
|
120
|
+
*
|
|
121
|
+
* This is the one call in the product that returns a secret over HTTP, and it
|
|
122
|
+
* returns it exactly once. The caller must render it and drop it; there is
|
|
123
|
+
* deliberately no second function here that could fetch it again.
|
|
124
|
+
*/
|
|
125
|
+
// `confirm: true` is the server's guard on the calls that change something
|
|
126
|
+
// real. The wizard has already shown the warning the guard exists for by the
|
|
127
|
+
// time it calls, so it confirms. Omitting the flag (the old bug) made the call
|
|
128
|
+
// fail on a fresh machine, so the wizard could not actually set anything up.
|
|
129
|
+
// `remember` defaults to true server-side, so the new vault keeps its phrase
|
|
130
|
+
// without asking again here.
|
|
131
|
+
//
|
|
132
|
+
// `storageMode` and `password` are named exactly as setupVaultSchema declares
|
|
133
|
+
// them — the schema is `.strict()`, and this seam has drifted three times
|
|
134
|
+
// before. The mode always travels, even at its default, so the vault created
|
|
135
|
+
// is the one the person saw selected; anything but the literal "plain" is sent
|
|
136
|
+
// as "sealed" so a stray value cannot smuggle an unencrypted vault past the
|
|
137
|
+
// choice. The password is omitted rather than sent empty: min(8) would reject
|
|
138
|
+
// "" and fail the whole create over a field the person never filled in.
|
|
139
|
+
export const createVault = ({ password, storageMode } = {}) => request(ROUTES.vault, {
|
|
140
|
+
method: "POST",
|
|
141
|
+
body: {
|
|
142
|
+
confirm: true,
|
|
143
|
+
storageMode: storageMode === "plain" ? "plain" : "sealed",
|
|
144
|
+
...(password ? { password } : {})
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Whether a password lock really exists on this machine now.
|
|
150
|
+
*
|
|
151
|
+
* Asked after a password vault is created, because the server deliberately
|
|
152
|
+
* refuses to fail the create over a lock it could not write — the phrase is
|
|
153
|
+
* being shown exactly once and must not be lost to a keychain hiccup. The done
|
|
154
|
+
* screen reports what is true rather than what was requested, and this is
|
|
155
|
+
* where the truth comes from.
|
|
156
|
+
*/
|
|
157
|
+
export const fetchUnlockCheck = () => request(ROUTES.unlockCheck);
|
|
158
|
+
|
|
159
|
+
/** Write-only by contract: no function here reads storage credentials back, masked or otherwise. */
|
|
160
|
+
export const saveStorage = (config) => request(ROUTES.storage, { method: "POST", body: config });
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Proves a target works without committing to it. Stores nothing: the credential
|
|
164
|
+
* is used for one small write and read, then dropped, so a failed test cannot
|
|
165
|
+
* leave a broken configuration behind.
|
|
166
|
+
*/
|
|
167
|
+
export const testStorage = (config) => request(ROUTES.storageTest, { method: "POST", body: config });
|
|
168
|
+
|
|
169
|
+
// `reclaim` is deliberately left at its server default of false: the service
|
|
170
|
+
// starts, but does not get standing permission to move a person's files to the
|
|
171
|
+
// trash from a single wizard click. That permission is granted knowingly, later,
|
|
172
|
+
// from Settings. The old body sent `{ enabled: true }`, a field the strict
|
|
173
|
+
// schema does not have, so the call was rejected and the service never started.
|
|
174
|
+
export const startService = () => request(ROUTES.service, { method: "POST", body: { confirm: true } });
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* A pairing code and the email it belongs to — never a password, never a
|
|
178
|
+
* phrase. The panel issues these under "Show a pairing code"; see the endpoint
|
|
179
|
+
* for why that distinction is load-bearing.
|
|
180
|
+
*/
|
|
181
|
+
export const enrollDevice = (email, code) => request(ROUTES.enroll, { method: "POST", body: { email, code } });
|