@north-light/sdk 0.3.414 → 0.3.415

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/dist/client.d.ts +14 -0
  2. package/dist/client.js +72 -33
  3. package/dist/crtr.d.ts +24 -0
  4. package/dist/crtr.js +50 -0
  5. package/dist/env-command.d.ts +15 -0
  6. package/dist/env-command.js +138 -0
  7. package/dist/error-codes.d.ts +1 -1
  8. package/dist/error-codes.js +2 -0
  9. package/dist/errors.d.ts +15 -0
  10. package/dist/errors.js +38 -24
  11. package/dist/index.d.ts +3 -3
  12. package/dist/index.js +2 -2
  13. package/dist/keygen-cli.js +6 -1
  14. package/dist/keygen-command.js +15 -41
  15. package/dist/oauth/index.d.ts +19 -1
  16. package/dist/oauth/index.js +111 -25
  17. package/dist/resources/request.d.ts +5 -0
  18. package/dist/resources/request.js +31 -0
  19. package/dist/resources/runs.d.ts +7 -0
  20. package/dist/resources/runs.js +37 -17
  21. package/dist/testing/clock.d.ts +7 -0
  22. package/dist/testing/clock.js +12 -0
  23. package/dist/testing/cloud.d.ts +19 -0
  24. package/dist/testing/cloud.js +48 -14
  25. package/dist/testing/conformance/index.d.ts +2 -0
  26. package/dist/testing/conformance/index.js +3 -0
  27. package/dist/testing/conformance/keyed-write.d.ts +43 -0
  28. package/dist/testing/conformance/keyed-write.js +124 -0
  29. package/dist/testing/index.d.ts +2 -0
  30. package/dist/testing/index.js +1 -0
  31. package/dist/testing/notices.d.ts +7 -1
  32. package/dist/testing/notices.js +7 -3
  33. package/dist/testing/run.d.ts +2 -0
  34. package/dist/testing/run.js +5 -5
  35. package/dist/testing/users.d.ts +9 -3
  36. package/dist/testing/users.js +11 -3
  37. package/dist/types.d.ts +8 -0
  38. package/dist/version.d.ts +1 -1
  39. package/dist/version.js +1 -1
  40. package/package.json +7 -3
package/dist/index.d.ts CHANGED
@@ -15,7 +15,7 @@ export type { Share, ShareCreated, ShareCreateParams } from './resources/shares.
15
15
  export type { MemoryDocument, MemoryGetParams, MemoryListItem, MemoryListPage, MemoryListParams } from './resources/memory.js';
16
16
  export type { Question, QuestionAnswered, QuestionFields, QuestionResponses, QuestionSlot, QuestionStatus } from './resources/questions.js';
17
17
  export type { ProviderToolEntry } from './resources/providers.js';
18
- export { OAuth2Client, ConnectedCrouter, MemoryConnectionStore, profileFromClaims } from './oauth/index.js';
18
+ export { OAuth2Client, ConnectedCrouter, DEFAULT_DIRECTORY_TIMEOUT_MS, MemoryConnectionStore, profileFromClaims } from './oauth/index.js';
19
19
  export { refusalOf } from './oauth/index.js';
20
20
  export type { RefreshFailure, Refusal, RefusalKind } from './oauth/index.js';
21
21
  export { RunWatcher } from './resources/run-watcher.js';
@@ -32,8 +32,8 @@ export type { RunNotice, VerifyRunNoticeOptions } from './resources/run-notice.j
32
32
  export type { NodeStreamEvent, NodeStreamEventListener, NodeStreamEventType } from './resources/node-stream.js';
33
33
  export { describeToolDefault, followActivity } from './resources/activity.js';
34
34
  export type { ActivityStep, DescribeTool } from './resources/activity.js';
35
- export { APIError, APIConnectionError, APIConnectionTimeoutError, APIUserAbortError, AuthenticationError, BadRequestError, ConflictError, CrouterError, InternalServerError, NotFoundError, PermissionDeniedError, RateLimitError, RuntimeVersionError, UnprocessableEntityError, mapError, } from './errors.js';
36
- export type { RuntimeVersionDetails } from './errors.js';
35
+ export { APIError, APIConnectionError, APIConnectionTimeoutError, APIUserAbortError, AuthenticationError, BadRequestError, ConflictError, CrouterError, DeadlineExceededError, InternalServerError, NotFoundError, PermissionDeniedError, RateLimitError, RuntimeVersionError, UnprocessableEntityError, mapError, } from './errors.js';
36
+ export type { DeadlineDetails, RuntimeVersionDetails } from './errors.js';
37
37
  export { SDK_VERSION } from './version.js';
38
38
  export { sdkErrorCodes } from './error-codes.js';
39
39
  export type { SdkErrorCode } from './error-codes.js';
package/dist/index.js CHANGED
@@ -3,7 +3,7 @@ export { NodeStream } from './resources/node-stream.js';
3
3
  export { RunStream } from './resources/run-stream.js';
4
4
  export { CustomObjects } from './resources/custom-objects.js';
5
5
  export { RunReply } from './resources/run-reply.js';
6
- export { OAuth2Client, ConnectedCrouter, MemoryConnectionStore, profileFromClaims } from './oauth/index.js';
6
+ export { OAuth2Client, ConnectedCrouter, DEFAULT_DIRECTORY_TIMEOUT_MS, MemoryConnectionStore, profileFromClaims } from './oauth/index.js';
7
7
  export { refusalOf } from './oauth/index.js';
8
8
  export { RunWatcher } from './resources/run-watcher.js';
9
9
  export { GrantWatcher } from './resources/grant-watcher.js';
@@ -12,6 +12,6 @@ export { parsePrivateJwk, privateKeyFromFile } from './oauth/keygen.js';
12
12
  export { cursorFromRequest, forwardRunEvents } from './resources/forward.js';
13
13
  export { RUN_NOTICE_SCOPE, RunNoticeError, verifyRunNotice } from './resources/run-notice.js';
14
14
  export { describeToolDefault, followActivity } from './resources/activity.js';
15
- export { APIError, APIConnectionError, APIConnectionTimeoutError, APIUserAbortError, AuthenticationError, BadRequestError, ConflictError, CrouterError, InternalServerError, NotFoundError, PermissionDeniedError, RateLimitError, RuntimeVersionError, UnprocessableEntityError, mapError, } from './errors.js';
15
+ export { APIError, APIConnectionError, APIConnectionTimeoutError, APIUserAbortError, AuthenticationError, BadRequestError, ConflictError, CrouterError, DeadlineExceededError, InternalServerError, NotFoundError, PermissionDeniedError, RateLimitError, RuntimeVersionError, UnprocessableEntityError, mapError, } from './errors.js';
16
16
  export { SDK_VERSION } from './version.js';
17
17
  export { sdkErrorCodes } from './error-codes.js';
@@ -1,6 +1,11 @@
1
1
  #!/usr/bin/env node
2
+ // The `crouter-sdk` bin: `keygen` and `env`. The file keeps its first command's name because the
3
+ // published bin path (`dist/keygen-cli.js`) is part of the package.
4
+ import { runEnv } from './env-command.js';
2
5
  import { runKeygen } from './keygen-command.js';
3
- runKeygen(process.argv.slice(2)).then((code) => { process.exitCode = code; }, (error) => {
6
+ const args = process.argv.slice(2);
7
+ const run = args[0] === 'env' ? runEnv(args) : runKeygen(args);
8
+ run.then((code) => { process.exitCode = code; }, (error) => {
4
9
  console.error(error instanceof Error ? error.message : String(error));
5
10
  process.exitCode = 1;
6
11
  });
@@ -1,10 +1,9 @@
1
- import { execFile } from 'node:child_process';
2
1
  import { mkdir, open, unlink } from 'node:fs/promises';
3
2
  import { dirname } from 'node:path';
4
3
  import { createInterface } from 'node:readline';
4
+ import { CRTR_TIMEOUT_MS, notJoinedLines, parseJson, runCrtr, say } from './crtr.js';
5
5
  import { generateKeyPair } from './oauth/keygen.js';
6
6
  const USAGE = 'Usage: crouter-sdk keygen [--out <path>] [--app <client id>] [--yes]';
7
- const CRTR_TIMEOUT_MS = 60_000;
8
7
  function parseArgs(args) {
9
8
  if (args[0] !== 'keygen')
10
9
  throw new Error(USAGE);
@@ -41,11 +40,6 @@ const addedSteps = (path, app) => [
41
40
  ' 2. Check that a real sign-in works.',
42
41
  ` 3. Retire the old key: on the app's Sign-in tab, or \`crtr cloud apps keys retire --app-id ${app} --kid <old kid>\` (\`crtr cloud apps keys list --app-id ${app}\` shows the kids).`,
43
42
  ];
44
- const seconds = (ms) => {
45
- const n = Math.max(1, Math.round(ms / 1000));
46
- return `${n} second${n === 1 ? '' : 's'}`;
47
- };
48
- const say = (lines) => console.error(lines.join('\n'));
49
43
  /** Asks on stderr; resolves true only for Enter or y/yes. End of input declines. */
50
44
  function confirm(question) {
51
45
  const rl = createInterface({ input: process.stdin, output: process.stderr });
@@ -62,41 +56,17 @@ function confirm(question) {
62
56
  });
63
57
  });
64
58
  }
65
- const parseJson = (text) => {
66
- try {
67
- return JSON.parse(text.trim());
68
- }
69
- catch {
70
- return undefined;
59
+ /** Runs `crtr cloud apps keys add` once. */
60
+ async function addKey(app, publicJwk, timeoutMs) {
61
+ const outcome = await runCrtr(['cloud', 'apps', 'keys', 'add', '--app-id', app, '--jwk', publicJwk, '--json'], timeoutMs);
62
+ if (outcome.kind !== 'ok')
63
+ return outcome;
64
+ const answer = parseJson(outcome.stdout);
65
+ if (typeof answer?.kid !== 'string') {
66
+ const last = outcome.stdout.split('\n').map((line) => line.trim()).filter(Boolean).at(-1);
67
+ return { kind: 'failed', message: `crtr answered without the added key: ${last ?? '(no output)'}` };
71
68
  }
72
- };
73
- const lastLine = (text) => text.split('\n').map((line) => line.trim()).filter(Boolean).at(-1);
74
- /** The `message` of a JSON error that ends `text`, even when other lines (a warning, a notice) precede it. */
75
- const errorMessage = (text) => {
76
- const body = parseJson(text.slice(text.lastIndexOf('\n{') + 1));
77
- return typeof body?.message === 'string' && body.message ? body.message : undefined;
78
- };
79
- /** Runs `crtr cloud apps keys add` once: execFile, no shell, the caller's environment. */
80
- function addKey(app, publicJwk, timeoutMs) {
81
- const argv = ['cloud', 'apps', 'keys', 'add', '--app-id', app, '--jwk', publicJwk, '--json'];
82
- return new Promise((resolve) => {
83
- execFile('crtr', argv, { timeout: timeoutMs, env: process.env, maxBuffer: 1024 * 1024 }, (error, stdout, stderr) => {
84
- const err = error;
85
- if (err?.code === 'ENOENT')
86
- return resolve({ kind: 'missing' });
87
- if (err) {
88
- const message = err.killed
89
- ? `crtr did not answer within ${seconds(timeoutMs)}.`
90
- : errorMessage(stdout) ?? errorMessage(stderr) ?? lastLine(stderr) ?? lastLine(stdout) ?? err.message;
91
- return resolve({ kind: 'failed', message });
92
- }
93
- const answer = parseJson(stdout);
94
- if (typeof answer?.kid !== 'string') {
95
- return resolve({ kind: 'failed', message: `crtr answered without the added key: ${lastLine(stdout) ?? '(no output)'}` });
96
- }
97
- resolve({ kind: 'added', kid: answer.kid, created: answer.created !== false });
98
- });
99
- });
69
+ return { kind: 'added', kid: answer.kid, created: answer.created !== false };
100
70
  }
101
71
  /** The `crouter-sdk keygen` command. Returns the process exit code. */
102
72
  export async function runKeygen(argv, options = {}) {
@@ -143,6 +113,10 @@ export async function runKeygen(argv, options = {}) {
143
113
  say(["Couldn't find crtr, so the key was not added.", ...manualSteps(path)]);
144
114
  return 0;
145
115
  }
116
+ if (outcome.kind === 'not_joined') {
117
+ say([...notJoinedLines('the key was not added', outcome.joinStep), ...manualSteps(path)]);
118
+ return 2;
119
+ }
146
120
  if (outcome.kind === 'failed') {
147
121
  say([`Couldn't add the key: ${outcome.message}`, ...manualSteps(path)]);
148
122
  return 2;
@@ -94,6 +94,15 @@ export interface OAuth2Options {
94
94
  onRefusal?: (refusal: Refusal) => void | Promise<void>;
95
95
  /** Transport override for the directory requests and for the runtime calls of every `client(userId)`. */
96
96
  fetch?: typeof fetch;
97
+ /**
98
+ * One deadline, in milliseconds, for each directory operation: a discovery read, a token request (the code
99
+ * exchange or a refresh) or a revocation. It covers every attempt of that operation, the reading of the answer
100
+ * and every `runtime_provisioning` retry wait, including a `Retry-After`; a wait that would pass it fails at
101
+ * once. A refresh runs this bound while it holds the user's store lock, so the lock is held at most this long
102
+ * (plus the store's own time) and a hung directory cannot stall the user's other refreshes. Defaults to
103
+ * `DEFAULT_DIRECTORY_TIMEOUT_MS` (10 s). The error is `DeadlineExceededError` (`code: 'deadline_exceeded'`).
104
+ */
105
+ timeoutMs?: number;
97
106
  }
98
107
  /** A runtime client on one person's stored connection (`oauth.client(userId)`): a `Crouter` that refreshes its
99
108
  * token through the `OAuth2Client`'s store, plus whose connection it is and the runtime it calls. */
@@ -104,10 +113,13 @@ export declare class ConnectedCrouter extends Crouter {
104
113
  readonly runtimeUrl: string;
105
114
  constructor(connection: Pick<Connection, 'userId' | 'runtimeUrl'>, options: Pick<CrouterOptions, 'tokenSource' | 'onUnauthorized' | 'fetch'>);
106
115
  }
116
+ /** The default deadline of one directory operation (`OAuth2Options.timeoutMs`): a discovery read is forgotten when it passes. */
117
+ export declare const DEFAULT_DIRECTORY_TIMEOUT_MS = 10000;
107
118
  export declare class OAuth2Client {
108
119
  private readonly options;
109
120
  private readonly issuer;
110
121
  private readonly fetcher;
122
+ private readonly timeoutMs;
111
123
  private readonly store;
112
124
  private jwks?;
113
125
  private discoveryPromise?;
@@ -117,8 +129,13 @@ export declare class OAuth2Client {
117
129
  private readonly settled;
118
130
  constructor(options: OAuth2Options);
119
131
  /** The issuer's discovery document. The first successful read is kept for the client's life; a read that fails or
120
- * takes longer than 10 s rejects and is forgotten, so the next call reads again. */
132
+ * passes the deadline (`timeoutMs`) rejects and is forgotten, so the next call reads again. */
121
133
  private discovery;
134
+ /** One directory request and the reading of its answer under `deadlineAt`; a deadline rejects `DeadlineExceededError`. */
135
+ private bounded;
136
+ /** The discovery document within the operation's `deadlineAt`: the memoised read keeps its own deadline, and this
137
+ * caller stops waiting for it at its own. */
138
+ private discoveryWithin;
122
139
  /**
123
140
  * The app's page on the signed-in person's crouter cloud account, where they change or remove what the app may do:
124
141
  * the discovery document's `account_app_uri_template` with its one `{client_id}` replaced by the encoded client id.
@@ -211,6 +228,7 @@ export declare class OAuth2Client {
211
228
  nonce?: string;
212
229
  }): Promise<IdTokenClaims>;
213
230
  revoke(connection: Connection): Promise<boolean>;
231
+ /** A token request. `deadlineAt` is the whole operation's deadline (discovery included); without one it starts now. */
214
232
  private tokenResponse;
215
233
  private connectionFrom;
216
234
  private post;
@@ -1,7 +1,9 @@
1
1
  import { calculateJwkThumbprint, decodeJwt, decodeProtectedHeader, exportJWK, importJWK, importPKCS8, SignJWT } from 'jose';
2
- import { JwksCache, parseScope, verifyIdToken } from '@north-light/identity';
2
+ import { boundedFetch, FetchDeadlineError, JwksCache, parseScope, verifyIdToken } from '@north-light/identity';
3
+ import { deadlineExceeded, sleepWithin } from '@north-light/api';
3
4
  import { APIError, CrouterError, mapError } from '../errors.js';
4
5
  import { Crouter } from '../client.js';
6
+ import { withinDeadline } from '../resources/request.js';
5
7
  import { RunWatcher } from '../resources/run-watcher.js';
6
8
  import { GrantWatcher } from '../resources/grant-watcher.js';
7
9
  import { attachRefusal, isRefusalCode, refusalKindOf } from './refusal.js';
@@ -73,8 +75,8 @@ export class ConnectedCrouter extends Crouter {
73
75
  this.runtimeUrl = connection.runtimeUrl;
74
76
  }
75
77
  }
76
- /** How long one discovery read may take before it rejects (and is forgotten). */
77
- const DISCOVERY_TIMEOUT_MS = 10_000;
78
+ /** The default deadline of one directory operation (`OAuth2Options.timeoutMs`): a discovery read is forgotten when it passes. */
79
+ export const DEFAULT_DIRECTORY_TIMEOUT_MS = 10_000;
78
80
  const assertionType = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer';
79
81
  let refusalScope;
80
82
  function handlerScope() {
@@ -88,6 +90,7 @@ export class OAuth2Client {
88
90
  options;
89
91
  issuer;
90
92
  fetcher;
93
+ timeoutMs;
91
94
  store;
92
95
  jwks;
93
96
  discoveryPromise;
@@ -104,20 +107,22 @@ export class OAuth2Client {
104
107
  this.issuer = options.issuer;
105
108
  this.store = options.store;
106
109
  this.fetcher = options.fetch ?? fetch;
110
+ this.timeoutMs = options.timeoutMs ?? DEFAULT_DIRECTORY_TIMEOUT_MS;
111
+ if (!Number.isFinite(this.timeoutMs) || this.timeoutMs <= 0 || this.timeoutMs > 2_147_483_647)
112
+ throw new CrouterError('OAuth2Options.timeoutMs must be a positive finite number of at most 2147483647');
107
113
  }
108
114
  /** The issuer's discovery document. The first successful read is kept for the client's life; a read that fails or
109
- * takes longer than 10 s rejects and is forgotten, so the next call reads again. */
115
+ * passes the deadline (`timeoutMs`) rejects and is forgotten, so the next call reads again. */
110
116
  discovery() {
111
117
  if (this.discoveryPromise)
112
118
  return this.discoveryPromise;
113
119
  const read = (async () => {
114
- const response = await this.fetcher(`${this.issuer.replace(/\/$/, '')}/.well-known/openid-configuration`, { signal: AbortSignal.timeout(DISCOVERY_TIMEOUT_MS) });
115
- if (!response.ok)
116
- throw new CrouterError(`Directory discovery failed: ${response.status}`);
117
- const value = await response.json();
118
- if (!value.authorization_endpoint || !value.token_endpoint || !value.jwks_uri)
119
- throw new CrouterError('Directory discovery has missing endpoints');
120
- return value;
120
+ const value = await this.bounded(`${this.issuer.replace(/\/$/, '')}/.well-known/openid-configuration`, {}, Date.now() + this.timeoutMs, async (response) => {
121
+ if (!response.ok)
122
+ throw new CrouterError(`Directory discovery failed: ${response.status}`);
123
+ return await response.json();
124
+ });
125
+ return parseDiscovery(value, this.issuer);
121
126
  })();
122
127
  this.discoveryPromise = read;
123
128
  // Only this read's own failure clears the memo, never a newer read started after it.
@@ -125,6 +130,27 @@ export class OAuth2Client {
125
130
  this.discoveryPromise = undefined; });
126
131
  return read;
127
132
  }
133
+ /** One directory request and the reading of its answer under `deadlineAt`; a deadline rejects `DeadlineExceededError`. */
134
+ async bounded(url, init, deadlineAt, read) {
135
+ try {
136
+ return await boundedFetch(this.fetcher, url, init, { deadlineAt }, read);
137
+ }
138
+ catch (error) {
139
+ if (error instanceof FetchDeadlineError)
140
+ throw mapError(deadlineExceeded(error.deadlineAt));
141
+ throw error;
142
+ }
143
+ }
144
+ /** The discovery document within the operation's `deadlineAt`: the memoised read keeps its own deadline, and this
145
+ * caller stops waiting for it at its own. */
146
+ async discoveryWithin(deadlineAt) {
147
+ try {
148
+ return await withinDeadline(this.discovery(), deadlineAt);
149
+ }
150
+ catch (error) {
151
+ throw error instanceof APIError ? mapError(error) : error;
152
+ }
153
+ }
128
154
  /**
129
155
  * The app's page on the signed-in person's crouter cloud account, where they change or remove what the app may do:
130
156
  * the discovery document's `account_app_uri_template` with its one `{client_id}` replaced by the encoded client id.
@@ -284,8 +310,10 @@ export class OAuth2Client {
284
310
  if (stored.refreshToken !== connection.refreshToken)
285
311
  return { kind: 'current', connection: stored };
286
312
  let response;
313
+ // The refresh's one deadline starts here, with the lock held, so the lock is held no longer than `timeoutMs`
314
+ // for the discovery read, the signing and the token request together (plus the store's own time).
287
315
  try {
288
- response = await this.tokenResponse({ grant_type: 'refresh_token', refresh_token: stored.refreshToken });
316
+ response = await this.tokenResponse({ grant_type: 'refresh_token', refresh_token: stored.refreshToken }, Date.now() + this.timeoutMs);
289
317
  }
290
318
  catch (error) {
291
319
  if (error instanceof APIError && error.origin === 'directory' && isRefusalCode(error.code))
@@ -376,8 +404,10 @@ export class OAuth2Client {
376
404
  }
377
405
  /** Verify an ID token: ES256 signature against the discovered JWKS, `iss`, `aud` = client id, `exp`, and `nonce` when given. A token that fails any check throws `id_token_invalid`; a JWKS that can't be fetched or is malformed rethrows that error. */
378
406
  async verifyIdToken(idToken, { nonce } = {}) {
379
- const discovery = await this.discovery();
380
- this.jwks ??= new JwksCache(this.issuer, this.fetcher, discovery.jwks_uri);
407
+ // One deadline for the operation: the discovery read and the key-set read share it.
408
+ const deadlineAt = Date.now() + this.timeoutMs;
409
+ const discovery = await this.discoveryWithin(deadlineAt);
410
+ this.jwks ??= new JwksCache(this.issuer, this.fetcher, discovery.jwks_uri, { timeoutMs: this.timeoutMs });
381
411
  const invalid = (reason) => new CrouterError(`ID token failed verification: ${reason}`, 'id_token_invalid');
382
412
  try {
383
413
  decodeProtectedHeader(idToken);
@@ -387,7 +417,7 @@ export class OAuth2Client {
387
417
  }
388
418
  let claims;
389
419
  try {
390
- claims = await verifyIdToken({ token: idToken, jwks: this.jwks, issuer: this.issuer, audience: this.options.clientId });
420
+ claims = await withinDeadline(verifyIdToken({ token: idToken, jwks: this.jwks, issuer: this.issuer, audience: this.options.clientId }), deadlineAt);
391
421
  }
392
422
  catch (error) {
393
423
  // A token that fails verification is id_token_invalid; a JWKS that cannot be fetched or is malformed is the
@@ -396,6 +426,10 @@ export class OAuth2Client {
396
426
  // The identity package's own checks throw "Invalid ID token …". Duck-typed, since jose may be a separate copy.
397
427
  const joseCode = error?.code;
398
428
  const message = error instanceof Error ? error.message : String(error);
429
+ if (error instanceof FetchDeadlineError)
430
+ throw mapError(deadlineExceeded(error.deadlineAt));
431
+ if (error instanceof APIError)
432
+ throw mapError(error);
399
433
  const keySetFault = joseCode === 'ERR_JWKS_INVALID' || joseCode === 'ERR_JWKS_TIMEOUT';
400
434
  if ((typeof joseCode === 'string' && joseCode.startsWith('ERR_J') && !keySetFault) || /^Invalid ID token/.test(message))
401
435
  throw invalid(message);
@@ -406,17 +440,19 @@ export class OAuth2Client {
406
440
  return claims;
407
441
  }
408
442
  async revoke(connection) {
409
- const metadata = await this.discovery();
443
+ const deadlineAt = Date.now() + this.timeoutMs;
444
+ const metadata = await this.discoveryWithin(deadlineAt);
410
445
  if (!metadata.revocation_endpoint)
411
446
  return false;
412
- await this.post(metadata.revocation_endpoint, { token: connection.refreshToken, token_type_hint: 'refresh_token' }, true);
447
+ await this.post(metadata.revocation_endpoint, { token: connection.refreshToken, token_type_hint: 'refresh_token' }, true, deadlineAt);
413
448
  return true;
414
449
  }
415
- async tokenResponse(params) {
416
- const metadata = await this.discovery();
450
+ /** A token request. `deadlineAt` is the whole operation's deadline (discovery included); without one it starts now. */
451
+ async tokenResponse(params, deadlineAt = Date.now() + this.timeoutMs) {
452
+ const metadata = await this.discoveryWithin(deadlineAt);
417
453
  if (!metadata.token_endpoint_auth_methods_supported?.includes('private_key_jwt'))
418
454
  throw new CrouterError('Directory does not support private_key_jwt');
419
- return await this.post(metadata.token_endpoint, params);
455
+ return await this.post(metadata.token_endpoint, params, false, deadlineAt);
420
456
  }
421
457
  connectionFrom(response, previous) {
422
458
  if (typeof response.access_token !== 'string' || typeof response.refresh_token !== 'string')
@@ -434,7 +470,7 @@ export class OAuth2Client {
434
470
  ...(idToken ? { idToken } : {}),
435
471
  };
436
472
  }
437
- async post(endpoint, values, revocation = false) {
473
+ async post(endpoint, values, revocation = false, deadlineAt = Date.now() + this.timeoutMs) {
438
474
  const key = this.options.privateKey;
439
475
  if (typeof key !== 'string' && key.alg !== undefined && key.alg !== 'ES256' && key.alg !== 'RS256')
440
476
  throw new CrouterError('privateKey must use ES256 or RS256');
@@ -455,19 +491,24 @@ export class OAuth2Client {
455
491
  imported = await importJWK(key, alg);
456
492
  }
457
493
  const kid = this.options.keyId ?? (typeof key === 'string' ? undefined : key.kid);
494
+ // `deadlineAt` is the whole operation's: discovery, signing, every attempt, the reading of each answer and each retry wait.
458
495
  for (let attempt = 0;; attempt++) {
459
496
  const now = Math.floor(Date.now() / 1000);
460
497
  const assertion = await new SignJWT({})
461
498
  .setProtectedHeader({ alg, typ: 'JWT', ...(kid ? { kid } : {}) })
462
499
  .setIssuer(this.options.clientId).setSubject(this.options.clientId).setAudience(endpoint)
463
500
  .setIssuedAt(now).setExpirationTime(now + 60).setJti(crypto.randomUUID()).sign(imported);
464
- const response = await this.fetcher(endpoint, {
501
+ const answer = await this.bounded(endpoint, {
465
502
  method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' },
466
503
  body: new URLSearchParams({ ...values, client_id: this.options.clientId, client_assertion_type: assertionType, client_assertion: assertion }),
504
+ }, deadlineAt, async (response) => {
505
+ if (revocation && response.ok)
506
+ return { response, payload: undefined };
507
+ return { response, payload: await response.json() };
467
508
  });
509
+ const { response, payload } = answer;
468
510
  if (revocation && response.ok)
469
511
  return undefined;
470
- const payload = await response.json();
471
512
  if (response.ok)
472
513
  return payload;
473
514
  const refusal = payload;
@@ -479,8 +520,13 @@ export class OAuth2Client {
479
520
  const reconnect = isRefusalCode(code);
480
521
  if (code === 'runtime_provisioning' && attempt < 2) {
481
522
  const retryAfter = refusal.retry_after_s ?? Number(response.headers.get('retry-after'));
482
- const delay = Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1_000 : 500 * 2 ** attempt * (0.5 + Math.random());
483
- await new Promise((resolve) => setTimeout(resolve, delay));
523
+ // A Retry-After longer than the time left fails now (DeadlineExceededError) instead of sleeping past the deadline.
524
+ try {
525
+ await sleepWithin(500 * 2 ** attempt * (0.5 + Math.random()), { deadlineAt }, Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter : undefined);
526
+ }
527
+ catch (stop) {
528
+ throw stop instanceof APIError ? mapError(stop) : stop;
529
+ }
484
530
  continue;
485
531
  }
486
532
  const message = refusal.error_description ?? refusal.error ?? 'Directory token request failed';
@@ -513,6 +559,46 @@ async function describeSigningKey(key, kid) {
513
559
  const named = thumbprint ? `signed without a kid by the key whose thumbprint is "${thumbprint}"` : 'signed without a kid';
514
560
  return { kid: null, ...(thumbprint ? { thumbprint } : {}), hint: `the directory rejected the client assertion ${named}; ${question}` };
515
561
  }
562
+ /** The discovery JSON as a `Discovery`, or `CrouterError` naming the field that is wrong. `authorization_endpoint`,
563
+ * `token_endpoint` and `jwks_uri` must be absolute `https:` URLs (`http:` when the issuer itself is `http:`, for a
564
+ * local directory); `revocation_endpoint`, when present, likewise; `token_endpoint_auth_methods_supported`, when
565
+ * present, an array of strings. Which origin an endpoint may sit on is not restricted: the directory chooses it. */
566
+ function parseDiscovery(value, issuer) {
567
+ if (typeof value !== 'object' || value === null || Array.isArray(value))
568
+ throw new CrouterError('Directory discovery is not a JSON object');
569
+ const record = value;
570
+ const allowHttp = new URL(issuer).protocol === 'http:';
571
+ const endpoint = (name) => {
572
+ const field = record[name];
573
+ if (field === undefined || field === null || field === '')
574
+ throw new CrouterError('Directory discovery has missing endpoints');
575
+ if (typeof field !== 'string')
576
+ throw new CrouterError(`Directory discovery's ${name} must be a URL string`);
577
+ let url;
578
+ try {
579
+ url = new URL(field);
580
+ }
581
+ catch {
582
+ throw new CrouterError(`Directory discovery's ${name} is not an absolute URL`);
583
+ }
584
+ if (url.protocol !== 'https:' && !(allowHttp && url.protocol === 'http:'))
585
+ throw new CrouterError(`Directory discovery's ${name} must be an https URL`);
586
+ return field;
587
+ };
588
+ const methods = record['token_endpoint_auth_methods_supported'];
589
+ if (methods !== undefined && (!Array.isArray(methods) || methods.some((method) => typeof method !== 'string'))) {
590
+ throw new CrouterError("Directory discovery's token_endpoint_auth_methods_supported must be an array of strings");
591
+ }
592
+ const template = record['account_app_uri_template'];
593
+ return {
594
+ authorization_endpoint: endpoint('authorization_endpoint'),
595
+ token_endpoint: endpoint('token_endpoint'),
596
+ jwks_uri: endpoint('jwks_uri'),
597
+ ...(record['revocation_endpoint'] === undefined || record['revocation_endpoint'] === null ? {} : { revocation_endpoint: endpoint('revocation_endpoint') }),
598
+ ...(methods === undefined ? {} : { token_endpoint_auth_methods_supported: methods }),
599
+ ...(typeof template === 'string' ? { account_app_uri_template: template } : {}),
600
+ };
601
+ }
516
602
  function notConnected(userId) {
517
603
  return new CrouterError(`No stored connection for ${userId}: they must sign in with crouter cloud again`, 'not_connected');
518
604
  }
@@ -5,3 +5,8 @@ export declare function withQuery(path: string, query: object | undefined): stri
5
5
  * request timeout by the hold, so the timeout bounds the answer rather than the wait. Shared by `runs.get` and
6
6
  * `grant.get`. */
7
7
  export declare function heldReadOptions(wait: number | undefined, options: RequestOptions): RequestOptions;
8
+ /** The call's one deadline, fixed at call start: `budgetMs` from now and/or `deadlineAt`, whichever is earlier, as
9
+ * `deadlineAt` (so a retry after a 401 and every transport attempt share it). Options without either pass through. */
10
+ export declare function deadline(options: RequestOptions | undefined): RequestOptions | undefined;
11
+ /** `promise`, or `deadline_exceeded` once `deadlineAt` passes first. */
12
+ export declare function withinDeadline<T>(promise: Promise<T>, deadlineAt: number | undefined): Promise<T>;
@@ -1,3 +1,4 @@
1
+ import { deadlineExceeded, MAX_TIMER_MS } from '@north-light/api';
1
2
  export function withQuery(path, query) {
2
3
  if (query === undefined)
3
4
  return path;
@@ -17,3 +18,33 @@ export function heldReadOptions(wait, options) {
17
18
  throw new RangeError('wait must be 1–25 seconds');
18
19
  return wait ? { ...options, timeout: (options.timeout ?? 30_000) + wait * 1_000 } : options;
19
20
  }
21
+ /** The call's one deadline, fixed at call start: `budgetMs` from now and/or `deadlineAt`, whichever is earlier, as
22
+ * `deadlineAt` (so a retry after a 401 and every transport attempt share it). Options without either pass through. */
23
+ export function deadline(options) {
24
+ if (options === undefined || (options.budgetMs === undefined && options.deadlineAt === undefined))
25
+ return options;
26
+ const { budgetMs, deadlineAt, ...rest } = options;
27
+ if (budgetMs !== undefined && (!Number.isFinite(budgetMs) || budgetMs <= 0 || budgetMs > MAX_TIMER_MS))
28
+ throw new RangeError(`budgetMs must be a positive finite number of at most ${MAX_TIMER_MS}`);
29
+ if (deadlineAt !== undefined && (!Number.isFinite(deadlineAt) || deadlineAt - Date.now() > MAX_TIMER_MS))
30
+ throw new RangeError(`deadlineAt must be a finite epoch-millisecond instant at most ${MAX_TIMER_MS} ms from now`);
31
+ return { ...rest, deadlineAt: Math.min(deadlineAt ?? Infinity, budgetMs === undefined ? Infinity : Date.now() + budgetMs) };
32
+ }
33
+ /** `promise`, or `deadline_exceeded` once `deadlineAt` passes first. */
34
+ export async function withinDeadline(promise, deadlineAt) {
35
+ if (deadlineAt === undefined)
36
+ return promise;
37
+ const remaining = deadlineAt - Date.now();
38
+ if (remaining <= 0) {
39
+ promise.catch(() => { });
40
+ throw deadlineExceeded(deadlineAt);
41
+ }
42
+ let timer;
43
+ try {
44
+ return await Promise.race([promise, new Promise((_resolve, reject) => { timer = setTimeout(() => reject(deadlineExceeded(deadlineAt)), remaining); })]);
45
+ }
46
+ finally {
47
+ if (timer !== undefined)
48
+ clearTimeout(timer);
49
+ }
50
+ }
@@ -150,6 +150,13 @@ export declare class Runs {
150
150
  start_turn?: boolean;
151
151
  idempotencyKey?: string;
152
152
  }): Promise<SentRunMessage>;
153
+ /** A keyed POST under one overall deadline. `budgetMs` / `deadlineAt` (fixed here, once) bound every attempt, every
154
+ * backoff and every `retry_after_s` wait together, so the call never outlasts its deadline: an application lease
155
+ * taken for the call needs only to exceed that deadline. A server `retry_after_s` is honored only when it fits the
156
+ * time left; otherwise the call rejects at once with `DeadlineExceededError` (`reason: 'retry_after'`), carrying
157
+ * the refusal as `cause`, rather than sleeping past the deadline. A send without a deadline keeps the capped
158
+ * backoff and honors any `retry_after_s`. */
159
+ private keyedSend;
153
160
  interrupt(runId: string, options?: RequestOptions & {
154
161
  children?: 'stop' | 'continue';
155
162
  }): Promise<RunObject>;
@@ -1,8 +1,9 @@
1
- import { APIError } from '../errors.js';
1
+ import { sleepWithin } from '@north-light/api';
2
+ import { APIError, mapError } from '../errors.js';
2
3
  import { RunStream } from './run-stream.js';
3
4
  import { RunReply } from './run-reply.js';
4
5
  import { resolveJsonSchema } from '../schema.js';
5
- import { heldReadOptions } from './request.js';
6
+ import { deadline, heldReadOptions } from './request.js';
6
7
  export class Runs {
7
8
  request;
8
9
  openEvents;
@@ -15,16 +16,7 @@ export class Runs {
15
16
  async start(params, options = {}) {
16
17
  const { idempotencyKey = crypto.randomUUID(), maxRetries = 2, ...rest } = options;
17
18
  const body = params.output_schema === undefined ? params : { ...params, output_schema: resolveJsonSchema(params.output_schema) };
18
- for (let attempt = 0;; attempt++) {
19
- try {
20
- return await this.request('POST', '/v1/runs', body, { ...rest, maxRetries: 0, headers: { ...rest.headers, 'Idempotency-Key': idempotencyKey } });
21
- }
22
- catch (error) {
23
- if (attempt >= maxRetries || !(error instanceof APIError) || !retryableSend(error))
24
- throw error;
25
- await pause(error.retryAfterS ? error.retryAfterS * 1_000 : 500 * 2 ** attempt, rest.signal);
26
- }
27
- }
19
+ return this.keyedSend('/v1/runs', body, idempotencyKey, maxRetries, rest);
28
20
  }
29
21
  get(runId, options = {}) {
30
22
  const { wait, ...rest } = options;
@@ -63,14 +55,38 @@ export class Runs {
63
55
  const body = { message, ...(start_turn === undefined ? {} : { start_turn }) };
64
56
  // One key per logical send, reused on every retry: the daemon answers a repeat with the first send's
65
57
  // result, so a retry after a lost response cannot deliver the message twice.
58
+ return this.keyedSend(`${runPath(runId)}/messages`, body, idempotencyKey, maxRetries, rest);
59
+ }
60
+ /** A keyed POST under one overall deadline. `budgetMs` / `deadlineAt` (fixed here, once) bound every attempt, every
61
+ * backoff and every `retry_after_s` wait together, so the call never outlasts its deadline: an application lease
62
+ * taken for the call needs only to exceed that deadline. A server `retry_after_s` is honored only when it fits the
63
+ * time left; otherwise the call rejects at once with `DeadlineExceededError` (`reason: 'retry_after'`), carrying
64
+ * the refusal as `cause`, rather than sleeping past the deadline. A send without a deadline keeps the capped
65
+ * backoff and honors any `retry_after_s`. */
66
+ async keyedSend(path, body, idempotencyKey, maxRetries, rest) {
67
+ const options = deadline(rest) ?? rest;
66
68
  for (let attempt = 0;; attempt++) {
67
69
  try {
68
- return await this.request('POST', `${runPath(runId)}/messages`, body, { ...rest, maxRetries: 0, headers: { ...rest.headers, 'Idempotency-Key': idempotencyKey } });
70
+ return await this.request('POST', path, body, { ...options, maxRetries: 0, headers: { ...options.headers, 'Idempotency-Key': idempotencyKey } });
69
71
  }
70
72
  catch (error) {
71
73
  if (attempt >= maxRetries || !(error instanceof APIError) || !retryableSend(error))
72
74
  throw error;
73
- await pause(error.retryAfterS ? error.retryAfterS * 1_000 : 500 * 2 ** attempt, rest.signal);
75
+ // The server's wait is honored only against a deadline (sleepWithin refuses a wait longer than the time
76
+ // left); with none, the plain backoff applies and a Retry-After is waited as before.
77
+ const waitS = error.retryAfterS;
78
+ try {
79
+ if (options.deadlineAt === undefined)
80
+ await pause(waitS ? waitS * 1_000 : 500 * 2 ** attempt, options.signal);
81
+ else
82
+ await sleepWithin(500 * 2 ** attempt, { deadlineAt: options.deadlineAt, signal: options.signal }, waitS, error);
83
+ }
84
+ catch (stop) {
85
+ // An abort during the wait rejects with the signal's own reason, as `pause` does.
86
+ if (options.signal?.aborted)
87
+ throw options.signal.reason;
88
+ throw stop instanceof APIError ? mapError(stop) : stop;
89
+ }
74
90
  }
75
91
  }
76
92
  }
@@ -120,9 +136,11 @@ export class Runs {
120
136
  /** Long-polls `runs.get` until the run is `settled`, `waiting_on_user`, or `idle`, and returns that response
121
137
  * unchanged (`result` is not parsed). Aborting `signal` rejects with its reason and leaves the run as it is. */
122
138
  async wait(runId, options = {}) {
123
- return abortable(options.signal, async () => {
139
+ // One deadline for the whole wait: every poll shares it instead of restarting `budgetMs`.
140
+ const bounded = deadline(options) ?? options;
141
+ return abortable(bounded.signal, async () => {
124
142
  for (;;) {
125
- const result = await this.get(runId, { ...options, wait: 25 });
143
+ const result = await this.get(runId, { ...bounded, wait: 25 });
126
144
  if (result.status === 'settled' || result.status === 'waiting_on_user' || result.status === 'idle')
127
145
  return result;
128
146
  }
@@ -133,7 +151,9 @@ export class Runs {
133
151
  * `APIError` code `waiting_on_user`. A run that stops without settling rejects `run_idle` (untouched:
134
152
  * send `runs.message` or `runs.cancel`). Aborting `signal` rejects with its reason; the run is never canceled. */
135
153
  async parse(params, options = {}) {
136
- const { onQuestion, idempotencyKey, ...rest } = options;
154
+ const { onQuestion, idempotencyKey, ...unbounded } = options;
155
+ // One deadline for the start, every poll and every answer.
156
+ const rest = deadline(unbounded) ?? unbounded;
137
157
  return abortable(rest.signal, () => this.parseRun(params, onQuestion, idempotencyKey, rest));
138
158
  }
139
159
  async parseRun(params, onQuestion, idempotencyKey, rest) {
@@ -0,0 +1,7 @@
1
+ /** A clock a test steps by hand: `now()` returns the same instant until `advance(ms)` moves it. */
2
+ export interface ManualClock {
3
+ now(): number;
4
+ advance(ms: number): void;
5
+ }
6
+ /** A `ManualClock` starting at `startMs` (default 2026-01-01T00:00:00Z). Pass it to `FakeCloud` as `clock`. */
7
+ export declare function manualClock(startMs?: number): ManualClock;
@@ -0,0 +1,12 @@
1
+ /** A `ManualClock` starting at `startMs` (default 2026-01-01T00:00:00Z). Pass it to `FakeCloud` as `clock`. */
2
+ export function manualClock(startMs = Date.UTC(2026, 0, 1)) {
3
+ let current = startMs;
4
+ return {
5
+ now: () => current,
6
+ advance(ms) {
7
+ if (!Number.isFinite(ms) || ms < 0)
8
+ throw new RangeError(`advance needs a non-negative number of ms, got ${ms}`);
9
+ current += ms;
10
+ },
11
+ };
12
+ }