@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/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 ctx.api.get<HostingStatus>(`/apps/${encodeURIComponent(appId)}/hosting`);
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 ctx.api.get<HostingStatus>(`/apps/${encodeURIComponent(appId)}/hosting`);
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 DDB flag already
227
- // reads `want`: the PATCH is what reaches the server-side reconcile that
228
- // repairs a wedged edge marker (a prior toggle whose flag write landed but
229
- // whose marker write lost every ETag race leaves the flag reading correct
230
- // while the marker disagrees). Skipping the PATCH on a matching flag would
231
- // leave such a gate wedged forever, since every later publish would skip it
232
- // too. `status` is the state `ensureHosting` just observed, used only to word
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
- // The currently-served version when it is `ready` and already carries
271
- // `hash`, else null. A null (no active version, a non-ready active version, or
272
- // a hash mismatch) means `publish` must upload. A first deploy has no active
273
- // version, so it always uploads; a version predating content hashing has no
274
- // recorded hash and so never matches, forcing one re-upload that self-heals.
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 `pending` OR any status this pinned
291
- // CLI does not recognize rather than returning on `!== 'pending'`. That way
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?: { sid: string };
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 = options.force === true ? null : await readPendingLogin(options.profile, env);
59
+ const existing = await readPendingLogin(options.profile, env);
56
60
  const resumed =
57
- existing !== null && existing.expiresAt - now() > MIN_USABLE_REMAINING_MS ? existing : null;
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.log.result({
63
- state: 'started',
64
- resumed: resumed !== null,
65
- verification_url: targetUrl(pending),
66
- user_code: pending.userCode,
67
- expires_at: pending.expiresAt,
68
- expires_in_seconds: remainingSeconds(pending, now()),
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
- const entries: Record<string, Uint8Array> = {};
128
- async function walk(dir: string): Promise<void> {
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. The CLI's OAuth client is the seeded
8
- // `brass_app_internal_cli`; its redirect allowlist is the loopback wildcards,
9
- // which the `/refresh` Origin gate checks the CLI's portless 127.0.0.1 origin
10
- // against on every later token refresh.
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
- // The seeded CLI OAuth client (see infra/lib/api-stack.ts). Stable across
17
- // environments, so one CLI build signs in against prod or dev.
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 new BrassApiError(0, `Network error reaching ${authBaseUrl}: ${String(cause)}`);
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
- // Default the lifetime when the server omits it: `undefined * 1000` is
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
- // Decode the email claim from a Cognito id token, best-effort (used only for
93
- // the "Signed in as ..." confirmation; never for authorization).
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 ?? 3600) * 1000,
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 `authorization_pending`, a transient non-2xx (5xx/429),
220
- // a non-JSON body, or an error code this pinned CLI does not recognize — is
221
- // "keep waiting". The caller's deadline check is the single terminal bound,
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.1.0';
6
+ export const VERSION = '0.3.0';
7
7
 
8
8
  export const CLI_CLIENT_ID = `cli/${VERSION}`;