@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.
- package/AGENTS.md +177 -11
- package/CHANGELOG.md +29 -0
- package/README.md +76 -14
- package/dist/api.d.ts +45 -0
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +21 -2
- package/dist/api.js.map +1 -1
- package/dist/approval-prompt.d.ts +25 -0
- package/dist/approval-prompt.d.ts.map +1 -0
- package/dist/approval-prompt.js +44 -0
- package/dist/approval-prompt.js.map +1 -0
- package/dist/args.d.ts.map +1 -1
- package/dist/args.js +10 -1
- package/dist/args.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +197 -19
- package/dist/cli.js.map +1 -1
- package/dist/commands.d.ts +28 -1
- package/dist/commands.d.ts.map +1 -1
- package/dist/commands.js +369 -29
- package/dist/commands.js.map +1 -1
- package/dist/config.d.ts +1 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +8 -1
- package/dist/config.js.map +1 -1
- package/dist/login.d.ts +1 -0
- package/dist/login.d.ts.map +1 -1
- package/dist/login.js +24 -12
- package/dist/login.js.map +1 -1
- package/dist/project.d.ts +8 -0
- package/dist/project.d.ts.map +1 -1
- package/dist/project.js +75 -7
- package/dist/project.js.map +1 -1
- package/dist/sdk-pairing.d.ts +9 -0
- package/dist/sdk-pairing.d.ts.map +1 -0
- package/dist/sdk-pairing.js +42 -0
- package/dist/sdk-pairing.js.map +1 -0
- package/dist/session.d.ts +18 -1
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +95 -12
- package/dist/session.js.map +1 -1
- package/dist/store.d.ts +10 -0
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +41 -0
- package/dist/store.js.map +1 -1
- package/dist/version.d.ts +2 -2
- package/dist/version.js +1 -1
- package/package.json +2 -1
- package/src/api.ts +80 -2
- package/src/approval-prompt.ts +75 -0
- package/src/args.ts +10 -1
- package/src/cli.ts +240 -18
- package/src/commands.ts +479 -36
- package/src/config.ts +15 -2
- package/src/login.ts +34 -11
- package/src/project.ts +82 -7
- package/src/sdk-pairing.ts +53 -0
- package/src/session.ts +135 -11
- package/src/store.ts +77 -0
- 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
|
-
|
|
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 {
|
|
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 =
|
|
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(
|
|
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
|
-
(
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
'
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
236
|
-
|
|
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
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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 {
|
|
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.
|
|
6
|
+
export const VERSION = '0.4.1';
|
|
7
7
|
|
|
8
8
|
export const CLI_CLIENT_ID = `cli/${VERSION}`;
|