@brass-build/cli 0.1.0 → 0.3.0
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 +35 -32
- package/CHANGELOG.md +32 -0
- package/README.md +26 -21
- package/dist/api.d.ts +3 -0
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +28 -8
- package/dist/api.js.map +1 -1
- package/dist/args.d.ts +4 -0
- package/dist/args.d.ts.map +1 -1
- package/dist/args.js +92 -6
- package/dist/args.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +166 -61
- package/dist/cli.js.map +1 -1
- package/dist/commands.d.ts.map +1 -1
- package/dist/commands.js +49 -19
- 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.map +1 -1
- package/dist/login.d.ts +1 -0
- package/dist/login.d.ts.map +1 -1
- package/dist/login.js +26 -12
- package/dist/login.js.map +1 -1
- package/dist/project.d.ts.map +1 -1
- package/dist/project.js +34 -5
- package/dist/project.js.map +1 -1
- package/dist/session.d.ts +2 -0
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +91 -20
- package/dist/session.js.map +1 -1
- package/dist/version.d.ts +2 -2
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/src/api.ts +38 -8
- package/src/args.ts +102 -6
- package/src/cli.ts +198 -60
- package/src/commands.ts +66 -19
- package/src/config.ts +5 -1
- package/src/login.ts +30 -12
- package/src/project.ts +39 -5
- package/src/session.ts +106 -21
- package/src/version.ts +1 -1
package/src/commands.ts
CHANGED
|
@@ -109,7 +109,7 @@ export async function publish(ctx: CommandContext, opts: PublishOptions): Promis
|
|
|
109
109
|
if (opts.visibility !== undefined) {
|
|
110
110
|
await ensureVisibility(ctx, appId, opts.visibility, resolved.detail?.visibility);
|
|
111
111
|
}
|
|
112
|
-
const hosting = await ensureHosting(ctx, appId, opts.slug);
|
|
112
|
+
const hosting = await ensureHosting(ctx, appId, opts.slug, opts.requireAccess);
|
|
113
113
|
if (opts.requireAccess !== undefined) {
|
|
114
114
|
await ensureGate(ctx, appId, opts.requireAccess, hosting);
|
|
115
115
|
}
|
|
@@ -126,7 +126,12 @@ export async function publish(ctx: CommandContext, opts: PublishOptions): Promis
|
|
|
126
126
|
// of a couple of reads instead of an upload and a poll loop.
|
|
127
127
|
const unchanged = await activeVersionMatches(ctx, appId, hash);
|
|
128
128
|
if (unchanged !== null) {
|
|
129
|
-
const status = await
|
|
129
|
+
const status = await awaitGateSettled(
|
|
130
|
+
ctx,
|
|
131
|
+
appId,
|
|
132
|
+
await ctx.api.get<HostingStatus>(`/apps/${encodeURIComponent(appId)}/hosting`),
|
|
133
|
+
opts.sleep ?? realSleep,
|
|
134
|
+
);
|
|
130
135
|
ctx.log.success(`Already up to date${status.url ? `: ${status.url}` : ''}`);
|
|
131
136
|
// Refreshed here too, so publishing twice says the same thing both times.
|
|
132
137
|
// A publisher acting on a warning re-runs publish to check, and a signal
|
|
@@ -164,7 +169,12 @@ export async function publish(ctx: CommandContext, opts: PublishOptions): Promis
|
|
|
164
169
|
// schema missing `family`) right here instead of silently later.
|
|
165
170
|
const warnings = await refreshCapabilities(ctx, appId);
|
|
166
171
|
|
|
167
|
-
const status = await
|
|
172
|
+
const status = await awaitGateSettled(
|
|
173
|
+
ctx,
|
|
174
|
+
appId,
|
|
175
|
+
await ctx.api.get<HostingStatus>(`/apps/${encodeURIComponent(appId)}/hosting`),
|
|
176
|
+
opts.sleep ?? realSleep,
|
|
177
|
+
);
|
|
168
178
|
if (status.url) ctx.log.success(`Deployed: ${status.url}`);
|
|
169
179
|
return { app_id: appId, version_id: version.version_id, url: status.url, warnings };
|
|
170
180
|
}
|
|
@@ -223,14 +233,13 @@ async function ensureVisibility(
|
|
|
223
233
|
}
|
|
224
234
|
|
|
225
235
|
// Converge the hosted load gate to `want` (`true` = gated to the audience,
|
|
226
|
-
// `false` = world-loadable). Always PATCH, even when the
|
|
227
|
-
// reads `want`: the PATCH is what
|
|
228
|
-
//
|
|
229
|
-
//
|
|
230
|
-
//
|
|
231
|
-
//
|
|
232
|
-
//
|
|
233
|
-
// the log line. (`require_access` absent === off.)
|
|
236
|
+
// `false` = world-loadable). Always PATCH, even when the reported state
|
|
237
|
+
// already reads `want`: the PATCH is what asks the platform to re-settle a
|
|
238
|
+
// gate whose reported and served states have drifted apart, which reading the
|
|
239
|
+
// reported one alone cannot detect. Skipping it on a match would leave such an
|
|
240
|
+
// app stuck, since every later publish would skip it too. `status` is the
|
|
241
|
+
// state `ensureHosting` just observed, used only to word the log line.
|
|
242
|
+
// (`require_access` absent === off.)
|
|
234
243
|
async function ensureGate(
|
|
235
244
|
ctx: CommandContext,
|
|
236
245
|
appId: string,
|
|
@@ -250,15 +259,22 @@ async function ensureGate(
|
|
|
250
259
|
}
|
|
251
260
|
}
|
|
252
261
|
|
|
262
|
+
// Enable hosting when the app is not hosted yet, at the load-gate state the
|
|
263
|
+
// publish wants. Stating the gate here rather than leaving it to the PATCH
|
|
264
|
+
// below settles it in one step: a new slot is gated by default, so a publish
|
|
265
|
+
// that wants a world-loadable one would otherwise turn the gate on and
|
|
266
|
+
// straight back off, doing twice the work to reach one state.
|
|
253
267
|
async function ensureHosting(
|
|
254
268
|
ctx: CommandContext,
|
|
255
269
|
appId: string,
|
|
256
270
|
slug?: string,
|
|
271
|
+
requireAccess?: boolean,
|
|
257
272
|
): Promise<HostingStatus> {
|
|
258
273
|
const status = await ctx.api.get<HostingStatus>(`/apps/${encodeURIComponent(appId)}/hosting`);
|
|
259
274
|
if (status.enabled) return status;
|
|
260
|
-
const body: { slug?: string } = {};
|
|
275
|
+
const body: { slug?: string; require_access?: boolean } = {};
|
|
261
276
|
if (slug !== undefined) body.slug = slug;
|
|
277
|
+
if (requireAccess !== undefined) body.require_access = requireAccess;
|
|
262
278
|
const enabled = await ctx.api.post<HostingStatus>(
|
|
263
279
|
`/apps/${encodeURIComponent(appId)}/hosting`,
|
|
264
280
|
body,
|
|
@@ -267,11 +283,42 @@ async function ensureHosting(
|
|
|
267
283
|
return enabled;
|
|
268
284
|
}
|
|
269
285
|
|
|
270
|
-
//
|
|
271
|
-
//
|
|
272
|
-
//
|
|
273
|
-
//
|
|
274
|
-
//
|
|
286
|
+
// A published bundle is not reachable until the platform has registered the
|
|
287
|
+
// slot to serve it, and that registration can lag or fail on its own. Reading
|
|
288
|
+
// the status is what asks the platform to settle it, so a slot that is not
|
|
289
|
+
// ready yet is re-read a few times before publish gives up. Publishing
|
|
290
|
+
// reports success only once the slot will actually serve, so an unreachable
|
|
291
|
+
// one is a failed publish rather than a URL that answers 404.
|
|
292
|
+
const GATE_SETTLE_ATTEMPTS = 3;
|
|
293
|
+
const GATE_SETTLE_GAP_MS = 2000;
|
|
294
|
+
|
|
295
|
+
async function awaitGateSettled(
|
|
296
|
+
ctx: CommandContext,
|
|
297
|
+
appId: string,
|
|
298
|
+
status: HostingStatus,
|
|
299
|
+
sleep: (ms: number) => Promise<void>,
|
|
300
|
+
): Promise<HostingStatus> {
|
|
301
|
+
// An api that does not report the field tells us nothing to act on.
|
|
302
|
+
if (status.gate_settled !== false) return status;
|
|
303
|
+
for (let attempt = 1; attempt < GATE_SETTLE_ATTEMPTS; attempt++) {
|
|
304
|
+
await sleep(GATE_SETTLE_GAP_MS);
|
|
305
|
+
const latest = await ctx.api.get<HostingStatus>(`/apps/${encodeURIComponent(appId)}/hosting`);
|
|
306
|
+
if (latest.gate_settled !== false) return latest;
|
|
307
|
+
}
|
|
308
|
+
// Says what is true of the SLOT, because both publish paths end here: the
|
|
309
|
+
// one that uploaded a new bundle and the one that found the app already
|
|
310
|
+
// serving this exact bundle and skipped the upload. A message naming an
|
|
311
|
+
// upload sends the second caller looking for one that never happened.
|
|
312
|
+
throw new Error(
|
|
313
|
+
'The hosted slot never registered, so it will not serve. ' +
|
|
314
|
+
'Run publish again to retry.',
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// The currently-served version when it is `ready` and already carries `hash`,
|
|
319
|
+
// else null. A null (no active version, a non-ready active version, or a hash
|
|
320
|
+
// mismatch) means `publish` must upload. A first deploy has no active version,
|
|
321
|
+
// so it always uploads.
|
|
275
322
|
async function activeVersionMatches(
|
|
276
323
|
ctx: CommandContext,
|
|
277
324
|
appId: string,
|
|
@@ -287,8 +334,8 @@ async function activeVersionMatches(
|
|
|
287
334
|
|
|
288
335
|
// Poll until the version reaches a KNOWN terminal state (`ready` / `failed`),
|
|
289
336
|
// then return it; time out otherwise. Deliberately loops while the status is
|
|
290
|
-
// anything other than a known terminal
|
|
291
|
-
// CLI does not recognize
|
|
337
|
+
// anything other than a known terminal (`pending`, or any status this pinned
|
|
338
|
+
// CLI does not recognize) rather than returning on `!== 'pending'`. That way
|
|
292
339
|
// a future server status (a finer-grained non-terminal like `unpacking`, or a
|
|
293
340
|
// new terminal-failure like `rejected`) is not mistaken for "done": an
|
|
294
341
|
// unrecognized non-terminal keeps polling, and an unrecognized terminal
|
package/src/config.ts
CHANGED
|
@@ -53,7 +53,11 @@ export type Profile = string;
|
|
|
53
53
|
// `BRASS_SERVICE_TOKEN`.
|
|
54
54
|
export interface StoredCredential {
|
|
55
55
|
token?: string;
|
|
56
|
-
session
|
|
56
|
+
// `authBaseUrl` is the auth origin the session was minted on, so a later
|
|
57
|
+
// `brass logout` revokes it there even when the invocation's own flags
|
|
58
|
+
// point elsewhere. Optional: a file an older CLI wrote carries only the
|
|
59
|
+
// sid, and the logout falls back to the invocation's auth origin.
|
|
60
|
+
session?: { sid: string; authBaseUrl?: string };
|
|
57
61
|
}
|
|
58
62
|
export interface CredentialsFile {
|
|
59
63
|
version: 1;
|
package/src/login.ts
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
// place with a fresh one whose code is printed. A caller that has to relay a
|
|
15
15
|
// second code because the first expired is the cost being avoided.
|
|
16
16
|
|
|
17
|
-
import { deviceAuthorize, pollDeviceTokenOnce, decodeEmail } from './session.js';
|
|
17
|
+
import { deviceAuthorize, pollDeviceTokenOnce, postDeviceCancel, decodeEmail } from './session.js';
|
|
18
18
|
import {
|
|
19
19
|
readPendingLogin,
|
|
20
20
|
writePendingLogin,
|
|
@@ -37,6 +37,10 @@ export interface LoginStartOptions {
|
|
|
37
37
|
// Mint a fresh grant even when a usable one is pending (`--new`), for a
|
|
38
38
|
// human who lost the relayed code.
|
|
39
39
|
force?: boolean;
|
|
40
|
+
// Set when a wait follows in the same command (`--start --wait`), whose own
|
|
41
|
+
// terminal result is the one the caller reads. `--json` is one document per
|
|
42
|
+
// run, so the started state prints to the human stream only.
|
|
43
|
+
resultFollows?: boolean;
|
|
40
44
|
env?: NodeJS.ProcessEnv;
|
|
41
45
|
now?: () => number;
|
|
42
46
|
}
|
|
@@ -52,21 +56,35 @@ export async function loginStart(options: LoginStartOptions): Promise<number> {
|
|
|
52
56
|
// second code and forgets the first, so an approval the human is part-way
|
|
53
57
|
// through completes a grant the CLI can no longer redeem and they are asked
|
|
54
58
|
// to sign in again.
|
|
55
|
-
const existing =
|
|
59
|
+
const existing = await readPendingLogin(options.profile, env);
|
|
56
60
|
const resumed =
|
|
57
|
-
|
|
61
|
+
options.force !== true &&
|
|
62
|
+
existing !== null &&
|
|
63
|
+
existing.expiresAt - now() > MIN_USABLE_REMAINING_MS
|
|
64
|
+
? existing
|
|
65
|
+
: null;
|
|
66
|
+
// A superseded grant that is still live stays approvable on the server for
|
|
67
|
+
// the rest of its TTL while this machine forgets the only copy of its
|
|
68
|
+
// device code, so cancel it before the record is overwritten. Best-effort:
|
|
69
|
+
// an undelivered cancel leaves a grant nobody can redeem from here, and the
|
|
70
|
+
// new grant is the one this command is for.
|
|
71
|
+
if (resumed === null && existing !== null && existing.expiresAt > now()) {
|
|
72
|
+
await postDeviceCancel(existing.authBaseUrl, existing.deviceCode);
|
|
73
|
+
}
|
|
58
74
|
const pending = resumed ?? (await mintPendingLogin(options.authBaseUrl, now(), options.profile, env));
|
|
59
75
|
promptFor(options.log, pending, {
|
|
60
76
|
lead: resumed === null ? null : 'A sign-in is already waiting for approval.',
|
|
61
77
|
});
|
|
62
|
-
options.
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
78
|
+
if (options.resultFollows !== true) {
|
|
79
|
+
options.log.result({
|
|
80
|
+
state: 'started',
|
|
81
|
+
resumed: resumed !== null,
|
|
82
|
+
verification_url: targetUrl(pending),
|
|
83
|
+
user_code: pending.userCode,
|
|
84
|
+
expires_at: pending.expiresAt,
|
|
85
|
+
expires_in_seconds: remainingSeconds(pending, now()),
|
|
86
|
+
});
|
|
87
|
+
}
|
|
70
88
|
return 0;
|
|
71
89
|
}
|
|
72
90
|
|
|
@@ -129,7 +147,7 @@ export async function loginCheck(options: LoginCheckOptions): Promise<number> {
|
|
|
129
147
|
}
|
|
130
148
|
await writeStoredCredential(
|
|
131
149
|
options.profile,
|
|
132
|
-
{ session: { sid: outcome.tokens.sessionToken } },
|
|
150
|
+
{ session: { sid: outcome.tokens.sessionToken, authBaseUrl: pending.authBaseUrl } },
|
|
133
151
|
env,
|
|
134
152
|
);
|
|
135
153
|
await writePendingLogin(options.profile, null, env);
|
package/src/project.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readFile, writeFile, readdir, stat, mkdir } from 'node:fs/promises';
|
|
1
|
+
import { readFile, writeFile, readdir, stat, mkdir, realpath } from 'node:fs/promises';
|
|
2
2
|
import { join, relative, sep, dirname } from 'node:path';
|
|
3
3
|
import { createHash } from 'node:crypto';
|
|
4
4
|
import { zipSync } from 'fflate';
|
|
@@ -123,22 +123,56 @@ export function mergeSchemaIntoManifest(
|
|
|
123
123
|
// by forward-slash relative path (zip entries never use the OS separator).
|
|
124
124
|
// Symlinks are followed via `stat`; empty directories are omitted (a static
|
|
125
125
|
// bundle has none that matter).
|
|
126
|
+
// The one entry name a zip built from an object map cannot carry. Assigning it
|
|
127
|
+
// on an ordinary object literal runs the prototype setter instead of adding an
|
|
128
|
+
// entry, and both this walk and fflate's own index are such maps, so the file
|
|
129
|
+
// drops out of the archive and the map inherits from the bytes it was handed.
|
|
130
|
+
// fflate then walks that index with `for...in`, reaches the inherited keys, and
|
|
131
|
+
// writes one bogus directory per byte of the file. Only the exact name at the
|
|
132
|
+
// root of the bundle does this: a `__proto__` inside a subdirectory is keyed by
|
|
133
|
+
// its whole relative path, which is an ordinary string.
|
|
134
|
+
const UNPUBLISHABLE_ENTRY_NAME = '__proto__';
|
|
135
|
+
|
|
126
136
|
export async function collectZipEntries(root: string): Promise<Record<string, Uint8Array>> {
|
|
127
|
-
|
|
128
|
-
|
|
137
|
+
// Null-prototype, so the name above lands as an ordinary key and the refusal
|
|
138
|
+
// below can see it. On a plain literal it is already gone by then.
|
|
139
|
+
const entries: Record<string, Uint8Array> = Object.create(null) as Record<
|
|
140
|
+
string,
|
|
141
|
+
Uint8Array
|
|
142
|
+
>;
|
|
143
|
+
// The real paths of the directories on the way down to `dir`, so a symlink
|
|
144
|
+
// pointing back at one of its own ancestors ends the descent instead of
|
|
145
|
+
// walking the subtree again at every level. `stat` resolves symlinks, so
|
|
146
|
+
// without this the walk re-reads the whole subtree until the kernel refuses
|
|
147
|
+
// the path at its symlink depth and the publish dies on a raw ELOOP naming a
|
|
148
|
+
// path thousands of characters long. Only an ANCESTOR ends it: two separate
|
|
149
|
+
// links to one directory are two real places to publish from, and each is
|
|
150
|
+
// still walked.
|
|
151
|
+
async function walk(dir: string, ancestors: readonly string[]): Promise<void> {
|
|
152
|
+
const real = await realpath(dir);
|
|
153
|
+
if (ancestors.includes(real)) return;
|
|
154
|
+
const chain = [...ancestors, real];
|
|
129
155
|
const names = await readdir(dir);
|
|
130
156
|
for (const name of names) {
|
|
131
157
|
const abs = join(dir, name);
|
|
132
158
|
const info = await stat(abs);
|
|
133
159
|
if (info.isDirectory()) {
|
|
134
|
-
await walk(abs);
|
|
160
|
+
await walk(abs, chain);
|
|
135
161
|
} else if (info.isFile()) {
|
|
136
162
|
const rel = relative(root, abs).split(sep).join('/');
|
|
137
163
|
entries[rel] = new Uint8Array(await readFile(abs));
|
|
138
164
|
}
|
|
139
165
|
}
|
|
140
166
|
}
|
|
141
|
-
await walk(root);
|
|
167
|
+
await walk(root, []);
|
|
168
|
+
if (UNPUBLISHABLE_ENTRY_NAME in entries) {
|
|
169
|
+
throw new Error(
|
|
170
|
+
`Cannot publish a file named "${UNPUBLISHABLE_ENTRY_NAME}" at the top ` +
|
|
171
|
+
`level of ${root}: the archive format keys entries by name, and that ` +
|
|
172
|
+
`name is an object's prototype rather than an entry. Rename or remove ` +
|
|
173
|
+
`it.`,
|
|
174
|
+
);
|
|
175
|
+
}
|
|
142
176
|
return entries;
|
|
143
177
|
}
|
|
144
178
|
|
package/src/session.ts
CHANGED
|
@@ -4,17 +4,25 @@
|
|
|
4
4
|
// code prefilled (and prints the URL + code as a fallback for a headless
|
|
5
5
|
// box), then polls until the human approves. Approval mints the same tokens
|
|
6
6
|
// `/refresh` returns, including the opaque `session_token` the CLI stores to
|
|
7
|
-
// keep refreshing
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
7
|
+
// keep refreshing, which is bound to the CLI's own app id. That app's
|
|
8
|
+
// registered redirect URLs are the loopback wildcards, which is what lets the
|
|
9
|
+
// portless 127.0.0.1 origin below pass the `/refresh` Origin check on every
|
|
10
|
+
// later token refresh.
|
|
11
11
|
|
|
12
12
|
import { spawn } from 'node:child_process';
|
|
13
|
-
import { BrassApiError } from './api.js';
|
|
13
|
+
import { BrassApiError, networkError } from './api.js';
|
|
14
14
|
import type { AuthProvider } from './auth.js';
|
|
15
15
|
|
|
16
|
-
//
|
|
17
|
-
//
|
|
16
|
+
// Every auth call carries a few hundred bytes, so a host still silent after
|
|
17
|
+
// this is one that is not going to answer.
|
|
18
|
+
const AUTH_TIMEOUT_MS = 15_000;
|
|
19
|
+
// Signing out on the server is best effort: `runLogout` clears the local
|
|
20
|
+
// credential whatever this returns, so a host that stalls must not be what
|
|
21
|
+
// stops the credential being cleared.
|
|
22
|
+
const SIGN_OUT_TIMEOUT_MS = 5_000;
|
|
23
|
+
|
|
24
|
+
// The app id the CLI signs in as. Stable across environments, so one CLI
|
|
25
|
+
// build authenticates against whichever stack the origin flags name.
|
|
18
26
|
export const CLI_APP_ID = 'brass_app_internal_cli';
|
|
19
27
|
|
|
20
28
|
// The Origin the CLI presents on every `/refresh` call (a command refreshing
|
|
@@ -30,7 +38,9 @@ export interface RefreshedTokens {
|
|
|
30
38
|
idToken: string;
|
|
31
39
|
expiresAt: number;
|
|
32
40
|
// Present only on the initial device-grant token exchange: the opaque
|
|
33
|
-
// session pointer the CLI persists and echoes on later refreshes.
|
|
41
|
+
// session pointer the CLI persists and echoes on later refreshes. Bound to
|
|
42
|
+
// the CLI's app id, so a copy of it authenticates as the CLI and nothing
|
|
43
|
+
// else.
|
|
34
44
|
sessionToken?: string;
|
|
35
45
|
}
|
|
36
46
|
|
|
@@ -41,6 +51,20 @@ interface RefreshWire {
|
|
|
41
51
|
session_token?: string;
|
|
42
52
|
}
|
|
43
53
|
|
|
54
|
+
// The lifetime to stamp a token set with, given whatever `expires_in` the
|
|
55
|
+
// response carried. Every value that is not a positive finite number stamps an
|
|
56
|
+
// `expiresAt` the refresh guard `Date.now() >= expiresAt` reads wrong: a
|
|
57
|
+
// missing or non-numeric one makes it NaN, so every comparison is false and
|
|
58
|
+
// the token is never re-refreshed, and a zero or negative one makes it already
|
|
59
|
+
// past, so every call refreshes again. The SDK's `tokenTtlSeconds` is the same
|
|
60
|
+
// guard on the same field.
|
|
61
|
+
const DEFAULT_TOKEN_TTL_SECONDS = 3600;
|
|
62
|
+
function tokenTtlSeconds(expiresIn: unknown): number {
|
|
63
|
+
return typeof expiresIn === 'number' && Number.isFinite(expiresIn) && expiresIn > 0
|
|
64
|
+
? expiresIn
|
|
65
|
+
: DEFAULT_TOKEN_TTL_SECONDS;
|
|
66
|
+
}
|
|
67
|
+
|
|
44
68
|
// Refresh an access token at `/refresh` from the stored session pointer
|
|
45
69
|
// (`sid`). A form-encoded body keeps this a CORS simple request (the auth API
|
|
46
70
|
// serves no preflight); the Origin header is required by the endpoint's
|
|
@@ -59,9 +83,10 @@ export async function postRefresh(
|
|
|
59
83
|
method: 'POST',
|
|
60
84
|
headers: { 'content-type': 'application/x-www-form-urlencoded', origin },
|
|
61
85
|
body,
|
|
86
|
+
signal: AbortSignal.timeout(AUTH_TIMEOUT_MS),
|
|
62
87
|
});
|
|
63
88
|
} catch (cause) {
|
|
64
|
-
throw
|
|
89
|
+
throw networkError(`reaching ${authBaseUrl}`, cause);
|
|
65
90
|
}
|
|
66
91
|
if (!response.ok) {
|
|
67
92
|
// Carry the status, and name what a 401 means. Every command reaches the
|
|
@@ -76,21 +101,79 @@ export async function postRefresh(
|
|
|
76
101
|
: `Sign-in exchange failed (${response.status}).`,
|
|
77
102
|
);
|
|
78
103
|
}
|
|
79
|
-
const json = (await response.json()) as RefreshWire
|
|
104
|
+
const json = (await response.json().catch(() => ({}))) as Partial<RefreshWire>;
|
|
105
|
+
// A 200 carrying no tokens is a failed exchange whatever the status said.
|
|
106
|
+
// Taken here because the fields are read unchecked otherwise: an absent
|
|
107
|
+
// `access_token` is stored as `undefined` and presented as the literal
|
|
108
|
+
// header `Bearer undefined`, which comes back 401 and is reported as an
|
|
109
|
+
// expired sign-in, sending the caller to `brass login` for a session that
|
|
110
|
+
// was never the problem. `pollDeviceToken` requires both fields already.
|
|
111
|
+
if (!json.access_token || !json.id_token) {
|
|
112
|
+
throw new BrassApiError(
|
|
113
|
+
response.status,
|
|
114
|
+
`Sign-in exchange returned no tokens (${response.status}).`,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
80
117
|
return {
|
|
81
118
|
accessToken: json.access_token,
|
|
82
119
|
idToken: json.id_token,
|
|
83
|
-
|
|
84
|
-
// `NaN`, which makes `expiresAt` NaN and the `Date.now() >= expiresAt`
|
|
85
|
-
// refresh guard permanently false, so the in-process token would never
|
|
86
|
-
// re-refresh. `pollDeviceToken` already guards the same field.
|
|
87
|
-
expiresAt: now + (json.expires_in ?? 3600) * 1000,
|
|
120
|
+
expiresAt: now + tokenTtlSeconds(json.expires_in) * 1000,
|
|
88
121
|
...(json.session_token ? { sessionToken: json.session_token } : {}),
|
|
89
122
|
};
|
|
90
123
|
}
|
|
91
124
|
|
|
92
|
-
//
|
|
93
|
-
//
|
|
125
|
+
// End this machine's sign-in on the server, not only in the local
|
|
126
|
+
// credentials file. The stored pointer keeps minting access tokens for the
|
|
127
|
+
// rest of the session's life, so forgetting it locally leaves a working
|
|
128
|
+
// credential behind wherever a copy of the file went.
|
|
129
|
+
//
|
|
130
|
+
// Reports whether the server confirmed it. The stored pointer is the caller's
|
|
131
|
+
// only handle on the session, so what the caller does with the record follows
|
|
132
|
+
// from this: an undelivered revoke keeps it for the retry.
|
|
133
|
+
export async function postSignOut(
|
|
134
|
+
authBaseUrl: string,
|
|
135
|
+
sid: string,
|
|
136
|
+
origin: string = REFRESH_ORIGIN,
|
|
137
|
+
): Promise<boolean> {
|
|
138
|
+
const url = `${authBaseUrl}/sign-out-of-app?${new URLSearchParams({ app_id: CLI_APP_ID }).toString()}`;
|
|
139
|
+
try {
|
|
140
|
+
const response = await fetch(url, {
|
|
141
|
+
method: 'POST',
|
|
142
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded', origin },
|
|
143
|
+
body: new URLSearchParams({ sid }),
|
|
144
|
+
signal: AbortSignal.timeout(SIGN_OUT_TIMEOUT_MS),
|
|
145
|
+
});
|
|
146
|
+
// A 401 means the pointer no longer resolves, which is the state the
|
|
147
|
+
// caller wanted: there is nothing left to revoke.
|
|
148
|
+
return response.ok || response.status === 401;
|
|
149
|
+
} catch {
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Abandon a sign-in this machine started and never redeemed, so the code it
|
|
155
|
+
// relayed to a human stops being redeemable. Reports whether the server took
|
|
156
|
+
// it, like `postSignOut`: the record naming the grant is the caller's only
|
|
157
|
+
// handle on it, so what the caller does with the record follows from this.
|
|
158
|
+
export async function postDeviceCancel(
|
|
159
|
+
authBaseUrl: string,
|
|
160
|
+
deviceCode: string,
|
|
161
|
+
): Promise<boolean> {
|
|
162
|
+
try {
|
|
163
|
+
const response = await fetch(`${authBaseUrl}/device/cancel`, {
|
|
164
|
+
method: 'POST',
|
|
165
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
166
|
+
body: new URLSearchParams({ device_code: deviceCode, app_id: CLI_APP_ID }),
|
|
167
|
+
signal: AbortSignal.timeout(SIGN_OUT_TIMEOUT_MS),
|
|
168
|
+
});
|
|
169
|
+
return response.ok;
|
|
170
|
+
} catch {
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Decode the email claim from an id token, best-effort (used only for the
|
|
176
|
+
// "Signed in as ..." confirmation; never for authorization).
|
|
94
177
|
export function decodeEmail(idToken: string): string | undefined {
|
|
95
178
|
const part = idToken.split('.')[1];
|
|
96
179
|
if (part === undefined) return undefined;
|
|
@@ -144,6 +227,7 @@ export async function deviceAuthorize(
|
|
|
144
227
|
method: 'POST',
|
|
145
228
|
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
146
229
|
body,
|
|
230
|
+
signal: AbortSignal.timeout(AUTH_TIMEOUT_MS),
|
|
147
231
|
});
|
|
148
232
|
} catch (cause) {
|
|
149
233
|
throw new Error(`Network error reaching ${authBaseUrl}: ${String(cause)}`);
|
|
@@ -190,6 +274,7 @@ export async function pollDeviceTokenOnce(
|
|
|
190
274
|
method: 'POST',
|
|
191
275
|
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
192
276
|
body,
|
|
277
|
+
signal: AbortSignal.timeout(AUTH_TIMEOUT_MS),
|
|
193
278
|
});
|
|
194
279
|
} catch {
|
|
195
280
|
// A transient network error mid-poll: the device grant is still valid
|
|
@@ -207,7 +292,7 @@ export async function pollDeviceTokenOnce(
|
|
|
207
292
|
tokens: {
|
|
208
293
|
accessToken: json.access_token,
|
|
209
294
|
idToken: json.id_token,
|
|
210
|
-
expiresAt: now + (json.expires_in
|
|
295
|
+
expiresAt: now + tokenTtlSeconds(json.expires_in) * 1000,
|
|
211
296
|
...(json.session_token ? { sessionToken: json.session_token } : {}),
|
|
212
297
|
},
|
|
213
298
|
};
|
|
@@ -216,9 +301,9 @@ export async function pollDeviceTokenOnce(
|
|
|
216
301
|
if (json.error === 'access_denied') return { state: 'denied' };
|
|
217
302
|
// RFC 8628 §3.5: polling too fast. The caller backs off.
|
|
218
303
|
if (json.error === 'slow_down') return { state: 'slow_down' };
|
|
219
|
-
// Everything else
|
|
220
|
-
// a non-JSON body, or an error code this pinned CLI does
|
|
221
|
-
//
|
|
304
|
+
// Everything else is "keep waiting": `authorization_pending`, a transient
|
|
305
|
+
// non-2xx (5xx/429), a non-JSON body, or an error code this pinned CLI does
|
|
306
|
+
// not recognize. The caller's deadline check is the single terminal bound,
|
|
222
307
|
// so an unknown error or a server blip does not abort a sign-in the user is
|
|
223
308
|
// one poll away from completing; a genuinely expired grant simply ends
|
|
224
309
|
// there as a clean timeout.
|
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.3.0';
|
|
7
7
|
|
|
8
8
|
export const CLI_CLIENT_ID = `cli/${VERSION}`;
|