@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 +96 -0
- package/README.md +7 -7
- package/docs/distributions.md +72 -7
- package/package.json +1 -1
- package/src/shared/attest-code.ts +125 -0
- package/src/shared/claim-code.ts +6 -2
- package/src/{ui/server/edge-exposure.ts → shared/edge/exposure.ts} +7 -1
- package/src/shared/github-app.ts +4 -0
- package/src/shared/github-device.ts +38 -1
- package/src/shared/owner-github.ts +100 -0
- package/src/shared/product.ts +45 -5
- package/src/shared/profile.ts +26 -2
- package/src/shared/web-session.ts +112 -6
- package/src/ui/cli/create.ts +92 -6
- package/src/ui/cli/doctor-command.ts +54 -15
- package/src/ui/cli/doctor-owner.ts +122 -0
- package/src/ui/cli/service-command.ts +16 -0
- package/src/ui/cli/web-command.ts +74 -12
- package/src/ui/server/device-flows.ts +45 -10
- package/src/ui/server/github-routes.ts +169 -16
- package/src/ui/server/index.ts +143 -12
- package/src/ui/server/public/error-page.js +115 -33
- package/src/ui/server/public/error.html +16 -0
- package/src/ui/server/public/header.js +5 -7
- package/src/ui/server/public/i18n/es.js +10 -1
- package/src/ui/server/public/i18n/ja.js +10 -1
- package/src/ui/server/public/settings-registry.js +1 -1
- package/src/ui/server/public/shell.js +10 -1
- package/src/ui/server/route-classes.ts +7 -0
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.
|
|
14
|
-
<img src="docs/images/0.4.
|
|
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.
|
|
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.
|
|
73
|
-
<img src="docs/images/0.4.
|
|
74
|
-
<img src="docs/images/0.4.
|
|
75
|
-
<img src="docs/images/0.4.
|
|
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
|
package/docs/distributions.md
CHANGED
|
@@ -1004,15 +1004,21 @@ brand:
|
|
|
1004
1004
|
- " └───────────┘"
|
|
1005
1005
|
```
|
|
1006
1006
|
|
|
1007
|
-
| Field | Used for
|
|
1008
|
-
| --------- |
|
|
1009
|
-
| `name` |
|
|
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
|
|
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
|
|
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
|
@@ -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
|
+
}
|
package/src/shared/claim-code.ts
CHANGED
|
@@ -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(
|
|
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,
|
|
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
|
|
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
|
package/src/shared/github-app.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
+
}
|