@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.
- package/CHANGELOG.md +122 -0
- package/README.md +7 -7
- package/docs/distributions.md +121 -13
- 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/secrets/index.ts +71 -3
- package/src/shared/secrets/onepassword.ts +87 -14
- package/src/shared/secrets/op-token.ts +270 -0
- package/src/shared/secrets/secret-env.ts +19 -6
- package/src/shared/secrets/strategies.ts +6 -0
- package/src/shared/secrets/types.ts +8 -0
- package/src/shared/web-session.ts +112 -6
- package/src/ui/cli/create.ts +92 -6
- package/src/ui/cli/doctor-command.ts +108 -17
- 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 +148 -13
- 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 +11 -1
- package/src/ui/server/public/i18n/ja.js +11 -1
- package/src/ui/server/public/settings-registry.js +23 -1
- package/src/ui/server/public/shell.js +11 -1
- 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.
|
|
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
|
@@ -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` |
|
|
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
|
|
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
|
|
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
|
@@ -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
|
|