@monster-orange/den 0.4.19 → 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.
Files changed (35) hide show
  1. package/CHANGELOG.md +122 -0
  2. package/README.md +7 -7
  3. package/docs/distributions.md +121 -13
  4. package/package.json +1 -1
  5. package/src/shared/attest-code.ts +125 -0
  6. package/src/shared/claim-code.ts +6 -2
  7. package/src/{ui/server/edge-exposure.ts → shared/edge/exposure.ts} +7 -1
  8. package/src/shared/github-app.ts +4 -0
  9. package/src/shared/github-device.ts +38 -1
  10. package/src/shared/owner-github.ts +100 -0
  11. package/src/shared/product.ts +45 -5
  12. package/src/shared/profile.ts +26 -2
  13. package/src/shared/secrets/index.ts +71 -3
  14. package/src/shared/secrets/onepassword.ts +87 -14
  15. package/src/shared/secrets/op-token.ts +270 -0
  16. package/src/shared/secrets/secret-env.ts +19 -6
  17. package/src/shared/secrets/strategies.ts +6 -0
  18. package/src/shared/secrets/types.ts +8 -0
  19. package/src/shared/web-session.ts +112 -6
  20. package/src/ui/cli/create.ts +92 -6
  21. package/src/ui/cli/doctor-command.ts +108 -17
  22. package/src/ui/cli/doctor-owner.ts +122 -0
  23. package/src/ui/cli/service-command.ts +16 -0
  24. package/src/ui/cli/web-command.ts +74 -12
  25. package/src/ui/server/device-flows.ts +45 -10
  26. package/src/ui/server/github-routes.ts +169 -16
  27. package/src/ui/server/index.ts +148 -13
  28. package/src/ui/server/public/error-page.js +115 -33
  29. package/src/ui/server/public/error.html +16 -0
  30. package/src/ui/server/public/header.js +5 -7
  31. package/src/ui/server/public/i18n/es.js +11 -1
  32. package/src/ui/server/public/i18n/ja.js +11 -1
  33. package/src/ui/server/public/settings-registry.js +23 -1
  34. package/src/ui/server/public/shell.js +11 -1
  35. package/src/ui/server/route-classes.ts +7 -0
package/CHANGELOG.md CHANGED
@@ -4,6 +4,128 @@ 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
+
103
+ ## [0.4.20] - 2026-11-09
104
+
105
+ ### Added
106
+
107
+ - **1Password works for a Den running as a service.** A Den under launchd
108
+ does not read your login shell, so `op` had no
109
+ `OP_SERVICE_ACCOUNT_TOKEN` and every 1Password `secret_env` ref failed
110
+ with a bare "not resolved". `secrets.onepassword.token` in
111
+ `config/local/user.yml` (per machine) now says where the token comes
112
+ from: `keychain:<service>` (macOS), `file:<path>` (mode 600 only) or
113
+ `env:<VAR>` (the default is `env:OP_SERVICE_ACCOUNT_TOKEN`, as before).
114
+ A bad value is refused at config load, naming the key. The token is read
115
+ when a ref is resolved, never cached, and goes only into the `op` child's
116
+ env; it is never logged, stored, put in an error, the activity records
117
+ or an API response.
118
+ - **"Not resolved" now says why.** A 1Password ref that fails reports one
119
+ of: no service-account token or session, no access to the vault, item or
120
+ field not found, rate limited, or a short safe summary. A token source
121
+ that fails (keychain item missing, file mode wrong, env var unset) is
122
+ named, never the token.
123
+ - **doctor** adds a `1password` row, "this process has no service-account
124
+ token. Set secrets.onepassword.token.", when 1Password is the provider
125
+ and the source yields none (a warning).
126
+ - **Settings › Secrets** shows the token's source, never the token
127
+ (`GET /api/settings` gains `op_token_source`, a string).
128
+
7
129
  ## [0.4.19] - 2026-11-08
8
130
 
9
131
  ### 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.19/hero-dark.png">
14
- <img src="docs/images/0.4.19/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.19/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.19/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.19/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.19/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.19/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
@@ -619,12 +619,12 @@ The backend is a strategy, chosen with `secrets.provider` in the org config
619
619
  (per engineer in `config/local/user.yml`, per shell with
620
620
  `<PREFIX>_SECRETS_PROVIDER`):
621
621
 
622
- | `secrets.provider` | What it is |
623
- | ------------------ | ---------------------------------------------------------------------------------------- |
624
- | `none` | No store. Nothing is resolved, no CLI is run; `den create` scaffolds this |
625
- | `env` | Plain environment variables of the process running Den |
626
- | `doppler` | The `doppler` CLI (`secrets.doppler: { project, config }` for a default scope) |
627
- | `1password` | The `op` CLI (`secrets.onepassword.account` for the account, else your own `OP_ACCOUNT`) |
622
+ | `secrets.provider` | What it is |
623
+ | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
624
+ | `none` | No store. Nothing is resolved, no CLI is run; `den create` scaffolds this |
625
+ | `env` | Plain environment variables of the process running Den |
626
+ | `doppler` | The `doppler` CLI (`secrets.doppler: { project, config }` for a default scope) |
627
+ | `1password` | The `op` CLI (`secrets.onepassword.account` for the account, else your own `OP_ACCOUNT`; [a service-account token for a service](#1password-for-a-den-running-as-a-service)) |
628
628
 
629
629
  ```yaml
630
630
  # config/acme.yml
@@ -799,6 +799,49 @@ OPENAI_API_KEY (names only; not resolved in a dry run)`.
799
799
  (`[A-Za-z_][A-Za-z0-9_]*`). A `stacks:` entry in the org config takes
800
800
  `secret_env` too.
801
801
 
802
+ ## 1Password for a Den running as a service
803
+
804
+ A Den started by launchd or systemd does not read your login shell, so
805
+ `op` has no `OP_SERVICE_ACCOUNT_TOKEN` and every 1Password ref fails.
806
+ `secrets.onepassword.token` says where this machine keeps the
807
+ service-account token. It is per machine: Den reads it from
808
+ `config/local/user.yml` only, and a value in the distribution's config is
809
+ ignored.
810
+
811
+ ```yaml
812
+ # config/local/user.yml
813
+ secrets:
814
+ onepassword:
815
+ token: keychain:op-service-account # or file:~/.config/op/token | env:OP_SERVICE_ACCOUNT_TOKEN
816
+ ```
817
+
818
+ | Value | Where the token comes from |
819
+ | -------------------- | ------------------------------------------------------------------------------------------------------------------------ |
820
+ | `keychain:<service>` | macOS: `security find-generic-password -s <service> -w`, run with no shell. Refused on other systems |
821
+ | `file:<path>` | A file whose mode is exactly 600 (a group or other bit refuses it). `~` expands; a trailing newline is dropped |
822
+ | `env:<VAR>` | The env var `<VAR>` of the process running Den. With no key set the default is `env:OP_SERVICE_ACCOUNT_TOKEN`, as before |
823
+
824
+ - **Checked at config load.** A value that is none of these shapes is
825
+ refused, naming `secrets.onepassword.token`. The message never repeats
826
+ the value.
827
+ - **Read each time.** The token is read when a ref is resolved, never
828
+ cached or written anywhere, and passed only in the `op` child's env as
829
+ `OP_SERVICE_ACCOUNT_TOKEN`. It is never in a log, an error, the activity
830
+ records or an API response.
831
+ - **Why a ref failed.** A `secret_env` key that does not resolve says why,
832
+ in plain words: no service-account token or session; no access to the
833
+ vault; item or field not found; rate limited; or a short summary of
834
+ what `op` said. When the token source itself fails (the keychain item is
835
+ missing, the file's mode is wrong, the env var is unset) the message
836
+ names the source, never the token.
837
+ - **doctor.** With `secrets.provider: 1password` and a source that yields
838
+ no token, `doctor` adds a `1password` row: "this process has no
839
+ service-account token. Set secrets.onepassword.token." It is a warning,
840
+ not a failure (a signed-in `op` app needs no token). `doctor` only looks
841
+ a keychain item up; it never reads the token.
842
+ - **Settings.** Settings › Secrets shows the source (`keychain: <service>`,
843
+ `file: <path>`, `env: <VAR>` or "not set"), never the token.
844
+
802
845
  ## The edge and TLS
803
846
 
804
847
  The edge is one Traefik per org that routes `<name>.<edge.domain>` (and
@@ -961,15 +1004,21 @@ brand:
961
1004
  - " └───────────┘"
962
1005
  ```
963
1006
 
964
- | Field | Used for |
965
- | --------- | ---------------------------------------------------------------------------- |
966
- | `name` | Web page `<title>`s, the banner fallback, the web route on the edge (`acme`) |
967
- | `tagline` | The line under the banner |
968
- | `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 |
969
1012
 
970
1013
  Runtime names (env vars, files, directories) always come from `den.yml`,
971
1014
  never from the brand.
972
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
+
973
1022
  ## Plugins
974
1023
 
975
1024
  A plugin adds to Den without changing it: a theme, a page in the web UI, and
@@ -1570,7 +1619,10 @@ user, and the CLI does that for you:
1570
1619
 
1571
1620
  - **The link** is `http://localhost:<port>/auth?c=<code>`, with `&next=<path>`
1572
1621
  for where to land (`web login --next /settings`). The code works once,
1573
- 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
1574
1626
  only on the one line you asked for (`web login`, or `web up` when it
1575
1627
  couldn't open the browser), never logged. Through the edge or a LAN name,
1576
1628
  the cookie belongs to that name: `web login --host acme.acme.localhost`
@@ -1810,7 +1862,8 @@ the account's id and login, never a token, and no other account gets in.
1810
1862
  last seen; "This device" on yours), each with **Revoke**, which ends its session at once (its
1811
1863
  open terminals included). **Pair a device** makes a one-time link and a
1812
1864
  QR code for an edge hostname you pick (`web login --host <name>` in the
1813
- 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).
1814
1867
  - **`web.github_client_id`** is the GitHub App's client id (public; no
1815
1868
  secret). The default is Den's own App, **Monster Orange Den**
1816
1869
  (`Iv23liKL12gnN6WvRV5b`), so Sign in with GitHub works out of the box. A
@@ -1825,6 +1878,61 @@ the account's id and login, never a token, and no other account gets in.
1825
1878
  it never prints the token. `GET /api/web/owner` reports it as
1826
1879
  `tokenExpiry: true`.
1827
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
+
1828
1936
  ### The GitHub App (once, by whoever ships the Den)
1829
1937
 
1830
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.19",
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