@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.
- package/CHANGELOG.md +243 -0
- package/README.md +24 -9
- package/docs/distributions.md +320 -26
- package/docs/reference.md +8 -3
- package/package.json +4 -3
- package/skills/install-den/SKILL.md +114 -0
- package/src/shared/agent-setup.ts +9 -0
- package/src/shared/auth/sudo-vault.ts +115 -11
- package/src/shared/cli-command.ts +186 -0
- package/src/shared/confirm-code.ts +34 -0
- package/src/shared/constants.ts +7 -0
- package/src/shared/den-config.ts +7 -0
- package/src/shared/dev-metrics/emit.ts +59 -2
- package/src/shared/edge/config.ts +39 -0
- package/src/shared/edge/render.ts +92 -18
- package/src/shared/edge/runtime.ts +57 -1
- package/src/shared/edge/types.ts +11 -1
- package/src/shared/github-app.ts +94 -0
- package/src/shared/github-device.ts +286 -0
- package/src/shared/port-pick.ts +60 -0
- package/src/shared/secrets/onepassword.ts +27 -4
- package/src/shared/web-owner.ts +91 -0
- package/src/shared/web-session.ts +135 -1
- package/src/ui/cli/create-main.ts +1 -1
- package/src/ui/cli/create.ts +263 -30
- package/src/ui/cli/doctor-command.ts +67 -4
- package/src/ui/cli/doctor-onboard.ts +291 -0
- package/src/ui/cli/flag-parser.ts +5 -1
- package/src/ui/cli/index.ts +246 -4
- package/src/ui/cli/json-out.ts +49 -0
- package/src/ui/cli/mcp-connect.ts +179 -15
- package/src/ui/cli/repo-command.ts +490 -0
- package/src/ui/cli/repo-detect.ts +192 -0
- package/src/ui/cli/web-command.ts +513 -33
- package/src/ui/mcp/connect.ts +134 -0
- package/src/ui/server/device-flows.ts +274 -0
- package/src/ui/server/edge-exposure.ts +16 -4
- package/src/ui/server/github-routes.ts +321 -0
- package/src/ui/server/http-errors.ts +10 -0
- package/src/ui/server/index.ts +463 -27
- package/src/ui/server/login-waits.ts +203 -0
- package/src/ui/server/pair-requests.ts +237 -0
- package/src/ui/server/pair-routes.ts +196 -0
- package/src/ui/server/public/error-page.js +452 -40
- package/src/ui/server/public/error.html +59 -0
- package/src/ui/server/public/i18n/es.js +74 -8
- package/src/ui/server/public/i18n/ja.js +74 -8
- package/src/ui/server/public/local-dev.html +16 -4
- package/src/ui/server/public/qr.js +317 -0
- package/src/ui/server/public/settings-page.js +10 -3
- package/src/ui/server/public/settings-registry.js +392 -1
- package/src/ui/server/public/shell.js +123 -10
- package/src/ui/server/public/topbar.js +7 -3
- package/src/ui/server/public/workspace-picker.js +1 -1
- package/src/ui/server/request-guard.ts +103 -36
- package/src/ui/server/route-classes.ts +102 -2
- 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
|
+
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[
|
|
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
|
-
"
|
|
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": "
|
|
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
|
|
146
|
-
"ws.setFocus": "
|
|
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
|
-
"
|
|
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
|
|
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
|
-
|
|
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 = !!(
|
|
1388
|
+
const saveToVault = !!(saveLabel() && saveCb?.checked);
|
|
1385
1389
|
try {
|
|
1386
1390
|
const res = await unlockWith(pw, { saveToVault });
|
|
1387
1391
|
if (res.ok) {
|