@basein/runner 0.1.1 → 0.2.1

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 (40) hide show
  1. package/README.md +10 -1
  2. package/dist/auth/client.d.ts +141 -13
  3. package/dist/auth/client.js +301 -14
  4. package/dist/bin/bir-hooks.d.ts +13 -0
  5. package/dist/bin/bir-hooks.js +58 -0
  6. package/dist/bin/bir.js +99 -17
  7. package/dist/control/server.d.ts +84 -1
  8. package/dist/control/server.js +546 -51
  9. package/dist/control/transcript.d.ts +40 -0
  10. package/dist/control/transcript.js +105 -0
  11. package/dist/proxy/session.js +5 -4
  12. package/dist/record/recorder.d.ts +178 -4
  13. package/dist/record/recorder.js +6 -0
  14. package/dist/record/remote-recorder.d.ts +20 -2
  15. package/dist/record/remote-recorder.js +66 -6
  16. package/dist/replay/bundle.d.ts +10 -1
  17. package/dist/replay/bundle.js +41 -3
  18. package/dist/replay/controller.d.ts +164 -5
  19. package/dist/replay/controller.js +556 -54
  20. package/dist/replay/coverage.js +2 -2
  21. package/dist/replay/derive.d.ts +20 -1
  22. package/dist/replay/derive.js +69 -12
  23. package/dist/replay/flatten.d.ts +125 -0
  24. package/dist/replay/flatten.js +182 -0
  25. package/dist/replay/handover.d.ts +60 -0
  26. package/dist/replay/handover.js +82 -0
  27. package/dist/replay/logic.d.ts +11 -0
  28. package/dist/replay/logic.js +17 -0
  29. package/dist/replay/plan.d.ts +105 -8
  30. package/dist/replay/plan.js +309 -47
  31. package/dist/replay/source-run.d.ts +24 -10
  32. package/dist/replay/source-run.js +65 -30
  33. package/dist/replay/types.d.ts +108 -5
  34. package/dist/replay/types.js +33 -3
  35. package/docs/calculatedReplayGuide.md +1 -1
  36. package/docs/installRun.md +28 -15
  37. package/docs/loginWeb.md +607 -0
  38. package/docs/my-first-sample.md +545 -0
  39. package/docs/quickstart.md +28 -3
  40. package/package.json +1 -1
@@ -0,0 +1,607 @@
1
+ # loginWeb — make `bir login` sign in through the browser
2
+
3
+ > **Status: implemented (2026-09-08), in runner 0.2.0.** Steps 1–3 of §8 have
4
+ > landed on both sides; step 4 — deleting `--password` and the fallback — is the
5
+ > release after. §15 records what shipped and the two things that changed shape
6
+ > on the way. The rest of this document is the plan as written, kept as the
7
+ > record of why.
8
+
9
+ It covers both halves — the runner in this repo and the BaseIn service
10
+ (`BaseIntSever/BaseIn`, referred to below as *the service*) — because a web
11
+ sign-in is a contract between them.
12
+
13
+ ---
14
+
15
+ ## 0. Context
16
+
17
+ Today `bir login` asks for an email and a password in the terminal and posts
18
+ them to `POST /auth/login` ([client.ts:392-408](../src/auth/client.ts#L392-L408)).
19
+ The answer — `{accessToken, refreshToken, expiresIn, user}` — is cached at
20
+ `~/.baseinstrunner/credentials.json`, mode 0600 ([client.ts:105](../src/auth/client.ts#L105)),
21
+ and from then on every process (`bir-hooks`, each `bir-proxy`, the CLI) reuses and
22
+ silently refreshes it. That second half works well and is not what this plan
23
+ changes.
24
+
25
+ The first half has four problems, one of which is a hard wall:
26
+
27
+ 1. **Google-only accounts cannot use the CLI at all.** The console offers
28
+ *Continue with Google*, and the Google callback creates users with no
29
+ password (`users.password_hash` is nullable; `createUser` at `routes.ts:273` in the
30
+ service). For them `POST /auth/login` can only ever answer
31
+ `invalid_credentials`. There is no workaround short of setting a password
32
+ they never wanted.
33
+ 2. **A password typed into a terminal is the weakest link we have.** It appears
34
+ in installer transcripts, it is the thing `describeAuthService`
35
+ ([client.ts:235](../src/auth/client.ts#L235)) exists to protect — a whole
36
+ probe was built because passwords were being posted to the wrong host.
37
+ 3. **Every future factor has to be built twice.** MFA, SSO, passkeys: anything
38
+ added to the console's sign-in would need a terminal re-implementation.
39
+ A browser flow inherits all of it for free.
40
+ 4. **Headless machines have no good story.** [installRun.md §7](installRun.md)
41
+ still says *pre-seed `credentials.json`* — a shared long-lived secret copied
42
+ by hand.
43
+
44
+ **The change:** `bir login` starts a sign-in the *browser* completes. The
45
+ terminal shows a short code and a link; the user signs in on the console with
46
+ whatever they already use; the CLI receives the same session payload it receives
47
+ today. Nothing downstream of `credentials.json` changes.
48
+
49
+ ### Not in scope
50
+
51
+ - The token model (24 h access JWT, rotating 30-day opaque refresh —
52
+ `config.ts:254-255`, `refresh.ts:72-81`). Unchanged.
53
+ - How `bir-hooks`, `bir-proxy` and `RemoteRecorder` use the cached session
54
+ ([remote-recorder.ts:285-345](../src/record/remote-recorder.ts#L285-L345)).
55
+ Unchanged, by design — see §2 decision 2.
56
+ - Machine-to-machine credentials (runner/service tokens for fleets). A real
57
+ follow-on; §11.
58
+
59
+ ---
60
+
61
+ ## 1. The flow
62
+
63
+ OAuth 2.0 **Device Authorization Grant**, [RFC 8628](https://www.rfc-editor.org/rfc/rfc8628),
64
+ the same shape as `gh auth login` and every cloud CLI.
65
+
66
+ ```
67
+ terminal (bir login) service (api.) browser (console)
68
+ ───────────────────── ────────────── ─────────────────
69
+ POST /auth/device/code ───────────────────▶ mint device_code (secret)
70
+ {clientName, clientVersion} + user_code "BKQM-TXZR"
71
+ ◀── {deviceCode, userCode, verificationUri,
72
+ verificationUriComplete, expiresIn:600, interval:5}
73
+
74
+ prints:
75
+ Sign in to BaseIn (https://api.bi2202.com)
76
+ Open https://bi2202.com/activate
77
+ Code BKQM-TXZR
78
+ Waiting for approval… (Ctrl-C to cancel)
79
+ tries to open verificationUriComplete ─────────────────────────────────▶ /activate?code=BKQM-TXZR
80
+ ├─ not signed in → /login
81
+ │ (password, Google, …)
82
+ │ → back to /activate?code=…
83
+ ├─ GET /auth/device/lookup?code=…
84
+ │ "bir 0.2.0 on DESKTOP-PQVT0H2
85
+ │ (1.2.3.4, 40 s ago) wants to
86
+ │ sign in as you@example.com"
87
+ every 5 s: └─ [Approve] [Deny]
88
+ POST /auth/device/token {deviceCode} ────▶ pending? → 400 authorization_pending
89
+ too fast? → 400 slow_down
90
+ denied? → 400 access_denied
91
+ expired? → 400 expired_token
92
+ approved → 200 {accessToken, refreshToken,
93
+ expiresIn, user} (single use)
94
+ saveCredentials(…) → "Signed in to https://api.bi2202.com as you@example.com."
95
+ ```
96
+
97
+ Two properties make this the right grant, and both come from the RFC's design:
98
+
99
+ - **The low-entropy code is only ever typed on the already-authenticated side.**
100
+ The browser session proves *who*; the code only says *which pending request*.
101
+ The secret that actually redeems a session (`device_code`) never leaves the
102
+ machine that asked for it.
103
+ - **The machine running `bir` needs no browser, no open port, no display.** The
104
+ link can be opened on a phone. That is the headless story §0 was missing.
105
+
106
+ ---
107
+
108
+ ## 2. Decisions
109
+
110
+ 1. **Device grant, not a loopback redirect.** The alternative (RFC 8252: the CLI
111
+ opens a local `http://127.0.0.1:port` listener and the console redirects back
112
+ to it) is smoother on a desktop and useless over SSH, in a container, or on a
113
+ runner with no browser — which is exactly where `bir` runs. It also needs a
114
+ listening port (Windows firewall prompt) and a redirect-URI allowlist on the
115
+ service. §12 has the comparison.
116
+ 2. **Same payload, same file.** `POST /auth/device/token` answers with the
117
+ existing `AuthSuccess` (`types.ts:15-20`) via the existing `issueSession()`.
118
+ The CLI maps it with the existing `toSession()` and writes the existing
119
+ `credentials.json`. `bir-hooks`, the proxies and the recorder's shared-file
120
+ refresh logic are not touched, and the smoke test's pre-seeded credentials
121
+ ([smoke-replay.mjs:157-167](../test/smoke-replay.mjs#L157-L167)) keep working.
122
+ 3. **Only `bir login` starts a sign-in.** Today `authenticate()` prompts from
123
+ *inside* any process that happens to have a TTY and no session
124
+ ([client.ts:377-398](../src/auth/client.ts#L377-L398)) — including
125
+ `bir-hooks` ([bir-hooks.ts:89](../src/bin/bir-hooks.ts#L89)). With a browser
126
+ flow that would mean a control server printing a code and blocking for ten
127
+ minutes, possibly under a supervisor where nobody sees it. `authenticate()`
128
+ becomes strictly silent: cached → refreshed → `undefined` with the existing
129
+ `auth.no_session … run bir login` line. The prompt lives in one place.
130
+ 4. **The password prompt survives one release, explicitly.** `bir login
131
+ --password` keeps the current flow, printing a deprecation line. It is also
132
+ the automatic fallback when the service answers `404` on
133
+ `/auth/device/code` — a new runner against an old service must still work.
134
+ Both go in the release after.
135
+ 5. **Opening the browser is best-effort and dependency-free.** `package.json`
136
+ has zero runtime dependencies and stays that way: spawn `cmd /c start "" <url>`
137
+ on Windows, `open` on macOS, `xdg-open` elsewhere, detached, errors ignored.
138
+ Skipped under `--no-browser`, when `SSH_CONNECTION`/`SSH_TTY` is set, or when
139
+ stderr is not a TTY. The link and code are printed regardless, so the flow
140
+ never depends on the browser having opened.
141
+ 6. **The approval page is a console route behind the existing guard.**
142
+ `/activate` sits inside `RequireAuth` like every other page (`App.tsx:92-135`),
143
+ so the console's sign-in — password, Google, verification links, the
144
+ "session expired" notice — is reused rather than reproduced. This is the
145
+ single reason the whole plan is small.
146
+ 7. **Every device sign-in is a login event.** `logLogin({method: 'device'})` at
147
+ redeem time, so *Sign-in history* shows it with the runner's IP and agent.
148
+ 8. **`bir logout` revokes on the server.** Today it only deletes the file
149
+ ([bir.ts:1061-1064](../src/bin/bir.ts#L1061-L1064)). It should
150
+ `POST /auth/logout {refreshToken}` first — the endpoint exists and is
151
+ idempotent (`routes.ts:186-200`).
152
+
153
+ ---
154
+
155
+ ## 3. Service — the API
156
+
157
+ All under `/auth`, so they inherit `authRouter`'s zod-and-`asyncHandler` style
158
+ and the `/auth` mount (`index.ts:63`).
159
+
160
+ ### 3.1 `POST /auth/device/code` — public
161
+
162
+ Request: `{ clientName?: string ≤ 100, clientVersion?: string ≤ 40 }` — the
163
+ runner sends `os.hostname()` and `packageVersion()`.
164
+
165
+ Response `200`:
166
+
167
+ ```json
168
+ {
169
+ "deviceCode": "…43 chars base64url…",
170
+ "userCode": "BKQM-TXZR",
171
+ "verificationUri": "https://bi2202.com/activate",
172
+ "verificationUriComplete": "https://bi2202.com/activate?code=BKQM-TXZR",
173
+ "expiresIn": 600,
174
+ "interval": 5
175
+ }
176
+ ```
177
+
178
+ `verificationUri` is `${config.webOrigin}/activate` — `webOrigin`
179
+ (`config.ts:51`) is already the one origin CORS trusts and the one the
180
+ verification-email links use, so it is the right source of truth.
181
+
182
+ ### 3.2 `POST /auth/device/token` — public, polled
183
+
184
+ Request: `{ deviceCode }`. Responses follow RFC 8628 §3.5 exactly, so any
185
+ future client can reuse a standard library:
186
+
187
+ | State | Status | Body |
188
+ |---|---|---|
189
+ | not yet decided | 400 | `{"error":"authorization_pending"}` |
190
+ | polled faster than `interval` | 400 | `{"error":"slow_down"}` (and the row's interval grows by 5 s) |
191
+ | user pressed Deny | 400 | `{"error":"access_denied"}` |
192
+ | past `expires_at`, or already redeemed | 400 | `{"error":"expired_token"}` |
193
+ | unknown code | 400 | `{"error":"invalid_grant"}` |
194
+ | approved | 200 | `issueSession(user)` — the `AuthSuccess` shape |
195
+
196
+ Success **consumes** the row (`status='consumed'`) and writes the
197
+ `login_events` row with the *poll request's* `req` — the IP and user-agent
198
+ recorded are the runner's, which is the machine that just received a session.
199
+
200
+ ### 3.3 `GET /auth/device/lookup?code=…` — `requireAuth`
201
+
202
+ Answers `{ clientName, clientVersion, ip, requestedAt }` for a **pending**
203
+ code, else `404 {"error":"unknown_code"}`. The page uses it to say what is
204
+ asking before offering *Approve*. Never reveals user data — there is none yet.
205
+
206
+ ### 3.4 `POST /auth/device/approve` and `/deny` — `requireAuth`
207
+
208
+ Request: `{ userCode }`. Approve sets `user_id = req.auth.sub`,
209
+ `status='approved'`, `decided_at`. Deny sets `status='denied'`. Anything not
210
+ pending → `404 unknown_code`, deliberately the same answer for *expired*,
211
+ *already decided* and *never existed*: the approving side learns nothing it
212
+ could use to enumerate codes. `204` on success.
213
+
214
+ ### 3.5 Rate limits — the one thing that will break if forgotten
215
+
216
+ `authLimiter` is **100 requests per 15 minutes per IP** on everything under
217
+ `/auth` (`index.ts:51-63`). A ten-minute device flow polling every five
218
+ seconds is **120 requests**: the CLI would be cut off by a `429 rate_limited`
219
+ two thirds of the way through, with no `slow_down` to tell it why.
220
+
221
+ So `/auth/device/token` needs its own limiter mounted before `authLimiter`
222
+ (generous per IP — say 600 / 15 min — because pacing is enforced per *code*
223
+ by `slow_down`), while `/auth/device/code` and `/approve` get a **stricter**
224
+ one (say 20 / 15 min per IP). With an 8-character code over a 20-letter
225
+ alphabet (2.6 × 10¹⁰ codes) and a 10-minute lifetime, 20 guesses per quarter
226
+ hour is not a threat; see §9.
227
+
228
+ ### 3.6 Storage — `src/db.ts`
229
+
230
+ ```sql
231
+ CREATE TABLE IF NOT EXISTS device_authorizations (
232
+ id TEXT PRIMARY KEY, -- 'da_' + uuid
233
+ device_code_hash TEXT NOT NULL UNIQUE, -- sha256, like refresh_tokens.token_hash
234
+ user_code TEXT NOT NULL, -- 'BKQMTXZR' (stored without the dash)
235
+ client_name TEXT,
236
+ client_version TEXT,
237
+ ip TEXT,
238
+ user_agent TEXT,
239
+ status TEXT NOT NULL, -- pending | approved | denied | consumed
240
+ user_id TEXT, -- set on approve
241
+ poll_interval INTEGER NOT NULL, -- seconds; grows on slow_down
242
+ last_poll_at TEXT,
243
+ created_at TEXT NOT NULL,
244
+ expires_at TEXT NOT NULL,
245
+ decided_at TEXT,
246
+ consumed_at TEXT
247
+ );
248
+ CREATE INDEX IF NOT EXISTS idx_device_auth_user_code ON device_authorizations(user_code, status);
249
+ ```
250
+
251
+ `user_code` uniqueness is enforced among *pending* rows at mint time (retry on
252
+ collision). Rows older than 24 h are deleted lazily when a new code is minted —
253
+ the same "housekeeping at the moment someone is looking" pattern
254
+ `recordings/routes.ts` already uses for pruning.
255
+
256
+ ### 3.7 Code generation — `src/auth/device.ts` (new)
257
+
258
+ - `device_code`: `randomBytes(32).toString('base64url')`, stored hashed with
259
+ the `hashToken` from `refresh.ts:9`. Returned once.
260
+ - `user_code`: 8 characters from `BCDFGHJKLMNPQRSTVWXZ` — no vowels (no
261
+ accidental words), no digits, no `I/O/U/Y/A/E` (no `0/O`, `1/I` confusion) —
262
+ displayed as `XXXX-XXXX`. Comparison uppercases and strips `-` and spaces.
263
+ - Hand `logLogin` a new union member: `method: … | 'device'`
264
+ (`middleware/loginLogger.ts:10`).
265
+ - **Neither code may ever reach a log line.** `req.log` calls in these handlers
266
+ log the row `id`, never the codes.
267
+
268
+ ### 3.8 Config
269
+
270
+ | Variable | Default | Meaning |
271
+ |---|---|---|
272
+ | `DEVICE_CODE_TTL` | `600` | seconds a pending code lives |
273
+ | `DEVICE_POLL_INTERVAL` | `5` | seconds between polls the CLI is told to wait |
274
+
275
+ Both read in `config.ts` next to the token TTLs.
276
+
277
+ ### 3.9 Tests — `test/auth-device.test.ts`
278
+
279
+ Same harness as `test/smoke.test.ts` (`DATABASE_PATH=':memory:'`,
280
+ `createApp()`, `listen(0)`):
281
+
282
+ - mint → poll → `authorization_pending`; approve without a token → 401;
283
+ approve wrong code → 404; approve → poll → 200 with a session whose refresh
284
+ token then works on `/auth/refresh`; poll again → `expired_token`
285
+ (single use).
286
+ - deny → `access_denied`.
287
+ - `DEVICE_CODE_TTL=1` → `expired_token`; lookup of an expired code → 404.
288
+ - two polls inside `interval` → second is `slow_down` and the stored interval
289
+ grew.
290
+ - a `login_events` row exists with `method='device'` and the poller's IP.
291
+ - 121 polls in a row are **not** rate-limited by `authLimiter` (guards §3.5).
292
+
293
+ ---
294
+
295
+ ## 4. Console — `web/`
296
+
297
+ ### 4.1 `pages/Activate.tsx` (new) — route `/activate`
298
+
299
+ Registered in `App.tsx` **inside `RequireAuth`**, like `/profile`. Behaviour:
300
+
301
+ 1. Read `?code=`. If absent, show one input (8 letters, auto-formats
302
+ `XXXX-XXXX`, accepts lowercase, ignores dashes).
303
+ 2. `GET /auth/device/lookup?code=…` → render *who is asking*:
304
+
305
+ > **bir 0.2.0 on DESKTOP-PQVT0H2** asked to sign in as **you@example.com**
306
+ > from 1.2.3.4, 40 seconds ago.
307
+ > Only approve this if *you* just ran `bir login` on that machine.
308
+ > [Approve] [Deny]
309
+
310
+ 3. Approve → `POST /auth/device/approve` → "Done. You can go back to your
311
+ terminal." Deny → the mirror sentence.
312
+ 4. `404 unknown_code` → "That code isn't valid or has expired. Run `bir login`
313
+ again to get a new one." Nothing else is distinguishable, on purpose (§3.4).
314
+
315
+ Wire `deviceLookup`, `deviceApprove`, `deviceDeny` into `api.ts` using the
316
+ `api()`/`apiOk()` helpers from `auth.tsx`, so refresh-on-401 comes for free.
317
+
318
+ ### 4.2 The Google return-path bug this exposes
319
+
320
+ The guard sends an unsigned-in visitor to `/login` with `from` set to
321
+ `/activate?code=…`, and `SignInCard` navigates back to it after a password
322
+ sign-in (`Login.tsx:401`, `dest = from.pathname + from.search`). **The Google
323
+ path does not:** `GoogleSuccessPage` ends with `navigate('/')` unconditionally
324
+ (`GoogleSuccess.tsx:54`), because Google's redirect round-trips through the
325
+ service and loses React Router's state.
326
+
327
+ This matters here more than anywhere: a Google-only user is the primary
328
+ beneficiary of the whole change, and today they would land on the dashboard
329
+ with the code gone. Fix: before `href={googleLoginUrl()}` (`Login.tsx:450`)
330
+ stash `dest` in `sessionStorage['rrepeat.returnTo']`; `GoogleSuccessPage`
331
+ reads and clears it and navigates there — after checking it is a same-origin
332
+ path (starts with a single `/`), so the stash can never become an open
333
+ redirect.
334
+
335
+ ### 4.3 Sign-in history
336
+
337
+ `LoginHistory.tsx:10` types `method` as a four-member union and
338
+ `METHOD_LABEL` (`:31`) maps each to a label. Add `'device'` →
339
+ *"Runner sign-in (device code)"*. Without it the row renders the raw word.
340
+
341
+ ### 4.4 What does not change
342
+
343
+ - **Caddy / CSP.** `try_files {path} /index.html` already serves any console
344
+ route, and `connect-src` already allows `https://api.bi2202.com`.
345
+ - **CORS.** `origin: config.webOrigin` (`index.ts:43`) is exactly the origin
346
+ `/activate` calls from.
347
+ - **Navigation.** No sidebar item; the page is reached by the link or by
348
+ typing the code. A one-line mention on `/guide/first-recording` where it
349
+ says `bir login` is enough.
350
+
351
+ ---
352
+
353
+ ## 5. Runner — this repo
354
+
355
+ ### 5.1 `src/auth/client.ts`
356
+
357
+ | Change | Detail |
358
+ |---|---|
359
+ | **Add** `startDeviceCode(authUrl, client, fetchImpl)` | `POST /auth/device/code`; returns the §3.1 shape. A `404` throws a typed `DeviceFlowUnsupported` so the caller can fall back (decision 4). |
360
+ | **Add** `pollDeviceToken(authUrl, deviceCode, {interval, expiresIn, fetchImpl, sleep, signal})` | The RFC loop. Honours `slow_down` by adding 5 s; stops on `access_denied`, `expired_token`, the deadline, or the signal. Returns an `AuthResponse`. `fetchImpl` and `sleep` are parameters so tests run in milliseconds. |
361
+ | **Add** `deviceLogin(authUrl, {openBrowser, log})` | Orchestrates: probe with `describeAuthService` (keep — the wrong-host mistake is still possible), mint, print the block from §1, open the browser (§2 decision 5), poll, `saveCredentials(toSession(res))`. |
362
+ | **Add** `openBrowser(url)` | `child_process.spawn`, detached, `stdio: 'ignore'`, never throws. No `shell: true`: the URL is one argument. On Windows the form is `cmd /c start "" <url>` — the empty string is `start`'s title argument and is required. |
363
+ | **Change** `authenticate()` | Delete the prompt branch at `:384-408`. After the refresh attempt it always returns `undefined` with `auth.no_session`. The TTY check at `:377` goes with it. |
364
+ | **Move** `prompt`, `promptPassword`, the `/auth/login` post | Into `legacyPasswordLogin(authUrl)`, called only by `bir login --password` and the 404 fallback. Delete with them in 0.3. |
365
+ | **Add** `logout(authUrl)` | Best-effort `POST /auth/logout {refreshToken}` from the cached session, then `clearCredentials()`. |
366
+
367
+ `loadCredentials`, `saveCredentials`, `clearCredentials`, `toSession`,
368
+ `normalizeAuthUrl`, `describeAuthService`, `AUTH_URL_HINT` are unchanged and
369
+ remain the whole contract with the rest of the runner.
370
+
371
+ ### 5.2 `src/bin/bir.ts`
372
+
373
+ - `login` (`:1041-1059`): after the existing URL settling and service probe,
374
+ call `deviceLogin`. On `DeviceFlowUnsupported`, print one line —
375
+ *"this service does not offer browser sign-in yet; falling back to
376
+ email/password"* — and call `legacyPasswordLogin`.
377
+ - Flags in `parseArgs` (`:127-197`): `--password` (force legacy, prints a
378
+ deprecation line), `--no-browser`.
379
+ - `logout` (`:1061-1064`): call the new `logout()`; keep the same output line.
380
+ - Usage text (`:71-100`): `login sign in to the BaseIn service (opens your
381
+ browser; --no-browser to only print the link)`.
382
+
383
+ Output discipline stays as it is: the interactive lines go to **stderr**
384
+ (`process.stderr.write`, as the prompt does today), the final
385
+ `Signed in to … as ….` to stdout via `out()`.
386
+
387
+ ### 5.3 `src/bin/bir-hooks.ts`
388
+
389
+ No code change at `:89` — but the *behaviour* changes: a `bir-hooks` started
390
+ with a TTY and no session no longer prompts; it logs
391
+ `recorder.disabled why="no BaseIn session — run bir login"` (already there at
392
+ `:91`) and runs as `NullRecorder`. `bir doctor` already turns that into
393
+ *"set BIR_AUTH_URL and run `bir login` in the terminal where you start
394
+ `bir-hooks`"* ([bir.ts:616-617](../src/bin/bir.ts#L616-L617)).
395
+
396
+ ### 5.4 Installer scripts
397
+
398
+ `scripts/install-runner.sh:144-145` and `install-runner.ps1:153-154` call
399
+ `bir login` in the foreground and need **no code change** — the device flow
400
+ prints its link and code on whatever terminal the script has, and works over
401
+ SSH because the link can be opened anywhere. What changes is the prose around
402
+ them (the *"the password does not appear as you type"* notes) — §7.
403
+
404
+ ### 5.5 Version
405
+
406
+ `0.2.0`: `authenticate()` stops prompting, which is a behaviour change other
407
+ processes can observe.
408
+
409
+ ---
410
+
411
+ ## 6. Tests — runner
412
+
413
+ `test/config.test.ts` already unit-tests `describeAuthService` and
414
+ `normalizeAuthUrl` with a fake `fetch` (`:27`, `:125-163`). Add
415
+ `test/auth.test.ts` in the same style:
416
+
417
+ - `pollDeviceToken`: `pending → pending → ok` resolves to the session and
418
+ called `sleep(5000)` twice; `slow_down` bumps the next sleep to 10 s;
419
+ `access_denied` and `expired_token` reject with typed errors; the deadline
420
+ rejects even while the server keeps saying `pending`.
421
+ - `startDeviceCode`: a `404` throws `DeviceFlowUnsupported`; an HTML answer
422
+ produces the existing *"this URL is a website"* wording from `postJson`.
423
+ - `authenticate()` with no cache and a TTY **never** writes to stdin/stdout and
424
+ returns `undefined` (the regression test for decision 3).
425
+ - `saveCredentials` output: shape, and mode `0600` on POSIX.
426
+
427
+ `test/cli.test.ts` runs the built CLI as a process (`runBir`, `:36`). Add:
428
+
429
+ - `bir login --no-browser` against a local `createServer` stand-in (the pattern
430
+ in [recorder.test.ts:55-100](../test/recorder.test.ts#L55-L100)) that answers
431
+ `/health` with JSON, mints a code, says `pending` once, then `200`. Assert the
432
+ code appears on stderr, `Signed in to … as …` on stdout, and
433
+ `credentials.json` under the test's `BIR_HOME`.
434
+ - `bir login` against a stand-in whose `/auth/device/code` is `404` and whose
435
+ `/auth/login` succeeds — with stdin fed `email\npassword\n` — exercises the
436
+ fallback. Delete this test in 0.3 with the fallback.
437
+ - `bir logout` hits `/auth/logout` with the cached refresh token and removes
438
+ the file.
439
+
440
+ `test/smoke-replay.mjs` is unchanged: it pre-seeds credentials and never logs in.
441
+
442
+ ---
443
+
444
+ ## 7. Documentation to change
445
+
446
+ Every place that shows or describes the prompt:
447
+
448
+ | File | What |
449
+ |---|---|
450
+ | [README.md](../README.md) `:54` | the `bir login # once` line gets the one-sentence description |
451
+ | [quickstart.md](quickstart.md) `:63-70` | the `Email:` / `Password:` transcript becomes the link-and-code block; delete *"The password does not appear as you type"* |
452
+ | [installInst.md](../installInst.md) Step 5 | same transcript change; the table row about `HTTP 405` stays |
453
+ | [installInstMAC.md](../installInstMAC.md) | same |
454
+ | [installRun.md](installRun.md) §5.1, §7 | §7 *Headless authentication* is rewritten: the device flow is the headless answer for **people** (approve from a phone); pre-seeded credentials remain for **service accounts**; runner tokens stay the follow-on |
455
+ | [calculatedReplayGuide.md](calculatedReplayGuide.md) §1 | the prerequisite table row |
456
+ | [my-first-sample.md](my-first-sample.md) §2 row 6, §4 | prerequisite row and Part B's `bir login # once` |
457
+ | `bir --help` | §5.2 |
458
+
459
+ ---
460
+
461
+ ## 8. Compatibility and rollout
462
+
463
+ Ship in this order; each step is safe on its own.
464
+
465
+ 1. **Service**: table, endpoints, limiter split, `login_events` method. Purely
466
+ additive — `/auth/login` is untouched, so every deployed runner keeps
467
+ working.
468
+ 2. **Console**: `/activate`, the Google return-path fix, the history label.
469
+ 3. **Runner 0.2.0**: device flow by default, `--password` and the 404 fallback
470
+ kept. A new runner against an old service degrades to today's behaviour with
471
+ one explanatory line.
472
+ 4. **Runner 0.3.0**: remove `--password`, the fallback, and `legacyPasswordLogin`.
473
+
474
+ Old runner + new service: unchanged. New runner + old service: fallback (step 3).
475
+
476
+ ---
477
+
478
+ ## 9. Security
479
+
480
+ The device grant's own threat model is RFC 8628 §5; this is how each item lands
481
+ here.
482
+
483
+ | Concern | Answer |
484
+ |---|---|
485
+ | Guessing a `user_code` | 20⁸ ≈ 2.6 × 10¹⁰ codes, 10-minute life, approve limited to ~20 attempts / 15 min / IP (§3.5), constant-time compare. Not a threat. |
486
+ | Stealing a `device_code` | It never leaves the runner, travels only in a POST body over TLS, is stored hashed, and is single-use. It is not in any URL. |
487
+ | Phishing — *"enter this code on your account"* | The one real risk of every device flow. Mitigations: the page states *what* is asking (client, version, IP, age) and *who* it will sign in as, with the sentence *only approve if you just ran `bir login` yourself*; codes expire in 10 minutes; a wrong approval is one session, visible and revocable in Sign-in history. |
488
+ | Codes in logs | Handlers log the row `id` only; `req.log` never sees either code. |
489
+ | Tokens in URLs | None — an improvement on the Google handoff, which puts them in a fragment (`routes.ts:288-293`). |
490
+ | CSRF | Not applicable: bearer JSON API, no cookies on these routes. |
491
+ | Open redirect via the Google stash | The stored return path must start with a single `/` (§4.2). |
492
+ | Shell injection via `openBrowser` | `spawn` without a shell; the URL is one argument built by the service from `webOrigin` plus a code from a fixed alphabet. |
493
+ | A runner under a supervisor | Cannot start a sign-in any more (decision 3), so no unattended process ever holds an open device code. |
494
+
495
+ ---
496
+
497
+ ## 10. Edge cases
498
+
499
+ - **Ctrl-C while waiting** — exit 130; the code stays pending until it expires;
500
+ nothing was written.
501
+ - **User approves after the CLI gave up** — the next `bir login` mints a fresh
502
+ code; the stale approval is consumed by nobody and pruned.
503
+ - **Two `bir login` on one machine** — independent codes; last writer wins
504
+ `credentials.json`. Same as today.
505
+ - **Approve twice** — the second is a `404`; the page says so.
506
+ - **Signed into the console as the wrong account** — the page names the account
507
+ it will sign the runner in as, before the button.
508
+ - **`BIR_HOME`, `BIR_AUTH_DISABLE=1`** — unchanged.
509
+ - **`describeAuthService` still runs first** — the wrong-host mistake now costs
510
+ nothing (no password is ever posted), but the diagnosis is still the useful
511
+ one.
512
+
513
+ ---
514
+
515
+ ## 11. Follow-ons this plan deliberately leaves out
516
+
517
+ - **Runner tokens** for fleets (installRun.md §7 item 3): long-lived, scoped,
518
+ revocable credentials created in the console and passed as `BIR_TOKEN` /
519
+ `bir login --token`. This is the right answer for service accounts and it is
520
+ a separate design: token scopes, a *Tokens* section on the account page, and
521
+ a revocation story.
522
+ - **Naming CLI sessions.** A `client_name` column on `refresh_tokens` would let
523
+ a future *Sessions* page show "bir on DESKTOP-PQVT0H2 — last used 2 h ago
524
+ — [sign out]". Cheap to add now while the device flow already knows the name;
525
+ worth doing if that page is coming.
526
+ - **A QR code in the terminal** for the phone case. Zero-dependency ASCII QR
527
+ is ~150 lines; only worth it once the flow is in use.
528
+
529
+ ---
530
+
531
+ ## 12. Alternatives considered
532
+
533
+ | Option | Why not |
534
+ |---|---|
535
+ | **Loopback redirect** (RFC 8252) | Fails exactly where `bir` runs: SSH, containers, runners with no browser. Needs a local listening port and a service-side redirect-URI allowlist. Could be added later as an *optimisation* of the same flow (auto-complete instead of poll) without changing the API. |
536
+ | **Paste a token from the console** | Works, but the user types a long secret into a terminal (history, transcripts), and "create a token" is a worse first-run than "approve". Kept as the shape of the fleet follow-on (§11), where it belongs. |
537
+ | **Keep the password, add "set a password" for Google users** | Forces a password on people who chose not to have one, and leaves problems 2–4 of §0 in place. |
538
+ | **Reverse device flow** — the console shows a code the user types into the terminal | Puts the low-entropy code on the *unauthenticated* side, which is the property the RFC's direction exists to avoid, and the terminal still has to poll or paste. |
539
+
540
+ ---
541
+
542
+ ## 13. Open questions
543
+
544
+ 1. Should sessions issued to a CLI have a different refresh lifetime than the
545
+ console's 30 days? (A runner is long-lived by nature; the console default is
546
+ probably right.)
547
+ 2. Is `DEVICE_POLL_INTERVAL=5` right for the hosted service, or should it start
548
+ at 3 with the first `slow_down` moving it to 8?
549
+ 3. Add the `client_name` column to `refresh_tokens` now (§11) or leave the
550
+ schema alone until a *Sessions* page is planned?
551
+
552
+ ---
553
+
554
+ ## 14. Size
555
+
556
+ | Piece | Effort |
557
+ |---|---|
558
+ | Service: table, `device.ts`, four routes, limiter split, tests | ~1 day |
559
+ | Console: `/activate`, Google return path, history label | ~1 day |
560
+ | Runner: client functions, CLI flags, logout, tests | ~1 day |
561
+ | Docs, installer prose, manual acceptance run | ~½ day |
562
+
563
+ **Acceptance, by hand:** `bir logout` → `bir login` → approve in the console
564
+ signed in **with a Google account that has no password** → `bir doctor` says
565
+ `Recording: yes` → record one run → Sign-in history shows *Runner sign-in
566
+ (device code)* with the runner's IP → `bir logout` → the old refresh token now
567
+ gets `401` from `/auth/refresh`.
568
+
569
+ ---
570
+
571
+ ## 15. What shipped
572
+
573
+ Implemented 2026-09-08. Runner **0.2.0**; service and console changes are
574
+ additive and went first, as §8 requires.
575
+
576
+ | Where | Files |
577
+ |---|---|
578
+ | Service | `src/auth/device.ts` (new) · `src/auth/routes.ts` (4 routes) · `src/db.ts` (`device_authorizations`) · `src/config.ts` (4 knobs) · `src/index.ts` (limiter split) · `src/middleware/loginLogger.ts` · `test/auth-device.test.ts` (new, 16 tests) |
579
+ | Console | `web/src/pages/Activate.tsx` (new) · `App.tsx` (route) · `api.ts` (return-path stash, device shapes) · `Login.tsx` + `GoogleSuccess.tsx` (the §4.2 fix) · `LoginHistory.tsx` · `FirstRecordingGuide.tsx` |
580
+ | Runner | `src/auth/client.ts` (device flow; `authenticate` made silent) · `src/bin/bir.ts` (login/logout, `--password`, `--no-browser`) · `src/proxy/session.ts` · `test/auth.test.ts` (new, 13 tests) · `test/cli.test.ts` (+5) |
581
+ | Docs | README · quickstart · installInst · installInstMAC · installRun §7 · calculatedReplayGuide · my-first-sample |
582
+
583
+ Green: service 202 tests, runner 253 (252 + 1 platform skip), console build,
584
+ `test:smoke`, and the §14 acceptance run against a **password-less account**.
585
+
586
+ ### Two things that changed shape
587
+
588
+ **`nonInteractive` is gone rather than defaulted.** §2 decision 3 made
589
+ `authenticate()` silent, which left `AuthenticateOptions.nonInteractive` a flag
590
+ that could not change anything. A no-op option invites someone to toggle it and
591
+ expect a different outcome, so it was removed and `proxy/session.ts` updated —
592
+ the guarantee is now structural rather than requested.
593
+
594
+ **The password fallback cannot be driven by a pipe, so its test asserts the
595
+ decision.** `legacyPasswordLogin` refuses a non-TTY stdin, deliberately: a
596
+ password must not be readable from somewhere a script could have put it. The
597
+ CLI test therefore proves the 404 → fallback path is *taken* (and that the
598
+ browser flow was tried first), not that a piped password is accepted. Driving
599
+ the prompt itself would need a pseudo-terminal, for code being deleted next
600
+ release.
601
+
602
+ ### One bug this found in existing code
603
+
604
+ The Google sign-in return path (§4.2) was already broken for every deep link,
605
+ not only `/activate` — `GoogleSuccessPage` navigated to `/` unconditionally, so
606
+ any guard-interrupted destination was lost for Google users. Fixed here because
607
+ a Google-only account is the primary beneficiary of the whole change.