@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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,102 @@ All notable changes to Den are documented here. Each release lists the
4
4
  config and manifest changes a distribution must make to move to it under
5
5
  **Migrations**.
6
6
 
7
+ ## [0.4.21] - 2026-11-09
8
+
9
+ ### Added
10
+
11
+ - **Every developer connects GitHub from the browser (D17).** A company
12
+ makes its own Den repo, every developer runs their own instance, and
13
+ everyone opens the same edge URL (such as `https://den.local.<zone>`,
14
+ whose DNS says 127.0.0.1). Before, they all got the signed-out page with
15
+ no way in from the browser: behind Docker Desktop, OrbStack or a TCP
16
+ passthrough the edge sees a gateway's address, so Den couldn't tell a
17
+ browser on its own machine; the one-time link needed a terminal, pairing
18
+ needed Settings, and Sign in with GitHub needed an owner already linked.
19
+ Now, while an instance has no owner, the signed-out page leads with
20
+ **Connect your GitHub account**:
21
+ - **This is my computer** (for a member of `web.allowed_github_org`):
22
+ a top-level round trip through `http://localhost:<port>/attest`, which
23
+ only a browser on this machine reaches, back to the edge page with a
24
+ short-lived, single-use code bound to that edge hostname and signed
25
+ with `web.secret` (in the URL fragment, never logged; only to this
26
+ Den's own edge hostnames). The GitHub sign-in that follows makes a
27
+ member of the org the owner and signs the browser in. The proof alone
28
+ never makes a session (any program on the machine can reach
29
+ localhost), so the GitHub approval is always the person's.
30
+ - **Sign in as @login** (`web.owner_github`): that login claims it from
31
+ any browser, such as a laptop reaching a devbox.
32
+ - Anyone else is refused with the reason (not a member; a member but not
33
+ on this computer; not that login; a private membership the GitHub App
34
+ can't read, naming the fix). With neither set, the page names both
35
+ settings and offers no claim.
36
+ - **`create --github-org <org>`** writes `web.allowed_github_org` into the
37
+ team config; without the flag it uses the GitHub owner of the repo the
38
+ new folder is in, else writes a commented line and says to set it
39
+ (`create --json`: `githubOrg`, null when unknown).
40
+ - **`web up`, `web restart` and `service install` record
41
+ `web.owner_github`** in `config/local/user.yml` from `gh api user` when
42
+ `gh` is signed in, no owner is linked and it isn't set, and print it.
43
+ When `gh` isn't signed in, they and `doctor` say so, with the fix
44
+ (`gh auth login`, or set `web.owner_github`).
45
+ - **doctor** shows who may claim the instance (`web:owner` in
46
+ `doctor --json`), and fails when there is no owner, neither setting, and
47
+ the edge is reachable from other machines.
48
+ - **`web.login_link_ttl` (D16)**: how long a sign-in link lasts, 5 minutes
49
+ unless set (it was 60 s, and expired while people switched windows);
50
+ 30s to 60m. `web login` shows the time left while it waits.
51
+
52
+ ### Fixed
53
+
54
+ - doctor no longer shows `usage (invalid config) ✗ unavailable: no selector
55
+ for this kind` on every fresh install (D9). Usage sources are all read at
56
+ once, so the row names them (`claude-code (implicit)`), as any strategy
57
+ kind with no selector of its own now does; never a failure. The state
58
+ column lines up past 20 characters.
59
+ - **Every page was broken for a distribution whose name has a hyphen**
60
+ (`acme-den`), since 0.4.20 at least. `header.js` put the
61
+ `__DEN_CLI__` placeholder inside a variable name, so the served file
62
+ read `let _acme-denConfig`, a syntax error: no page mounted anything,
63
+ the signed-out page included ("Error / local"), and Settings showed an
64
+ error. A new check fills every served page and script with hyphenated,
65
+ digit-bearing and short names and parses each one, and no placeholder
66
+ may touch a name's letters.
67
+ - `/attest` reached through the edge shows its own "only on the computer
68
+ this Den runs on" page, not the generic signed-out one.
69
+ - doctor, when `web.owner_github` isn't set but `gh` is signed in, says to
70
+ run `web restart` to record it (not that gh isn't signed in).
71
+
72
+ ### Changed
73
+
74
+ - The sign-in smoke runs against a remote browser with `DEN_PW_WS` (a
75
+ Playwright run-server): WebKit from Playwright's Linux image when the
76
+ shell can't start a GUI app (CONTRIBUTING.md).
77
+ - The GitHub code on the sign-in page has its copy button right beside it,
78
+ on its line (as every command on that page), at any width.
79
+ - Tests put a stub `launchctl` first on PATH, as for gh and the agent
80
+ CLIs: no test can reach this machine's real launchd.
81
+ - Pages, `--version`, doctor and the server agent's name say the brand's
82
+ name (`brand.name` in the org config: "Sign in to Acme Den"), not the CLI
83
+ name upper-cased ("Acme-den") (D11). A `brand.name` with characters that
84
+ could break a page (quotes, `&`, `<`) falls back to the old title.
85
+ - `product.cli` in den.yml must be lower-case letters, digits and `-`,
86
+ starting with a letter (as `product.name`). `product.config`,
87
+ `manifest`, `services_dir` and `workspaces_dir` may use letters, digits,
88
+ `.`, `_`, `-` and `/` only: the pages carry these names, and a quote or
89
+ `$` would break their scripts. A value outside that is refused at start,
90
+ naming the key.
91
+ - The page behind a `web login` link that asks for its code is a plain
92
+ sign-in page: no shell frame and no request id, the code field focused.
93
+
94
+ ### Migrations
95
+
96
+ - A `product.cli` or product path outside the characters above now stops
97
+ the CLI and server with a message naming the key: rename it.
98
+ - To let your team connect GitHub from the browser, set
99
+ `web.allowed_github_org: <your GitHub org>` in the team config (or run
100
+ `web up` with `gh` signed in, for `web.owner_github`). The GitHub App
101
+ needs the org's Members (read) permission to see private memberships.
102
+
7
103
  ## [0.4.20] - 2026-11-09
8
104
 
9
105
  ### Added
package/README.md CHANGED
@@ -10,8 +10,8 @@
10
10
  </p>
11
11
 
12
12
  <picture>
13
- <source media="(prefers-color-scheme: dark)" srcset="docs/images/0.4.20/hero-dark.png">
14
- <img src="docs/images/0.4.20/hero-light.png" width="100%" alt="Den's Graph page: the demo workspace's services grouped by application, all healthy, with the status chip and the left rail">
13
+ <source media="(prefers-color-scheme: dark)" srcset="docs/images/0.4.21/hero-dark.png">
14
+ <img src="docs/images/0.4.21/hero-light.png" width="100%" alt="Den's Graph page: the demo workspace's services grouped by application, all healthy, with the status chip and the left rail">
15
15
  </picture>
16
16
 
17
17
  ## Set up by your agent
@@ -39,7 +39,7 @@ It doesn't replace your tools. Your repos, Docker, your test runners, your secre
39
39
  yours. Den connects them and turns on good practice from the start.
40
40
 
41
41
  <p align="center">
42
- <img src="docs/images/0.4.20/up.gif" width="100%" alt="make up in the Graph page: every stack down, then demo-api, demo-web and demo-notes come up in dependency order until the status chip reads All 3 healthy">
42
+ <img src="docs/images/0.4.21/up.gif" width="100%" alt="make up in the Graph page: every stack down, then demo-api, demo-web and demo-notes come up in dependency order until the status chip reads All 3 healthy">
43
43
  </p>
44
44
 
45
45
  ## How it helps
@@ -69,10 +69,10 @@ yours. Den connects them and turns on good practice from the start.
69
69
  lives in your own repo.
70
70
 
71
71
  <p align="center">
72
- <img src="docs/images/0.4.20/agents.png" width="49%" alt="The Agents tab: the demo workspace's sessions on the left, and one open in a terminal pane on the right, running demo-api's test and then the service">
73
- <img src="docs/images/0.4.20/workspaces.png" width="49%" alt="The Workspaces page: the demo workspace's repos with their health, mode and Start, Stop, Restart and Logs actions">
74
- <img src="docs/images/0.4.20/status-drawer.png" width="49%" alt="The status drawer: nothing needs attention, and every service in the demo workspace, by application, with its latency">
75
- <img src="docs/images/0.4.20/edge.png" width="49%" alt="The Edge page: the edge container up, its ports and TLS, and every route it serves, Den's own and each stack's, with its upstream">
72
+ <img src="docs/images/0.4.21/agents.png" width="49%" alt="The Agents tab: the demo workspace's sessions on the left, and one open in a terminal pane on the right, running demo-api's test and then the service">
73
+ <img src="docs/images/0.4.21/workspaces.png" width="49%" alt="The Workspaces page: the demo workspace's repos with their health, mode and Start, Stop, Restart and Logs actions">
74
+ <img src="docs/images/0.4.21/status-drawer.png" width="49%" alt="The status drawer: nothing needs attention, and every service in the demo workspace, by application, with its latency">
75
+ <img src="docs/images/0.4.21/edge.png" width="49%" alt="The Edge page: the edge container up, its ports and TLS, and every route it serves, Den's own and each stack's, with its upstream">
76
76
  </p>
77
77
 
78
78
  ## Get started
@@ -1004,15 +1004,21 @@ brand:
1004
1004
  - " └───────────┘"
1005
1005
  ```
1006
1006
 
1007
- | Field | Used for |
1008
- | --------- | ---------------------------------------------------------------------------- |
1009
- | `name` | Web page `<title>`s, the banner fallback, the web route on the edge (`acme`) |
1010
- | `tagline` | The line under the banner |
1011
- | `banner` | The CLI and web server startup banner |
1007
+ | Field | Used for |
1008
+ | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
1009
+ | `name` | The name people see: page titles and copy ("Sign in to Acme Den"), `--version`, doctor, the server agent's name, the banner fallback, the web route on the edge (`acme`) |
1010
+ | `tagline` | The line under the banner |
1011
+ | `banner` | The CLI and web server startup banner |
1012
1012
 
1013
1013
  Runtime names (env vars, files, directories) always come from `den.yml`,
1014
1014
  never from the brand.
1015
1015
 
1016
+ `brand.name` is used in pages and messages only when it is letters, digits,
1017
+ spaces, `.`, `_` and `-` (up to 40, starting with a letter or digit), so
1018
+ it can't break a page. Anything else (a quote, `&`, `<`) falls back to the
1019
+ product name with its first letter upper-cased ("Acme-den"); the page
1020
+ `<title>` still shows it.
1021
+
1016
1022
  ## Plugins
1017
1023
 
1018
1024
  A plugin adds to Den without changing it: a theme, a page in the web UI, and
@@ -1613,7 +1619,10 @@ user, and the CLI does that for you:
1613
1619
 
1614
1620
  - **The link** is `http://localhost:<port>/auth?c=<code>`, with `&next=<path>`
1615
1621
  for where to land (`web login --next /settings`). The code works once,
1616
- within 60 seconds, and only this server's secret makes one. It is printed
1622
+ within `web.login_link_ttl` (5 minutes unless set; 30s to 60m, as `90s`,
1623
+ `5m` or `1h`, in `config/local/user.yml` or the team config), and only
1624
+ this server's secret makes one. `web login` shows the time left while it
1625
+ waits, on a terminal. It is printed
1617
1626
  only on the one line you asked for (`web login`, or `web up` when it
1618
1627
  couldn't open the browser), never logged. Through the edge or a LAN name,
1619
1628
  the cookie belongs to that name: `web login --host acme.acme.localhost`
@@ -1853,7 +1862,8 @@ the account's id and login, never a token, and no other account gets in.
1853
1862
  last seen; "This device" on yours), each with **Revoke**, which ends its session at once (its
1854
1863
  open terminals included). **Pair a device** makes a one-time link and a
1855
1864
  QR code for an edge hostname you pick (`web login --host <name>` in the
1856
- browser): scan it on the other device within 60 seconds.
1865
+ browser): scan it on the other device before it expires
1866
+ (`web.login_link_ttl`; the page counts down).
1857
1867
  - **`web.github_client_id`** is the GitHub App's client id (public; no
1858
1868
  secret). The default is Den's own App, **Monster Orange Den**
1859
1869
  (`Iv23liKL12gnN6WvRV5b`), so Sign in with GitHub works out of the box. A
@@ -1868,6 +1878,61 @@ the account's id and login, never a token, and no other account gets in.
1868
1878
  it never prints the token. `GET /api/web/owner` reports it as
1869
1879
  `tokenExpiry: true`.
1870
1880
 
1881
+ ### A company's Den: every developer connects GitHub from the browser
1882
+
1883
+ The common shape: a company makes its own Den repo (`create`), pushes it to
1884
+ its GitHub org, and every developer clones it and runs their own instance
1885
+ on their own machine. Everyone opens the same URL (an edge name such as
1886
+ `https://den.local.<your zone>`, whose DNS says 127.0.0.1) and connects
1887
+ their GitHub account there, with no terminal and nothing to explain.
1888
+
1889
+ ```yaml
1890
+ # config/<name>.yml (the team's, committed): who may claim an instance
1891
+ web:
1892
+ allowed_github_org: acme
1893
+ ```
1894
+
1895
+ - **`create` writes it**: `--github-org <org>`, else the GitHub owner of the
1896
+ repo the new folder is in. Neither: a commented line and a next step.
1897
+ - **`web.owner_github`** (`config/local/user.yml`, per machine) is the
1898
+ GitHub login of whoever runs this instance. `web up`, `web restart` and
1899
+ `service install` record it from `gh` (`gh api user`) when `gh` is signed
1900
+ in, no owner is linked and it isn't set, and print it. Only the login is
1901
+ kept. This is the path most people take: org memberships are private
1902
+ by default. When `gh` isn't signed in, `web up` and `doctor` say so,
1903
+ with the fix (`gh auth login`, then `web restart`; or set
1904
+ `web.owner_github` yourself).
1905
+ - **The signed-out page leads with Connect your GitHub account** while the
1906
+ instance has no owner and one of the two is set. Who may claim it:
1907
+ - **A member of `allowed_github_org`, on this computer.** **This is my
1908
+ computer** navigates to `http://localhost:<port>/attest` and straight
1909
+ back: only a browser on the machine Den runs on reaches it there. It
1910
+ returns with a short-lived (2 minutes), single-use code, signed with
1911
+ `web.secret`, bound to the edge hostname, in the URL fragment (never
1912
+ sent to a server or logged), and only to one of this Den's own edge
1913
+ hostnames. The page then starts Sign in with GitHub with it; when
1914
+ GitHub says the account is a member of the org, it becomes the owner
1915
+ and the browser is signed in. This exists because Docker Desktop,
1916
+ OrbStack and a TCP passthrough show the edge a gateway's address, so
1917
+ Den can't tell a browser on its own machine from another one.
1918
+ - **`owner_github`'s login, from any browser** (a devbox you reach from a
1919
+ laptop).
1920
+ - **Anyone else is refused, with the reason**: not a member of the org;
1921
+ a member, but not on this computer; not the login `owner_github` names.
1922
+ The proof alone never signs a browser in: any program on the machine
1923
+ can reach localhost, so the GitHub approval is always the person's.
1924
+ - **Membership.** Den asks GitHub with the sign-in's own token: the
1925
+ account's own membership (`GET /user/memberships/orgs/<org>`, which needs
1926
+ the App to have the org's Members (read) permission), then the public
1927
+ member list. A private membership the App can't read is refused, saying
1928
+ so: make the membership public, give the App that permission, or set
1929
+ `web.owner_github`.
1930
+ - **Neither set**: the page names both settings and offers no claim.
1931
+ `doctor` shows who may claim the instance, and fails (✗) when neither is
1932
+ set and the edge is reachable from other machines.
1933
+ - Once claimed, it is an ordinary owner: Sign in with GitHub from any
1934
+ browser, as above.
1935
+
1871
1936
  ### The GitHub App (once, by whoever ships the Den)
1872
1937
 
1873
1938
  Den's default App is Monster Orange Den. A distribution that wants its own
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@monster-orange/den",
3
- "version": "0.4.20",
3
+ "version": "0.4.21",
4
4
  "description": "Den: a local multi-repo dev orchestrator, HTTPS edge and E2E harness",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,125 @@
1
+ /**
2
+ * "This is my computer" (D17, fix A): how a browser on the machine Den runs
3
+ * on proves it, when the edge can't tell (Docker Desktop, OrbStack or a TCP
4
+ * passthrough hand the edge a gateway's address, so edgeClient says
5
+ * `unattested`).
6
+ *
7
+ * The signed-out edge page navigates the top level to
8
+ * `http://localhost:<port>/attest?return=<its own edge URL>`. Only a browser
9
+ * on this machine reaches this server there: a browser elsewhere reaches
10
+ * nothing, or its own machine's Den, whose web.secret doesn't verify here.
11
+ * The server answers 303 to the edge URL with the code in the fragment
12
+ * (`#den-attest=<code>`: never sent to a server, never in a log), and the
13
+ * page starts Sign in with GitHub with it.
14
+ *
15
+ * The proof alone never makes a session. Any program running as this OS
16
+ * user (an agent with a shell, too) can reach localhost, so a session from
17
+ * it would let an agent approve its own changes. It only lets a GitHub
18
+ * sign-in claim a Den that has no owner, for a member of
19
+ * web.allowed_github_org (github-routes.ts decideFlow): the person still
20
+ * approves on github.com.
21
+ *
22
+ * - `a1.<nonce>.<expiry>.<mac>`, the mac HMAC-SHA256(web.secret, context +
23
+ * the rest + the edge host it is for), its context its own: never a
24
+ * login or a claim code. A code minted for one host fails on another.
25
+ * - Valid for ATTEST_CODE_TTL_MS (2 minutes): the page uses it at once.
26
+ * - Single use: its nonce is spent when the sign-in starts (ClaimNonces,
27
+ * a file, so a restart can't revive one).
28
+ * - `return` must be one of this server's own edge hosts (attestReturn).
29
+ */
30
+ import { createHmac, randomBytes, timingSafeEqual } from "crypto";
31
+
32
+ export const ATTEST_CODE_TTL_MS = 2 * 60_000;
33
+ const SKEW_MS = 5_000;
34
+ const CONTEXT = "den-attest-v1:";
35
+ /** Where the spent attest nonces are kept (ClaimNonces' format). */
36
+ export const ATTEST_NONCES_FILE = "attest-nonces.json";
37
+
38
+ function mac(secret: Buffer, body: string, host: string): Buffer {
39
+ return createHmac("sha256", secret)
40
+ .update(`${CONTEXT}${body}\n${host.toLowerCase()}`)
41
+ .digest();
42
+ }
43
+
44
+ /** A fresh attest code for `host` (the edge hostname, no port). */
45
+ export function mintAttestCode(
46
+ secret: Buffer,
47
+ host: string,
48
+ now: number = Date.now(),
49
+ ): string {
50
+ const nonce = randomBytes(16).toString("base64url");
51
+ const body = `a1.${nonce}.${(now + ATTEST_CODE_TTL_MS).toString(36)}`;
52
+ return `${body}.${mac(secret, body, host).toString("base64url")}`;
53
+ }
54
+
55
+ export type AttestCodeCheck =
56
+ | { ok: true; nonce: string; expiresAt: number }
57
+ | { ok: false; reason: "malformed" | "bad_mac" | "expired" };
58
+
59
+ /**
60
+ * Whether `code` is an attest code this secret minted for `host`, still in
61
+ * its time. Constant-time mac. Single use is the caller's.
62
+ */
63
+ export function checkAttestCode(
64
+ code: string,
65
+ secret: Buffer,
66
+ host: string,
67
+ now: number = Date.now(),
68
+ ): AttestCodeCheck {
69
+ const m =
70
+ /^(a1\.([A-Za-z0-9_-]{22})\.([0-9a-z]{1,11}))\.([A-Za-z0-9_-]{43})$/.exec(
71
+ code,
72
+ );
73
+ if (!m) return { ok: false, reason: "malformed" };
74
+ const given = Buffer.from(m[4], "base64url");
75
+ const want = mac(secret, m[1], host);
76
+ if (given.length !== want.length || !timingSafeEqual(given, want))
77
+ return { ok: false, reason: "bad_mac" };
78
+ const expiresAt = parseInt(m[3], 36);
79
+ if (
80
+ !Number.isFinite(expiresAt) ||
81
+ now >= expiresAt ||
82
+ expiresAt - now > ATTEST_CODE_TTL_MS + SKEW_MS
83
+ )
84
+ return { ok: false, reason: "expired" };
85
+ return { ok: true, nonce: m[2], expiresAt };
86
+ }
87
+
88
+ /**
89
+ * `/attest`'s `return`, checked: an http(s) URL whose host is one of
90
+ * `edgeHosts` (this server's own edge hostnames), no credentials in it.
91
+ * Its origin and same-origin path, or null for anything else.
92
+ */
93
+ export function attestReturn(
94
+ raw: string | null | undefined,
95
+ edgeHosts: readonly string[],
96
+ ): { origin: string; host: string; next: string } | null {
97
+ if (!raw) return null;
98
+ let u: URL;
99
+ try {
100
+ u = new URL(raw);
101
+ } catch {
102
+ return null;
103
+ }
104
+ if (u.protocol !== "https:" && u.protocol !== "http:") return null;
105
+ if (u.username || u.password) return null;
106
+ const host = u.hostname.toLowerCase();
107
+ if (!edgeHosts.map((h) => h.toLowerCase()).includes(host)) return null;
108
+ const next = `${u.pathname}${u.search}`;
109
+ return {
110
+ origin: u.origin,
111
+ host,
112
+ next: next.startsWith("/auth") ? "/" : next,
113
+ };
114
+ }
115
+
116
+ /** The fragment key the code rides in back to the edge page. */
117
+ export const ATTEST_FRAGMENT = "den-attest";
118
+
119
+ /** Where /attest sends the browser: the edge page, the code in the fragment. */
120
+ export function attestRedirect(
121
+ ret: { origin: string; next: string },
122
+ code: string,
123
+ ): string {
124
+ return `${ret.origin}${ret.next}#${ATTEST_FRAGMENT}=${encodeURIComponent(code)}`;
125
+ }
@@ -108,10 +108,14 @@ export function claimRefusalForAgent(env: NodeJS.ProcessEnv): string | null {
108
108
 
109
109
  /** The used claim nonces, kept until they expire (a file, 0600). */
110
110
  export class ClaimNonces {
111
- constructor(private readonly stateDir: string) {}
111
+ constructor(
112
+ private readonly stateDir: string,
113
+ /** The file's name (D17: the attest codes keep their own). */
114
+ private readonly fileName: string = CLAIM_NONCES_FILE,
115
+ ) {}
112
116
 
113
117
  private file(): string {
114
- return path.join(this.stateDir, CLAIM_NONCES_FILE);
118
+ return path.join(this.stateDir, this.fileName);
115
119
  }
116
120
 
117
121
  /** The used nonces; null when the file is there but won't parse (fail closed). */
@@ -15,7 +15,13 @@
15
15
  */
16
16
  import { execFile } from "child_process";
17
17
  import { promises as dns } from "dns";
18
- import { isLoopbackAddress } from "./request-guard.js";
18
+ import * as net from "net";
19
+
20
+ /** A loopback IP (127/8, ::1, or either IPv4-mapped); request-guard.ts has the same. */
21
+ function isLoopbackAddress(addr: string): boolean {
22
+ const a = addr.toLowerCase().replace(/^::ffff:/, "");
23
+ return a === "::1" || (net.isIPv4(a) && a.startsWith("127."));
24
+ }
19
25
 
20
26
  /**
21
27
  * The warning, by `web.edge_unattested` (SEC-2a): with `signin` (the
@@ -41,6 +41,8 @@ export interface GithubEndpoints {
41
41
  deviceCode: string;
42
42
  accessToken: string;
43
43
  user: string;
44
+ /** The REST API's base (D17: an org's membership), no trailing slash. */
45
+ api: string;
44
46
  /** A test's fake GitHub is in use (GITHUB_TEST_BASE_ENV). */
45
47
  fake: boolean;
46
48
  }
@@ -68,12 +70,14 @@ export function githubEndpoints(
68
70
  deviceCode: `${fake}/login/device/code`,
69
71
  accessToken: `${fake}/login/oauth/access_token`,
70
72
  user: `${fake}/user`,
73
+ api: fake,
71
74
  fake: true,
72
75
  };
73
76
  return {
74
77
  deviceCode: "https://github.com/login/device/code",
75
78
  accessToken: "https://github.com/login/oauth/access_token",
76
79
  user: "https://api.github.com/user",
80
+ api: "https://api.github.com",
77
81
  fake: false,
78
82
  };
79
83
  }
@@ -56,11 +56,27 @@ export interface GithubIdentity {
56
56
  login: string;
57
57
  }
58
58
 
59
- /** The three calls, swappable in a unit test. */
59
+ /**
60
+ * Whether a login is a member of an org (D17), as far as its own token can
61
+ * tell: `member`, `not_member`, or `unreadable` (the membership is private
62
+ * and this token may not read it: the app lacks the org's Members
63
+ * permission, or isn't installed there).
64
+ */
65
+ export type OrgMembership = "member" | "not_member" | "unreadable";
66
+
67
+ /** The calls, swappable in a unit test. */
60
68
  export interface GithubDeviceClient {
61
69
  start(clientId: string): Promise<DeviceCode>;
62
70
  poll(clientId: string, deviceCode: string): Promise<PollResult>;
63
71
  user(token: string): Promise<GithubIdentity>;
72
+ /**
73
+ * D17: is `login` (the token's own account) a member of `org`? First its
74
+ * own membership (`GET /user/memberships/orgs/<org>`, which a GitHub App
75
+ * reads with the org's Members permission), then the public list (`GET
76
+ * /orgs/<org>/members/<login>`: 204 for a public member). A network
77
+ * failure throws.
78
+ */
79
+ orgMember(token: string, org: string, login: string): Promise<OrgMembership>;
64
80
  }
65
81
 
66
82
  const TIMEOUT_MS = 15_000;
@@ -218,6 +234,27 @@ export function githubDeviceClient(
218
234
  throw new Error(`GitHub's /user answered ${r.status}`);
219
235
  return { id: j.id, login: j.login };
220
236
  },
237
+ async orgMember(token, org, login) {
238
+ const headers = {
239
+ accept: "application/vnd.github+json",
240
+ authorization: `Bearer ${token}`,
241
+ "x-github-api-version": "2022-11-28",
242
+ };
243
+ const own = await request(
244
+ `${ep.api}/user/memberships/orgs/${encodeURIComponent(org)}`,
245
+ { method: "GET", headers },
246
+ );
247
+ if (own.status === 200)
248
+ return parse(own.body).state === "active" ? "member" : "not_member";
249
+ const pub = await request(
250
+ `${ep.api}/orgs/${encodeURIComponent(org)}/members/${encodeURIComponent(login)}`,
251
+ { method: "GET", headers },
252
+ );
253
+ if (pub.status === 204) return "member";
254
+ // A 404 for its own membership means none; a 403 means this token
255
+ // may not read it, so a private membership can't be told apart.
256
+ return own.status === 404 ? "not_member" : "unreadable";
257
+ },
221
258
  };
222
259
  }
223
260
 
@@ -0,0 +1,100 @@
1
+ /**
2
+ * `web.owner_github` (D17): the GitHub login of the person who runs this
3
+ * instance, recorded in config/local/user.yml at `web up` or `service
4
+ * install` from `gh` when it is signed in here and no owner is linked yet.
5
+ * With it, that person connects their GitHub account from any browser, on
6
+ * any machine (a devbox they reach from a laptop), and nobody else can
7
+ * claim the instance from another machine (github-routes.ts decideFlow).
8
+ *
9
+ * Only the login is kept: never a token. `gh api user` runs with a short
10
+ * timeout; gh missing or signed out records nothing.
11
+ */
12
+ import { execFileSync } from "child_process";
13
+ import { readUserConfig, writeUserConfig } from "./den-config.js";
14
+ import { parseGithubName } from "./web-session.js";
15
+ import { readWebOwner } from "./web-owner.js";
16
+
17
+ export interface OwnerGithubDeps {
18
+ /** The login `gh` is signed in as here, or null. */
19
+ ghLogin?: () => string | null;
20
+ /** Whether an owner is linked already. */
21
+ ownerLinked?: () => boolean;
22
+ read?: typeof readUserConfig;
23
+ write?: typeof writeUserConfig;
24
+ }
25
+
26
+ /** `gh api user`'s login, or null (gh missing, signed out, slow, odd output). */
27
+ export function ghLogin(env: NodeJS.ProcessEnv = process.env): string | null {
28
+ try {
29
+ const out = execFileSync("gh", ["api", "user", "--jq", ".login"], {
30
+ env,
31
+ timeout: 8_000,
32
+ stdio: ["ignore", "pipe", "ignore"],
33
+ encoding: "utf-8",
34
+ }).trim();
35
+ return parseGithubName(out, "gh api user", "gh") ?? null;
36
+ } catch {
37
+ return null;
38
+ }
39
+ }
40
+
41
+ export type OwnerGithubResult =
42
+ | { state: "linked" }
43
+ | { state: "set"; login: string }
44
+ | { state: "recorded"; login: string }
45
+ | { state: "none" };
46
+
47
+ /**
48
+ * Record `web.owner_github` from gh when no owner is linked and it isn't
49
+ * set: `recorded`. Already set: `set`. An owner linked: `linked` (nothing
50
+ * to do). gh can't say: `none`. Never throws.
51
+ */
52
+ export function recordOwnerGithub(
53
+ deps: OwnerGithubDeps = {},
54
+ ): OwnerGithubResult {
55
+ try {
56
+ if ((deps.ownerLinked ?? (() => readWebOwner() !== null))())
57
+ return { state: "linked" };
58
+ const read = deps.read ?? readUserConfig;
59
+ const cfg = read();
60
+ const web = { ...((cfg as { web?: Record<string, unknown> }).web ?? {}) };
61
+ let set: string | undefined;
62
+ try {
63
+ set = parseGithubName(
64
+ web.owner_github,
65
+ "web.owner_github",
66
+ "config/local/user.yml",
67
+ );
68
+ } catch {
69
+ return { state: "none" }; // a value someone typed: leave it alone
70
+ }
71
+ if (set) return { state: "set", login: set };
72
+ const login = (deps.ghLogin ?? ghLogin)();
73
+ if (!login) return { state: "none" };
74
+ web.owner_github = login;
75
+ (deps.write ?? writeUserConfig)({ ...cfg, web } as typeof cfg);
76
+ return { state: "recorded", login };
77
+ } catch {
78
+ return { state: "none" };
79
+ }
80
+ }
81
+
82
+ /** The line `web up` and `service install` print, or null. */
83
+ export function ownerGithubLine(
84
+ r: OwnerGithubResult,
85
+ cli = "den",
86
+ ): string | null {
87
+ if (r.state === "recorded")
88
+ return `GitHub owner: @${r.login} (from gh; web.owner_github in config/local/user.yml). Connect your GitHub account from any browser to sign in.`;
89
+ if (r.state === "none") return ghNotSignedInHint(cli);
90
+ return null;
91
+ }
92
+
93
+ /** No owner, no web.owner_github, and gh can't say who you are: the fix. */
94
+ export function ghNotSignedInHint(cli = "den"): string {
95
+ return (
96
+ "GitHub owner: not known, because gh isn't signed in here. " +
97
+ `Run \`gh auth login\`, then \`${cli} web restart\`; or set web.owner_github: <your GitHub login> in config/local/user.yml. ` +
98
+ "Then you can connect your GitHub account from any browser."
99
+ );
100
+ }