@monster-orange/den 0.4.4 → 0.4.5

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 (43) hide show
  1. package/CHANGELOG.md +169 -0
  2. package/README.md +10 -9
  3. package/docs/distributions.md +223 -26
  4. package/docs/reference.md +8 -3
  5. package/package.json +1 -1
  6. package/src/shared/auth/sudo-vault.ts +115 -11
  7. package/src/shared/cli-command.ts +186 -0
  8. package/src/shared/confirm-code.ts +34 -0
  9. package/src/shared/den-config.ts +7 -0
  10. package/src/shared/dev-metrics/emit.ts +31 -1
  11. package/src/shared/edge/config.ts +39 -0
  12. package/src/shared/edge/render.ts +92 -18
  13. package/src/shared/edge/runtime.ts +57 -1
  14. package/src/shared/edge/types.ts +11 -1
  15. package/src/shared/github-app.ts +94 -0
  16. package/src/shared/github-device.ts +286 -0
  17. package/src/shared/secrets/onepassword.ts +27 -4
  18. package/src/shared/web-owner.ts +91 -0
  19. package/src/shared/web-session.ts +135 -1
  20. package/src/ui/cli/flag-parser.ts +1 -1
  21. package/src/ui/cli/web-command.ts +512 -33
  22. package/src/ui/server/device-flows.ts +274 -0
  23. package/src/ui/server/edge-exposure.ts +16 -4
  24. package/src/ui/server/github-routes.ts +321 -0
  25. package/src/ui/server/http-errors.ts +10 -0
  26. package/src/ui/server/index.ts +463 -27
  27. package/src/ui/server/login-waits.ts +203 -0
  28. package/src/ui/server/pair-requests.ts +237 -0
  29. package/src/ui/server/pair-routes.ts +196 -0
  30. package/src/ui/server/public/error-page.js +452 -40
  31. package/src/ui/server/public/error.html +59 -0
  32. package/src/ui/server/public/i18n/es.js +74 -8
  33. package/src/ui/server/public/i18n/ja.js +74 -8
  34. package/src/ui/server/public/local-dev.html +16 -4
  35. package/src/ui/server/public/qr.js +317 -0
  36. package/src/ui/server/public/settings-page.js +10 -3
  37. package/src/ui/server/public/settings-registry.js +392 -1
  38. package/src/ui/server/public/shell.js +123 -10
  39. package/src/ui/server/public/topbar.js +7 -3
  40. package/src/ui/server/public/workspace-picker.js +1 -1
  41. package/src/ui/server/request-guard.ts +102 -35
  42. package/src/ui/server/route-classes.ts +102 -2
  43. package/src/ui/server/ui-assets.ts +2 -0
@@ -21,6 +21,7 @@ import { createHash, createHmac, randomBytes, timingSafeEqual } from "crypto";
21
21
  import * as fs from "fs";
22
22
  import * as path from "path";
23
23
  import * as yaml from "yaml";
24
+ import { parseMachineName } from "./cli-command.js";
24
25
  import { getStateDir } from "./paths.js";
25
26
 
26
27
  export const LOGIN_CODE_TTL_MS = 60_000;
@@ -98,6 +99,14 @@ export function checkLoginCode(
98
99
  return { ok: true, nonce: m[2], expiresAt };
99
100
  }
100
101
 
102
+ /** A login code's nonce, from its shape alone (unverified), or null. */
103
+ export function loginCodeNonce(code: string): string | null {
104
+ const m = /^v1\.([A-Za-z0-9_-]{22})\.[0-9a-z]{1,11}\.[A-Za-z0-9_-]{43}$/.exec(
105
+ code,
106
+ );
107
+ return m ? m[1] : null;
108
+ }
109
+
101
110
  /**
102
111
  * Where a sign-in may send the browser: a same-origin relative path, else
103
112
  * `/`. `//evil`, `/\evil`, `http://evil`, a control character or a path back
@@ -167,8 +176,30 @@ export interface WebAuthConfig {
167
176
  * never the distribution's), and warned about at start.
168
177
  */
169
178
  plainHttpInDistribution?: string;
179
+ /**
180
+ * `web.github_client_id` (SEC-2a): the GitHub App whose device flow signs
181
+ * the owner in. user.yml over the distribution config; unset: the MO Den
182
+ * app's (github-app.ts).
183
+ */
184
+ githubClientId?: string;
185
+ /**
186
+ * `web.edge_unattested` (SEC-2a, user.yml only): what an edge request
187
+ * whose client Den can't place gets. `signin` (the default): it signs in;
188
+ * `local`: it is treated as this machine (0.4.4's allow).
189
+ */
190
+ edgeUnattested: EdgeUnattested;
191
+ /** The distribution config's file when it sets `web.edge_unattested`: ignored there. */
192
+ edgeUnattestedInDistribution?: string;
193
+ /**
194
+ * `web.machine_name` (SEC-2a, user.yml only): the name the signed-out
195
+ * page gives this machine ("runs on <name>"). Unset: the short hostname.
196
+ */
197
+ machineName?: string;
170
198
  }
171
199
 
200
+ /** `web.edge_unattested`'s values. */
201
+ export type EdgeUnattested = "local" | "signin";
202
+
172
203
  function readYaml(file: string): Record<string, unknown> {
173
204
  try {
174
205
  const v = yaml.parse(fs.readFileSync(file, "utf-8")) as unknown;
@@ -210,6 +241,12 @@ export function ignoredConfigWarning(cfg: WebAuthConfig): string | null {
210
241
  `${cfg.ignored.join(", ")}: ignored. A browser session never expires; ` +
211
242
  "it ends at sign-out or `web logout`. Remove the key to silence this.",
212
243
  );
244
+ if (cfg.edgeUnattestedInDistribution)
245
+ lines.push(
246
+ `web.edge_unattested (${cfg.edgeUnattestedInDistribution}): ignored. ` +
247
+ "It is a choice for one machine, read only from config/local/user.yml; " +
248
+ "move it there on the machine that needs it, and remove it here.",
249
+ );
213
250
  if (cfg.plainHttpInDistribution)
214
251
  lines.push(
215
252
  `web.allow_plain_http_remote (${cfg.plainHttpInDistribution}): ignored. ` +
@@ -241,15 +278,47 @@ export function readWebAuthConfig(
241
278
  throw new Error(
242
279
  `web.allow_plain_http_remote must be true or false, got ${JSON.stringify(plain)}`,
243
280
  );
281
+ const unattested = user.edge_unattested;
282
+ if (
283
+ unattested !== undefined &&
284
+ unattested !== "local" &&
285
+ unattested !== "signin"
286
+ )
287
+ throw new Error(
288
+ `web.edge_unattested must be local or signin, got ${JSON.stringify(unattested)}`,
289
+ );
290
+ const clientId =
291
+ clientIdOf(user, "config/local/user.yml") ?? clientIdOf(team, configFile);
292
+ const machineName = parseMachineName(user.machine_name);
244
293
  return {
294
+ ...(machineName ? { machineName } : {}),
245
295
  allowPlainHttpRemote: plain === true,
246
296
  ignored,
297
+ edgeUnattested: unattested ?? "signin",
298
+ ...(clientId !== undefined ? { githubClientId: clientId } : {}),
247
299
  ...(team.allow_plain_http_remote !== undefined
248
300
  ? { plainHttpInDistribution: configFile }
249
301
  : {}),
302
+ ...(team.edge_unattested !== undefined
303
+ ? { edgeUnattestedInDistribution: configFile }
304
+ : {}),
250
305
  };
251
306
  }
252
307
 
308
+ /** `web.github_client_id` in one file: a GitHub App client id, or unset. */
309
+ function clientIdOf(
310
+ w: Record<string, unknown>,
311
+ file: string,
312
+ ): string | undefined {
313
+ const v = w.github_client_id;
314
+ if (v === undefined || v === null) return undefined;
315
+ if (typeof v !== "string" || !/^[A-Za-z0-9._-]{0,64}$/.test(v.trim()))
316
+ throw new Error(
317
+ `web.github_client_id (${file}) must be a GitHub App client id (letters, digits, dots, dashes), got ${JSON.stringify(v)}`,
318
+ );
319
+ return v.trim();
320
+ }
321
+
253
322
  // ── The session store ──────────────────────────────────────────────────────
254
323
 
255
324
  export interface SessionRecord {
@@ -260,6 +329,14 @@ export interface SessionRecord {
260
329
  csrf: string;
261
330
  /** The Host it was issued on. */
262
331
  host: string;
332
+ /** SEC-2a: how it signed in: a GitHub sign-in, or a one-time link (pairing). Unset: a link (before 0.4.5). */
333
+ via?: "github" | "link" | "code";
334
+ /** SEC-2a: the client's address when Den could attest it. */
335
+ client?: string;
336
+ /** SEC-2a: a GitHub sign-in's login (the chip's "@login"). */
337
+ login?: string;
338
+ /** SEC-2a: not yet greeted ("This device is signed in…", once). */
339
+ welcome?: true;
263
340
  }
264
341
 
265
342
  interface StoreFile {
@@ -384,7 +461,13 @@ export class WebSessionStore {
384
461
  }
385
462
 
386
463
  /** A new session: its id (the cookie's value) and record. */
387
- create(meta: { userAgent?: string; host?: string }): {
464
+ create(meta: {
465
+ userAgent?: string;
466
+ host?: string;
467
+ via?: "github" | "link" | "code";
468
+ client?: string;
469
+ login?: string;
470
+ }): {
388
471
  id: string;
389
472
  session: SessionView;
390
473
  } {
@@ -400,6 +483,10 @@ export class WebSessionStore {
400
483
  host: String(meta.host ?? "")
401
484
  .toLowerCase()
402
485
  .slice(0, 255),
486
+ ...(meta.via ? { via: meta.via } : {}),
487
+ ...(meta.client ? { client: String(meta.client).slice(0, 64) } : {}),
488
+ ...(meta.login ? { login: String(meta.login).slice(0, 64) } : {}),
489
+ welcome: true,
403
490
  };
404
491
  const data = structuredClone(this.load());
405
492
  this.prune(data, now);
@@ -438,6 +525,19 @@ export class WebSessionStore {
438
525
  }
439
526
  }
440
527
 
528
+ /**
529
+ * Whether this session is yet to be greeted, once: true the first time
530
+ * after its sign-in, and never again (the flag is cleared).
531
+ */
532
+ takeWelcome(key: string): boolean {
533
+ const data = this.load();
534
+ if (!data.sessions[key]?.welcome) return false;
535
+ const next = structuredClone(data);
536
+ delete next.sessions[key].welcome;
537
+ this.trySave(next);
538
+ return true;
539
+ }
540
+
441
541
  /** Every session, newest first. */
442
542
  list(): SessionView[] {
443
543
  return Object.entries(this.load().sessions)
@@ -489,3 +589,37 @@ export class WebSessionStore {
489
589
  export function sessionIdPrefix(key: string): string {
490
590
  return key.slice(0, 8);
491
591
  }
592
+
593
+ /**
594
+ * A linked device's name from its User-Agent (SEC-2a: "Chrome on macOS"),
595
+ * or "Unknown browser".
596
+ */
597
+ export function deviceName(userAgent: string | undefined): string {
598
+ const ua = String(userAgent ?? "");
599
+ const browser = /Edg\//.test(ua)
600
+ ? "Edge"
601
+ : /Firefox\//.test(ua)
602
+ ? "Firefox"
603
+ : /(Chrome|CriOS)\//.test(ua)
604
+ ? "Chrome"
605
+ : /Safari\//.test(ua)
606
+ ? "Safari"
607
+ : /curl\//.test(ua)
608
+ ? "curl"
609
+ : null;
610
+ const os = /iPhone|iPad|iPod/.test(ua)
611
+ ? "iOS"
612
+ : /Android/.test(ua)
613
+ ? "Android"
614
+ : /Mac OS X|Macintosh/.test(ua)
615
+ ? "macOS"
616
+ : /Windows/.test(ua)
617
+ ? "Windows"
618
+ : /CrOS/.test(ua)
619
+ ? "ChromeOS"
620
+ : /Linux/.test(ua)
621
+ ? "Linux"
622
+ : null;
623
+ if (browser && os) return `${browser} on ${os}`;
624
+ return browser ?? (os ? `A browser on ${os}` : "Unknown browser");
625
+ }
@@ -179,7 +179,7 @@ function validateFlagScopes(parsed: ParsedCliArgs): void {
179
179
  */
180
180
  export const SUBCOMMAND_OWN_HELP: Readonly<Record<string, readonly string[]>> =
181
181
  {
182
- web: ["token", "login", "sessions", "logout"],
182
+ web: ["token", "login", "sessions", "logout", "owner", "approve"],
183
183
  mcp: ["connect", "disconnect", "clients", "config"],
184
184
  };
185
185