@apostra/sdk 0.38.0 → 0.40.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/README.md CHANGED
@@ -26,8 +26,11 @@ values. Use `accessToken` for an existing REST/M2M bearer token, or
26
26
  one credential source. `m2mTokenProvider` acquires and caches a server-side
27
27
  client-credentials token with the published M2M endpoint; it refreshes 60
28
28
  seconds before expiry and shares a concurrent refresh. Never put API keys or
29
- client secrets in browser bundles, URLs, examples or logs. The package does not
30
- persist credentials or replay 401 responses. M2M token refreshes have a
29
+ client secrets in browser bundles, URLs, examples or logs. With no
30
+ explicit credential and no `APOSTRA_API_KEY`, a Node.js client uses the login
31
+ that `apostra login` stored for the base URL, if there is one; the SDK never
32
+ opens a browser and writes only to that login's file (refreshed tokens). The
33
+ package does not replay 401 responses. M2M token refreshes have a
31
34
  30-second deadline by default; pass `timeoutMs` to override it.
32
35
 
33
36
  ```ts
package/SKILL.md CHANGED
@@ -5,7 +5,7 @@ description: Choose the Apostra TypeScript SDK method for a coding task, then re
5
5
 
6
6
  # Apostra TypeScript SDK
7
7
 
8
- Read the [SDK guide](https://docs.apostra.com/v2/sdk), [V3 HTTP API
8
+ Read the [SDK guide](https://docs.apostra.com/v3/sdk-guide), [V3 HTTP API
9
9
  guide](https://docs.apostra.com/v3/http-api), and [authentication
10
10
  guide](https://docs.apostra.com/v3/authentication) before relying on an
11
11
  operation. They are the source of truth for inputs, results, permissions, and
@@ -0,0 +1,76 @@
1
+ import type { TokenProvider } from './transport.js';
2
+ export type CliLoginEntry = {
3
+ issuer: string;
4
+ resource: string;
5
+ clientId: string;
6
+ accessToken: string;
7
+ refreshToken: string;
8
+ /** ISO-8601 time the access token stops working. */
9
+ expiresAt: string;
10
+ accountId: string;
11
+ scopes: string[];
12
+ };
13
+ export type CliLoginDiscovery = {
14
+ issuer: string;
15
+ authorizationEndpoint: string;
16
+ tokenEndpoint: string;
17
+ revocationEndpoint?: string;
18
+ codeChallengeMethods?: string[];
19
+ };
20
+ export type CliLoginTokens = Pick<CliLoginEntry, 'accessToken' | 'refreshToken' | 'expiresAt'> & {
21
+ scopes?: string[];
22
+ accountId?: string;
23
+ };
24
+ type Fetch = typeof globalThis.fetch;
25
+ type Options = {
26
+ fetch?: Fetch;
27
+ };
28
+ /** `${XDG_CONFIG_HOME:-~/.config}/apostra/credentials.json` (`%APPDATA%` on Windows). */
29
+ export declare function credentialsPath(): string;
30
+ /** The store key: the API base URL without a trailing slash. */
31
+ export declare function normalizeBaseUrl(raw: string): string;
32
+ /** The first-party public client for an API origin. */
33
+ export declare const cliClientId: (resource: string) => string;
34
+ export declare function readCliLogin(baseUrl: string): CliLoginEntry | undefined;
35
+ /** Like `readCliLogin`, but never throws: for the SDK's default credential chain. */
36
+ export declare function storedCliLogin(baseUrl: string): CliLoginEntry | undefined;
37
+ export declare const isFresh: (entry: Pick<CliLoginEntry, "expiresAt">) => boolean;
38
+ /**
39
+ * Serialise every read-modify-write of the store, across processes. Refresh
40
+ * tokens rotate, so two processes must never present the same one outside the
41
+ * server's reuse grace. A lock older than 30 seconds belongs to a dead process.
42
+ */
43
+ export declare function withCliLoginLock<T>(fn: () => Promise<T>): Promise<T>;
44
+ /** Save a login. Call inside `withCliLoginLock`. */
45
+ export declare function writeCliLogin(baseUrl: string, entry: CliLoginEntry): void;
46
+ /** Protected-resource metadata, then authorization-server metadata (RFC 9728, RFC 8414). */
47
+ export declare function discoverCliLogin(resource: string, { fetch: f }?: Options): Promise<CliLoginDiscovery>;
48
+ export declare function exchangeCliLoginCode(options: Options, discovery: CliLoginDiscovery, resource: string, grant: {
49
+ code: string;
50
+ verifier: string;
51
+ redirectUri: string;
52
+ }): Promise<CliLoginTokens>;
53
+ /**
54
+ * Return a usable login for `baseUrl`, refreshing it if the access token is
55
+ * about to expire. After taking the lock the credential store is read again:
56
+ * if another process refreshed first, its token is used and no request is made.
57
+ */
58
+ export declare function refreshCliLogin(baseUrl: string, options?: Options): Promise<CliLoginEntry>;
59
+ /**
60
+ * Revoke the refresh token (RFC 7009), then delete the entry. The entry is
61
+ * deleted even when revocation fails; `revoked` tells the caller which.
62
+ */
63
+ export declare function logoutCliLogin(baseUrl: string, { fetch: f }?: Options): Promise<{
64
+ found: boolean;
65
+ revoked: boolean;
66
+ }>;
67
+ /**
68
+ * A token provider backed by `apostra login`. Use it as `tokenProvider`, or
69
+ * rely on the default credential chain, which uses it when no explicit
70
+ * credential or `APOSTRA_API_KEY` is set and a login exists for the base URL.
71
+ */
72
+ export declare function cliLoginTokenProvider(options?: {
73
+ baseUrl?: string;
74
+ fetch?: Fetch;
75
+ }): TokenProvider;
76
+ export {};
@@ -0,0 +1,557 @@
1
+ /**
2
+ * The credential store behind `apostra login`, shared by the CLI and the SDK.
3
+ *
4
+ * Node only. Nothing here imports a Node module at load time (it uses
5
+ * `process.getBuiltinModule`), so a browser bundle can include the SDK and
6
+ * simply never call it. The SDK never opens a browser; the interactive sign-in
7
+ * lives in `@apostra/cli`.
8
+ */
9
+ import { baseUrl as defaultBaseUrl } from './generated/metadata.js';
10
+ const FRESH_MARGIN_MS = 60_000;
11
+ const REQUEST_TIMEOUT_MS = 15_000;
12
+ const LOCK_STALE_MS = 30_000;
13
+ const LOCK_HEARTBEAT_MS = 5_000;
14
+ const LOCK_WAIT_MS = 30_000;
15
+ function proc() {
16
+ const p = globalThis.process;
17
+ if (!p?.versions?.node || typeof p.getBuiltinModule !== 'function')
18
+ throw new Error('Apostra CLI login is available only in Node.js');
19
+ return p;
20
+ }
21
+ function node() {
22
+ const p = proc();
23
+ const get = p.getBuiltinModule;
24
+ return {
25
+ fs: get('node:fs'),
26
+ os: get('node:os'),
27
+ path: get('node:path'),
28
+ module: get('node:module'),
29
+ };
30
+ }
31
+ const isRecord = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
32
+ const errorCode = (error) => isRecord(error) && typeof error.code === 'string' ? error.code : undefined;
33
+ /** `${XDG_CONFIG_HOME:-~/.config}/apostra/credentials.json` (`%APPDATA%` on Windows). */
34
+ export function credentialsPath() {
35
+ const { os, path } = node();
36
+ const { env, platform } = proc();
37
+ const base = platform === 'win32'
38
+ ? (env.APPDATA ?? path.join(os.homedir(), 'AppData', 'Roaming'))
39
+ : env.XDG_CONFIG_HOME && path.isAbsolute(env.XDG_CONFIG_HOME)
40
+ ? env.XDG_CONFIG_HOME
41
+ : path.join(os.homedir(), '.config');
42
+ return path.join(base, 'apostra', 'credentials.json');
43
+ }
44
+ function secureUrl(value, name) {
45
+ const url = new URL(typeof value === 'string' ? value : '');
46
+ if (url.username ||
47
+ url.password ||
48
+ url.hash ||
49
+ (url.protocol !== 'https:' &&
50
+ !(url.protocol === 'http:' &&
51
+ ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname))))
52
+ throw new TypeError(`${name} must be an HTTPS URL (HTTP only on loopback)`);
53
+ return url.href;
54
+ }
55
+ /** The store key: the API base URL without a trailing slash. */
56
+ export function normalizeBaseUrl(raw) {
57
+ const url = new URL(secureUrl(raw, 'Base URL'));
58
+ if (url.search)
59
+ throw new TypeError('Base URL must not have a query string');
60
+ return url.href.replace(/\/$/, '');
61
+ }
62
+ /** The first-party public client for an API origin. */
63
+ export const cliClientId = (resource) => `${new URL(resource).origin}/oauth/clients/apostra-cli.json`;
64
+ function parseEntry(value) {
65
+ if (!isRecord(value))
66
+ return undefined;
67
+ const text = ['issuer', 'resource', 'clientId', 'accessToken', 'refreshToken'];
68
+ if (!text.every((k) => typeof value[k] === 'string' && value[k]) ||
69
+ typeof value.expiresAt !== 'string' ||
70
+ Number.isNaN(Date.parse(value.expiresAt)) ||
71
+ typeof value.accountId !== 'string' ||
72
+ !Array.isArray(value.scopes) ||
73
+ !value.scopes.every((s) => typeof s === 'string'))
74
+ return undefined;
75
+ return value;
76
+ }
77
+ function readFileLogins() {
78
+ const { fs } = node();
79
+ try {
80
+ const file = JSON.parse(fs.readFileSync(credentialsPath(), 'utf8'));
81
+ return isRecord(file) && isRecord(file.logins) ? file.logins : {};
82
+ }
83
+ catch {
84
+ return {};
85
+ }
86
+ }
87
+ /** Atomic (temp file + rename), directory 0700, file 0600. */
88
+ function writeFileLogins(logins) {
89
+ const { fs, path } = node();
90
+ const file = credentialsPath();
91
+ const dir = path.dirname(file);
92
+ if (Object.keys(logins).length === 0) {
93
+ fs.rmSync(file, { force: true });
94
+ return;
95
+ }
96
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
97
+ fs.chmodSync(dir, 0o700);
98
+ const temp = `${file}.${proc().pid}.${Math.random().toString(36).slice(2)}.tmp`;
99
+ const fd = fs.openSync(temp, 'wx', 0o600);
100
+ try {
101
+ fs.writeSync(fd, `${JSON.stringify({ version: 1, logins }, null, 2)}\n`);
102
+ }
103
+ finally {
104
+ fs.closeSync(fd);
105
+ }
106
+ fs.renameSync(temp, file);
107
+ }
108
+ const keyringService = 'Apostra CLI login';
109
+ /**
110
+ * Resolve the native binding only when this Node-only feature is used. A static
111
+ * import would make the browser SDK bundle a native module.
112
+ */
113
+ function keyringEntry(resource) {
114
+ const { module } = node();
115
+ const required = module.createRequire(import.meta.url)('@napi-rs/keyring');
116
+ if (!isRecord(required) || typeof required.Entry !== 'function')
117
+ throw new Error('Apostra credential-store binding is unavailable');
118
+ const Entry = required.Entry;
119
+ // Do not accept keyutils on Linux. It is ephemeral and is not the requested
120
+ // Secret Service store, so an inaccessible service deliberately uses the
121
+ // documented 0600-file fallback instead.
122
+ return new Entry(keyringService, resource, proc().platform === 'linux'
123
+ ? { linux: { store: 'secret-service' } }
124
+ : undefined);
125
+ }
126
+ function readSecureLogin(resource) {
127
+ try {
128
+ const password = keyringEntry(resource).getPassword();
129
+ if (!password)
130
+ return { available: true };
131
+ return { available: true, entry: parseEntry(JSON.parse(password)) };
132
+ }
133
+ catch {
134
+ return { available: false };
135
+ }
136
+ }
137
+ function writeSecureLogin(resource, entry) {
138
+ try {
139
+ keyringEntry(resource).setPassword(JSON.stringify(entry));
140
+ return true;
141
+ }
142
+ catch {
143
+ return false;
144
+ }
145
+ }
146
+ function deleteSecureLogin(resource) {
147
+ try {
148
+ return keyringEntry(resource).deletePassword();
149
+ }
150
+ catch {
151
+ return undefined;
152
+ }
153
+ }
154
+ function warnFileFallback() {
155
+ const { fs, path } = node();
156
+ const dir = path.dirname(credentialsPath());
157
+ const marker = path.join(dir, '.file-store-warning');
158
+ try {
159
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
160
+ fs.chmodSync(dir, 0o700);
161
+ const fd = fs.openSync(marker, 'wx', 0o600);
162
+ fs.closeSync(fd);
163
+ }
164
+ catch (error) {
165
+ if (errorCode(error) === 'EEXIST')
166
+ return;
167
+ // If the marker cannot be written, warn for this process rather than
168
+ // silently accepting plaintext storage.
169
+ }
170
+ proc().emitWarning?.(`Apostra could not access your OS credential store. Login tokens are stored in ${credentialsPath()} with 0600 permissions. Use Keychain, Credential Manager, or Secret Service when available.`);
171
+ }
172
+ /**
173
+ * Move every legacy entry while holding the normal refresh lock. Reads that
174
+ * arrive first use a short synchronous instance of the same lock; contenders
175
+ * keep reading the legacy file until the migration finishes.
176
+ */
177
+ function migrateFileLogins(lockHeld = false) {
178
+ const { fs, path } = node();
179
+ const fileLogins = readFileLogins();
180
+ const entries = Object.entries(fileLogins).flatMap(([resource, value]) => {
181
+ const entry = parseEntry(value);
182
+ return entry ? [[resource, entry]] : [];
183
+ });
184
+ if (entries.length === 0)
185
+ return false;
186
+ const lock = `${credentialsPath()}.lock`;
187
+ let owner;
188
+ if (!lockHeld) {
189
+ fs.mkdirSync(path.dirname(lock), { recursive: true, mode: 0o700 });
190
+ owner = `migration:${proc().pid}:${globalThis.crypto.randomUUID()}`;
191
+ try {
192
+ const fd = fs.openSync(lock, 'wx', 0o600);
193
+ try {
194
+ fs.writeSync(fd, owner);
195
+ }
196
+ finally {
197
+ fs.closeSync(fd);
198
+ }
199
+ }
200
+ catch {
201
+ return false;
202
+ }
203
+ }
204
+ try {
205
+ for (const [resource, entry] of entries) {
206
+ // A keyring value is authoritative: preserving it avoids overwriting a
207
+ // token another process has already refreshed during an interrupted
208
+ // earlier migration.
209
+ const stored = readSecureLogin(resource);
210
+ if (stored.entry)
211
+ continue;
212
+ if (!stored.available || !writeSecureLogin(resource, entry))
213
+ return false;
214
+ }
215
+ fs.rmSync(credentialsPath(), { force: true });
216
+ return true;
217
+ }
218
+ finally {
219
+ if (owner) {
220
+ try {
221
+ if (fs.readFileSync(lock, 'utf8') === owner)
222
+ fs.rmSync(lock, { force: true });
223
+ }
224
+ catch {
225
+ // The regular lock protocol protects against removing another owner.
226
+ }
227
+ }
228
+ }
229
+ }
230
+ export function readCliLogin(baseUrl) {
231
+ const resource = normalizeBaseUrl(baseUrl);
232
+ const secure = readSecureLogin(resource);
233
+ if (secure.entry)
234
+ return secure.entry;
235
+ const fileLogins = readFileLogins();
236
+ if (Object.keys(fileLogins).length === 0)
237
+ return undefined;
238
+ if (migrateFileLogins())
239
+ return readSecureLogin(resource).entry;
240
+ if (!secure.available)
241
+ warnFileFallback();
242
+ return parseEntry(fileLogins[resource]);
243
+ }
244
+ /** Like `readCliLogin`, but never throws: for the SDK's default credential chain. */
245
+ export function storedCliLogin(baseUrl) {
246
+ try {
247
+ return readCliLogin(baseUrl);
248
+ }
249
+ catch {
250
+ return undefined;
251
+ }
252
+ }
253
+ export const isFresh = (entry) => Date.parse(entry.expiresAt) - FRESH_MARGIN_MS > Date.now();
254
+ /**
255
+ * Remove a lock whose heartbeat stopped, without ever removing a live one.
256
+ * Only the holder of the takeover guard checks and removes. Checking again
257
+ * under the guard means a lock another process took after the first check is
258
+ * fresh and is left alone. A guard a crashed process left behind goes stale
259
+ * like a lock does. Returns whether this call held the guard, so the caller
260
+ * retries at once; otherwise it waits like any other contender.
261
+ */
262
+ function breakStaleLock(fs, lock) {
263
+ const guard = `${lock}.takeover`;
264
+ try {
265
+ fs.closeSync(fs.openSync(guard, 'wx', 0o600));
266
+ }
267
+ catch (error) {
268
+ if (errorCode(error) !== 'EEXIST')
269
+ throw error;
270
+ try {
271
+ if (Date.now() - fs.statSync(guard).mtimeMs > LOCK_STALE_MS)
272
+ fs.rmSync(guard, { force: true });
273
+ }
274
+ catch {
275
+ // The guard went away between the two calls; the caller retries.
276
+ }
277
+ return false;
278
+ }
279
+ try {
280
+ if (Date.now() - fs.statSync(lock).mtimeMs > LOCK_STALE_MS)
281
+ fs.rmSync(lock, { force: true });
282
+ }
283
+ catch {
284
+ // The lock went away; the caller retries.
285
+ }
286
+ finally {
287
+ fs.rmSync(guard, { force: true });
288
+ }
289
+ return true;
290
+ }
291
+ /**
292
+ * Serialise every read-modify-write of the store, across processes. Refresh
293
+ * tokens rotate, so two processes must never present the same one outside the
294
+ * server's reuse grace. A lock older than 30 seconds belongs to a dead process.
295
+ */
296
+ export async function withCliLoginLock(fn) {
297
+ const { fs, path } = node();
298
+ const lock = `${credentialsPath()}.lock`;
299
+ fs.mkdirSync(path.dirname(lock), { recursive: true, mode: 0o700 });
300
+ // The lock file holds its owner's token, so a process only ever removes a
301
+ // lock it owns, and a heartbeat keeps a live lock fresh however long the
302
+ // bounded refresh takes. Only a lock whose heartbeat stopped is stale.
303
+ const owner = `${proc().pid}:${globalThis.crypto.randomUUID()}`;
304
+ const ownerOf = () => {
305
+ try {
306
+ return fs.readFileSync(lock, 'utf8');
307
+ }
308
+ catch {
309
+ return undefined;
310
+ }
311
+ };
312
+ const deadline = Date.now() + LOCK_WAIT_MS;
313
+ for (;;) {
314
+ try {
315
+ const fd = fs.openSync(lock, 'wx', 0o600);
316
+ try {
317
+ fs.writeSync(fd, owner);
318
+ }
319
+ finally {
320
+ fs.closeSync(fd);
321
+ }
322
+ break;
323
+ }
324
+ catch (error) {
325
+ if (errorCode(error) !== 'EEXIST')
326
+ throw error;
327
+ try {
328
+ if (Date.now() - fs.statSync(lock).mtimeMs > LOCK_STALE_MS) {
329
+ if (breakStaleLock(fs, lock))
330
+ continue;
331
+ }
332
+ }
333
+ catch {
334
+ continue;
335
+ }
336
+ if (Date.now() > deadline)
337
+ throw new Error('Timed out waiting for another Apostra login process');
338
+ await new Promise((r) => setTimeout(r, 25 + Math.random() * 50));
339
+ }
340
+ }
341
+ const heartbeat = setInterval(() => {
342
+ try {
343
+ if (ownerOf() === owner)
344
+ fs.utimesSync(lock, new Date(), new Date());
345
+ }
346
+ catch {
347
+ // A missed beat only shortens the lease; the owner check still holds.
348
+ }
349
+ }, LOCK_HEARTBEAT_MS);
350
+ heartbeat.unref?.();
351
+ try {
352
+ return await fn();
353
+ }
354
+ finally {
355
+ clearInterval(heartbeat);
356
+ if (ownerOf() === owner)
357
+ fs.rmSync(lock, { force: true });
358
+ }
359
+ }
360
+ /** Save a login. Call inside `withCliLoginLock`. */
361
+ export function writeCliLogin(baseUrl, entry) {
362
+ const resource = normalizeBaseUrl(baseUrl);
363
+ // Callers hold the cross-process lock, so the legacy file cannot overwrite
364
+ // a rotated credential while it is being migrated.
365
+ migrateFileLogins(true);
366
+ if (writeSecureLogin(resource, entry))
367
+ return;
368
+ warnFileFallback();
369
+ writeFileLogins({ ...readFileLogins(), [resource]: entry });
370
+ }
371
+ async function send(fetcher, url, init = {}) {
372
+ return fetcher(url, {
373
+ ...init,
374
+ headers: { Accept: 'application/json', ...init.headers },
375
+ redirect: 'error',
376
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
377
+ });
378
+ }
379
+ async function getJson(fetcher, url) {
380
+ const response = await send(fetcher, url);
381
+ if (!response.ok)
382
+ throw new Error(`Could not read ${url} (HTTP ${response.status})`);
383
+ return response.json();
384
+ }
385
+ function wellKnown(name, location) {
386
+ const url = new URL(location);
387
+ return `${url.origin}/.well-known/${name}${url.pathname === '/' ? '' : url.pathname}`;
388
+ }
389
+ /** Protected-resource metadata, then authorization-server metadata (RFC 9728, RFC 8414). */
390
+ export async function discoverCliLogin(resource, { fetch: f = globalThis.fetch } = {}) {
391
+ const prm = await getJson(f, wellKnown('oauth-protected-resource', resource));
392
+ if (!isRecord(prm) ||
393
+ typeof prm.resource !== 'string' ||
394
+ prm.resource.replace(/\/$/, '') !== resource ||
395
+ !Array.isArray(prm.authorization_servers))
396
+ throw new Error(`${resource} does not publish OAuth protected-resource metadata`);
397
+ const issuer = secureUrl(prm.authorization_servers[0], 'Issuer').replace(/\/$/, '');
398
+ const as = await getJson(f, wellKnown('oauth-authorization-server', issuer));
399
+ if (!isRecord(as) || as.issuer !== issuer)
400
+ throw new Error(`${issuer} published mismatched authorization-server metadata`);
401
+ const methods = as.code_challenge_methods_supported;
402
+ return {
403
+ issuer,
404
+ authorizationEndpoint: secureUrl(as.authorization_endpoint, 'authorization_endpoint'),
405
+ tokenEndpoint: secureUrl(as.token_endpoint, 'token_endpoint'),
406
+ revocationEndpoint: as.revocation_endpoint === undefined
407
+ ? undefined
408
+ : secureUrl(as.revocation_endpoint, 'revocation_endpoint'),
409
+ codeChallengeMethods: Array.isArray(methods)
410
+ ? methods.map(String)
411
+ : undefined,
412
+ };
413
+ }
414
+ async function tokenRequest({ fetch: f = globalThis.fetch }, discovery, resource, form) {
415
+ const response = await send(f, discovery.tokenEndpoint, {
416
+ method: 'POST',
417
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
418
+ body: new URLSearchParams({ ...form, resource }),
419
+ });
420
+ const body = await response.json().catch(() => undefined);
421
+ if (!response.ok) {
422
+ // Never echo the response body: it may carry reflected credentials.
423
+ if (isRecord(body) && body.error === 'invalid_grant')
424
+ throw new Error('Your Apostra login expired or was revoked. Run `apostra login` again.');
425
+ throw new Error(`Apostra token request failed (HTTP ${response.status})`);
426
+ }
427
+ if (!isRecord(body) ||
428
+ typeof body.access_token !== 'string' ||
429
+ !body.access_token ||
430
+ typeof body.expires_in !== 'number' ||
431
+ !(body.expires_in > 0))
432
+ throw new Error('Apostra token response is invalid');
433
+ // Rotation: the old refresh token is spent; keep the new one. A refresh that
434
+ // does not rotate keeps the old one, but a login with no refresh token
435
+ // could not be kept past the access token's hour, so it is refused here,
436
+ // before anything is written.
437
+ const refreshToken = typeof body.refresh_token === 'string' && body.refresh_token
438
+ ? body.refresh_token
439
+ : form.refresh_token;
440
+ if (!refreshToken)
441
+ throw new Error('Apostra did not return a refresh token, so this login cannot be kept. Run `apostra login` again.');
442
+ const account = body.account_id;
443
+ return {
444
+ accessToken: body.access_token,
445
+ refreshToken,
446
+ expiresAt: new Date(Date.now() + body.expires_in * 1000).toISOString(),
447
+ scopes: typeof body.scope === 'string'
448
+ ? body.scope.split(' ').filter(Boolean)
449
+ : undefined,
450
+ accountId: typeof account === 'string' || typeof account === 'number'
451
+ ? String(account)
452
+ : undefined,
453
+ };
454
+ }
455
+ export function exchangeCliLoginCode(options, discovery, resource, grant) {
456
+ return tokenRequest(options, discovery, resource, {
457
+ grant_type: 'authorization_code',
458
+ code: grant.code,
459
+ code_verifier: grant.verifier,
460
+ redirect_uri: grant.redirectUri,
461
+ client_id: cliClientId(resource),
462
+ });
463
+ }
464
+ /**
465
+ * Return a usable login for `baseUrl`, refreshing it if the access token is
466
+ * about to expire. After taking the lock the credential store is read again:
467
+ * if another process refreshed first, its token is used and no request is made.
468
+ */
469
+ export async function refreshCliLogin(baseUrl, options = {}) {
470
+ const key = normalizeBaseUrl(baseUrl);
471
+ const missing = () => new Error(`Not signed in to ${key}. Run \`apostra login\`.`);
472
+ const first = readCliLogin(key);
473
+ if (!first)
474
+ throw missing();
475
+ if (isFresh(first))
476
+ return first;
477
+ return withCliLoginLock(async () => {
478
+ const entry = readCliLogin(key);
479
+ if (!entry)
480
+ throw missing();
481
+ if (isFresh(entry))
482
+ return entry;
483
+ const discovery = await discoverCliLogin(entry.resource, options);
484
+ const tokens = await tokenRequest(options, discovery, entry.resource, {
485
+ grant_type: 'refresh_token',
486
+ refresh_token: entry.refreshToken,
487
+ client_id: entry.clientId,
488
+ });
489
+ const next = {
490
+ ...entry,
491
+ accessToken: tokens.accessToken,
492
+ refreshToken: tokens.refreshToken,
493
+ expiresAt: tokens.expiresAt,
494
+ scopes: tokens.scopes ?? entry.scopes,
495
+ };
496
+ writeCliLogin(key, next);
497
+ return next;
498
+ });
499
+ }
500
+ /**
501
+ * Revoke the refresh token (RFC 7009), then delete the entry. The entry is
502
+ * deleted even when revocation fails; `revoked` tells the caller which.
503
+ */
504
+ export async function logoutCliLogin(baseUrl, { fetch: f = globalThis.fetch } = {}) {
505
+ const key = normalizeBaseUrl(baseUrl);
506
+ return withCliLoginLock(async () => {
507
+ const entry = readCliLogin(key);
508
+ if (!entry)
509
+ return { found: false, revoked: false };
510
+ let revoked = false;
511
+ try {
512
+ const { revocationEndpoint } = await discoverCliLogin(entry.resource, {
513
+ fetch: f,
514
+ });
515
+ if (revocationEndpoint)
516
+ revoked = (await send(f, revocationEndpoint, {
517
+ method: 'POST',
518
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
519
+ body: new URLSearchParams({
520
+ token: entry.refreshToken,
521
+ token_type_hint: 'refresh_token',
522
+ client_id: entry.clientId,
523
+ }),
524
+ })).ok;
525
+ }
526
+ catch {
527
+ revoked = false;
528
+ }
529
+ deleteSecureLogin(key);
530
+ // A prior fallback login can still be present even when the credential
531
+ // store has no entry (or is available again). Remove it unconditionally:
532
+ // otherwise its refresh token could be migrated back after logout.
533
+ const { [key]: _removed, ...rest } = readFileLogins();
534
+ writeFileLogins(rest);
535
+ return { found: true, revoked };
536
+ });
537
+ }
538
+ /**
539
+ * A token provider backed by `apostra login`. Use it as `tokenProvider`, or
540
+ * rely on the default credential chain, which uses it when no explicit
541
+ * credential or `APOSTRA_API_KEY` is set and a login exists for the base URL.
542
+ */
543
+ export function cliLoginTokenProvider(options = {}) {
544
+ const key = normalizeBaseUrl(options.baseUrl ?? proc().env.APOSTRA_BASE_URL ?? defaultBaseUrl);
545
+ let refreshing;
546
+ return () => {
547
+ const entry = readCliLogin(key);
548
+ if (entry && isFresh(entry))
549
+ return entry.accessToken;
550
+ refreshing ??= refreshCliLogin(key, { fetch: options.fetch })
551
+ .then((e) => e.accessToken)
552
+ .finally(() => {
553
+ refreshing = undefined;
554
+ });
555
+ return refreshing;
556
+ };
557
+ }