@north-light/sdk 0.3.418 → 0.3.419
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/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/oauth/index.d.ts +3 -3
- package/dist/oauth/index.js +1 -1
- package/dist/oauth/refusal.d.ts +20 -1
- package/dist/oauth/refusal.js +22 -1
- package/dist/testing/cloud.d.ts +66 -0
- package/dist/testing/cloud.js +124 -1
- package/dist/testing/index.d.ts +3 -3
- package/dist/testing/index.js +1 -1
- package/dist/testing/users.d.ts +14 -1
- package/dist/testing/users.js +2 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +3 -3
package/dist/index.d.ts
CHANGED
|
@@ -16,8 +16,8 @@ export type { MemoryDocument, MemoryGetParams, MemoryListItem, MemoryListPage, M
|
|
|
16
16
|
export type { Question, QuestionAnswered, QuestionFields, QuestionResponses, QuestionSlot, QuestionStatus } from './resources/questions.js';
|
|
17
17
|
export type { ProviderToolEntry } from './resources/providers.js';
|
|
18
18
|
export { OAuth2Client, ConnectedCrouter, DEFAULT_DIRECTORY_TIMEOUT_MS, MemoryConnectionStore, profileFromClaims } from './oauth/index.js';
|
|
19
|
-
export { refusalOf } from './oauth/index.js';
|
|
20
|
-
export type { RefreshFailure, Refusal, RefusalKind } from './oauth/index.js';
|
|
19
|
+
export { authorizationRefusalOf, refusalOf } from './oauth/index.js';
|
|
20
|
+
export type { AuthorizationRefusal, RefreshFailure, Refusal, RefusalKind } from './oauth/index.js';
|
|
21
21
|
export { RunWatcher } from './resources/run-watcher.js';
|
|
22
22
|
export type { PendingRun, RunActivityEvent, RunEnded, RunEnding, RunWatchOptions } from './resources/run-watcher.js';
|
|
23
23
|
export { GrantWatcher } from './resources/grant-watcher.js';
|
package/dist/index.js
CHANGED
|
@@ -4,7 +4,7 @@ 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
6
|
export { OAuth2Client, ConnectedCrouter, DEFAULT_DIRECTORY_TIMEOUT_MS, MemoryConnectionStore, profileFromClaims } from './oauth/index.js';
|
|
7
|
-
export { refusalOf } from './oauth/index.js';
|
|
7
|
+
export { authorizationRefusalOf, refusalOf } from './oauth/index.js';
|
|
8
8
|
export { RunWatcher } from './resources/run-watcher.js';
|
|
9
9
|
export { GrantWatcher } from './resources/grant-watcher.js';
|
|
10
10
|
export { generateKeyPair } from './oauth/keygen.js';
|
package/dist/oauth/index.d.ts
CHANGED
|
@@ -4,8 +4,8 @@ import { Crouter, type CrouterOptions } from '../client.js';
|
|
|
4
4
|
import { RunWatcher, type RunWatchOptions } from '../resources/run-watcher.js';
|
|
5
5
|
import { GrantWatcher, type GrantWatchOptions } from '../resources/grant-watcher.js';
|
|
6
6
|
import { type Refusal } from './refusal.js';
|
|
7
|
-
export { refusalOf } from './refusal.js';
|
|
8
|
-
export type { Refusal, RefusalKind } from './refusal.js';
|
|
7
|
+
export { authorizationRefusalOf, refusalOf } from './refusal.js';
|
|
8
|
+
export type { AuthorizationRefusal, Refusal, RefusalKind } from './refusal.js';
|
|
9
9
|
/** One connection `refreshIdleConnections` could not refresh and that is still stored: an error other than a
|
|
10
10
|
* refusal, or a refusal whose `onRefusal` threw (then `refusalOf(error)` is set and `error.cause` is what it threw). */
|
|
11
11
|
export interface RefreshFailure {
|
|
@@ -81,7 +81,7 @@ export interface OAuth2Options {
|
|
|
81
81
|
* rotates and saves under its `lock`, and a refused connection is removed from it. */
|
|
82
82
|
store: ConnectionStore;
|
|
83
83
|
/**
|
|
84
|
-
* Called when the directory refuses to refresh a stored connection (`grant_removed`, `grant_suspended`,
|
|
84
|
+
* Called when the directory refuses to refresh a stored connection (`grant_removed`, `grant_suspended`, `app_in_testing`,
|
|
85
85
|
* `refresh_token_expired`, `refresh_token_reused`, `grant_revoked`): act on the person's data here, once, for
|
|
86
86
|
* every caller (a request, `refreshIdleConnections`, a `RunWatcher`). `refusal.kind` says what it means.
|
|
87
87
|
*
|
package/dist/oauth/index.js
CHANGED
|
@@ -7,7 +7,7 @@ import { withinDeadline } from '../resources/request.js';
|
|
|
7
7
|
import { RunWatcher } from '../resources/run-watcher.js';
|
|
8
8
|
import { GrantWatcher } from '../resources/grant-watcher.js';
|
|
9
9
|
import { attachRefusal, isRefusalCode, refusalKindOf } from './refusal.js';
|
|
10
|
-
export { refusalOf } from './refusal.js';
|
|
10
|
+
export { authorizationRefusalOf, refusalOf } from './refusal.js';
|
|
11
11
|
/** A connection store for one process (local development and tests): its lock does not reach other processes. */
|
|
12
12
|
export class MemoryConnectionStore {
|
|
13
13
|
connections = new Map();
|
package/dist/oauth/refusal.d.ts
CHANGED
|
@@ -5,6 +5,9 @@ import { APIError } from '../errors.js';
|
|
|
5
5
|
* deleted the app's runs; delete what the app stores for them.
|
|
6
6
|
* - `paused` (`grant_suspended`): the grant is suspended or expired. Nothing was deleted; keep the
|
|
7
7
|
* person's data and show the app paused until they sign in again.
|
|
8
|
+
* - `paused` also covers `app_in_testing`: the app is still in testing and the person is not its admin or a named tester
|
|
9
|
+
* (they were removed as a tester, or their email or phone stopped matching). The grant is revoked, not removed, and
|
|
10
|
+
* the person's data is kept; they get access back when the developer adds them again and they sign in again.
|
|
8
11
|
* - `reconnect` (`refresh_token_expired`, `refresh_token_reused`, or `grant_revoked` with no reason given):
|
|
9
12
|
* the grant still exists. Nothing was deleted; ask them to sign in again.
|
|
10
13
|
*/
|
|
@@ -14,7 +17,7 @@ export declare const isRefusalCode: (code: string) => boolean;
|
|
|
14
17
|
* refused call's error with `refusalOf(error)`. The SDK removes the stored connection once the handler resolves. */
|
|
15
18
|
export interface Refusal {
|
|
16
19
|
kind: RefusalKind;
|
|
17
|
-
/** The directory's code: `grant_removed`, `grant_suspended`, `refresh_token_expired`, `refresh_token_reused`, or `grant_revoked`. */
|
|
20
|
+
/** The directory's code: `grant_removed`, `grant_suspended`, `app_in_testing`, `refresh_token_expired`, `refresh_token_reused`, or `grant_revoked`. */
|
|
18
21
|
code: string;
|
|
19
22
|
/** The connection's crouter cloud user id (`user:<id>`). */
|
|
20
23
|
userId: string;
|
|
@@ -30,3 +33,19 @@ export declare function attachRefusal(error: APIError, refusal: Refusal): void;
|
|
|
30
33
|
* SDK has already run. Only an error raised by `OAuth2Client` refreshing a stored connection carries one; a 429,
|
|
31
34
|
* `cap_exhausted`, `scope_missing`, or a runtime-origin `grant_revoked` does not. */
|
|
32
35
|
export declare function refusalOf(error: unknown): Refusal | null;
|
|
36
|
+
/** A sign-in the directory refused before issuing a code, read from the redirect back to the app (`authorizationRefusalOf`). */
|
|
37
|
+
export interface AuthorizationRefusal {
|
|
38
|
+
/** The directory's reason, the redirect's `error_description`: `app_in_testing`. */
|
|
39
|
+
code: 'app_in_testing';
|
|
40
|
+
/** The redirect's `error`: always `access_denied`. */
|
|
41
|
+
error: 'access_denied';
|
|
42
|
+
/** The redirect's `state`, or null when it carried none. Check it against the saved value before showing anything. */
|
|
43
|
+
state: string | null;
|
|
44
|
+
}
|
|
45
|
+
type CallbackQuery = string | URL | URLSearchParams | Readonly<Record<string, string | readonly string[] | undefined>>;
|
|
46
|
+
/** The `AuthorizationRefusal` a sign-in redirect carries when the directory refused the person (`error=access_denied`
|
|
47
|
+
* with a reason the SDK knows), else `null`. Pass the callback's query: a `URL`, a URL or query string, a
|
|
48
|
+
* `URLSearchParams`, or a parsed query object. A person who pressed Cancel (`access_denied` without a known reason)
|
|
49
|
+
* and any other error answer `null`; handle those yourself. There is no grant to refresh, so no `onRefusal` runs. */
|
|
50
|
+
export declare function authorizationRefusalOf(callback: CallbackQuery): AuthorizationRefusal | null;
|
|
51
|
+
export {};
|
package/dist/oauth/refusal.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { APIError } from '../errors.js';
|
|
2
2
|
/** The one map from a directory refresh refusal code to what it means. Every other use of these codes reads its keys. */
|
|
3
3
|
const refusalKinds = {
|
|
4
|
-
grant_removed: 'removed', grant_suspended: 'paused',
|
|
4
|
+
grant_removed: 'removed', grant_suspended: 'paused', app_in_testing: 'paused',
|
|
5
5
|
refresh_token_expired: 'reconnect', refresh_token_reused: 'reconnect', grant_revoked: 'reconnect',
|
|
6
6
|
};
|
|
7
7
|
export const isRefusalCode = (code) => Object.hasOwn(refusalKinds, code);
|
|
@@ -15,3 +15,24 @@ export function attachRefusal(error, refusal) { Object.defineProperty(error, ref
|
|
|
15
15
|
export function refusalOf(error) {
|
|
16
16
|
return error instanceof APIError ? error[refusalKey] ?? null : null;
|
|
17
17
|
}
|
|
18
|
+
/** The `AuthorizationRefusal` a sign-in redirect carries when the directory refused the person (`error=access_denied`
|
|
19
|
+
* with a reason the SDK knows), else `null`. Pass the callback's query: a `URL`, a URL or query string, a
|
|
20
|
+
* `URLSearchParams`, or a parsed query object. A person who pressed Cancel (`access_denied` without a known reason)
|
|
21
|
+
* and any other error answer `null`; handle those yourself. There is no grant to refresh, so no `onRefusal` runs. */
|
|
22
|
+
export function authorizationRefusalOf(callback) {
|
|
23
|
+
const read = (name) => {
|
|
24
|
+
if (callback instanceof URLSearchParams)
|
|
25
|
+
return callback.get(name);
|
|
26
|
+
if (callback instanceof URL)
|
|
27
|
+
return callback.searchParams.get(name);
|
|
28
|
+
if (typeof callback === 'string') {
|
|
29
|
+
const query = callback.includes('?') ? callback.slice(callback.indexOf('?') + 1).split('#')[0] : callback;
|
|
30
|
+
return new URLSearchParams(query).get(name);
|
|
31
|
+
}
|
|
32
|
+
const value = callback[name];
|
|
33
|
+
return Array.isArray(value) ? value[0] ?? null : value ?? null;
|
|
34
|
+
};
|
|
35
|
+
if (read('error') !== 'access_denied' || read('error_description') !== 'app_in_testing')
|
|
36
|
+
return null;
|
|
37
|
+
return { code: 'app_in_testing', error: 'access_denied', state: read('state') };
|
|
38
|
+
}
|
package/dist/testing/cloud.d.ts
CHANGED
|
@@ -98,6 +98,45 @@ export interface FakeCloudOptions {
|
|
|
98
98
|
now(): number;
|
|
99
99
|
};
|
|
100
100
|
}
|
|
101
|
+
/** A tester entry: an email (matched case-insensitively against a verified email) or a phone (E.164, matched against the confirmed phone). */
|
|
102
|
+
export type FakeTesterRef = {
|
|
103
|
+
email: string;
|
|
104
|
+
phone?: never;
|
|
105
|
+
} | {
|
|
106
|
+
phone: string;
|
|
107
|
+
email?: never;
|
|
108
|
+
};
|
|
109
|
+
/** A stored tester entry, normalized (email lowercased and trimmed). */
|
|
110
|
+
export interface FakeTester {
|
|
111
|
+
kind: 'email' | 'phone';
|
|
112
|
+
value: string;
|
|
113
|
+
}
|
|
114
|
+
/** Testing mode for the app this cloud serves: an unverified third-party app admits only its admin and up to 100 named testers. */
|
|
115
|
+
export interface FakeTesting {
|
|
116
|
+
/** Whether the app is in testing. Default false (verified). */
|
|
117
|
+
readonly active: boolean;
|
|
118
|
+
/** Put the app in testing. `admin` is the user id who manages it and is always allowed. Every active grant whose
|
|
119
|
+
* person is not allowed is revoked with `app_in_testing`; the user ids are returned. */
|
|
120
|
+
enable(options?: {
|
|
121
|
+
admin?: string;
|
|
122
|
+
}): string[];
|
|
123
|
+
/** Take the app out of testing: everyone is allowed again. Revoked grants stay revoked until the person signs in (`authorize`). */
|
|
124
|
+
disable(): void;
|
|
125
|
+
/** The tester entries, oldest first. Kept while the app is not in testing. */
|
|
126
|
+
readonly testers: readonly FakeTester[];
|
|
127
|
+
/** Add a tester by `{email}` or `{phone}` (E.164). Returns false when the entry was already present. The 101st entry throws. */
|
|
128
|
+
addTester(tester: FakeTesterRef): boolean;
|
|
129
|
+
/** Remove a tester. When the app is in testing, every active grant whose person is no longer allowed is revoked with
|
|
130
|
+
* `app_in_testing`; those user ids are returned. Their next refresh answers `invalid_grant` `app_in_testing`. */
|
|
131
|
+
removeTester(tester: FakeTesterRef): {
|
|
132
|
+
removed: boolean;
|
|
133
|
+
revoked: string[];
|
|
134
|
+
};
|
|
135
|
+
/** Whether `userId` may use the app now: not in testing, the admin, or matched by a tester entry. */
|
|
136
|
+
allowed(userId: string): boolean;
|
|
137
|
+
}
|
|
138
|
+
/** The directory's cap on tester entries per app. */
|
|
139
|
+
export declare const FAKE_TESTER_LIMIT = 100;
|
|
101
140
|
/** `cloud.runs` and `user.runs`: the runs in start order, and the responder for them. */
|
|
102
141
|
export interface FakeRuns {
|
|
103
142
|
/** The runs in start order (deleted ones included, with `deleted` set). */
|
|
@@ -120,6 +159,8 @@ export declare class FakeCloud {
|
|
|
120
159
|
/** What responders threw. */
|
|
121
160
|
readonly responderErrors: unknown[];
|
|
122
161
|
readonly runs: FakeRuns;
|
|
162
|
+
/** Testing mode for this cloud's app. */
|
|
163
|
+
readonly testing: FakeTesting;
|
|
123
164
|
readonly providers: {
|
|
124
165
|
define(provider: string, definition: FakeProvider): void;
|
|
125
166
|
};
|
|
@@ -137,10 +178,33 @@ export declare class FakeCloud {
|
|
|
137
178
|
private readonly host;
|
|
138
179
|
/** Superseded refresh tokens → owner, for `refresh_token_reused`. */
|
|
139
180
|
private readonly superseded;
|
|
181
|
+
private inTesting;
|
|
182
|
+
private testingAdmin;
|
|
183
|
+
private readonly testerEntries;
|
|
184
|
+
/** Authorization codes `authorize` issued → person. */
|
|
185
|
+
private readonly codes;
|
|
186
|
+
private codeSerial;
|
|
140
187
|
private tokenSerial;
|
|
141
188
|
private refreshSerial;
|
|
142
189
|
private runSerial;
|
|
143
190
|
constructor(options: FakeCloudOptions);
|
|
191
|
+
private testerEntry;
|
|
192
|
+
/** The directory's `allowedInTesting(app, user)`: the app is not in testing, the person is its admin, or a tester entry matches their account. */
|
|
193
|
+
private allowedInTesting;
|
|
194
|
+
/** The testing sweep: revoke each active grant whose person is no longer allowed. */
|
|
195
|
+
private sweepTesting;
|
|
196
|
+
/**
|
|
197
|
+
* The redirect the directory sends the browser back to after `userId` signs in to the app: the person's
|
|
198
|
+
* `redirectUri` with `code` and `state`, or with `error=access_denied`, `error_description=app_in_testing` and `state`
|
|
199
|
+
* when the app is in testing and they are not allowed (the testing page's "Back", or `prompt=none`). An allowed
|
|
200
|
+
* person's grant is active (default scopes `crtr:llm`, `crtr:act`; an existing grant keeps its scopes) and `code` exchanges at the fake `/token`
|
|
201
|
+
* through `oauth.exchangeCode`. Nothing is written for a refused person.
|
|
202
|
+
*/
|
|
203
|
+
authorize(userId: string, options: {
|
|
204
|
+
redirectUri: string;
|
|
205
|
+
state: string;
|
|
206
|
+
scopes?: FakeScope[];
|
|
207
|
+
}): URL;
|
|
144
208
|
/** A `FakeError` envelope for intercepts, `failNext` and provider handlers. `retryable` defaults to `status === 503`. */
|
|
145
209
|
static error(input: {
|
|
146
210
|
status: number;
|
|
@@ -206,6 +270,8 @@ export declare class FakeCloud {
|
|
|
206
270
|
/** `grant_type=client_credentials` + `sub`: the directory's per-user runtime-token mint. The client assertion is not verified. */
|
|
207
271
|
private mint;
|
|
208
272
|
private refresh;
|
|
273
|
+
/** `grant_type=authorization_code`: the code `authorize` issued, once. The client assertion and PKCE verifier are not verified. */
|
|
274
|
+
private exchange;
|
|
209
275
|
private newRefreshToken;
|
|
210
276
|
private accessToken;
|
|
211
277
|
private runtime;
|
package/dist/testing/cloud.js
CHANGED
|
@@ -12,6 +12,8 @@ import { RunNotices, SigningKey } from './notices.js';
|
|
|
12
12
|
import { FakeRun } from './run.js';
|
|
13
13
|
import { UserState, normalizeScopes } from './users.js';
|
|
14
14
|
import { FakeRefused, decodeUnsigned, envelopeBody, fakeError, integerParam, invalid, isFakeError, jsonAnswer, notFound, objectBody, refuse, requestedScopes, sha256Hex, unsignedJwt, waitSeconds, } from './wire.js';
|
|
15
|
+
/** The directory's cap on tester entries per app. */
|
|
16
|
+
export const FAKE_TESTER_LIMIT = 100;
|
|
15
17
|
const ROUTES = [
|
|
16
18
|
['GET', /^\/v1\/grant$/, 'grant.get'],
|
|
17
19
|
['POST', /^\/v1\/runs$/, 'run.start'],
|
|
@@ -69,6 +71,8 @@ export class FakeCloud {
|
|
|
69
71
|
/** What responders threw. */
|
|
70
72
|
responderErrors = [];
|
|
71
73
|
runs;
|
|
74
|
+
/** Testing mode for this cloud's app. */
|
|
75
|
+
testing;
|
|
72
76
|
providers;
|
|
73
77
|
options;
|
|
74
78
|
clock;
|
|
@@ -84,6 +88,12 @@ export class FakeCloud {
|
|
|
84
88
|
host;
|
|
85
89
|
/** Superseded refresh tokens → owner, for `refresh_token_reused`. */
|
|
86
90
|
superseded = new Map();
|
|
91
|
+
inTesting = false;
|
|
92
|
+
testingAdmin = null;
|
|
93
|
+
testerEntries = [];
|
|
94
|
+
/** Authorization codes `authorize` issued → person. */
|
|
95
|
+
codes = new Map();
|
|
96
|
+
codeSerial = 0;
|
|
87
97
|
tokenSerial = 0;
|
|
88
98
|
refreshSerial = 0;
|
|
89
99
|
runSerial = 0;
|
|
@@ -122,8 +132,96 @@ export class FakeCloud {
|
|
|
122
132
|
throw new TypeError(`invalid provider id ${provider}`);
|
|
123
133
|
this.providerDefs.set(provider, definition);
|
|
124
134
|
} };
|
|
135
|
+
const cloud = this;
|
|
136
|
+
this.testing = {
|
|
137
|
+
get active() { return cloud.inTesting; },
|
|
138
|
+
enable: (enableOptions = {}) => {
|
|
139
|
+
this.inTesting = true;
|
|
140
|
+
this.testingAdmin = enableOptions.admin ?? null;
|
|
141
|
+
return this.sweepTesting();
|
|
142
|
+
},
|
|
143
|
+
disable: () => { this.inTesting = false; },
|
|
144
|
+
get testers() { return cloud.testerEntries.map((entry) => ({ ...entry })); },
|
|
145
|
+
addTester: (ref) => {
|
|
146
|
+
const entry = this.testerEntry(ref);
|
|
147
|
+
if (this.testerEntries.some((other) => other.kind === entry.kind && other.value === entry.value))
|
|
148
|
+
return false;
|
|
149
|
+
if (this.testerEntries.length >= FAKE_TESTER_LIMIT)
|
|
150
|
+
throw new TypeError(`This app has ${FAKE_TESTER_LIMIT} testers. Remove one before adding another.`);
|
|
151
|
+
this.testerEntries.push(entry);
|
|
152
|
+
return true;
|
|
153
|
+
},
|
|
154
|
+
removeTester: (ref) => {
|
|
155
|
+
const entry = this.testerEntry(ref);
|
|
156
|
+
const index = this.testerEntries.findIndex((other) => other.kind === entry.kind && other.value === entry.value);
|
|
157
|
+
if (index < 0)
|
|
158
|
+
return { removed: false, revoked: [] };
|
|
159
|
+
this.testerEntries.splice(index, 1);
|
|
160
|
+
return { removed: true, revoked: this.sweepTesting() };
|
|
161
|
+
},
|
|
162
|
+
allowed: (userId) => this.allowedInTesting(this.state(userId)),
|
|
163
|
+
};
|
|
125
164
|
this.fetch = (input, init) => this.route(input, init);
|
|
126
165
|
}
|
|
166
|
+
testerEntry(ref) {
|
|
167
|
+
const { email, phone } = ref;
|
|
168
|
+
if ((email === undefined) === (phone === undefined))
|
|
169
|
+
throw new TypeError('Pass email or phone, not both.');
|
|
170
|
+
if (email !== undefined) {
|
|
171
|
+
const value = email.trim().toLowerCase();
|
|
172
|
+
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value))
|
|
173
|
+
throw new TypeError(`${email} is not an email address`);
|
|
174
|
+
return { kind: 'email', value };
|
|
175
|
+
}
|
|
176
|
+
if (!/^\+[1-9]\d{6,14}$/.test(phone))
|
|
177
|
+
throw new TypeError(`${phone} is not an E.164 phone number (+15551234567)`);
|
|
178
|
+
return { kind: 'phone', value: phone };
|
|
179
|
+
}
|
|
180
|
+
/** The directory's `allowedInTesting(app, user)`: the app is not in testing, the person is its admin, or a tester entry matches their account. */
|
|
181
|
+
allowedInTesting(user) {
|
|
182
|
+
if (!this.inTesting || user.id === this.testingAdmin)
|
|
183
|
+
return true;
|
|
184
|
+
const { email, emailVerified = true, phone } = user.account;
|
|
185
|
+
return this.testerEntries.some((entry) => entry.kind === 'email'
|
|
186
|
+
? email !== undefined && emailVerified && email.trim().toLowerCase() === entry.value
|
|
187
|
+
: phone !== undefined && phone === entry.value);
|
|
188
|
+
}
|
|
189
|
+
/** The testing sweep: revoke each active grant whose person is no longer allowed. */
|
|
190
|
+
sweepTesting() {
|
|
191
|
+
const revoked = [];
|
|
192
|
+
if (!this.inTesting)
|
|
193
|
+
return revoked;
|
|
194
|
+
for (const user of this.users.values()) {
|
|
195
|
+
if (!user.active || this.allowedInTesting(user))
|
|
196
|
+
continue;
|
|
197
|
+
user.revoke('app_in_testing');
|
|
198
|
+
revoked.push(user.id);
|
|
199
|
+
}
|
|
200
|
+
return revoked;
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* The redirect the directory sends the browser back to after `userId` signs in to the app: the person's
|
|
204
|
+
* `redirectUri` with `code` and `state`, or with `error=access_denied`, `error_description=app_in_testing` and `state`
|
|
205
|
+
* when the app is in testing and they are not allowed (the testing page's "Back", or `prompt=none`). An allowed
|
|
206
|
+
* person's grant is active (default scopes `crtr:llm`, `crtr:act`; an existing grant keeps its scopes) and `code` exchanges at the fake `/token`
|
|
207
|
+
* through `oauth.exchangeCode`. Nothing is written for a refused person.
|
|
208
|
+
*/
|
|
209
|
+
authorize(userId, options) {
|
|
210
|
+
const target = new URL(options.redirectUri);
|
|
211
|
+
const state = this.state(userId);
|
|
212
|
+
if (!this.allowedInTesting(state)) {
|
|
213
|
+
target.searchParams.set('error', 'access_denied');
|
|
214
|
+
target.searchParams.set('error_description', 'app_in_testing');
|
|
215
|
+
target.searchParams.set('state', options.state);
|
|
216
|
+
return target;
|
|
217
|
+
}
|
|
218
|
+
this.activateState(state, options.scopes === undefined ? {} : { scopes: options.scopes });
|
|
219
|
+
const code = `code-${++this.codeSerial}-${userId}`;
|
|
220
|
+
this.codes.set(code, userId);
|
|
221
|
+
target.searchParams.set('code', code);
|
|
222
|
+
target.searchParams.set('state', options.state);
|
|
223
|
+
return target;
|
|
224
|
+
}
|
|
127
225
|
/** A `FakeError` envelope for intercepts, `failNext` and provider handlers. `retryable` defaults to `status === 503`. */
|
|
128
226
|
static error(input) {
|
|
129
227
|
return fakeError(input);
|
|
@@ -166,6 +264,7 @@ export class FakeCloud {
|
|
|
166
264
|
if (code !== 'ok')
|
|
167
265
|
state.minSerial = cloud.tokenSerial + 1;
|
|
168
266
|
},
|
|
267
|
+
setAccount(account) { state.account = { ...account }; },
|
|
169
268
|
expireTokens() { state.minSerial = cloud.tokenSerial + 1; },
|
|
170
269
|
get reads() { return cloud.calls.filter((call) => call.userId === userId && call.op === 'grant.get'); },
|
|
171
270
|
get holding() { return state.holds.size; },
|
|
@@ -362,7 +461,8 @@ export class FakeCloud {
|
|
|
362
461
|
const presented = params?.get('refresh_token') ?? '';
|
|
363
462
|
const credentials = params?.get('grant_type') === 'client_credentials';
|
|
364
463
|
const owner = op === 'directory.token' && credentials ? params.get('sub') || null
|
|
365
|
-
: op === 'directory.token'
|
|
464
|
+
: op === 'directory.token' && params.get('grant_type') === 'authorization_code' ? this.codes.get(params.get('code') ?? '') ?? null
|
|
465
|
+
: op === 'directory.token' ? [...this.users.values()].find((user) => user.refreshToken === presented)?.id ?? this.superseded.get(presented) ?? null : null;
|
|
366
466
|
const call = { op, userId: owner, answer: 'ok', at: this.clock.now() };
|
|
367
467
|
this.directoryCalls.push(call);
|
|
368
468
|
const scripted = await this.runIntercepts(op, call, signal);
|
|
@@ -384,6 +484,8 @@ export class FakeCloud {
|
|
|
384
484
|
}
|
|
385
485
|
if (op === 'directory.jwks')
|
|
386
486
|
return Response.json(await this.key.jwks());
|
|
487
|
+
if (params?.get('grant_type') === 'authorization_code')
|
|
488
|
+
return this.exchange(params, call);
|
|
387
489
|
return credentials ? this.mint(params, call) : this.refresh(params, presented, call);
|
|
388
490
|
}
|
|
389
491
|
/** `grant_type=client_credentials` + `sub`: the directory's per-user runtime-token mint. The client assertion is not verified. */
|
|
@@ -410,6 +512,9 @@ export class FakeCloud {
|
|
|
410
512
|
if (call.userId === null)
|
|
411
513
|
return refused('grant_revoked');
|
|
412
514
|
const user = this.state(call.userId);
|
|
515
|
+
// "Why a refresh is refused": after `grant_suspended`, before `refresh_token_reused`. Nothing is written.
|
|
516
|
+
if (user.active && !this.allowedInTesting(user))
|
|
517
|
+
return refused('app_in_testing');
|
|
413
518
|
if (user.refreshToken !== presented)
|
|
414
519
|
return refused('refresh_token_reused');
|
|
415
520
|
if (user.refusal)
|
|
@@ -421,6 +526,24 @@ export class FakeCloud {
|
|
|
421
526
|
refresh_token: user.refreshToken, runtimeUrl: this.runtimeUrlFor(user.id),
|
|
422
527
|
});
|
|
423
528
|
}
|
|
529
|
+
/** `grant_type=authorization_code`: the code `authorize` issued, once. The client assertion and PKCE verifier are not verified. */
|
|
530
|
+
exchange(params, call) {
|
|
531
|
+
const code = params.get('code') ?? '';
|
|
532
|
+
const userId = this.codes.get(code);
|
|
533
|
+
this.codes.delete(code);
|
|
534
|
+
if (userId === undefined) {
|
|
535
|
+
call.answer = 'grant_revoked';
|
|
536
|
+
return Response.json({ error: 'invalid_grant' }, { status: 400 });
|
|
537
|
+
}
|
|
538
|
+
const user = this.state(userId);
|
|
539
|
+
if (user.refreshToken)
|
|
540
|
+
this.superseded.set(user.refreshToken, user.id);
|
|
541
|
+
user.refreshToken = this.newRefreshToken(user.id);
|
|
542
|
+
return Response.json({
|
|
543
|
+
access_token: this.accessToken(user), token_type: 'Bearer', expires_in: 3600,
|
|
544
|
+
refresh_token: user.refreshToken, runtimeUrl: this.runtimeUrlFor(user.id),
|
|
545
|
+
});
|
|
546
|
+
}
|
|
424
547
|
newRefreshToken(userId) { return `rt-${++this.refreshSerial}-${userId}`; }
|
|
425
548
|
accessToken(user, ttlSeconds = 3600) {
|
|
426
549
|
const now = Math.floor(this.clock.now() / 1000);
|
package/dist/testing/index.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
export { FakeCloud } from './cloud.js';
|
|
1
|
+
export { FakeCloud, FAKE_TESTER_LIMIT } from './cloud.js';
|
|
2
2
|
export { manualClock } from './clock.js';
|
|
3
3
|
export type { ManualClock } from './clock.js';
|
|
4
|
-
export type { FakeAnswer, FakeCall, FakeCloudOptions, FakeDirectoryCall, FakeDirectoryOp, FakeInterceptOp, FakeOp, FakeProvider, FakeRuns, FakeToolHandler, Intercept, } from './cloud.js';
|
|
4
|
+
export type { FakeAnswer, FakeCall, FakeCloudOptions, FakeDirectoryCall, FakeDirectoryOp, FakeInterceptOp, FakeOp, FakeProvider, FakeRuns, FakeTester, FakeTesterRef, FakeTesting, FakeToolHandler, Intercept, } from './cloud.js';
|
|
5
5
|
export type { FakeRun, FakeRunEnding, FakeRunMessage, FakeRunParams, FakeQuestion } from './run.js';
|
|
6
6
|
export type { FakeNotice, RunNoticeOptions } from './notices.js';
|
|
7
|
-
export type { FakeMemoryDoc, FakeRefusalCode, FakeScope, FakeUser, GrantPatch, Responder, ResponderInput } from './users.js';
|
|
7
|
+
export type { FakeAccount, FakeMemoryDoc, FakeRefusalCode, FakeScope, FakeUser, GrantPatch, Responder, ResponderInput } from './users.js';
|
package/dist/testing/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
// `@north-light/sdk/testing`: a fake crouter cloud (directory + runtime) your real `OAuth2Client` and `Crouter` talk to
|
|
2
2
|
// through `fetch`. Node-only; no test framework. See docs/sdk/testing.md.
|
|
3
|
-
export { FakeCloud } from './cloud.js';
|
|
3
|
+
export { FakeCloud, FAKE_TESTER_LIMIT } from './cloud.js';
|
|
4
4
|
export { manualClock } from './clock.js';
|
package/dist/testing/users.d.ts
CHANGED
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
import type { GrantGetDTO, ProviderDependency, SetupState } from '@north-light/api';
|
|
2
2
|
import type { FakeRun } from './run.js';
|
|
3
3
|
/** The directory's reasons for refusing a refresh token (the SDK's refusal table). */
|
|
4
|
-
export type FakeRefusalCode = 'grant_removed' | 'grant_suspended' | 'refresh_token_expired' | 'refresh_token_reused' | 'grant_revoked' | 'runtime_provisioning';
|
|
4
|
+
export type FakeRefusalCode = 'grant_removed' | 'grant_suspended' | 'app_in_testing' | 'refresh_token_expired' | 'refresh_token_reused' | 'grant_revoked' | 'runtime_provisioning';
|
|
5
|
+
/** The identifiers a person's account holds. Only `email` (when `emailVerified`) and `phone` match a tester entry. */
|
|
6
|
+
export interface FakeAccount {
|
|
7
|
+
email?: string;
|
|
8
|
+
/** Default true when `email` is set. An unverified email matches no tester entry. */
|
|
9
|
+
emailVerified?: boolean;
|
|
10
|
+
/** The confirmed phone, E.164 (`+15551234567`). */
|
|
11
|
+
phone?: string;
|
|
12
|
+
}
|
|
5
13
|
/** What a run's responder receives. */
|
|
6
14
|
export interface ResponderInput {
|
|
7
15
|
run: FakeRun;
|
|
@@ -61,6 +69,8 @@ export declare class UserState {
|
|
|
61
69
|
refreshToken: string | null;
|
|
62
70
|
/** Access tokens with a serial below this answer 401 `unauthorized`. */
|
|
63
71
|
minSerial: number;
|
|
72
|
+
/** The identifiers the account holds, which tester entries match (`FakeUser.setAccount`). */
|
|
73
|
+
account: FakeAccount;
|
|
64
74
|
readonly grantId: string;
|
|
65
75
|
readonly runs: FakeRun[];
|
|
66
76
|
responder: Responder | null;
|
|
@@ -108,6 +118,9 @@ export interface FakeUser {
|
|
|
108
118
|
/** Active again at `rev` +1; the directory answers `ok`. */
|
|
109
119
|
restore(patch?: GrantPatch): void;
|
|
110
120
|
};
|
|
121
|
+
/** The email and phone this person's account holds, replacing any set before. A tester entry matches them
|
|
122
|
+
* (`cloud.testing`); nothing is revoked when they stop matching, and the next refresh or `authorize` is refused. */
|
|
123
|
+
setAccount(account: FakeAccount): void;
|
|
111
124
|
/** The directory refuses each refresh with `code` (`'ok'` clears it); tokens issued before now answer 401 `unauthorized`. */
|
|
112
125
|
refuse(code: FakeRefusalCode | 'ok'): void;
|
|
113
126
|
/** Tokens issued before now answer 401 `unauthorized`, forcing a refresh; the directory's answer is unchanged. */
|
package/dist/testing/users.js
CHANGED
|
@@ -18,6 +18,8 @@ export class UserState {
|
|
|
18
18
|
refreshToken = null;
|
|
19
19
|
/** Access tokens with a serial below this answer 401 `unauthorized`. */
|
|
20
20
|
minSerial = 0;
|
|
21
|
+
/** The identifiers the account holds, which tester entries match (`FakeUser.setAccount`). */
|
|
22
|
+
account = {};
|
|
21
23
|
grantId;
|
|
22
24
|
runs = [];
|
|
23
25
|
responder = null;
|
package/dist/version.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** This SDK's release version. It is also the oldest runtime the SDK accepts: one release is one version set. */
|
|
2
|
-
export declare const SDK_VERSION = "0.3.
|
|
2
|
+
export declare const SDK_VERSION = "0.3.419";
|
package/dist/version.js
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
// Written by scripts/release-version.mjs on every release; do not edit by hand.
|
|
2
2
|
/** This SDK's release version. It is also the oldest runtime the SDK accepts: one release is one version set. */
|
|
3
|
-
export const SDK_VERSION = '0.3.
|
|
3
|
+
export const SDK_VERSION = '0.3.419';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@north-light/sdk",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.419",
|
|
4
4
|
"description": "Typed Node and browser client for running crouter agents through the crtrd /v1 API.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -39,8 +39,8 @@
|
|
|
39
39
|
"build": "tsc -p tsconfig.json && chmod 755 dist/keygen-cli.js"
|
|
40
40
|
},
|
|
41
41
|
"dependencies": {
|
|
42
|
-
"@north-light/api": "0.3.
|
|
43
|
-
"@north-light/identity": "0.3.
|
|
42
|
+
"@north-light/api": "0.3.419",
|
|
43
|
+
"@north-light/identity": "0.3.419",
|
|
44
44
|
"jose": "^6.2.1"
|
|
45
45
|
},
|
|
46
46
|
"peerDependencies": {
|