@monster-orange/den 0.4.20 → 0.4.21

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.
@@ -0,0 +1,122 @@
1
+ /**
2
+ * doctor's owner check (D17): who can connect their GitHub account to this
3
+ * instance from the browser.
4
+ *
5
+ * - An owner is linked: ok, naming it.
6
+ * - No owner, but web.owner_github or web.allowed_github_org says who may
7
+ * claim it: ok, naming them.
8
+ * - Neither, and the edge is reachable from other machines: ✗ (a fail):
9
+ * nobody can get in from a browser there, and the page can only name
10
+ * the settings.
11
+ * - Neither, edge not reachable beyond this machine: a warning.
12
+ */
13
+ import { readWebOwner } from "../../shared/web-owner.js";
14
+ import {
15
+ readWebAuthConfig,
16
+ type WebAuthConfig,
17
+ } from "../../shared/web-session.js";
18
+ import { getDistRoot, getStateDir } from "../../shared/paths.js";
19
+ import { product } from "../../shared/product.js";
20
+ import { readEdgeConfig, readWebPort } from "../../shared/edge/config.js";
21
+ import { edgeNames, webRouteHosts } from "../../shared/edge/render.js";
22
+ import { edgeExposure } from "../../shared/edge/exposure.js";
23
+ import type { OnboardCheck } from "./doctor-onboard.js";
24
+ import { ghLogin, ghNotSignedInHint } from "../../shared/owner-github.js";
25
+
26
+ export interface OwnerCheckDeps {
27
+ owner?: () => { login: string } | null;
28
+ web?: () => WebAuthConfig;
29
+ /** Why the edge is reachable beyond this machine, or null. */
30
+ exposed?: () => Promise<string | null>;
31
+ configFile?: string;
32
+ /** The login gh is signed in as here, or null (default: ask gh). */
33
+ ghLogin?: () => string | null;
34
+ }
35
+
36
+ /**
37
+ * What to do when web.owner_github isn't set: gh is signed in but nothing
38
+ * recorded it yet (a restart does), or gh isn't signed in.
39
+ */
40
+ export function ownerGithubHint(login: string | null, cli: string): string {
41
+ return login
42
+ ? `gh is signed in as @${login}: run \`${cli} web restart\` to record it as this Den's owner (web.owner_github), then connect your GitHub account from any browser.`
43
+ : ghNotSignedInHint(cli);
44
+ }
45
+
46
+ async function defaultExposed(): Promise<string | null> {
47
+ try {
48
+ const cfg = readEdgeConfig();
49
+ const hosts = webRouteHosts(cfg, new Set([readWebPort()])).map(
50
+ (r) => r.host,
51
+ );
52
+ return await edgeExposure(
53
+ hosts,
54
+ cfg.ports ? edgeNames(cfg.org).container : null,
55
+ );
56
+ } catch {
57
+ return null; // no edge
58
+ }
59
+ }
60
+
61
+ export async function ownerCheck(
62
+ deps: OwnerCheckDeps = {},
63
+ ): Promise<OnboardCheck> {
64
+ const owner = (deps.owner ?? (() => readWebOwner(getStateDir())))();
65
+ if (owner)
66
+ return {
67
+ id: "web:owner",
68
+ status: "ok",
69
+ detail: `owner: @${owner.login} (linked; Sign in with GitHub from any browser)`,
70
+ };
71
+ let web: WebAuthConfig;
72
+ try {
73
+ web = (
74
+ deps.web ?? (() => readWebAuthConfig(getDistRoot(), product().configFile))
75
+ )();
76
+ } catch (e) {
77
+ return { id: "web:owner", status: "fail", detail: (e as Error).message };
78
+ }
79
+ const configFile = deps.configFile ?? product().configFile;
80
+ const who = [
81
+ ...(web.ownerGithub
82
+ ? [`@${web.ownerGithub} from any browser (web.owner_github)`]
83
+ : []),
84
+ ...(web.allowedGithubOrg
85
+ ? [
86
+ `a member of ${web.allowedGithubOrg} on this computer (web.allowed_github_org)`,
87
+ ]
88
+ : []),
89
+ ];
90
+ // D17: owner_github carries most people (org memberships are usually
91
+ // private); without it, say how to get it.
92
+ const ghHint = web.ownerGithub
93
+ ? {}
94
+ : { ask: ownerGithubHint((deps.ghLogin ?? ghLogin)(), product().cli) };
95
+ if (who.length)
96
+ return {
97
+ id: "web:owner",
98
+ status: "ok",
99
+ detail: `no owner yet; who may connect GitHub to claim it: ${who.join(", or ")}`,
100
+ ...ghHint,
101
+ };
102
+ const exposed = await (deps.exposed ?? defaultExposed)();
103
+ // Only this machine reaches it: a note, not a warning (every fresh
104
+ // install starts this way, D12).
105
+ return {
106
+ id: "web:owner",
107
+ status: exposed ? "fail" : "ok",
108
+ detail:
109
+ `no owner, and nothing says who may claim it, so nobody can connect GitHub from the browser` +
110
+ (exposed
111
+ ? `; the edge is reachable from other machines (${exposed})`
112
+ : ""),
113
+ ask: `Which GitHub org's members may claim it (web.allowed_github_org in ${configFile}), or which GitHub login runs it here (web.owner_github in config/local/user.yml)?`,
114
+ };
115
+ }
116
+
117
+ /** The check as doctor prints it. */
118
+ export function ownerLine(c: OnboardCheck): string {
119
+ const mark =
120
+ c.status === "fail" ? "✗" : c.status === "warn" ? "⚠" : c.ask ? "ℹ" : "✓";
121
+ return ` ${mark} ${c.detail}${c.ask ? `\n ${c.ask}` : ""}`;
122
+ }
@@ -15,6 +15,11 @@
15
15
  * port, and writes and loads nothing; exit 1 when that server is foreign.
16
16
  * `status` also names a foreign server on the port.
17
17
  */
18
+ import {
19
+ ownerGithubLine,
20
+ recordOwnerGithub,
21
+ type OwnerGithubResult,
22
+ } from "../../shared/owner-github.js";
18
23
  import { spawnSync } from "child_process";
19
24
  import * as os from "os";
20
25
  import * as path from "path";
@@ -100,6 +105,8 @@ export async function waitForServerInfo(
100
105
 
101
106
  export interface ServiceDeps {
102
107
  run?: ServiceRunner;
108
+ /** D17: record web.owner_github (default: from gh, unless `run` is a test's). */
109
+ recordOwner?: () => OwnerGithubResult;
103
110
  config?: DenConfig;
104
111
  distRoot?: string;
105
112
  engineRoot?: string;
@@ -214,6 +221,15 @@ export async function cmdService(
214
221
  return 0;
215
222
  }
216
223
 
224
+ // D17: whoever installs it can connect their GitHub account from any
225
+ // browser (web.owner_github, from gh), before the server reads it.
226
+ if (sub === "install") {
227
+ const rec =
228
+ deps.recordOwner ??
229
+ (deps.run ? () => ({ state: "linked" as const }) : recordOwnerGithub);
230
+ const line = ownerGithubLine(rec(), product().cli);
231
+ if (line) log(line);
232
+ }
217
233
  const code = run("bash", [script, sub!], {
218
234
  ...env,
219
235
  // The script then leaves saying "running" to us (after the poll below).
@@ -1,3 +1,7 @@
1
+ import {
2
+ ownerGithubLine,
3
+ recordOwnerGithub,
4
+ } from "../../shared/owner-github.js";
1
5
  import chalk from "chalk";
2
6
  import { execFileSync, spawn, spawnSync } from "child_process";
3
7
  import * as fs from "fs";
@@ -27,6 +31,7 @@ import {
27
31
  ignoredConfigWarning,
28
32
  localhostOrigin,
29
33
  loginLink,
34
+ LOGIN_CODE_TTL_MS,
30
35
  mintLoginCode,
31
36
  parseOrigin,
32
37
  readWebAuthConfig,
@@ -1182,7 +1187,7 @@ export function displayWebHelp(): void {
1182
1187
  " open Start if needed, and sign this browser in (a new tab)",
1183
1188
  );
1184
1189
  console.log(
1185
- " login Print a one-time sign-in link (60 s) and its code, then wait until it's used",
1190
+ " login Print a one-time sign-in link (5 min; web.login_link_ttl) and its code, then wait until it's used",
1186
1191
  );
1187
1192
  console.log(" sessions List the signed-in browsers");
1188
1193
  console.log(
@@ -1330,6 +1335,13 @@ export async function cmdWeb(args: string[]): Promise<void> {
1330
1335
  return;
1331
1336
  }
1332
1337
 
1338
+ // D17: whoever starts it can connect their GitHub account from any
1339
+ // browser (web.owner_github, from gh), before the server reads it.
1340
+ if (options.action === "up" || options.action === "restart") {
1341
+ const line = ownerGithubLine(recordOwnerGithub(), product().cli);
1342
+ if (line) console.log(chalk.dim(`\n ${line}`));
1343
+ }
1344
+
1333
1345
  if (options.action === "restart") {
1334
1346
  const stopped = await stopDenWebServer(options.port, vouched);
1335
1347
  if (stopped.pids.length > 0 && !stopped.stopped) {
@@ -1411,7 +1423,8 @@ export async function cmdWeb(args: string[]): Promise<void> {
1411
1423
  return;
1412
1424
  }
1413
1425
 
1414
- // SEC-1b: what opens is a sign-in link (localhost, one-time, 60 s). It
1426
+ // SEC-1b: what opens is a sign-in link (localhost, one-time, D16:
1427
+ // web.login_link_ttl). It
1415
1428
  // is printed only when the browser couldn't be opened: the one line the
1416
1429
  // person then needs.
1417
1430
  // Inside an agent's session: the plain page, never a sign-in link.
@@ -1453,7 +1466,9 @@ export async function cmdWeb(args: string[]): Promise<void> {
1453
1466
  } else {
1454
1467
  console.log(chalk.yellow("\n Could not open a browser automatically."));
1455
1468
  }
1456
- console.log(` Sign in (once, within 60 s): ${signIn()}\n`);
1469
+ console.log(
1470
+ ` Sign in (once, within ${ttlText(loginLinkTtl())}): ${signIn()}\n`,
1471
+ );
1457
1472
  }
1458
1473
 
1459
1474
  // ── SEC-1b: sign-in (`web login`, `web sessions`, `web logout`) ─────────────
@@ -1467,7 +1482,11 @@ export function freshLoginLink(
1467
1482
  ): string {
1468
1483
  const refused = personGrantRefusal(process.env);
1469
1484
  if (refused) throw new Error(refused);
1470
- return loginLink(origin, mintLoginCode(ensureWebSecret(stateDir)), next);
1485
+ return loginLink(
1486
+ origin,
1487
+ mintLoginCode(ensureWebSecret(stateDir), Date.now(), loginLinkTtl()),
1488
+ next,
1489
+ );
1471
1490
  }
1472
1491
 
1473
1492
  /**
@@ -1616,7 +1635,8 @@ function parseLoginArgs(
1616
1635
 
1617
1636
  const LOGIN_HELP = () =>
1618
1637
  LOGIN_USAGE() +
1619
- " Prints a one-time sign-in link (open it within 60 s) for one browser, and a\n" +
1638
+ " Prints a one-time sign-in link (open it within web.login_link_ttl, 5 minutes\n" +
1639
+ " unless set) for one browser, and a\n" +
1620
1640
  " code the page behind the link asks for, then waits until the link is used:\n" +
1621
1641
  ' "Signed in: <browser> (<address>) at <time>", or "Expired: nobody used the\n' +
1622
1642
  ' link" (exit 1). On localhost nothing asks for a sign-in; it is needed when\n' +
@@ -1655,12 +1675,36 @@ export function cmdWebLogin(args: string[], io: CliIo = stdIo): number {
1655
1675
  io.stateDir ?? getStateDir(),
1656
1676
  );
1657
1677
  io.err(
1658
- `${product().cli}: open this link within 60 seconds; it signs in one browser, once.\n`,
1678
+ `${product().cli}: open this link within ${ttlText(loginLinkTtl())}; it signs in one browser, once.\n`,
1659
1679
  );
1660
1680
  io.out(`${link}\n`);
1661
1681
  return 0;
1662
1682
  }
1663
1683
 
1684
+ /** D16: web.login_link_ttl, read now (LOGIN_CODE_TTL_MS when unreadable). */
1685
+ export function loginLinkTtl(): number {
1686
+ try {
1687
+ return readWebAuthConfig(getDistRoot(), product().configFile)
1688
+ .loginLinkTtlMs;
1689
+ } catch {
1690
+ return LOGIN_CODE_TTL_MS;
1691
+ }
1692
+ }
1693
+
1694
+ /** "5 minutes", "90 seconds", "1 minute". */
1695
+ export function ttlText(ms: number): string {
1696
+ const s = Math.round(ms / 1000);
1697
+ if (s % 60 !== 0) return `${s} seconds`;
1698
+ const m = s / 60;
1699
+ return m === 1 ? "1 minute" : `${m} minutes`;
1700
+ }
1701
+
1702
+ /** D16: the countdown's text, "4:59 left". */
1703
+ export function countdownLeft(ms: number): string {
1704
+ const s = Math.max(0, Math.ceil(ms / 1000));
1705
+ return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")} left`;
1706
+ }
1707
+
1664
1708
  /** "10:42" in this machine's time. */
1665
1709
  function clockTime(iso: string): string {
1666
1710
  const d = new Date(iso);
@@ -1680,7 +1724,7 @@ function clockTime(iso: string): string {
1680
1724
  export async function cmdWebLoginWait(
1681
1725
  args: string[],
1682
1726
  io: CliIo = stdIo,
1683
- opts: { pollMs?: number; maxWaitMs?: number } = {},
1727
+ opts: { pollMs?: number; maxWaitMs?: number; countdown?: boolean } = {},
1684
1728
  ): Promise<number> {
1685
1729
  if (isHelp(args)) {
1686
1730
  io.out(LOGIN_HELP());
@@ -1701,7 +1745,8 @@ export async function cmdWebLoginWait(
1701
1745
  const stateDir = io.stateDir ?? getStateDir();
1702
1746
  const secret = ensureWebSecret(stateDir);
1703
1747
  const now = Date.now();
1704
- const loginCode = mintLoginCode(secret, now);
1748
+ const ttl = loginLinkTtl();
1749
+ const loginCode = mintLoginCode(secret, now, ttl);
1705
1750
  const checked = checkLoginCode(loginCode, secret, now);
1706
1751
  if (!checked.ok) {
1707
1752
  io.err(`${product().cli}: could not make a sign-in link\n`);
@@ -1722,7 +1767,7 @@ export async function cmdWebLoginWait(
1722
1767
  io.err(
1723
1768
  `${product().cli}: no ${product().title} web server answers on port ${a.port}, so this ` +
1724
1769
  "doesn't wait and the link asks for no code (as --no-wait). Start the server " +
1725
- `(\`${product().cli} web up\`), then open this link within 60 seconds; it signs in one browser, once.\n`,
1770
+ `(\`${product().cli} web up\`), then open this link within ${ttlText(ttl)}; it signs in one browser, once.\n`,
1726
1771
  );
1727
1772
  io.out(`${link}\n`);
1728
1773
  return 0;
@@ -1733,15 +1778,23 @@ export async function cmdWebLoginWait(
1733
1778
  return 1;
1734
1779
  }
1735
1780
  io.err(
1736
- `${product().cli}: open this link within 60 seconds, in the browser to sign in. ` +
1781
+ `${product().cli}: open this link within ${ttlText(ttl)}, in the browser to sign in. ` +
1737
1782
  `It asks for this code: ${confirm}\n`,
1738
1783
  );
1739
1784
  io.out(`${link}\n`);
1740
- io.err(`${product().cli}: waiting for the link to be used…\n`);
1785
+ // D16: on a terminal, the time left, in place.
1786
+ const tty = opts.countdown ?? (io === stdIo && !!process.stderr.isTTY);
1787
+ const waiting = `${product().cli}: waiting for the link to be used…`;
1788
+ const tick = () =>
1789
+ io.err(`\r${waiting} ${countdownLeft(checked.expiresAt - Date.now())} `);
1790
+ if (tty) tick();
1791
+ else io.err(`${waiting}\n`);
1741
1792
  const pollMs = opts.pollMs ?? 1000;
1742
- const deadline = Date.now() + (opts.maxWaitMs ?? 7 * 60_000);
1793
+ // The link's own time, then the 5 minutes an opened link has for its code.
1794
+ const deadline = Date.now() + (opts.maxWaitMs ?? ttl + 6 * 60_000);
1743
1795
  for (;;) {
1744
1796
  await new Promise((r) => setTimeout(r, pollMs));
1797
+ if (tty) tick();
1745
1798
  const r = await serverCall(
1746
1799
  stateDir,
1747
1800
  a.port,
@@ -1754,6 +1807,15 @@ export async function cmdWebLoginWait(
1754
1807
  client?: string;
1755
1808
  usedAt?: string;
1756
1809
  };
1810
+ // The countdown's line ends before the outcome's.
1811
+ const over =
1812
+ !r ||
1813
+ j.state === "used" ||
1814
+ j.state === "refused" ||
1815
+ j.state === "expired" ||
1816
+ r.status === 404 ||
1817
+ Date.now() > deadline;
1818
+ if (tty && over) io.err("\n");
1757
1819
  if (r && j.state === "used") {
1758
1820
  io.out(
1759
1821
  `Signed in: ${j.device ?? "a browser"}${j.client ? ` (${j.client})` : ""}` +
@@ -22,8 +22,29 @@ import { GITHUB_DEVICE_PAGE } from "../../shared/github-app.js";
22
22
  import type {
23
23
  GithubDeviceClient,
24
24
  GithubIdentity,
25
+ OrgMembership,
25
26
  } from "../../shared/github-device.js";
26
27
 
28
+ /** What `decide` may ask while the token is still held (D17). */
29
+ export interface DecideContext {
30
+ /**
31
+ * D17: the sign-in started with this machine's proof ("This is my
32
+ * computer", attest-code.ts).
33
+ */
34
+ local: boolean;
35
+ /** Whether the approving account is a member of `org`. */
36
+ orgMember(org: string): Promise<OrgMembership>;
37
+ }
38
+
39
+ /** What an identity means: ok, or refused (with why, for the page). */
40
+ export type Decision = "ok" | "refused" | { refused: string };
41
+
42
+ export type Decide = (
43
+ purpose: FlowPurpose,
44
+ identity: GithubIdentity,
45
+ ctx: DecideContext,
46
+ ) => Decision | Promise<Decision>;
47
+
27
48
  export type FlowPurpose = "signin" | "link";
28
49
  export type FlowState =
29
50
  | "pending"
@@ -43,7 +64,7 @@ export interface FlowView {
43
64
  expiresAt: number;
44
65
  /** ok or refused: the GitHub login that approved it. */
45
66
  login?: string;
46
- /** error: what went wrong (no secret in it). */
67
+ /** error: what went wrong; refused: why, when the decision said (no secret in it). */
47
68
  message?: string;
48
69
  }
49
70
 
@@ -53,6 +74,8 @@ interface Flow extends FlowView {
53
74
  errors: number;
54
75
  /** ok: who approved it (the id, for the caller). */
55
76
  identity?: GithubIdentity;
77
+ /** D17: started with this machine's proof. */
78
+ local: boolean;
56
79
  /** When a finished flow is forgotten. */
57
80
  forgetAt?: number;
58
81
  cancel?: () => void;
@@ -81,18 +104,12 @@ export class DeviceFlows {
81
104
  private readonly max: number;
82
105
  private readonly minIntervalMs: number;
83
106
  private readonly onExpiringToken?: () => void;
84
- private readonly decide: (
85
- purpose: FlowPurpose,
86
- identity: GithubIdentity,
87
- ) => "ok" | "refused";
107
+ private readonly decide: Decide;
88
108
 
89
109
  constructor(opts: {
90
110
  client: GithubDeviceClient;
91
111
  clientId: () => string;
92
- decide: (
93
- purpose: FlowPurpose,
94
- identity: GithubIdentity,
95
- ) => "ok" | "refused";
112
+ decide: Decide;
96
113
  now?: () => number;
97
114
  schedule?: Schedule;
98
115
  max?: number;
@@ -126,6 +143,7 @@ export class DeviceFlows {
126
143
  */
127
144
  async start(
128
145
  purpose: FlowPurpose,
146
+ meta: { local?: boolean } = {},
129
147
  ): Promise<FlowView | { error: "busy" | "no_client_id" }> {
130
148
  const clientId = this.clientId();
131
149
  if (!clientId) return { error: "no_client_id" };
@@ -142,6 +160,7 @@ export class DeviceFlows {
142
160
  deviceCode: "",
143
161
  intervalMs: this.minIntervalMs,
144
162
  errors: 0,
163
+ local: meta.local === true,
145
164
  };
146
165
  this.flows.set(id, placeholder);
147
166
  let code;
@@ -240,10 +259,26 @@ export class DeviceFlows {
240
259
  } catch (e) {
241
260
  return this.finish(f, "error", { message: (e as Error).message });
242
261
  }
243
- const outcome = this.decide(f.purpose, identity);
262
+ let decision: Decision;
263
+ try {
264
+ // The token is still only for this flow's own calls, then dropped.
265
+ const token = r.token;
266
+ decision = await this.decide(f.purpose, identity, {
267
+ local: f.local,
268
+ orgMember: (org) =>
269
+ this.client.orgMember(token, org, identity.login),
270
+ });
271
+ } catch (e) {
272
+ return this.finish(f, "error", { message: (e as Error).message });
273
+ }
274
+ if (this.flows.get(id) !== f || f.state !== "pending") return;
275
+ const outcome = typeof decision === "string" ? decision : "refused";
244
276
  return this.finish(f, outcome, {
245
277
  login: identity.login,
246
278
  ...(outcome === "ok" ? { identity } : {}),
279
+ ...(typeof decision === "object"
280
+ ? { message: decision.refused }
281
+ : {}),
247
282
  });
248
283
  }
249
284
  }