@monster-orange/den 0.4.4 → 0.4.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/CHANGELOG.md +243 -0
  2. package/README.md +24 -9
  3. package/docs/distributions.md +320 -26
  4. package/docs/reference.md +8 -3
  5. package/package.json +4 -3
  6. package/skills/install-den/SKILL.md +114 -0
  7. package/src/shared/agent-setup.ts +9 -0
  8. package/src/shared/auth/sudo-vault.ts +115 -11
  9. package/src/shared/cli-command.ts +186 -0
  10. package/src/shared/confirm-code.ts +34 -0
  11. package/src/shared/constants.ts +7 -0
  12. package/src/shared/den-config.ts +7 -0
  13. package/src/shared/dev-metrics/emit.ts +59 -2
  14. package/src/shared/edge/config.ts +39 -0
  15. package/src/shared/edge/render.ts +92 -18
  16. package/src/shared/edge/runtime.ts +57 -1
  17. package/src/shared/edge/types.ts +11 -1
  18. package/src/shared/github-app.ts +94 -0
  19. package/src/shared/github-device.ts +286 -0
  20. package/src/shared/port-pick.ts +60 -0
  21. package/src/shared/secrets/onepassword.ts +27 -4
  22. package/src/shared/web-owner.ts +91 -0
  23. package/src/shared/web-session.ts +135 -1
  24. package/src/ui/cli/create-main.ts +1 -1
  25. package/src/ui/cli/create.ts +263 -30
  26. package/src/ui/cli/doctor-command.ts +67 -4
  27. package/src/ui/cli/doctor-onboard.ts +291 -0
  28. package/src/ui/cli/flag-parser.ts +5 -1
  29. package/src/ui/cli/index.ts +246 -4
  30. package/src/ui/cli/json-out.ts +49 -0
  31. package/src/ui/cli/mcp-connect.ts +179 -15
  32. package/src/ui/cli/repo-command.ts +490 -0
  33. package/src/ui/cli/repo-detect.ts +192 -0
  34. package/src/ui/cli/web-command.ts +513 -33
  35. package/src/ui/mcp/connect.ts +134 -0
  36. package/src/ui/server/device-flows.ts +274 -0
  37. package/src/ui/server/edge-exposure.ts +16 -4
  38. package/src/ui/server/github-routes.ts +321 -0
  39. package/src/ui/server/http-errors.ts +10 -0
  40. package/src/ui/server/index.ts +463 -27
  41. package/src/ui/server/login-waits.ts +203 -0
  42. package/src/ui/server/pair-requests.ts +237 -0
  43. package/src/ui/server/pair-routes.ts +196 -0
  44. package/src/ui/server/public/error-page.js +452 -40
  45. package/src/ui/server/public/error.html +59 -0
  46. package/src/ui/server/public/i18n/es.js +74 -8
  47. package/src/ui/server/public/i18n/ja.js +74 -8
  48. package/src/ui/server/public/local-dev.html +16 -4
  49. package/src/ui/server/public/qr.js +317 -0
  50. package/src/ui/server/public/settings-page.js +10 -3
  51. package/src/ui/server/public/settings-registry.js +392 -1
  52. package/src/ui/server/public/shell.js +123 -10
  53. package/src/ui/server/public/topbar.js +7 -3
  54. package/src/ui/server/public/workspace-picker.js +1 -1
  55. package/src/ui/server/request-guard.ts +103 -36
  56. package/src/ui/server/route-classes.ts +102 -2
  57. package/src/ui/server/ui-assets.ts +2 -0
@@ -28,6 +28,7 @@
28
28
  * config.
29
29
  */
30
30
  import { themeNotice, themeOptions } from "./appearance.js";
31
+ import { qrSvg } from "./qr.js";
31
32
 
32
33
  export const DEN_SETTING_CATEGORIES = [
33
34
  {
@@ -71,6 +72,14 @@ export const DEN_SETTING_CATEGORIES = [
71
72
  description:
72
73
  "The runtimes agents run on, installed first. Paths you set are this machine's only: everywhere (config/local/user.yml) or for one workspace (config/local/workspaces.yml), which wins.",
73
74
  },
75
+ {
76
+ // SEC-2a: who may come in from other devices.
77
+ id: "access",
78
+ label: "Access",
79
+ order: 57,
80
+ description:
81
+ "Reach this Den from your other devices: sign in with GitHub as its owner, or pair a device with a one-time link. On localhost nothing asks.",
82
+ },
74
83
  {
75
84
  id: "cli",
76
85
  label: "Command line",
@@ -113,6 +122,386 @@ const distRow = (id, label, description, keywords, value) => ({
113
122
  const effective = (list, chosen) =>
114
123
  chosen ?? (list || []).find((e) => e.available)?.key ?? null;
115
124
 
125
+ // ── Access (SEC-2a) ──────────────────────────────────────────────────────
126
+ // What these rows draw, and the routes they use (github-routes.ts). Pure
127
+ // helpers are exported for tests.
128
+
129
+ function escHtml(v) {
130
+ return String(v ?? "").replace(
131
+ /[&<>"']/g,
132
+ (c) =>
133
+ ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[
134
+ c
135
+ ],
136
+ );
137
+ }
138
+
139
+ /** "3 min ago", "2 h ago", "5 d ago", or "just now"; `now` for tests. */
140
+ export function seenAgo(iso, now = Date.now()) {
141
+ const t = Date.parse(iso);
142
+ if (!Number.isFinite(t)) return "";
143
+ const s = Math.max(0, Math.round((now - t) / 1000));
144
+ if (s < 90) return "just now";
145
+ if (s < 5400) return `${Math.round(s / 60)} min ago`;
146
+ if (s < 129600) return `${Math.round(s / 3600)} h ago`;
147
+ return `${Math.round(s / 86400)} d ago`;
148
+ }
149
+
150
+ /**
151
+ * One linked device's line (SEC-2a: every sign-in, whatever the method):
152
+ * its browser and OS, "This device" for this browser, then how it signed
153
+ * in, when, from where (the client address and the browser), and when it
154
+ * was last seen.
155
+ */
156
+ export function deviceLine(d, now = Date.now()) {
157
+ const how =
158
+ d.via === "github"
159
+ ? `signed in with GitHub${d.login ? ` (@${d.login})` : ""}`
160
+ : d.via === "code"
161
+ ? "approved with a code"
162
+ : "signed in with a one-time link";
163
+ const name = d.name || "Unknown browser";
164
+ const from = d.client || d.host;
165
+ return {
166
+ title: name,
167
+ current: !!d.current,
168
+ detail: [
169
+ how,
170
+ `created ${seenAgo(d.createdAt, now)}`,
171
+ from ? `from ${from} (${name})` : "",
172
+ `last seen ${seenAgo(d.lastSeenAt, now)}`,
173
+ ]
174
+ .filter(Boolean)
175
+ .join(" · "),
176
+ };
177
+ }
178
+
179
+ const ROW_STYLE =
180
+ "display:flex;align-items:center;gap:8px;flex-wrap:wrap;width:100%;padding:6px 0;border-top:1px solid var(--border)";
181
+ const CODE_STYLE =
182
+ "font:22px/1.4 var(--mono);letter-spacing:0.12em;padding:6px 12px;border:1px solid var(--border);border-radius:var(--radius-button);background:var(--sunken)";
183
+
184
+ /** Linking the owner in the page: start, show the code, poll the server. */
185
+ function drawOwner(host, ctx) {
186
+ const api = (m, p, b) => ctx.shell.api(m, p, b);
187
+ let timer = null;
188
+ const draw = (o) => {
189
+ clearTimeout(timer);
190
+ if (o.owner) {
191
+ host.innerHTML =
192
+ `<output class="set-ro" id="set-owner">Owner: <b>@${escHtml(o.owner.login)}</b> <span class="set-note">(${escHtml(o.owner.id)})</span></output> ` +
193
+ '<button type="button" class="btn" id="set-owner-unlink">Unlink</button>';
194
+ host.querySelector("#set-owner-unlink").addEventListener("click", () =>
195
+ api("POST", "/api/web/owner/unlink", {})
196
+ .then(load)
197
+ .catch((e) => fail(e.message)),
198
+ );
199
+ return;
200
+ }
201
+ host.innerHTML =
202
+ '<output class="set-ro" id="set-owner">This Den has no owner identity yet.</output> ' +
203
+ (o.clientId
204
+ ? '<button type="button" class="btn primary" id="set-owner-link">Link your GitHub account</button>'
205
+ : '<span class="set-note" id="set-owner-noclient" style="flex-basis:100%">Linking needs a GitHub App client id, and none is configured: set <code>web.github_client_id</code> in config/local/user.yml, then restart the server.</span>') +
206
+ '<div id="set-owner-flow" style="flex-basis:100%" aria-live="polite"></div>';
207
+ host.querySelector("#set-owner-link")?.addEventListener("click", startLink);
208
+ };
209
+ const fail = (text) => {
210
+ const box = host.querySelector("#set-owner-flow");
211
+ if (box)
212
+ box.innerHTML = `<span class="set-note set-warn" role="alert">${escHtml(text)}</span>`;
213
+ const b = host.querySelector("#set-owner-link");
214
+ if (b) b.disabled = false;
215
+ };
216
+ const poll = (id) =>
217
+ fetch(`/api/web/owner/link/${encodeURIComponent(id)}`, {
218
+ cache: "no-store",
219
+ })
220
+ .then((r) => r.json())
221
+ .then((b) => {
222
+ if (b.state === "pending") {
223
+ timer = setTimeout(() => poll(id), 1500);
224
+ return;
225
+ }
226
+ if (b.state === "ok") return load();
227
+ fail(b.error || "Linking failed. Try again.");
228
+ })
229
+ .catch(() => fail("The server didn't answer. Try again."));
230
+ function startLink() {
231
+ const b = host.querySelector("#set-owner-link");
232
+ if (b) b.disabled = true;
233
+ api("POST", "/api/web/owner/link", {})
234
+ .then((f) => {
235
+ const box = host.querySelector("#set-owner-flow");
236
+ const url = f.verificationUri || "https://github.com/login/device";
237
+ box.innerHTML =
238
+ `<p class="set-note" style="margin:8px 0">Enter this code at <a href="${escHtml(url)}" target="_blank" rel="noopener noreferrer">${escHtml(url.replace(/^https:\/\//, ""))}</a> and approve the app. It asks for no permissions.</p>` +
239
+ `<span id="set-owner-code" style="${CODE_STYLE}">${escHtml(f.userCode)}</span> ` +
240
+ `<a class="btn" href="${escHtml(url)}" target="_blank" rel="noopener noreferrer">Open GitHub</a>` +
241
+ '<p class="set-note" style="margin:8px 0 0">Waiting for GitHub…</p>';
242
+ timer = setTimeout(() => poll(f.id), 1500);
243
+ })
244
+ .catch((e) => fail(e.message));
245
+ }
246
+ function load() {
247
+ fetch("/api/web/owner", { cache: "no-store" })
248
+ .then((r) => r.json())
249
+ .then(draw)
250
+ .catch(() => {
251
+ host.innerHTML =
252
+ '<output class="set-ro">The owner can only be shown signed in.</output>';
253
+ });
254
+ }
255
+ load();
256
+ }
257
+
258
+ function drawDevices(host, ctx) {
259
+ const load = () =>
260
+ fetch("/api/web/sessions", { cache: "no-store" })
261
+ .then((r) =>
262
+ r.ok ? r.json() : Promise.reject(new Error(String(r.status))),
263
+ )
264
+ .then((b) => {
265
+ const list = b.sessions || [];
266
+ if (!list.length) {
267
+ host.innerHTML =
268
+ '<output class="set-ro" id="set-devices">No linked devices. A browser that signs in with GitHub, is approved with a code, or opens a one-time link appears here.</output>';
269
+ return;
270
+ }
271
+ const now = Date.now();
272
+ host.innerHTML =
273
+ '<div id="set-devices" style="width:100%">' +
274
+ list
275
+ .map((d) => {
276
+ const l = deviceLine(d, now);
277
+ return (
278
+ `<div class="set-device" data-id="${escHtml(d.id)}" style="${ROW_STYLE}">` +
279
+ `<div style="flex:1 1 220px;min-width:0"><div>${escHtml(l.title)}` +
280
+ (l.current
281
+ ? ' <b class="set-this-device" style="font-weight:600">· This device</b>'
282
+ : "") +
283
+ `</div>` +
284
+ `<div class="set-note" style="overflow-wrap:anywhere">${escHtml(l.detail)}</div></div>` +
285
+ `<button type="button" class="btn" data-revoke="${escHtml(d.id)}">Revoke</button></div>`
286
+ );
287
+ })
288
+ .join("") +
289
+ "</div>";
290
+ host.querySelectorAll("[data-revoke]").forEach((btn) =>
291
+ btn.addEventListener("click", () => {
292
+ btn.disabled = true;
293
+ ctx.shell
294
+ .api("POST", "/api/web/logout", { id: btn.dataset.revoke })
295
+ .then(load)
296
+ .catch(() => {
297
+ btn.disabled = false;
298
+ });
299
+ }),
300
+ );
301
+ })
302
+ .catch(() => {
303
+ host.innerHTML =
304
+ '<output class="set-ro">The devices can only be listed signed in.</output>';
305
+ });
306
+ load();
307
+ }
308
+
309
+ /** One waiting device's line: its code, browser and OS, address, age. */
310
+ export function waitingLine(w, now = Date.now()) {
311
+ return {
312
+ title: `${w.code} · ${w.device || "Unknown browser"}`,
313
+ detail: [w.client, `asked ${seenAgo(w.createdAt, now)}`]
314
+ .filter(Boolean)
315
+ .join(" · "),
316
+ };
317
+ }
318
+
319
+ /**
320
+ * Waiting devices (SEC-2a): the computers whose signed-out page shows a
321
+ * code, with Approve and Deny. Approving signs that page in by itself.
322
+ */
323
+ function drawWaiting(host, ctx) {
324
+ let timer = null;
325
+ const load = () => {
326
+ clearTimeout(timer);
327
+ if (!host.isConnected && timer !== null) return;
328
+ fetch("/api/web/waiting", { cache: "no-store" })
329
+ .then((r) =>
330
+ r.ok ? r.json() : Promise.reject(new Error(String(r.status))),
331
+ )
332
+ .then((b) => {
333
+ const list = b.waiting || [];
334
+ const now = Date.now();
335
+ host.innerHTML = list.length
336
+ ? '<div id="set-waiting" style="width:100%">' +
337
+ list
338
+ .map((w) => {
339
+ const l = waitingLine(w, now);
340
+ return (
341
+ `<div class="set-waiting" data-code="${escHtml(w.code)}" style="${ROW_STYLE}">` +
342
+ `<div style="flex:1 1 220px;min-width:0"><div class="mono">${escHtml(l.title)}</div>` +
343
+ `<div class="set-note" style="overflow-wrap:anywhere">${escHtml(l.detail)}</div></div>` +
344
+ `<button type="button" class="btn primary" data-approve="${escHtml(w.code)}">Approve</button>` +
345
+ `<button type="button" class="btn" data-deny="${escHtml(w.code)}">Deny</button></div>`
346
+ );
347
+ })
348
+ .join("") +
349
+ '</div><div id="set-waiting-out" style="flex-basis:100%" aria-live="polite"></div>'
350
+ : '<output class="set-ro" id="set-waiting">No computers are waiting. A signed-out page on another computer shows a code; it appears here to approve.</output>';
351
+ const act = (btn, path, code) => {
352
+ btn.disabled = true;
353
+ ctx.shell
354
+ .api("POST", path, { code })
355
+ .then(load)
356
+ .catch((e) => {
357
+ btn.disabled = false;
358
+ const out = host.querySelector("#set-waiting-out");
359
+ if (out)
360
+ out.innerHTML = `<span class="set-note set-warn" role="alert">${escHtml(e.message)}</span>`;
361
+ });
362
+ };
363
+ host
364
+ .querySelectorAll("[data-approve]")
365
+ .forEach((b) =>
366
+ b.addEventListener("click", () =>
367
+ act(b, "/api/web/approve", b.dataset.approve),
368
+ ),
369
+ );
370
+ host
371
+ .querySelectorAll("[data-deny]")
372
+ .forEach((b) =>
373
+ b.addEventListener("click", () =>
374
+ act(b, "/api/web/deny", b.dataset.deny),
375
+ ),
376
+ );
377
+ // A new request shows up without a reload.
378
+ timer = setTimeout(load, 5000);
379
+ })
380
+ .catch(() => {
381
+ host.innerHTML =
382
+ '<output class="set-ro">Waiting devices can only be listed signed in, or on this machine.</output>';
383
+ });
384
+ };
385
+ load();
386
+ }
387
+
388
+ function drawPair(host, ctx) {
389
+ let timer = null;
390
+ fetch("/api/web/pair", { cache: "no-store" })
391
+ .then((r) => r.json())
392
+ .then((b) => {
393
+ const hosts = b.hosts || [];
394
+ if (!hosts.length) {
395
+ host.innerHTML =
396
+ '<output class="set-ro" id="set-pair">No edge hostname routes to this server, so there is no address another device could open. Add one to the config\'s edge block (edge.aliases for a VPN name), then <code>__DEN_CLI__ edge up</code>.</output>';
397
+ return;
398
+ }
399
+ host.innerHTML =
400
+ '<select id="set-pair-host" aria-label="Edge hostname">' +
401
+ hosts
402
+ .map((h) => `<option value="${escHtml(h)}">${escHtml(h)}</option>`)
403
+ .join("") +
404
+ "</select> " +
405
+ '<button type="button" class="btn primary" id="set-pair-go">Pair a device</button>' +
406
+ '<div id="set-pair-out" style="flex-basis:100%" aria-live="polite"></div>';
407
+ const out = host.querySelector("#set-pair-out");
408
+ const go = () => {
409
+ clearTimeout(timer);
410
+ ctx.shell
411
+ .api("POST", "/api/web/pair", {
412
+ host: host.querySelector("#set-pair-host").value,
413
+ })
414
+ .then((p) => {
415
+ out.innerHTML =
416
+ `<div style="display:flex;gap:16px;flex-wrap:wrap;align-items:flex-start;margin-top:8px">` +
417
+ `<div id="set-pair-qr" style="line-height:0">${qrSvg(p.url, { scale: 4, margin: 4 })}</div>` +
418
+ `<div style="flex:1 1 220px;min-width:0"><p class="set-note" style="margin:0 0 8px">Scan it with the other device, or open the link there. It works once, within 60 seconds, and signs that browser in for good.</p>` +
419
+ `<a id="set-pair-link" href="${escHtml(p.url)}" style="font:12px var(--mono);overflow-wrap:anywhere">${escHtml(p.url)}</a>` +
420
+ `<p class="set-note" id="set-pair-left" style="margin:8px 0 0"></p></div></div>`;
421
+ const until = Date.parse(p.expiresAt);
422
+ const left = out.querySelector("#set-pair-left");
423
+ const tick = () => {
424
+ const s = Math.max(0, Math.round((until - Date.now()) / 1000));
425
+ left.textContent = s
426
+ ? `Expires in ${s} s.`
427
+ : "This link expired. Pair again for a new one.";
428
+ if (s) timer = setTimeout(tick, 1000);
429
+ };
430
+ tick();
431
+ })
432
+ .catch((e) => {
433
+ out.innerHTML = `<span class="set-note set-warn" role="alert">${escHtml(e.message)}</span>`;
434
+ });
435
+ };
436
+ host.querySelector("#set-pair-go").addEventListener("click", go);
437
+ })
438
+ .catch(() => {
439
+ host.innerHTML =
440
+ '<output class="set-ro">Pairing works only signed in.</output>';
441
+ });
442
+ }
443
+
444
+ const ACCESS_ROWS = [
445
+ {
446
+ id: "access.owner",
447
+ category: "access",
448
+ label: "Owner",
449
+ description:
450
+ "The GitHub account this Den belongs to. Linking stores only its id (the token is thrown away), and from then on that account can sign in from any device. __DEN_CLI__ web owner link does the same from the command line.",
451
+ keywords: ["owner", "github", "identity", "link", "account"],
452
+ scope: "user",
453
+ control: { type: "custom", render: drawOwner },
454
+ read: () => null,
455
+ },
456
+ {
457
+ id: "access.devices",
458
+ category: "access",
459
+ label: "Linked devices",
460
+ description:
461
+ "The browsers signed in to this Den. Revoke ends one at once. __DEN_CLI__ web logout --all ends them all.",
462
+ keywords: ["devices", "sessions", "revoke", "browsers", "signed in"],
463
+ scope: "user",
464
+ control: { type: "custom", render: drawDevices },
465
+ read: () => null,
466
+ },
467
+ {
468
+ id: "access.waiting",
469
+ category: "access",
470
+ label: "Waiting devices",
471
+ description:
472
+ "Computers whose signed-out page shows a code. Approve one and its page signs in by itself; the code lasts 10 minutes. __DEN_CLI__ web approve <code> does the same from the command line.",
473
+ keywords: ["waiting", "approve", "code", "pair", "device", "deny"],
474
+ scope: "user",
475
+ control: { type: "custom", render: drawWaiting },
476
+ read: () => null,
477
+ },
478
+ {
479
+ id: "access.machine_name",
480
+ category: "access",
481
+ label: "Machine name",
482
+ description:
483
+ 'What the signed-out page calls this machine ("runs on <name>"), so people on other computers know where to run a command. Empty: its short hostname.',
484
+ keywords: ["machine", "name", "hostname", "host", "signed out"],
485
+ scope: "user",
486
+ control: { type: "text" },
487
+ keys: ["machine_name"],
488
+ read: (ctx) => ctx.data.machine_name ?? "",
489
+ write: (v, ctx) =>
490
+ ctx.post({ machine_name: String(v ?? "").trim() || null }),
491
+ },
492
+ {
493
+ id: "access.pair",
494
+ category: "access",
495
+ label: "Pair a device",
496
+ description:
497
+ "Without GitHub, or offline: a one-time link and QR code for one edge hostname (__DEN_CLI__ web login --host <name>).",
498
+ keywords: ["pair", "qr", "device", "link", "phone", "edge"],
499
+ scope: "user",
500
+ control: { type: "custom", render: drawPair },
501
+ read: () => null,
502
+ },
503
+ ];
504
+
116
505
  export const DEN_SETTINGS = [
117
506
  // ── Appearance ─────────────────────────────────────────────────────────
118
507
  {
@@ -502,7 +891,7 @@ export const DEN_SETTINGS = [
502
891
  category: "cli",
503
892
  label: "Browser sign-in",
504
893
  description:
505
- "Signing in is needed only when this server is reached from another machine (through the edge, or a LAN address); on localhost, and through the edge from this machine, nothing asks for it. __DEN_CLI__ web login prints a sign-in link; __DEN_CLI__ web sessions lists signed-in browsers.",
894
+ "On localhost nothing asks for a sign-in. Through the edge, a browser signs in unless the edge shows it is this machine (web.edge_unattested: local counts one it can't place as local), and from another machine it always does: Sign in with GitHub, or a one-time link (__DEN_CLI__ web login). Settings › Access lists the signed-in browsers.",
506
895
  keywords: ["sign in", "sign out", "login", "logout", "session", "browser"],
507
896
  scope: "user",
508
897
  control: {
@@ -576,6 +965,8 @@ export const DEN_SETTINGS = [
576
965
  read: () => null,
577
966
  },
578
967
 
968
+ ...ACCESS_ROWS,
969
+
579
970
  // ── Distribution (read-only) ───────────────────────────────────────────
580
971
  distRow(
581
972
  "brand",
@@ -132,7 +132,9 @@
132
132
  "org.title": "Org: {title}",
133
133
  "org.titleId": "Org: {title} ({id})",
134
134
  "ws.all": "All active",
135
- "ws.focus": "focus",
135
+ "ws.focus": "CLI default",
136
+ "ws.focusTip":
137
+ "CLI commands run here when you don't name a workspace. Picking a workspace in the top bar only changes what pages show.",
136
138
  "ws.none": "No active workspaces",
137
139
  "ws.manage": "Manage workspaces…",
138
140
  "ws.allN": "All active ({n})",
@@ -142,12 +144,12 @@
142
144
  "ws.viewTitle":
143
145
  "View: what every page shows ({ws}). A view filter only: it starts, stops and routes nothing.",
144
146
  "ws.viewTitleFocus":
145
- "View: what every page shows ({ws}). A view filter only: it starts, stops and routes nothing, and focus ({focus}) stays the target for commands.",
146
- "ws.setFocus": "Set {ws} as focus",
147
+ "View: what every page shows ({ws}). A view filter only: it starts, stops and routes nothing, and the CLI default ({focus}) stays the target for commands.",
148
+ "ws.setFocus": "Make {ws} the CLI default",
147
149
  "ws.setFocusTip":
148
- "Make it the default target for commands (writes workspaces.yml)",
150
+ "CLI commands run in it when you don't name a workspace (writes workspaces.yml)",
149
151
  "ws.viewFailed": "Could not change the view: {msg}",
150
- "ws.focusFailed": "Could not set the focus: {msg}",
152
+ "ws.focusFailed": "Could not change the CLI default: {msg}",
151
153
  // A count's singular is its .one (0.3.0-dev.40: en had none, so the org
152
154
  // menu said "1 workspaces").
153
155
  "org.n": "{n} workspaces",
@@ -354,19 +356,71 @@
354
356
  "This server answers only on its own addresses, so a page on another name can't reach it.",
355
357
  "err.421.open": "Open on localhost",
356
358
  "err.refused.title": "Request refused",
357
- "err.signin.title": "Sign in to use {name}",
358
359
  "err.signin.terminal": "Sign in to use the terminal",
359
360
  "err.signin.agents": "Sign in to start agents",
360
361
  "err.signin.sub": "This browser isn't signed in",
361
362
  "err.signin.lead": "Run {cmd} and open the link.",
362
363
  "err.signin.expiredTitle": "Sign-in link expired",
363
- "err.signin.expired":
364
- "That sign-in link expired or was used. Run {cmd} for a new one.",
365
364
  "err.signin.local":
366
365
  "A sign-in belongs to one address. This page is on {host}: open the same page on localhost, where this browser is signed in.",
367
- "err.signin.why":
368
- "There is no password: the link comes from a file only your user account can read.",
369
366
  "err.signin.copy": "Copy command",
367
+ "err.signin.title": "Sign in to {name}",
368
+ "err.signin.where":
369
+ "You're opening {name} from another computer. {name} runs on {host}, so it needs to know this is you.",
370
+ "err.signin.githubNote":
371
+ "Uses your GitHub account. The owner of this {name} has to have linked theirs.",
372
+ "err.signin.githubNoOwner":
373
+ "Sign in with GitHub needs an owner, and this {name} has none yet. On {host} (not this computer), run:",
374
+ "err.signin.githubRefused":
375
+ "@{login} isn't the owner of this {name}. Ask the owner to add you, or use 'Approve this computer'.",
376
+ "err.signin.approveOr": "Or approve this computer",
377
+ "err.signin.approve": "Approve this computer",
378
+ "err.signin.step1": "On {host} (not this computer), run:",
379
+ "err.signin.step1Or":
380
+ "Or open {name} on {host} itself and go to Settings › Access › Waiting devices.",
381
+ "err.signin.step2":
382
+ "This page signs you in by itself once approved. The code expires in 10 minutes.",
383
+ "err.signin.waiting": "Waiting for approval…",
384
+ "err.signin.noTerminal":
385
+ "Don't have a terminal on {host}? From this computer, run:",
386
+ "err.signin.openTerminal": "Open Terminal",
387
+ "err.signin.sshNote":
388
+ "Open Terminal opens ssh://{target} in your terminal app. It needs SSH access to {target}.",
389
+ "err.signin.whyTitle": "Why?",
390
+ "err.signin.whyText":
391
+ "{name} can start terminals and agents on {host}, so it only lets in people who prove they're the owner. You only do this once per computer: you stay signed in.",
392
+ "err.signin.other": "Other ways (the one-time link with {cmd})",
393
+ "err.signin.otherStep":
394
+ "On {host} (not this computer), run this, then open the link it prints in this browser:",
395
+ "err.signin.codeExpired": "This code expired.",
396
+ "err.signin.codeDenied": "This computer was denied on {host}.",
397
+ "err.signin.codeFailed": "{name} didn't give this page a code. Try again.",
398
+ "err.signin.newCode": "Get a new code",
399
+ "err.signin.youreIn": "You're in.",
400
+ "err.signin.expiredLead":
401
+ "That sign-in link expired or was used. Sign in again below.",
402
+ "err.signin.confirmLead":
403
+ "Enter the code that web login printed on {host}:",
404
+ "err.signin.confirmGo": "Sign in",
405
+ "err.signin.confirmWrong":
406
+ "That code isn't right. Check what web login printed and try again.",
407
+ "err.signin.confirmBusy": "Too many tries. Wait a minute and try again.",
408
+ "err.signin.confirmGone":
409
+ "This link no longer works. On {host}, run web login again for a new one.",
410
+ "err.signin.confirmWhy":
411
+ "The link alone isn't enough: it needs the code shown on {host} too, so a link someone else sees can't sign them in.",
412
+ "err.signin.github": "Sign in with GitHub",
413
+ "err.signin.githubEnter": "Enter this code at {url}:",
414
+ "err.signin.githubOpen": "Open GitHub",
415
+ "err.signin.githubCopy": "Copy code",
416
+ "err.signin.githubWaiting": "Waiting for you to approve it on GitHub…",
417
+ "err.signin.githubDone": "Signed in. Loading…",
418
+ "err.signin.githubExpired": "The code expired before it was approved.",
419
+ "err.signin.githubDenied": "The request was cancelled on GitHub.",
420
+ "err.signin.githubBusy":
421
+ "Too many sign-ins are in progress. Try again in a few minutes.",
422
+ "err.signin.githubFailed": "GitHub couldn't be reached. Try again.",
423
+ "err.signin.githubAgain": "Start again",
370
424
  "err.422.title": "Config is invalid",
371
425
  "err.422.sub": "The distribution config fails validation",
372
426
  "err.422.lead":
@@ -405,6 +459,14 @@
405
459
  "set.cli.web_port": "Your web port",
406
460
  "set.cli.signin": "Browser sign-in",
407
461
  "set.cli.sudo": "Admin password",
462
+ "set.access.owner": "Owner",
463
+ "set.access.devices": "Linked devices",
464
+ "ident.local": "Local",
465
+ "ident.device": "Signed in · this device",
466
+ "ident.toast": "This device is signed in to {name} on {host}",
467
+ "set.access.waiting": "Waiting devices",
468
+ "set.access.machine_name": "Machine name",
469
+ "set.access.pair": "Pair a device",
408
470
  "set.distribution.brand": "Product name",
409
471
  "set.distribution.github_org": "GitHub org",
410
472
  "set.distribution.orgs": "Org labels",
@@ -584,6 +646,9 @@
584
646
  agents:
585
647
  '<rect x="3" y="5" width="18" height="14" rx="2"/><path d="M7 10l3 2-3 2M12 15h5"/>',
586
648
  edge: '<path d="M12 3l8 4v5c0 4.5-3.4 8.3-8 9-4.6-.7-8-4.5-8-9V7z"/><path d="M8 12h8M12 8v8"/>',
649
+ // SEC-2a: who this browser is (the rail footer's identity chip).
650
+ person:
651
+ '<circle cx="12" cy="8" r="3.5"/><path d="M5 20c.8-3.6 3.6-5.5 7-5.5s6.2 1.9 7 5.5"/>',
587
652
  };
588
653
  function icon(name) {
589
654
  var d = ICONS[name] || (/^</.test(name || "") ? name : ICONS.page);
@@ -874,6 +939,9 @@
874
939
  // Whether the edge is configured (the Edge page's when): null until
875
940
  // known, the last answer cached so the rail doesn't jump.
876
941
  edge: null,
942
+ // SEC-2a: who this browser is (GET /api/session `identity`): local,
943
+ // a signed-in device, or a GitHub login. Null until known.
944
+ identity: null,
877
945
  };
878
946
  try {
879
947
  var cached = JSON.parse(localStorage.getItem(LS) || "{}");
@@ -1780,6 +1848,7 @@
1780
1848
  },
1781
1849
  page === SETTINGS.id,
1782
1850
  );
1851
+ html += identityChip();
1783
1852
  html +=
1784
1853
  '<button class="den-ni den-collapse-btn" type="button" data-act="collapse" aria-expanded="' +
1785
1854
  (state.collapsed ? "false" : "true") +
@@ -1793,6 +1862,49 @@
1793
1862
  return html;
1794
1863
  }
1795
1864
 
1865
+ /**
1866
+ * SEC-2a: the rail footer's identity chip: "Local" (this machine, no
1867
+ * sign-in), "Signed in · this device" (a one-time link or a code) or
1868
+ * "@login" (GitHub). A rail item like Settings (the same .den-ni
1869
+ * metrics), opening Settings › Access.
1870
+ */
1871
+ function identityLabel(id) {
1872
+ if (!id) return "";
1873
+ if (id.kind === "local") return t("ident.local");
1874
+ if (id.kind === "github" && id.login) return "@" + id.login;
1875
+ if (id.kind === "github" || id.kind === "device") return t("ident.device");
1876
+ return "";
1877
+ }
1878
+ function identityChip() {
1879
+ var label = identityLabel(state.identity);
1880
+ if (!label) return "";
1881
+ return item(
1882
+ {
1883
+ id: "identity",
1884
+ href: "/settings#access",
1885
+ title: label,
1886
+ icon: "person",
1887
+ },
1888
+ false,
1889
+ "",
1890
+ ' id="den-ident" data-ident="' + esc(state.identity.kind) + '"',
1891
+ );
1892
+ }
1893
+ /** What GET /api/session said about this browser: the chip, and a toast once after a sign-in. */
1894
+ function applyIdentity(b) {
1895
+ if (!b || !b.identity) return;
1896
+ var was = state.identity;
1897
+ if (!was || was.kind !== b.identity.kind || was.login !== b.identity.login)
1898
+ setState({ identity: b.identity });
1899
+ if (b.justSignedIn)
1900
+ toast(
1901
+ t("ident.toast", {
1902
+ name: b.product || "__DEN_TITLE__",
1903
+ host: b.machine || "",
1904
+ }),
1905
+ );
1906
+ }
1907
+
1796
1908
  // One tab stop for the rail: the current page, else the first item.
1797
1909
  function rove() {
1798
1910
  var links = Array.prototype.slice.call(nav.querySelectorAll(".den-ni"));
@@ -3525,6 +3637,7 @@
3525
3637
  function (b) {
3526
3638
  csrf.pending = null;
3527
3639
  csrf.token = b && typeof b.csrf === "string" ? b.csrf : null;
3640
+ applyIdentity(b);
3528
3641
  return csrf.token;
3529
3642
  },
3530
3643
  function () {
@@ -1156,7 +1156,9 @@ export async function unlockWith(password, { saveToVault = false } = {}) {
1156
1156
  });
1157
1157
  const data = await r.json().catch(() => ({}));
1158
1158
  if (r.ok) {
1159
- if (data.savedToVault) sudo.vault.hasStored = true;
1159
+ // LK-4: a first save also wrote the ref, so it is configured now.
1160
+ if (data.savedToVault)
1161
+ sudo.vault = { ...sudo.vault, hasStored: true, configured: true };
1160
1162
  return {
1161
1163
  ok: true,
1162
1164
  savedToVault: !!data.savedToVault,
@@ -1311,11 +1313,13 @@ function checkVault() {
1311
1313
  function notConfiguredNote() {
1312
1314
  const v = sudo.vault;
1313
1315
  if (!v || v.configured !== false || !v.configFile) return null;
1316
+ // LK-4: saving writes the ref itself; nothing to edit.
1317
+ if (v.canSave === true) return null;
1314
1318
  return t("lock.cfg", { file: v.configFile });
1315
1319
  }
1316
1320
 
1317
1321
  function saveLabel() {
1318
- if (!sudo.vault.backendAvailable) return null;
1322
+ if (!sudo.vault.backendAvailable || sudo.vault.canSave === false) return null;
1319
1323
  return sudo.vault.hasStored
1320
1324
  ? t("lock.update")
1321
1325
  : t("lock.saveTo", { s: sudo.vault.label || sudo.vault.backend });
@@ -1381,7 +1385,7 @@ function openLock() {
1381
1385
  btn.textContent = t("lock.checking");
1382
1386
  btn.disabled = true;
1383
1387
  err.hidden = true;
1384
- const saveToVault = !!(sudo.vault.backendAvailable && saveCb?.checked);
1388
+ const saveToVault = !!(saveLabel() && saveCb?.checked);
1385
1389
  try {
1386
1390
  const res = await unlockWith(pw, { saveToVault });
1387
1391
  if (res.ok) {