@brass-build/cli 0.3.0 → 0.4.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 (60) hide show
  1. package/AGENTS.md +177 -11
  2. package/CHANGELOG.md +29 -0
  3. package/README.md +76 -14
  4. package/dist/api.d.ts +45 -0
  5. package/dist/api.d.ts.map +1 -1
  6. package/dist/api.js +21 -2
  7. package/dist/api.js.map +1 -1
  8. package/dist/approval-prompt.d.ts +25 -0
  9. package/dist/approval-prompt.d.ts.map +1 -0
  10. package/dist/approval-prompt.js +44 -0
  11. package/dist/approval-prompt.js.map +1 -0
  12. package/dist/args.d.ts.map +1 -1
  13. package/dist/args.js +10 -1
  14. package/dist/args.js.map +1 -1
  15. package/dist/cli.d.ts.map +1 -1
  16. package/dist/cli.js +197 -19
  17. package/dist/cli.js.map +1 -1
  18. package/dist/commands.d.ts +28 -1
  19. package/dist/commands.d.ts.map +1 -1
  20. package/dist/commands.js +369 -29
  21. package/dist/commands.js.map +1 -1
  22. package/dist/config.d.ts +1 -0
  23. package/dist/config.d.ts.map +1 -1
  24. package/dist/config.js +8 -1
  25. package/dist/config.js.map +1 -1
  26. package/dist/login.d.ts +1 -0
  27. package/dist/login.d.ts.map +1 -1
  28. package/dist/login.js +24 -12
  29. package/dist/login.js.map +1 -1
  30. package/dist/project.d.ts +8 -0
  31. package/dist/project.d.ts.map +1 -1
  32. package/dist/project.js +75 -7
  33. package/dist/project.js.map +1 -1
  34. package/dist/sdk-pairing.d.ts +9 -0
  35. package/dist/sdk-pairing.d.ts.map +1 -0
  36. package/dist/sdk-pairing.js +42 -0
  37. package/dist/sdk-pairing.js.map +1 -0
  38. package/dist/session.d.ts +18 -1
  39. package/dist/session.d.ts.map +1 -1
  40. package/dist/session.js +95 -12
  41. package/dist/session.js.map +1 -1
  42. package/dist/store.d.ts +10 -0
  43. package/dist/store.d.ts.map +1 -1
  44. package/dist/store.js +41 -0
  45. package/dist/store.js.map +1 -1
  46. package/dist/version.d.ts +2 -2
  47. package/dist/version.js +1 -1
  48. package/package.json +2 -1
  49. package/src/api.ts +80 -2
  50. package/src/approval-prompt.ts +75 -0
  51. package/src/args.ts +10 -1
  52. package/src/cli.ts +240 -18
  53. package/src/commands.ts +479 -36
  54. package/src/config.ts +15 -2
  55. package/src/login.ts +34 -11
  56. package/src/project.ts +82 -7
  57. package/src/sdk-pairing.ts +53 -0
  58. package/src/session.ts +135 -11
  59. package/src/store.ts +77 -0
  60. package/src/version.ts +1 -1
package/src/config.ts CHANGED
@@ -79,7 +79,13 @@ export interface CredentialInputs {
79
79
  // refreshes into short-lived access tokens.
80
80
  export type ResolvedCredential =
81
81
  | { kind: 'service'; token: string; source: 'flag' | 'env' | 'file' }
82
- | { kind: 'session'; sid: string; source: 'file' };
82
+ // `authBaseUrl` is the origin the session was minted on, carried so every
83
+ // command refreshes it THERE. The profile is keyed on the api host, so an
84
+ // invocation naming `--api-url` alone lands on a stored session and would
85
+ // otherwise refresh it against whatever auth origin the flags defaulted to,
86
+ // which answers 401 and reads as the sign-in having gone stale. Absent on a
87
+ // file an older CLI wrote, which falls back to the invocation's origin.
88
+ | { kind: 'session'; sid: string; source: 'file'; authBaseUrl?: string };
83
89
 
84
90
  // Resolve which credential to use, most-explicit first: an inline `--token`,
85
91
  // then `BRASS_SERVICE_TOKEN`, then the stored login session, then a stored
@@ -94,7 +100,14 @@ export function resolveCredential(inputs: CredentialInputs): ResolvedCredential
94
100
  }
95
101
  const stored = inputs.file?.credentials[inputs.profile];
96
102
  if (stored?.session && isNonEmpty(stored.session.sid)) {
97
- return { kind: 'session', sid: stored.session.sid.trim(), source: 'file' };
103
+ return {
104
+ kind: 'session',
105
+ sid: stored.session.sid.trim(),
106
+ source: 'file',
107
+ ...(isNonEmpty(stored.session.authBaseUrl)
108
+ ? { authBaseUrl: stored.session.authBaseUrl.trim() }
109
+ : {}),
110
+ };
98
111
  }
99
112
  if (isNonEmpty(stored?.token)) {
100
113
  return { kind: 'service', token: stored.token.trim(), source: 'file' };
package/src/login.ts CHANGED
@@ -21,6 +21,7 @@ import {
21
21
  writeStoredCredential,
22
22
  type PendingLogin,
23
23
  } from './store.js';
24
+ import { renderApprovalPrompt } from './approval-prompt.js';
24
25
  import type { Logger } from './log.js';
25
26
  import type { Profile } from './config.js';
26
27
 
@@ -37,6 +38,9 @@ export interface LoginStartOptions {
37
38
  // Mint a fresh grant even when a usable one is pending (`--new`), for a
38
39
  // human who lost the relayed code.
39
40
  force?: boolean;
41
+ // The app to approve alongside the CLI (`--app`), so one human action
42
+ // covers the sign-in and the app the machine will hand a browser.
43
+ targetAppId?: string;
40
44
  // Set when a wait follows in the same command (`--start --wait`), whose own
41
45
  // terminal result is the one the caller reads. `--json` is one document per
42
46
  // run, so the started state prints to the human stream only.
@@ -71,7 +75,9 @@ export async function loginStart(options: LoginStartOptions): Promise<number> {
71
75
  if (resumed === null && existing !== null && existing.expiresAt > now()) {
72
76
  await postDeviceCancel(existing.authBaseUrl, existing.deviceCode);
73
77
  }
74
- const pending = resumed ?? (await mintPendingLogin(options.authBaseUrl, now(), options.profile, env));
78
+ const pending =
79
+ resumed ??
80
+ (await mintPendingLogin(options.authBaseUrl, now(), options.profile, env, options.targetAppId));
75
81
  promptFor(options.log, pending, {
76
82
  lead: resumed === null ? null : 'A sign-in is already waiting for approval.',
77
83
  });
@@ -131,7 +137,13 @@ export async function loginCheck(options: LoginCheckOptions): Promise<number> {
131
137
 
132
138
  for (;;) {
133
139
  if (now() >= pending.expiresAt) {
134
- pending = await mintPendingLogin(pending.authBaseUrl, now(), options.profile, env);
140
+ pending = await mintPendingLogin(
141
+ pending.authBaseUrl,
142
+ now(),
143
+ options.profile,
144
+ env,
145
+ pending.targetAppId,
146
+ );
135
147
  intervalSeconds = pending.intervalSeconds;
136
148
  renewed = true;
137
149
  promptFor(options.log, pending, {
@@ -162,6 +174,16 @@ export async function loginCheck(options: LoginCheckOptions): Promise<number> {
162
174
  options.log.result({ state: 'denied' });
163
175
  return 1;
164
176
  }
177
+ // The server has stopped accepting this grant, so the stored code is one
178
+ // the approval page refuses. Clearing it is what stops `brass status`
179
+ // relaying it, and what a caller reads as "start a new one" rather than
180
+ // "keep waiting".
181
+ if (outcome.state === 'expired') {
182
+ await writePendingLogin(options.profile, null, env);
183
+ options.log.info('That sign-in expired. Run `brass login --start` to begin a new one.');
184
+ options.log.result({ state: 'expired' });
185
+ return 1;
186
+ }
165
187
  // RFC 8628 §3.5: polling too fast. Back off for the rest of this wait.
166
188
  if (outcome.state === 'slow_down') intervalSeconds += 5;
167
189
 
@@ -190,11 +212,13 @@ async function mintPendingLogin(
190
212
  now: number,
191
213
  profile: Profile,
192
214
  env: NodeJS.ProcessEnv,
215
+ targetAppId?: string,
193
216
  ): Promise<PendingLogin> {
194
- const auth = await deviceAuthorize(authBaseUrl, now);
217
+ const auth = await deviceAuthorize(authBaseUrl, now, targetAppId);
195
218
  const pending: PendingLogin = {
196
219
  authBaseUrl,
197
220
  deviceCode: auth.deviceCode,
221
+ ...(targetAppId !== undefined ? { targetAppId } : {}),
198
222
  userCode: auth.userCode,
199
223
  verificationUri: auth.verificationUri,
200
224
  ...(auth.verificationUriComplete !== undefined
@@ -215,15 +239,14 @@ function remainingSeconds(pending: PendingLogin, now: number): number {
215
239
  return Math.max(0, Math.round((pending.expiresAt - now) / 1000));
216
240
  }
217
241
 
218
- // The one rendering of "here is what to relay, and here is what to run next",
219
- // so a resumed, renewed, and freshly minted grant all read the same to whoever
220
- // is relaying it.
221
242
  function promptFor(log: Logger, pending: PendingLogin, opts: { lead: string | null }): void {
222
243
  log.info(
223
- (opts.lead !== null ? `${opts.lead}\n` : '') +
224
- 'To approve this sign-in, go to:\n' +
225
- ` ${targetUrl(pending)}\n` +
226
- `and confirm the code: ${pending.userCode}\n` +
227
- '\nThen run `brass login --check --wait` to finish signing in.',
244
+ renderApprovalPrompt({
245
+ url: targetUrl(pending),
246
+ code: pending.userCode,
247
+ lead: opts.lead,
248
+ nextCommand: 'brass login --check --wait',
249
+ relaying: true,
250
+ }),
228
251
  );
229
252
  }
package/src/project.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { readFile, writeFile, readdir, stat, mkdir, realpath } from 'node:fs/promises';
2
- import { join, relative, sep, dirname } from 'node:path';
2
+ import { join, relative, resolve, sep, dirname } from 'node:path';
3
3
  import { createHash } from 'node:crypto';
4
4
  import { zipSync } from 'fflate';
5
5
  import type { BrassSchemaManifest } from './api.js';
@@ -84,15 +84,90 @@ export interface AppManifest {
84
84
  [key: string]: unknown;
85
85
  }
86
86
 
87
+ // The manifest filename, and the bundle-relative directory the capability
88
+ // docs put it in.
89
+ export const MANIFEST_FILENAME = 'brass-app.json';
90
+ const WELL_KNOWN_DIR = '.well-known';
91
+
92
+ // Where `publish` and `status` look for the served manifest, most
93
+ // authoritative first. The copy inside the bundle is the one the platform
94
+ // reads back after a deploy, so it outranks the repo root, which is the flat
95
+ // layout an app that keeps its manifest beside `package.json` uses.
96
+ export function publishManifestCandidates(dir: string): string[] {
97
+ return [join(dir, WELL_KNOWN_DIR, MANIFEST_FILENAME), MANIFEST_FILENAME];
98
+ }
99
+
100
+ // Where `schema pull` looks for a manifest to merge a pulled schema into.
101
+ // Source locations only: a bundler rewrites its output directory on the next
102
+ // build, so a schema written there is gone before the app is published. Each
103
+ // candidate is used only when it already holds a file, so a layout this list
104
+ // does not name falls back to the root default rather than being guessed at.
105
+ export function sourceManifestCandidates(): string[] {
106
+ return [
107
+ MANIFEST_FILENAME,
108
+ join(WELL_KNOWN_DIR, MANIFEST_FILENAME),
109
+ join('public', WELL_KNOWN_DIR, MANIFEST_FILENAME),
110
+ join('static', WELL_KNOWN_DIR, MANIFEST_FILENAME),
111
+ ];
112
+ }
113
+
114
+ // Absent means no file at this path, which is what lets a caller walk a
115
+ // candidate list. Every other failure throws: a manifest that exists and
116
+ // cannot be parsed is a mistake to report, and answering it with `null` drops
117
+ // the `name` and `client_token` it was written to carry, which reads as a
118
+ // missing manifest and creates a duplicate app on the next stateless run.
87
119
  export async function readManifest(path: string): Promise<AppManifest | null> {
120
+ let raw: string;
88
121
  try {
89
- const raw = await readFile(path, 'utf8');
90
- const parsed = JSON.parse(raw) as unknown;
91
- if (typeof parsed !== 'object' || parsed === null) return null;
92
- return parsed as AppManifest;
93
- } catch {
94
- return null;
122
+ raw = await readFile(path, 'utf8');
123
+ } catch (err) {
124
+ const code = (err as NodeJS.ErrnoException).code;
125
+ if (code === 'ENOENT' || code === 'ENOTDIR') return null;
126
+ throw new Error(`Could not read the manifest at ${path}: ${(err as Error).message}`);
127
+ }
128
+ let parsed: unknown;
129
+ try {
130
+ parsed = JSON.parse(raw) as unknown;
131
+ } catch (err) {
132
+ throw new Error(`The manifest at ${path} is not valid JSON: ${(err as Error).message}`);
133
+ }
134
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
135
+ throw new Error(`The manifest at ${path} must hold a JSON object.`);
136
+ }
137
+ return parsed as AppManifest;
138
+ }
139
+
140
+ // The first candidate that holds a manifest, paired with the path it came
141
+ // from so a caller can name it. A candidate with no file is skipped; one that
142
+ // is present and unreadable throws out of `readManifest`, so a malformed
143
+ // manifest is reported rather than passed over for the next candidate down.
144
+ export async function loadFirstManifest(
145
+ cwd: string,
146
+ candidates: readonly string[],
147
+ ): Promise<{ path: string; manifest: AppManifest } | null> {
148
+ for (const candidate of candidates) {
149
+ const manifest = await readManifest(resolve(cwd, candidate));
150
+ if (manifest !== null) return { path: candidate, manifest };
151
+ }
152
+ return null;
153
+ }
154
+
155
+ // The first candidate that holds a file, without parsing it. `schema pull`
156
+ // picks its destination this way so it merges into a malformed manifest's
157
+ // path and fails there, instead of stepping over it and writing a second
158
+ // manifest the bundle never serves.
159
+ export async function firstExistingManifestPath(
160
+ cwd: string,
161
+ candidates: readonly string[],
162
+ ): Promise<string | null> {
163
+ for (const candidate of candidates) {
164
+ try {
165
+ if ((await stat(resolve(cwd, candidate))).isFile()) return candidate;
166
+ } catch {
167
+ // Not there, or not reachable through this path. Try the next.
168
+ }
95
169
  }
170
+ return null;
96
171
  }
97
172
 
98
173
  export async function writeManifest(path: string, manifest: AppManifest): Promise<void> {
@@ -0,0 +1,53 @@
1
+ // Which `@brass-build/client` this CLI is allowed to use.
2
+ //
3
+ // The dependency is pinned to one exact version, and what the CLI imports is
4
+ // the `internal` subpath, the entry that promises nothing from one release to
5
+ // the next. So the only copy it can run against is the one it names, and a
6
+ // correct install always provides it.
7
+ //
8
+ // What does not is a CLI running somewhere its own dependency is not
9
+ // reachable, and every way in is quiet. `npx brass` from a directory with no
10
+ // `node_modules` fetches the CLI alone from the registry. `npm i -g` nests a
11
+ // registry copy under the CLI. Installing the CLI tarball by itself resolves
12
+ // the SDK from npm, which succeeds precisely because an unreleased build in a
13
+ // working tree still carries the last released version number.
14
+ //
15
+ // The check is on the VERSION, not on any one export, and that is the point:
16
+ // a check per export is re-earned every time the CLI reaches for a new one,
17
+ // and it reports whatever that call happened to need rather than the pairing
18
+ // that is actually wrong. One version comparison covers every surface the CLI
19
+ // imports today and every one it adds later.
20
+
21
+ export interface LoadedSdk {
22
+ version: string;
23
+ // Which file answered. The version cannot identify a copy on its own: a
24
+ // machine can hold several that all report the same one, which is exactly
25
+ // the case here, so the path is what ends the search.
26
+ path: string;
27
+ }
28
+
29
+ // The mismatch, as a sentence, or `null` when the pairing is sound.
30
+ //
31
+ // `loaded` is null when the SDK could not be resolved from the CLI's own
32
+ // location. That is not a mismatch: it is the shape an in-repo run takes,
33
+ // where the module specifier is mapped to the SDK's source and there is no
34
+ // installed package to read a version from. Being unable to check is not
35
+ // evidence of a fault, so it passes.
36
+ export function sdkMismatchMessage(args: {
37
+ pinned: string | undefined;
38
+ loaded: LoadedSdk | null;
39
+ }): string | null {
40
+ const { pinned, loaded } = args;
41
+ if (pinned === undefined || loaded === null) return null;
42
+ if (loaded.version === pinned) return null;
43
+ return (
44
+ `This CLI is built against @brass-build/client ${pinned}, and the copy it ` +
45
+ `loads is ${loaded.version} (${loaded.path}). It reads the SDK's internal ` +
46
+ `entry, which changes between releases, so the two have to match. Install ` +
47
+ `the CLI into the directory you run it from ("npm i @brass-build/cli") and ` +
48
+ `run it as ./node_modules/.bin/brass with absolute paths: a global install ` +
49
+ `keeps its own copy of the SDK, and npx resolves node_modules from the ` +
50
+ `current directory, so it fetches one from the registry when run anywhere ` +
51
+ `else.`
52
+ );
53
+ }
package/src/session.ts CHANGED
@@ -11,6 +11,7 @@
11
11
 
12
12
  import { spawn } from 'node:child_process';
13
13
  import { BrassApiError, networkError } from './api.js';
14
+ import { renderApprovalPrompt } from './approval-prompt.js';
14
15
  import type { AuthProvider } from './auth.js';
15
16
 
16
17
  // Every auth call carries a few hundred bytes, so a host still silent after
@@ -42,11 +43,18 @@ export interface RefreshedTokens {
42
43
  // the CLI's app id, so a copy of it authenticates as the CLI and nothing
43
44
  // else.
44
45
  sessionToken?: string;
46
+ // The acting-app assertion minted alongside the access token, naming the
47
+ // CLI as the app asking. The access token names the person and never the
48
+ // app, so this is what the platform reads to tell a CLI call from any
49
+ // other caller signed in as the same person. Absent against a platform
50
+ // build that mints none.
51
+ appAssertion?: string;
45
52
  }
46
53
 
47
54
  interface RefreshWire {
48
55
  access_token: string;
49
56
  id_token: string;
57
+ app_assertion?: string;
50
58
  expires_in: number;
51
59
  session_token?: string;
52
60
  }
@@ -119,6 +127,7 @@ export async function postRefresh(
119
127
  idToken: json.id_token,
120
128
  expiresAt: now + tokenTtlSeconds(json.expires_in) * 1000,
121
129
  ...(json.session_token ? { sessionToken: json.session_token } : {}),
130
+ ...(json.app_assertion ? { appAssertion: json.app_assertion } : {}),
122
131
  };
123
132
  }
124
133
 
@@ -151,6 +160,77 @@ export async function postSignOut(
151
160
  }
152
161
  }
153
162
 
163
+ export type BrowserSessionOutcome =
164
+ | { state: 'ready'; url: string; expiresIn: number }
165
+ // The app is not approved for the platform session behind this machine's
166
+ // sign-in. The URL is a page the human opens to grant it; the mint is a
167
+ // re-run of the same command afterwards.
168
+ | { state: 'needs_approval'; approvalUrl: string; expiresIn: number };
169
+
170
+ interface BrowserSessionWire {
171
+ url?: string;
172
+ expires_in?: number;
173
+ error?: string;
174
+ error_description?: string;
175
+ approval_url?: string;
176
+ }
177
+
178
+ // Ask the auth API for a URL that signs a browser in as the human this
179
+ // machine is signed in as. The stored device pointer is the credential, and
180
+ // the answer is `return_to` with a one-time code on it, so what reaches the
181
+ // browser expires in minutes and is spent once.
182
+ //
183
+ // The URL is a credential for as long as it lives. It belongs in the browser
184
+ // the caller is about to drive, not in a log, a PR body, or a commit.
185
+ export async function postDeviceAppSession(
186
+ authBaseUrl: string,
187
+ sid: string,
188
+ args: { appId: string; returnTo: string },
189
+ origin: string = REFRESH_ORIGIN,
190
+ ): Promise<BrowserSessionOutcome> {
191
+ const url = `${authBaseUrl}/device/app-session`;
192
+ let response: Response;
193
+ try {
194
+ response = await fetch(url, {
195
+ method: 'POST',
196
+ headers: { 'content-type': 'application/x-www-form-urlencoded', origin },
197
+ body: new URLSearchParams({ sid, app_id: args.appId, return_to: args.returnTo }),
198
+ signal: AbortSignal.timeout(AUTH_TIMEOUT_MS),
199
+ });
200
+ } catch (cause) {
201
+ throw networkError(`reaching ${authBaseUrl}`, cause);
202
+ }
203
+ const json = (await response.json().catch(() => ({}))) as BrowserSessionWire;
204
+ if (response.ok) {
205
+ if (!json.url) {
206
+ throw new BrassApiError(response.status, 'Brass returned no browser-session URL.');
207
+ }
208
+ return {
209
+ state: 'ready',
210
+ url: json.url,
211
+ expiresIn: tokenTtlSeconds(json.expires_in),
212
+ };
213
+ }
214
+ if (json.error === 'app_not_approved' && json.approval_url) {
215
+ return {
216
+ state: 'needs_approval',
217
+ approvalUrl: json.approval_url,
218
+ expiresIn: tokenTtlSeconds(json.expires_in),
219
+ };
220
+ }
221
+ // The server's own wording where it has one: every refusal here names a
222
+ // specific thing the caller can fix (an unknown app, a return_to off the
223
+ // allowlist, a sign-in that has ended), and rewording them locally would
224
+ // lose which.
225
+ throw new BrassApiError(
226
+ response.status,
227
+ json.error_description ??
228
+ (response.status === 401
229
+ ? "Your Brass sign-in is no longer valid (401). Run 'brass login' to sign in again."
230
+ : `Could not mint a browser session (${response.status}).`),
231
+ );
232
+ }
233
+
154
234
  // Abandon a sign-in this machine started and never redeemed, so the code it
155
235
  // relayed to a human stops being redeemable. Reports whether the server took
156
236
  // it, like `postSignOut`: the record naming the grant is the caller's only
@@ -216,11 +296,17 @@ interface DeviceAuthorizeWire {
216
296
  expires_in: number;
217
297
  }
218
298
 
299
+ // `targetAppId` is the app the machine will hand a browser once it is signed
300
+ // in (`brass login --app`). The grant still mints for the CLI alone; naming a
301
+ // target only widens what the human's one approval covers, so a later
302
+ // `brass browser-session` needs no second trip to a browser.
219
303
  export async function deviceAuthorize(
220
304
  authBaseUrl: string,
221
305
  now: number = Date.now(),
306
+ targetAppId?: string,
222
307
  ): Promise<DeviceAuthorization> {
223
308
  const body = new URLSearchParams({ app_id: CLI_APP_ID });
309
+ if (targetAppId !== undefined) body.set('target_app_id', targetAppId);
224
310
  let response: Response;
225
311
  try {
226
312
  response = await fetch(`${authBaseUrl}/device/authorize`, {
@@ -232,8 +318,18 @@ export async function deviceAuthorize(
232
318
  } catch (cause) {
233
319
  throw new Error(`Network error reaching ${authBaseUrl}: ${String(cause)}`);
234
320
  }
235
- if (!response.ok) throw new Error(`Device sign-in could not start (${response.status})`);
236
- const json = (await response.json()) as DeviceAuthorizeWire;
321
+ // The server's own reason where it sent one. Every 400 here names a
322
+ // specific thing the caller got wrong (an app id that is not an app, an app
323
+ // that does not support device sign-in), and a status on its own sends them
324
+ // looking at the network instead.
325
+ const json = (await response.json().catch(() => ({}))) as DeviceAuthorizeWire & {
326
+ error_description?: string;
327
+ };
328
+ if (!response.ok) {
329
+ throw new Error(
330
+ json.error_description ?? `Device sign-in could not start (${response.status})`,
331
+ );
332
+ }
237
333
  return {
238
334
  deviceCode: json.device_code,
239
335
  userCode: json.user_code,
@@ -260,7 +356,12 @@ export type DevicePollOutcome =
260
356
  | { state: 'approved'; tokens: RefreshedTokens }
261
357
  | { state: 'pending' }
262
358
  | { state: 'slow_down' }
263
- | { state: 'denied' };
359
+ | { state: 'denied' }
360
+ // The grant is spent, cancelled, unknown, or its session has ended. The
361
+ // server returns this to stop the polling, so a caller that reads it as
362
+ // "keep waiting" relays a code the approval page refuses for the rest of
363
+ // its own deadline.
364
+ | { state: 'expired' };
264
365
 
265
366
  export async function pollDeviceTokenOnce(
266
367
  authBaseUrl: string,
@@ -294,11 +395,17 @@ export async function pollDeviceTokenOnce(
294
395
  idToken: json.id_token,
295
396
  expiresAt: now + tokenTtlSeconds(json.expires_in) * 1000,
296
397
  ...(json.session_token ? { sessionToken: json.session_token } : {}),
398
+ ...(json.app_assertion ? { appAssertion: json.app_assertion } : {}),
297
399
  },
298
400
  };
299
401
  }
300
- // The user declined: the one terminal refusal.
402
+ // The user declined: the one terminal refusal the person chose.
301
403
  if (json.error === 'access_denied') return { state: 'denied' };
404
+ // The other terminal one, which the server chose: the grant was consumed,
405
+ // cancelled, is unknown, or its session has ended. No later poll of this
406
+ // grant can be approved, so waiting out the deadline only relays a code the
407
+ // approval page will not take.
408
+ if (json.error === 'expired_token') return { state: 'expired' };
302
409
  // RFC 8628 §3.5: polling too fast. The caller backs off.
303
410
  if (json.error === 'slow_down') return { state: 'slow_down' };
304
411
  // Everything else is "keep waiting": `authorization_pending`, a transient
@@ -328,12 +435,17 @@ export async function pollDeviceToken(
328
435
  const outcome = await pollDeviceTokenOnce(authBaseUrl, auth.deviceCode, now());
329
436
  if (outcome.state === 'approved') return outcome.tokens;
330
437
  if (outcome.state === 'denied') throw new Error('Sign-in was denied.');
438
+ if (outcome.state === 'expired') {
439
+ throw new Error('Device sign-in expired. Run `brass login` again.');
440
+ }
331
441
  if (outcome.state === 'slow_down') intervalSeconds += 5;
332
442
  }
333
443
  }
334
444
 
335
445
  export interface DeviceLoginOptions {
336
446
  authBaseUrl: string;
447
+ // An app to approve alongside the CLI (`brass login --app`).
448
+ targetAppId?: string;
337
449
  // Shows the verification URL + user code to the human; defaults to stderr.
338
450
  onPrompt?: (auth: DeviceAuthorization) => void;
339
451
  // Opens the approval page (user code prefilled) in a browser; defaults to
@@ -344,7 +456,7 @@ export interface DeviceLoginOptions {
344
456
  }
345
457
 
346
458
  export async function loginDevice(options: DeviceLoginOptions): Promise<LoginResult> {
347
- const auth = await deviceAuthorize(options.authBaseUrl);
459
+ const auth = await deviceAuthorize(options.authBaseUrl, Date.now(), options.targetAppId);
348
460
  (options.onPrompt ?? defaultDevicePrompt)(auth);
349
461
  (options.openBrowser ?? openBrowser)(auth.verificationUriComplete ?? auth.verificationUri);
350
462
  const tokens = await pollDeviceToken(
@@ -357,12 +469,14 @@ export async function loginDevice(options: DeviceLoginOptions): Promise<LoginRes
357
469
  }
358
470
 
359
471
  function defaultDevicePrompt(auth: DeviceAuthorization): void {
360
- const target = auth.verificationUriComplete ?? auth.verificationUri;
361
472
  process.stderr.write(
362
- '\nOpening your browser to approve this sign-in.\n' +
363
- `If it doesn't open, go to:\n ${target}\n` +
364
- `and confirm the code: ${auth.userCode}\n` +
365
- '\nWaiting for approval...\n',
473
+ `\n${renderApprovalPrompt({
474
+ url: auth.verificationUriComplete ?? auth.verificationUri,
475
+ code: auth.userCode,
476
+ // This command polls to approval on its own, so there is nothing further
477
+ // to run; what the reader needs is that the wait has started.
478
+ closing: 'Waiting for approval...',
479
+ })}\n`,
366
480
  );
367
481
  }
368
482
 
@@ -398,7 +512,17 @@ export function sessionAuth(authBaseUrl: string, sid: string): AuthProvider {
398
512
  if (cached === null || Date.now() >= cached.expiresAt - SKEW_MS) {
399
513
  cached = await postRefresh(authBaseUrl, sid);
400
514
  }
401
- return { authorization: `Bearer ${cached.accessToken}`, 'x-id-token': cached.idToken };
515
+ return {
516
+ authorization: `Bearer ${cached.accessToken}`,
517
+ 'x-id-token': cached.idToken,
518
+ // Refreshed on the same call as the token, so the two always name
519
+ // the same grant. Omitted rather than sent empty when the platform
520
+ // minted none, since a header the verifier cannot read is the same
521
+ // as no header and an empty one only obscures which it was.
522
+ ...(cached.appAssertion
523
+ ? { 'x-brass-app-assertion': cached.appAssertion }
524
+ : {}),
525
+ };
402
526
  },
403
527
  };
404
528
  }
package/src/store.ts CHANGED
@@ -47,6 +47,9 @@ export async function writeStoredCredential(
47
47
  export interface PendingLogin {
48
48
  authBaseUrl: string;
49
49
  deviceCode: string;
50
+ // The app named by `brass login --app`, carried so a grant this CLI renews
51
+ // in place asks for the same approval the relayed one did.
52
+ targetAppId?: string;
50
53
  userCode: string;
51
54
  verificationUri: string;
52
55
  verificationUriComplete?: string;
@@ -112,6 +115,80 @@ export async function writePendingLogin(
112
115
  } satisfies PendingLoginsFile);
113
116
  }
114
117
 
118
+ // An app approval `brass browser-session` asked for and no one has granted
119
+ // yet. Recorded for the same reason a pending sign-in is: the caller relayed a
120
+ // URL to a human and now has to wait, and `brass status` is where it asks what
121
+ // is outstanding. Without it, status reports the app as ready and names a next
122
+ // step that is not the blocked one.
123
+ //
124
+ // One per profile, carrying the app it is for, so status reports it only
125
+ // against the app it resolved. A second app asked about while a first is
126
+ // pending overwrites it; the link it replaces stays valid on the server, and
127
+ // re-running the command for that app reports it again.
128
+ export interface PendingApproval {
129
+ appId: string;
130
+ approvalUrl: string;
131
+ authBaseUrl: string;
132
+ expiresAt: number;
133
+ }
134
+
135
+ interface PendingApprovalsFile {
136
+ version: 1;
137
+ pending: Record<Profile, PendingApproval>;
138
+ }
139
+
140
+ export function pendingApprovalFilePath(env: NodeJS.ProcessEnv = process.env): string {
141
+ return join(dirname(credentialsFilePath(env)), 'pending-approval.json');
142
+ }
143
+
144
+ async function readPendingApprovalsFile(
145
+ env: NodeJS.ProcessEnv,
146
+ ): Promise<PendingApprovalsFile | null> {
147
+ try {
148
+ const raw = await readFile(pendingApprovalFilePath(env), 'utf8');
149
+ const parsed = JSON.parse(raw) as PendingApprovalsFile;
150
+ if (parsed.version !== 1 || typeof parsed.pending !== 'object') return null;
151
+ return parsed;
152
+ } catch {
153
+ return null;
154
+ }
155
+ }
156
+
157
+ export async function readPendingApproval(
158
+ profile: Profile,
159
+ env: NodeJS.ProcessEnv = process.env,
160
+ ): Promise<PendingApproval | null> {
161
+ const entry = (await readPendingApprovalsFile(env))?.pending[profile];
162
+ if (
163
+ entry === undefined ||
164
+ typeof entry.appId !== 'string' ||
165
+ typeof entry.approvalUrl !== 'string' ||
166
+ typeof entry.expiresAt !== 'number'
167
+ ) {
168
+ return null;
169
+ }
170
+ return entry;
171
+ }
172
+
173
+ // The approval URL names a question rather than carrying a credential (the
174
+ // page still gates on the reader's own cookie), but the file is written
175
+ // owner-only like its siblings: what it discloses is which machine is asking
176
+ // about which app.
177
+ export async function writePendingApproval(
178
+ profile: Profile,
179
+ pending: PendingApproval | null,
180
+ env: NodeJS.ProcessEnv = process.env,
181
+ ): Promise<void> {
182
+ const existing = (await readPendingApprovalsFile(env)) ?? { version: 1 as const, pending: {} };
183
+ const entries = { ...existing.pending };
184
+ if (pending === null) delete entries[profile];
185
+ else entries[profile] = pending;
186
+ await writeOwnerOnlyJson(pendingApprovalFilePath(env), {
187
+ version: 1,
188
+ pending: entries,
189
+ } satisfies PendingApprovalsFile);
190
+ }
191
+
115
192
  async function writeOwnerOnlyJson(path: string, payload: unknown): Promise<void> {
116
193
  await mkdir(dirname(path), { recursive: true });
117
194
  await writeFile(path, `${JSON.stringify(payload, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
package/src/version.ts CHANGED
@@ -3,6 +3,6 @@
3
3
  // free here, unlike the SDK's query-param channel). Lets the platform
4
4
  // measure the CLI version distribution, in particular pinned CI copies.
5
5
  // A unit test pins this to package.json's version.
6
- export const VERSION = '0.3.0';
6
+ export const VERSION = '0.4.1';
7
7
 
8
8
  export const CLI_CLIENT_ID = `cli/${VERSION}`;