@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 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';
@@ -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
  *
@@ -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();
@@ -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 {};
@@ -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
+ }
@@ -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;
@@ -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' ? [...this.users.values()].find((user) => user.refreshToken === presented)?.id ?? this.superseded.get(presented) ?? null : null;
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);
@@ -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';
@@ -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';
@@ -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. */
@@ -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.418";
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.418';
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.418",
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.418",
43
- "@north-light/identity": "0.3.418",
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": {