@monster-orange/den 0.4.4 → 0.4.5

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 (43) hide show
  1. package/CHANGELOG.md +169 -0
  2. package/README.md +10 -9
  3. package/docs/distributions.md +223 -26
  4. package/docs/reference.md +8 -3
  5. package/package.json +1 -1
  6. package/src/shared/auth/sudo-vault.ts +115 -11
  7. package/src/shared/cli-command.ts +186 -0
  8. package/src/shared/confirm-code.ts +34 -0
  9. package/src/shared/den-config.ts +7 -0
  10. package/src/shared/dev-metrics/emit.ts +31 -1
  11. package/src/shared/edge/config.ts +39 -0
  12. package/src/shared/edge/render.ts +92 -18
  13. package/src/shared/edge/runtime.ts +57 -1
  14. package/src/shared/edge/types.ts +11 -1
  15. package/src/shared/github-app.ts +94 -0
  16. package/src/shared/github-device.ts +286 -0
  17. package/src/shared/secrets/onepassword.ts +27 -4
  18. package/src/shared/web-owner.ts +91 -0
  19. package/src/shared/web-session.ts +135 -1
  20. package/src/ui/cli/flag-parser.ts +1 -1
  21. package/src/ui/cli/web-command.ts +512 -33
  22. package/src/ui/server/device-flows.ts +274 -0
  23. package/src/ui/server/edge-exposure.ts +16 -4
  24. package/src/ui/server/github-routes.ts +321 -0
  25. package/src/ui/server/http-errors.ts +10 -0
  26. package/src/ui/server/index.ts +463 -27
  27. package/src/ui/server/login-waits.ts +203 -0
  28. package/src/ui/server/pair-requests.ts +237 -0
  29. package/src/ui/server/pair-routes.ts +196 -0
  30. package/src/ui/server/public/error-page.js +452 -40
  31. package/src/ui/server/public/error.html +59 -0
  32. package/src/ui/server/public/i18n/es.js +74 -8
  33. package/src/ui/server/public/i18n/ja.js +74 -8
  34. package/src/ui/server/public/local-dev.html +16 -4
  35. package/src/ui/server/public/qr.js +317 -0
  36. package/src/ui/server/public/settings-page.js +10 -3
  37. package/src/ui/server/public/settings-registry.js +392 -1
  38. package/src/ui/server/public/shell.js +123 -10
  39. package/src/ui/server/public/topbar.js +7 -3
  40. package/src/ui/server/public/workspace-picker.js +1 -1
  41. package/src/ui/server/request-guard.ts +102 -35
  42. package/src/ui/server/route-classes.ts +102 -2
  43. package/src/ui/server/ui-assets.ts +2 -0
package/CHANGELOG.md CHANGED
@@ -4,6 +4,175 @@ 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.5] - 2026-11-07
8
+
9
+ Sign in with GitHub as the owner, from your other devices; approve a
10
+ computer with a code; see that you're signed in (SEC-2a). Save the admin
11
+ password without editing YAML (LK-4). Dev-metrics stay on this machine
12
+ unless you opt in.
13
+
14
+ ### Added
15
+
16
+ - **Sign in with GitHub.** A Den has one owner, a GitHub account:
17
+ `<cli> web owner link` runs GitHub's device flow in the terminal (a code
18
+ to enter at https://github.com/login/device) and stores only
19
+ `github:<id>` and the login (`web-owner.json`, 0600), never the token.
20
+ `web owner show` and `web owner unlink` too. The signed-out page then
21
+ has **Sign in with GitHub**: the server runs the flow, and the owner's
22
+ approval sets the same never-expiring session a one-time link does;
23
+ any other account sees "@<login> isn't allowed on this Den". At most
24
+ three sign-ins wait at once. Before an owner is linked the page says to
25
+ run `web owner link`. See docs/distributions.md, "Signing in with GitHub".
26
+ - **Settings › Access.** The owner (link and unlink: shell-grade, so a
27
+ signed-in browser and never an agent's token), the
28
+ linked devices (browser, how it signed in, the client address the edge
29
+ vouched for, first and last seen) each with **Revoke**, which ends that
30
+ session at once, and **Pair a device**: a one-time link and its QR code
31
+ for an edge hostname you pick.
32
+ - **`web.github_client_id`**: the GitHub App's client id (public). The
33
+ default is Den's own App, "Monster Orange Den" (`Iv23liKL12gnN6WvRV5b`);
34
+ a distribution sets its own App's id here, and an empty value turns Sign
35
+ in with GitHub off (the page then offers only the code and the link).
36
+ The App needs "Enable Device Flow" ticked and "Expire user authorization
37
+ tokens" off (docs/distributions.md, "The GitHub App").
38
+ - **Expiring GitHub tokens are noticed.** If the App hands out expiring
39
+ tokens (`expires_in` or `refresh_token` in the answer), sign-in still
40
+ completes, and Den says once to switch off token expiry in the App's
41
+ settings (never with the token). `GET /api/web/owner` reports it as
42
+ `tokenExpiry`. Den never asks for `offline_access`.
43
+ - **Approve this computer with a code.** The signed-out page shows a
44
+ six-character code (`XXX-XXX`, about 30 bits; the page polls with its own
45
+ 128-bit id) and the command that approves it on the machine Den runs on:
46
+ `<cli> web approve <code>` (any case, dash optional; `--list` shows what
47
+ waits). The page signs itself in when it's approved, onto the page it
48
+ asked for. From a machine with SSH access the page gives the one-liner
49
+ (`ssh <host> '<cli> web approve <code>'`) and, for a host name, an Open
50
+ Terminal link. Codes expire after 10 minutes, are single-use, at most
51
+ five wait at once (the oldest drops) and are kept in memory only;
52
+ approvals are rate-limited and audited with who approved. The device is
53
+ labelled "approved with a code".
54
+ - **Settings › Access › Waiting devices**: the computers waiting with a
55
+ code (browser and OS, address, when), each with **Approve** and **Deny**.
56
+ - **`web.machine_name`** (Settings › Access › Machine name, in user.yml):
57
+ the name the signed-out page gives this machine. Default: the short
58
+ hostname. It's never taken from the browser's Host; the ssh one-liner's
59
+ target is the Host the browser used (without the port).
60
+ - **The signed-out page says where to act.** "Sign in to <product>", which
61
+ machine Den runs on and why it asks (once per computer), Sign in with
62
+ GitHub (only with a client id), Approve this computer, and the one-time
63
+ link under "Other ways". Every command it prints names the machine it
64
+ runs on, has a copy button, and is `<cli>` when that's on PATH and this
65
+ distribution's, else the absolute path of its bin. English, Japanese and
66
+ Spanish.
67
+ - **See that you're signed in.** A chip at the foot of the rail says
68
+ "Local", "Signed in · this device" or "@<login>" and opens Settings ›
69
+ Access. The first page after a sign-in says "This device is signed in
70
+ to <product> on <machine>", once. Linked devices lists every session
71
+ (GitHub, a code, a one-time link) with how it signed in, when, from
72
+ where, last seen and Revoke, and marks the one you're using as "This
73
+ device".
74
+ - **Save the admin password without editing YAML (LK-4).** When a secrets
75
+ backend that keeps secrets (1Password, Doppler) is available and no
76
+ `secrets.refs.sudo` is set, the admin password prompt (shown when a
77
+ command is about to run sudo, LK-0) still offers "Save to <backend>".
78
+ Saving creates the item (1Password: "<Product> admin password" in your
79
+ default vault; Doppler: `<PREFIX>_LOCAL_ADMIN_PWD`) and writes
80
+ `secrets.refs.sudo` to your `config/local/user.yml`, so the password
81
+ survives restarts. A ref in the distribution's config still wins. The
82
+ vault status says so as `canSave`. A save error no longer carries the
83
+ backend's command line.
84
+ - **`edge.proxy_protocol.trusted_ips`** (empty by default): the front
85
+ proxies (a TCP passthrough in front of the edge) whose PROXY protocol
86
+ every entrypoint accepts, so Den sees the real client through them.
87
+ - **`web.edge_unattested: local | signin`** (default `signin`, read only
88
+ from `config/local/user.yml`): whether an edge client Den can't place
89
+ signs in. `local` keeps 0.4.4's (and 0.4.3's) behaviour.
90
+
91
+ ### Changed
92
+
93
+ - **Dev-metrics are local unless you opt in.** Den sent its dev-metrics
94
+ whenever a DSN was present (in the env or a synced
95
+ `dev-metrics.config`). Sending now needs `metrics: { share: true }` in
96
+ `config/local/user.yml` (the boolean only); a DSN alone never sends. The
97
+ local `events.jsonl` is unchanged, and `CI=true` and
98
+ `DEV_METRICS_DISABLED=1` still stop sending.
99
+ - **`<cli> web login` waits.** It prints the link and an 8-character code,
100
+ and blocks until the link is used ("Signed in: <browser> (<address>) at
101
+ <time>") or expires ("Expired: nobody used the link", exit 1). The link
102
+ asks for the code before it signs in; a wrong code is refused (five burn
103
+ the link, and wrong codes are rate-limited) and the link stays
104
+ single-use. `--no-wait` prints the link alone, as before, and asks for no
105
+ code; with no server running it behaves like `--no-wait` and says so.
106
+ - **Edge visitors Den can't place sign in.** A client the edge reports
107
+ with no `X-Forwarded-For`, or with a Docker/VM gateway or bridge address
108
+ (what a TCP passthrough or Docker Desktop and OrbStack hand it), now sees
109
+ the signed-out page with Sign in with GitHub, unless
110
+ `web.edge_unattested: local`. Loopback and this machine's own addresses,
111
+ as the edge reports them, stay local, and localhost straight to the
112
+ server's port never signs in.
113
+ - **The client is the right-most `X-Forwarded-For` entry**, the one the
114
+ edge appended (0.4.4 and earlier read the left-most, which a client could
115
+ write).
116
+ - **The edge believes forwarded headers only from its own networks.** Den
117
+ renders `forwardedHeaders.trustedIPs` (loopback and the Docker ranges)
118
+ on every entrypoint, never `forwardedHeaders.insecure`. An `edge.args`
119
+ entry that sets `forwardedHeaders` or `proxyProtocol` is dropped with a
120
+ notice from `edge up`.
121
+ - **"Your edge is reachable from other machines"** now reads "A browser it
122
+ can't place as this one signs in first (Sign in with GitHub, or a
123
+ pairing link)." (with `web.edge_unattested: local`, that anyone it can't
124
+ place can use terminals).
125
+ - **`edge up` says when the running edge's command differs** from the one
126
+ it renders, and why, before recreating it.
127
+
128
+ ### Fixed
129
+
130
+ - **The workspace commands run in is named: "CLI default".** Local Dev's
131
+ workspace list marked it with a bare accent dot and no label, and
132
+ picking a workspace in the top bar never moved the dot (by design: that
133
+ picks the view), so it looked broken. It now says "CLI default", with
134
+ a tooltip: CLI commands run there when you don't name a workspace, and
135
+ the top bar only changes what pages show. The top bar's Workspace menu
136
+ tags it "CLI default" (was "focus") and offers "Make <ws> the CLI
137
+ default"; the Workspaces pane's badge reads "active · CLI default" and
138
+ its button "Make CLI default". Nothing behaves differently.
139
+
140
+ - **A browser signs in on a plain-http LAN bind** (`DEN_BIND_HOST` with
141
+ `web.allow_plain_http_remote`). A `web login --origin http://<LAN IP>:<port>`
142
+ link got 403 `origin_rejected`: the browser sends a plain-http origin that
143
+ isn't localhost no `Sec-Fetch-*` headers, and a navigation sends no
144
+ Origin. The sign-in GETs (`/auth` and the GitHub sign-in poll), which
145
+ carry their own one-time credential, now pass with neither header. Either
146
+ header, when present, is still checked, and every other GET that runs
147
+ something still needs this server's page or a bearer.
148
+ - **`web up` with `DEN_BIND_HOST` on one address is ready when it serves.**
149
+ Its readiness probe (and `web sessions`/`web logout`'s calls) asked
150
+ 127.0.0.1, so a server bound to a LAN IP or `::1` printed "did not become
151
+ ready" and exited non-zero. They ask the bound address now (loopback for
152
+ 0.0.0.0 and `::`), with a Host the allowlist accepts and the CLI's
153
+ bearer: a LAN address answers an unsigned probe 401 `signin_required`.
154
+ `web up` names the bound address (`http://<LAN IP>:<port>`), not
155
+ localhost, which a LAN-only bind doesn't answer.
156
+
157
+ ### Migrations
158
+
159
+ - **One edge recreate.** The edge's command changes (forwarded-header
160
+ trust), so the first `<cli> edge up` (or `stack up`) after upgrading
161
+ recreates it once, and says so: "<org>-edge's settings changed
162
+ (forwarded-header trust): recreating it once." Routes drop only while it
163
+ restarts.
164
+ - **Edge visitors now see Sign in with GitHub** where 0.4.4 let a client
165
+ it couldn't place in. Link an owner (`<cli> web owner link`), approve
166
+ the computer with its code (`<cli> web approve <code>`) or pair with
167
+ `<cli> web login --host <name>`;
168
+ or set `web.edge_unattested: local` in `config/local/user.yml` to keep
169
+ 0.4.4's behaviour on that machine.
170
+ - **Scripts that run `<cli> web login` and read its output** should pass
171
+ `--no-wait`: it now waits for the link to be used.
172
+ - **Remove `forwardedHeaders.*` and `proxyProtocol.*` from `edge.args`.**
173
+ They are ignored now; use `edge.proxy_protocol.trusted_ips` for a front
174
+ proxy.
175
+
7
176
  ## [0.4.4] - 2026-11-07
8
177
 
9
178
  The admin password is for sudo, and nothing else (LK-0). An agent's
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.4/hero-dark.png">
14
- <img src="docs/images/0.4.4/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.5/hero-dark.png">
14
+ <img src="docs/images/0.4.5/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
  ## What Den is
@@ -24,7 +24,7 @@ It doesn't replace your tools. Your repos, Docker, your test runners, your secre
24
24
  yours. Den connects them and turns on good practice from the start.
25
25
 
26
26
  <p align="center">
27
- <img src="docs/images/0.4.4/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">
27
+ <img src="docs/images/0.4.5/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">
28
28
  </p>
29
29
 
30
30
  ## How it helps
@@ -46,17 +46,18 @@ yours. Den connects them and turns on good practice from the start.
46
46
  and audit trail as a person, under a token of its own. Agents can read and investigate, and acting is opt-in.
47
47
  Starting an agent never asks for your admin password: Den asks for it only when a command it runs needs sudo.
48
48
  - **Safe on localhost, signed in beyond it.** On localhost there is nothing to sign in to. Reached through the
49
- edge or your network, every page and call needs a sign-in, and there is no password: `acme web login` prints a
50
- one-time link.
49
+ edge from another machine, or over your network, every page and call needs a sign-in, and there is no password:
50
+ Sign in with GitHub as the Den's owner, approve the computer with a code (`acme web approve <code>`), or open a
51
+ one-time link from `acme web login`.
51
52
  - **Opinionated, not locked in.** Good defaults are switched on, and almost everything can be switched off. The
52
53
  safety guards stay on. Den never needs our accounts, secrets or certificates. Everything specific to you
53
54
  lives in your own repo.
54
55
 
55
56
  <p align="center">
56
- <img src="docs/images/0.4.4/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">
57
- <img src="docs/images/0.4.4/workspaces.png" width="49%" alt="The Workspaces page: the demo workspace's repos with their health, mode and Start, Stop, Restart and Logs actions">
58
- <img src="docs/images/0.4.4/status-drawer.png" width="49%" alt="The status drawer: nothing needs attention, and every service in the demo workspace, by application, with its latency">
59
- <img src="docs/images/0.4.4/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">
57
+ <img src="docs/images/0.4.5/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">
58
+ <img src="docs/images/0.4.5/workspaces.png" width="49%" alt="The Workspaces page: the demo workspace's repos with their health, mode and Start, Stop, Restart and Logs actions">
59
+ <img src="docs/images/0.4.5/status-drawer.png" width="49%" alt="The status drawer: nothing needs attention, and every service in the demo workspace, by application, with its latency">
60
+ <img src="docs/images/0.4.5/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">
60
61
  </p>
61
62
 
62
63
  ## Get started
@@ -12,6 +12,7 @@ Den's own checkout is itself a distribution, named `den`.
12
12
  - [The bin shim](#the-bin-shim)
13
13
  - [make: den.mk](#make-denmk)
14
14
  - [Two roots](#two-roots)
15
+ - [Dev metrics: local unless you opt in](#dev-metrics-local-unless-you-opt-in)
15
16
  - [Pinning and upgrading](#pinning-and-upgrading)
16
17
  - [Developing Den against a distribution](#developing-den-against-a-distribution)
17
18
 
@@ -379,9 +380,23 @@ a ref that is not set is **not configured**, and its feature says so
379
380
  ("not configured: set secrets.provider and secrets.refs.sudo in
380
381
  config/acme.yml") instead of running a CLI. Typing the password always
381
382
  works; only the saved password needs `refs.sudo`.
383
+
384
+ **Saving writes `refs.sudo` for you** (LK-4, 0.4.5). When `secrets.provider`
385
+ is `1password` or `doppler`, its CLI is available, and no `refs.sudo` is
386
+ set, the admin password prompt (shown when a command is about to run sudo)
387
+ still offers "Save to 1Password". Saving creates the item
388
+ (1Password: a Password item "<Product> admin password", field `password`,
389
+ in your default vault; Doppler: the key `<PREFIX>_LOCAL_ADMIN_PWD`) and
390
+ then writes that ref to `config/local/user.yml`, because the password is
391
+ the person's, not the distribution's. From then on it survives restarts.
392
+ A failed save writes nothing, and its error never carries the password.
393
+ `env` keeps nothing past the process, so it offers no save. A `refs.sudo`
394
+ in the distribution's config always wins over the one in user.yml.
395
+
382
396
  `/api/sudo-password/vault-status` reports `backend`, `label`,
383
- `configured`, `backendAvailable`, `reason` and `hasStored` (`opAvailable`
384
- is a deprecated alias of `backendAvailable`, kept for one minor).
397
+ `configured`, `canSave` (saving works with no YAML to edit),
398
+ `backendAvailable`, `reason` and `hasStored` (`opAvailable` is a
399
+ deprecated alias of `backendAvailable`, kept for one minor).
385
400
 
386
401
  The admin password is for sudo, and nothing else (LK-0). It is no sign-in
387
402
  and no access check: starting, stopping and resuming agents, sessions and
@@ -1209,11 +1224,13 @@ are the same directory.
1209
1224
 
1210
1225
  On localhost nothing asks you to sign in: every page, the agents and the
1211
1226
  terminal work as they are, and the admin password (R8) is the only prompt,
1212
- asked only when a command Den runs needs sudo. The edge counts as local too (0.4.3): opening
1213
- `https://acme.acme.localhost` from this machine asks for nothing.
1214
- Signing in is for when the server is reached from another machine: the
1215
- edge for a client the edge says is elsewhere, a LAN address, or a bind
1216
- that isn't loopback (`DEN_BIND_HOST`).
1227
+ asked only when a command Den runs needs sudo. Through the edge, a browser
1228
+ the edge shows is on this machine asks for nothing either. Signing in is
1229
+ for everything else: the edge for a client it can't place here (0.4.5), a
1230
+ LAN address, or a bind that isn't loopback (`DEN_BIND_HOST`). From another
1231
+ device, sign in with GitHub as the Den's owner, approve the computer with
1232
+ the code its page shows, or open a one-time link (below, and
1233
+ [Signing in with GitHub](#signing-in-with-github)).
1217
1234
 
1218
1235
  There is no password. Reading `web.secret` (32 random bytes, mode 0600 in
1219
1236
  the state dir, made at the first `web up`) proves you are this computer's
@@ -1221,7 +1238,10 @@ user, and the CLI does that for you:
1221
1238
 
1222
1239
  ```bash
1223
1240
  ./bin/acme web open # start if needed, and sign this browser in (a new tab)
1224
- ./bin/acme web login # print a one-time sign-in link, for another browser
1241
+ ./bin/acme web login # a one-time sign-in link and its code; waits until it's used
1242
+ ./bin/acme web login --no-wait # the link alone (asks for no code), and return
1243
+ ./bin/acme web approve ABC-123 # approve the computer showing this code
1244
+ ./bin/acme web approve --list # the computers waiting with a code
1225
1245
  ./bin/acme web sessions # the signed-in browsers: id, created, last seen, host, user agent
1226
1246
  ./bin/acme web logout <id> # sign out one browser (an id prefix from `web sessions`)
1227
1247
  ./bin/acme web logout # sign out every browser
@@ -1238,7 +1258,19 @@ user, and the CLI does that for you:
1238
1258
  among them; its URL comes from the edge config, and a name the edge
1239
1259
  doesn't route here is refused, saying what to add), and
1240
1260
  `web login --origin http://<lan name>:<port>` for any other allowed name
1241
- (the signed-out page there shows the exact command).
1261
+ (the signed-out page there shows the exact command, under "Other ways").
1262
+ - **`web login` waits** (0.4.5). Besides the link it prints an
1263
+ 8-character code ("It asks for this code: ABCD-EFGH") and blocks until
1264
+ the link is used, then says who: "Signed in: Safari on macOS
1265
+ (100.64.0.7) at 10:42". Nobody within the minute: "Expired: nobody used
1266
+ the link", exit 1. The link's page asks for the code before it signs in,
1267
+ so the link alone is useless; once opened it waits 5 minutes for the
1268
+ code. A wrong code is refused; five burn the link ("Refused: too many
1269
+ wrong codes", exit 1), more than 20 wrong a minute (any link) are slowed,
1270
+ and the link still works once. The CLI asks the running server through
1271
+ its bearer and never logs the code. `--no-wait` prints the link alone and
1272
+ returns, as before (no code); with no server running `web login` does
1273
+ that too, and says so.
1242
1274
  - **The cookie** (`den_session_<port>`) is HttpOnly, SameSite=Strict and
1243
1275
  Path=/, and Secure when the request came over HTTPS through the edge. The
1244
1276
  session lives server-side (`web-sessions.json`, 0600, in the state dir)
@@ -1256,36 +1288,111 @@ user, and the CLI does that for you:
1256
1288
  The Host allowlist, the Origin check and the CSRF token are what stop
1257
1289
  another site or a rebinding DNS name. From another machine, every page
1258
1290
  and call needs a signed-in browser (or a bearer). A page there shows
1259
- "Sign in to use <name>" with the command to run; it never asks for a
1291
+ "Sign in to <name>" and the ways to sign in (below); it never asks for a
1260
1292
  secret. `/api` answers 401 `signin_required`.
1261
- - **The edge counts as local.** A request for the edge hostname from the
1262
- edge (an edge peer, below) is treated as loopback: no sign-in on any
1263
- route, the agents and the terminal included. Host, Origin, CSRF and the
1264
- agent-token rules still apply. Who the client is comes from the left-most
1265
- `X-Forwarded-For` entry the edge sends:
1293
+ - **The signed-out page says where to act.** It names the machine Den runs
1294
+ on ("You're opening Den from another computer. Den runs on
1295
+ **studio**, so it needs to know this is you."), then:
1296
+ - **Sign in with GitHub**, only when a client id is set (below);
1297
+ - **Approve this computer**: a code (`XXX-XXX`) and the command that
1298
+ approves it, to run on that machine (`<cli> web approve <code>`), or
1299
+ Settings › Access › Waiting devices there. The page signs itself in
1300
+ when it's approved and goes to the page you asked for; it counts down
1301
+ the 10 minutes, and an expired code is a click away from a new one.
1302
+ From a computer with SSH access to it, the page gives the one-liner
1303
+ (`ssh <host> '<cli> web approve <code>'`) and, when the host is a name,
1304
+ an Open Terminal (`ssh://`) link;
1305
+ - **Other ways**, collapsed: the one-time link with
1306
+ `web login --origin`.
1307
+
1308
+ Every command the page prints names the machine it runs on and has a
1309
+ copy button. It is `<cli>` when the first `<cli>` on that machine's PATH
1310
+ is this distribution's, else the absolute path of its bin.
1311
+
1312
+ - **The machine's name** on the page is `web.machine_name` from
1313
+ `config/local/user.yml` (Settings › Access › Machine name), else the short
1314
+ host name (up to the first dot). It is never taken from the browser's
1315
+ Host. The ssh one-liner's target is the Host the browser used, without
1316
+ the port (the machine's host name if it had none).
1317
+ - **Codes.** About 30 bits (Crockford base32: no I, L, O or U; typed in any
1318
+ case, the dash optional); the page polls with a separate 128-bit id. A
1319
+ code expires after 10 minutes and works once; at most five wait at once
1320
+ (a sixth drops the oldest). They live in the server's memory only.
1321
+ Approving is rate-limited (10 a minute) and recorded in the activity log
1322
+ with who approved, never the code. The new session is "approved with a
1323
+ code". **Deny** (Settings › Access, or `web deny` through the API) tells
1324
+ the page it was refused.
1325
+ - **Seeing that you're signed in.** The chip at the foot of the rail says
1326
+ "Local" (this machine), "Signed in · this device" (a code or a one-time
1327
+ link) or "@<login>" (GitHub), and opens Settings › Access. The first page
1328
+ after a sign-in says "This device is signed in to Den on <machine>",
1329
+ once. Linked devices lists every session (GitHub, a code, a one-time
1330
+ link): how it signed in, when, from where (the address and the browser
1331
+ and OS), last seen, and **Revoke**; the one you're using says "This
1332
+ device".
1333
+ - **The edge counts as local only when it shows the client is.** A
1334
+ request for the edge hostname from the edge (an edge peer, below) is
1335
+ treated as loopback (no sign-in on any route, the agents and the
1336
+ terminal included) when the client the edge reports is this machine.
1337
+ Host, Origin, CSRF and the agent-token rules still apply. Who the client
1338
+ is comes from the right-most `X-Forwarded-For` entry, the one the edge
1339
+ itself appended (anything left of it came from the client, and is never
1340
+ believed):
1266
1341
  - loopback, or one of this machine's own interface addresses (a LAN
1267
- address, or a VPN's such as a Tailscale `100.x`): local;
1342
+ address, or a VPN's such as a Tailscale `100.x`): local, no sign-in;
1268
1343
  - missing, or a container/VM network's address (the Docker ranges, or a
1269
1344
  subnet of one of this machine's Docker/OrbStack/VM bridges): Den can't
1270
1345
  tell (Docker Desktop and OrbStack on macOS hand the edge their
1271
1346
  gateway's address, and a TCP passthrough in front of the edge hides the
1272
- client), so local;
1273
- - any other address: another machine, which needs a sign-in.
1274
-
1347
+ client), so it signs in: `web.edge_unattested`, below;
1348
+ - any other address: another machine, which signs in.
1349
+
1350
+ `web.edge_unattested: local` in `config/local/user.yml` counts a client
1351
+ Den can't place as local again (0.4.4's behaviour). It is read only from
1352
+ that file (a choice for that machine); the distribution config's key is
1353
+ ignored with a warning. The default is `signin`. A request straight to
1354
+ the server's port on localhost (loopback Host and peer) never signs in.
1275
1355
  With `DEN_BIND_HOST` beyond loopback (a deliberate exposure, other than
1276
- the Linux edge's Docker bridge address) the edge needs a sign-in as
1277
- before. With `forwardedHeaders.insecure` (the edge's default) Traefik
1278
- keeps an `X-Forwarded-For` the client sent, so this is a convenience,
1279
- not a boundary: whoever can reach the edge can use the terminal.
1356
+ the Linux edge's Docker bridge address) the edge always signs in.
1357
+
1358
+ - **The edge believes forwarded headers only from its own networks.** Den
1359
+ renders each entrypoint with `forwardedHeaders.trustedIPs` set to
1360
+ loopback and the Docker ranges (`127.0.0.1/32`, `::1/128`,
1361
+ `172.16.0.0/12`, `192.168.65.0/24`), never `forwardedHeaders.insecure`,
1362
+ so a client can't hand the edge an `X-Forwarded-For` of its own. An
1363
+ `edge.args` entry that sets `forwardedHeaders` or `proxyProtocol` is
1364
+ dropped, and `edge up` says so.
1365
+ - **A TCP passthrough in front of the edge** (a gateway that publishes
1366
+ `:443` and passes TLS through) hides the client: the edge sees the
1367
+ gateway's address. Have the gateway send the PROXY protocol, and list it
1368
+ in `edge.proxy_protocol.trusted_ips` (empty by default; IP addresses or
1369
+ CIDRs; `config/local/user.yml`'s list replaces the team's):
1370
+
1371
+ ```yaml
1372
+ edge:
1373
+ proxy_protocol:
1374
+ trusted_ips: [172.18.0.5]
1375
+ ```
1376
+
1377
+ Every entrypoint then takes the PROXY protocol from those addresses, and
1378
+ the client it carries is what Den sees. Configuring the gateway is the
1379
+ gateway's job.
1380
+
1381
+ - **`edge up` recreates the edge when its command changed.** It compares
1382
+ the running edge's command with the one it renders and says why ("acme-edge's
1383
+ settings changed (forwarded-header trust): recreating it once. Routes
1384
+ drop only while it restarts."). Upgrading to 0.4.5 does this once.
1280
1385
 
1281
1386
  - **"Your edge is reachable from other machines".** When Den can tell, at
1282
1387
  start, that the edge is reachable beyond this machine (an edge hostname
1283
1388
  that resolves to an address that isn't loopback, such as a LAN or
1284
1389
  Tailscale address, or the edge container publishing a port on every
1285
1390
  interface, from `docker inspect`), it writes "Your edge is reachable from
1286
- other machines; anyone who can reach it can use terminals. Auth is
1287
- coming." once to `web.log`, and every page shows it as a banner (dismissed
1288
- per browser).
1391
+ other machines. A browser it can't place as this one signs in first
1392
+ (Sign in with GitHub, or a pairing link)." once to `web.log`, and every
1393
+ page shows it as a banner (dismissed per browser). With
1394
+ `web.edge_unattested: local` it says instead that anyone it can't place
1395
+ can use terminals, and to remove the key to have them sign in.
1289
1396
  - **localhost is the canonical name.** A cookie set on `localhost` isn't
1290
1397
  sent to `127.0.0.1`, so the links Den prints use `localhost`.
1291
1398
  - **Browsers.** Chromium, Firefox and Safari 16.4 or later: a sign-in link
@@ -1319,6 +1426,76 @@ logout --all` the old token is refused; the CLI reads the new one.
1319
1426
  421 `misdirected`, so a LAN client can't send the edge's name straight to
1320
1427
  the server's port.
1321
1428
 
1429
+ ## Signing in with GitHub
1430
+
1431
+ A Den has one owner: a GitHub account. Once it is linked, that account
1432
+ signs in from any device (a phone, another laptop, a browser through the
1433
+ edge) with **Sign in with GitHub** on the signed-out page. Den stores only
1434
+ the account's id and login, never a token, and no other account gets in.
1435
+
1436
+ ```bash
1437
+ ./bin/acme web owner link # link the owner: GitHub's device flow, in the terminal
1438
+ ./bin/acme web owner # show who it is (also: web owner show, --json)
1439
+ ./bin/acme web owner unlink # forget the owner (signed-in browsers stay signed in)
1440
+ ```
1441
+
1442
+ - **`web owner link`** prints `https://github.com/login/device` and a code.
1443
+ Enter the code there and approve; Den asks GitHub who approved, stores
1444
+ `owner: github:<id>` and the login in `web-owner.json` (0600, in the
1445
+ state dir) and throws the token away. A second link is refused until
1446
+ `web owner unlink`. Settings › Access does the same in the browser
1447
+ (Link your GitHub account, Unlink): shell-grade actions, for a signed-in
1448
+ browser and never an agent's token.
1449
+ - **Sign in with GitHub** on the signed-out page shows a code and
1450
+ `github.com/login/device`. The server runs the flow, and when the owner
1451
+ approves it sets the same never-expiring session cookie a one-time link
1452
+ sets, and the page reloads signed in. Another account sees "@<login>
1453
+ isn't allowed on this Den". The token is thrown away either way. At most
1454
+ three sign-ins wait for GitHub at once; a code expires as GitHub says
1455
+ (15 minutes). With no owner linked the page says to run
1456
+ `web owner link`.
1457
+ - **Settings › Access** lists the linked devices (the browser, how it
1458
+ signed in, the client address when the edge vouched for one, created and
1459
+ last seen; "This device" on yours), each with **Revoke**, which ends its session at once (its
1460
+ open terminals included). **Pair a device** makes a one-time link and a
1461
+ QR code for an edge hostname you pick (`web login --host <name>` in the
1462
+ browser): scan it on the other device within 60 seconds.
1463
+ - **`web.github_client_id`** is the GitHub App's client id (public; no
1464
+ secret). The default is Den's own App, **Monster Orange Den**
1465
+ (`Iv23liKL12gnN6WvRV5b`), so Sign in with GitHub works out of the box. A
1466
+ distribution that ships its own App sets its client id here (below);
1467
+ `config/local/user.yml`'s wins over the team's. An empty value
1468
+ (`github_client_id: ""`) turns Sign in with GitHub off: the page then
1469
+ offers the code and the one-time link only.
1470
+ - **Expiring tokens.** If the App hands out expiring tokens (GitHub's
1471
+ answer carries `expires_in` or a `refresh_token`), sign-in still
1472
+ completes, and Den says once, in the log and on stderr, to switch off
1473
+ token expiry in the App's settings ("Expire user authorization tokens");
1474
+ it never prints the token. `GET /api/web/owner` reports it as
1475
+ `tokenExpiry: true`.
1476
+
1477
+ ### The GitHub App (once, by whoever ships the Den)
1478
+
1479
+ Den's default App is Monster Orange Den. A distribution that wants its own
1480
+ (its name on GitHub's approval page) needs a GitHub App with the device
1481
+ flow on. Create it once (organization or account › Settings › Developer settings › GitHub
1482
+ Apps › New GitHub App):
1483
+
1484
+ 1. No callback URL; untick "Request user authorization during
1485
+ installation". Webhook off.
1486
+ 2. **Enable Device Flow**: ticked. The device flow needs only the client
1487
+ id.
1488
+ 3. **Expire user authorization tokens**: off. Den never asks for
1489
+ `offline_access`, and keeps no token either way.
1490
+ 4. Account permissions: none. Den reads only `GET /user` (the id and the
1491
+ login), which needs none. Repository permissions are only for features
1492
+ that act on a repository (Den's default App has Issues for feedback).
1493
+ 5. **Where can this GitHub App be installed**: Any account. GitHub lets only
1494
+ members of the owning organization authorize a private App, so anyone
1495
+ else couldn't sign in.
1496
+ 6. Put the **Client ID** in `web.github_client_id`. Never generate or
1497
+ share a client secret or a private key for Den: it never uses one.
1498
+
1322
1499
  ## Running the web UI as a service (macOS)
1323
1500
 
1324
1501
  Den ships a launchd installer that runs the web UI at login and restarts it if
@@ -2129,6 +2306,26 @@ with both workspaces named. Any key of `agents:` other than `max_concurrent`
2129
2306
  and `persona_packs` is an agent id, and its only key is `grants`. A grant
2130
2307
  naming a workspace that doesn't exist stops `start`, naming the key.
2131
2308
 
2309
+ ## Dev metrics: local unless you opt in
2310
+
2311
+ Den records each command it runs (its name, surface, duration and exit
2312
+ code, never arguments or values) as one JSON line in
2313
+ `.dev-metrics/events.jsonl` under the distribution root. That file stays
2314
+ on the machine.
2315
+
2316
+ Nothing is sent anywhere unless the engineer opts in, per machine:
2317
+
2318
+ ```yaml
2319
+ # config/local/user.yml (gitignored)
2320
+ metrics:
2321
+ share: true
2322
+ ```
2323
+
2324
+ Only the boolean `true` counts. With it, and a Sentry DSN in
2325
+ `DEV_METRICS_SENTRY_DSN` (or a synced `dev-metrics.config`), each line is
2326
+ also sent, best effort. A DSN on its own never sends. `CI=true` and
2327
+ `DEV_METRICS_DISABLED=1` stop sending even when opted in.
2328
+
2132
2329
  ## Pinning and upgrading
2133
2330
 
2134
2331
  `package.json` pins an exact Den version (`create` writes the version that
package/docs/reference.md CHANGED
@@ -110,9 +110,14 @@ That is `examples/demo/demo-api/.den.yml`; the other demo repos are in
110
110
  workspace's checkout, with an MCP server pinned to that workspace and a
111
111
  token of its own. The web UI's Agents tab lists them and opens their
112
112
  terminals.
113
- - **Browser sign-in beyond loopback.** On localhost no route needs a sign-in.
114
- Through the edge or on a non-loopback bind, every route needs one:
115
- `<cli> web login` prints a one-time link (`web open` opens it).
113
+ - **Browser sign-in beyond loopback.** On localhost no route needs a sign-in,
114
+ nor through the edge when it shows the browser is on this machine.
115
+ Anywhere else, every route needs one: Sign in with GitHub as the Den's
116
+ owner (`<cli> web owner link` links the account once), approving the
117
+ computer with the code its page shows (`<cli> web approve <code>`, or
118
+ Settings › Access › Waiting devices), or a one-time link (`<cli> web
119
+ login`, which waits until it's used; `web open` opens it; Settings ›
120
+ Access pairs a device with a QR code). The rail's chip shows who you are.
116
121
  - **Web UI and CLI** over the same state.
117
122
 
118
123
  ## Strategies
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@monster-orange/den",
3
- "version": "0.4.4",
3
+ "version": "0.4.5",
4
4
  "description": "Den: a local multi-repo dev orchestrator, HTTPS edge and E2E harness",
5
5
  "type": "module",
6
6
  "bin": {