@north-light/sdk 0.3.414 → 0.3.416
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/dist/client.d.ts +14 -0
- package/dist/client.js +72 -33
- package/dist/crtr.d.ts +24 -0
- package/dist/crtr.js +50 -0
- package/dist/env-command.d.ts +15 -0
- package/dist/env-command.js +138 -0
- package/dist/error-codes.d.ts +1 -1
- package/dist/error-codes.js +2 -0
- package/dist/errors.d.ts +15 -0
- package/dist/errors.js +38 -24
- package/dist/index.d.ts +3 -3
- package/dist/index.js +2 -2
- package/dist/keygen-cli.js +6 -1
- package/dist/keygen-command.js +15 -41
- package/dist/oauth/index.d.ts +19 -1
- package/dist/oauth/index.js +111 -25
- package/dist/resources/request.d.ts +5 -0
- package/dist/resources/request.js +31 -0
- package/dist/resources/runs.d.ts +7 -0
- package/dist/resources/runs.js +37 -17
- package/dist/testing/clock.d.ts +7 -0
- package/dist/testing/clock.js +12 -0
- package/dist/testing/cloud.d.ts +19 -0
- package/dist/testing/cloud.js +48 -14
- package/dist/testing/conformance/index.d.ts +2 -0
- package/dist/testing/conformance/index.js +3 -0
- package/dist/testing/conformance/keyed-write.d.ts +43 -0
- package/dist/testing/conformance/keyed-write.js +124 -0
- package/dist/testing/index.d.ts +2 -0
- package/dist/testing/index.js +1 -0
- package/dist/testing/notices.d.ts +7 -1
- package/dist/testing/notices.js +7 -3
- package/dist/testing/run.d.ts +2 -0
- package/dist/testing/run.js +5 -5
- package/dist/testing/users.d.ts +9 -3
- package/dist/testing/users.js +11 -3
- package/dist/types.d.ts +8 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- 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';
|
package/dist/keygen-cli.js
CHANGED
|
@@ -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
|
-
|
|
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
|
});
|
package/dist/keygen-command.js
CHANGED
|
@@ -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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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;
|
package/dist/oauth/index.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
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;
|
package/dist/oauth/index.js
CHANGED
|
@@ -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
|
-
/**
|
|
77
|
-
const
|
|
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
|
-
*
|
|
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
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
380
|
-
|
|
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
|
|
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
|
-
|
|
416
|
-
|
|
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
|
|
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
|
-
|
|
483
|
-
|
|
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
|
+
}
|
package/dist/resources/runs.d.ts
CHANGED
|
@@ -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>;
|
package/dist/resources/runs.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
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',
|
|
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
|
-
|
|
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
|
-
|
|
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, { ...
|
|
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, ...
|
|
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
|
+
}
|