@north-light/sdk 0.0.0 → 0.3.414

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/LICENSE +18 -0
  2. package/README.md +187 -2
  3. package/dist/client.d.ts +162 -0
  4. package/dist/client.js +543 -0
  5. package/dist/error-codes.d.ts +7 -0
  6. package/dist/error-codes.js +41 -0
  7. package/dist/errors.d.ts +57 -0
  8. package/dist/errors.js +87 -0
  9. package/dist/index.d.ts +41 -0
  10. package/dist/index.js +17 -0
  11. package/dist/keygen-cli.d.ts +2 -0
  12. package/dist/keygen-cli.js +6 -0
  13. package/dist/keygen-command.d.ts +6 -0
  14. package/dist/keygen-command.js +157 -0
  15. package/dist/oauth/index.d.ts +217 -0
  16. package/dist/oauth/index.js +535 -0
  17. package/dist/oauth/keygen.d.ts +18 -0
  18. package/dist/oauth/keygen.js +46 -0
  19. package/dist/oauth/refusal.d.ts +32 -0
  20. package/dist/oauth/refusal.js +17 -0
  21. package/dist/resources/activity.d.ts +22 -0
  22. package/dist/resources/activity.js +43 -0
  23. package/dist/resources/attachments.d.ts +15 -0
  24. package/dist/resources/attachments.js +13 -0
  25. package/dist/resources/bash.d.ts +9 -0
  26. package/dist/resources/bash.js +15 -0
  27. package/dist/resources/canvas/history.d.ts +11 -0
  28. package/dist/resources/canvas/history.js +20 -0
  29. package/dist/resources/canvas.d.ts +19 -0
  30. package/dist/resources/canvas.js +42 -0
  31. package/dist/resources/crons.d.ts +15 -0
  32. package/dist/resources/crons.js +33 -0
  33. package/dist/resources/custom-objects.d.ts +20 -0
  34. package/dist/resources/custom-objects.js +84 -0
  35. package/dist/resources/files.d.ts +34 -0
  36. package/dist/resources/files.js +34 -0
  37. package/dist/resources/follower.d.ts +71 -0
  38. package/dist/resources/follower.js +202 -0
  39. package/dist/resources/forward.d.ts +7 -0
  40. package/dist/resources/forward.js +67 -0
  41. package/dist/resources/grant-watcher.d.ts +57 -0
  42. package/dist/resources/grant-watcher.js +100 -0
  43. package/dist/resources/human/inbox.d.ts +14 -0
  44. package/dist/resources/human/inbox.js +30 -0
  45. package/dist/resources/human/requests.d.ts +13 -0
  46. package/dist/resources/human/requests.js +26 -0
  47. package/dist/resources/human.d.ts +8 -0
  48. package/dist/resources/human.js +10 -0
  49. package/dist/resources/identifiers.d.ts +8 -0
  50. package/dist/resources/identifiers.js +33 -0
  51. package/dist/resources/memory.d.ts +69 -0
  52. package/dist/resources/memory.js +30 -0
  53. package/dist/resources/models/config.d.ts +8 -0
  54. package/dist/resources/models/config.js +9 -0
  55. package/dist/resources/models/credentials.d.ts +10 -0
  56. package/dist/resources/models/credentials.js +17 -0
  57. package/dist/resources/models.d.ts +8 -0
  58. package/dist/resources/models.js +10 -0
  59. package/dist/resources/node-stream.d.ts +34 -0
  60. package/dist/resources/node-stream.js +176 -0
  61. package/dist/resources/nodes/jobs.d.ts +9 -0
  62. package/dist/resources/nodes/jobs.js +14 -0
  63. package/dist/resources/nodes/result.d.ts +8 -0
  64. package/dist/resources/nodes/result.js +11 -0
  65. package/dist/resources/nodes/worktree.d.ts +9 -0
  66. package/dist/resources/nodes/worktree.js +14 -0
  67. package/dist/resources/nodes.d.ts +23 -0
  68. package/dist/resources/nodes.js +47 -0
  69. package/dist/resources/providers.d.ts +51 -0
  70. package/dist/resources/providers.js +15 -0
  71. package/dist/resources/questions.d.ts +49 -0
  72. package/dist/resources/questions.js +21 -0
  73. package/dist/resources/request.d.ts +7 -0
  74. package/dist/resources/request.js +19 -0
  75. package/dist/resources/run-notice.d.ts +34 -0
  76. package/dist/resources/run-notice.js +67 -0
  77. package/dist/resources/run-reply.d.ts +79 -0
  78. package/dist/resources/run-reply.js +93 -0
  79. package/dist/resources/run-stream.d.ts +65 -0
  80. package/dist/resources/run-stream.js +243 -0
  81. package/dist/resources/run-watcher.d.ts +98 -0
  82. package/dist/resources/run-watcher.js +162 -0
  83. package/dist/resources/runs.d.ts +192 -0
  84. package/dist/resources/runs.js +208 -0
  85. package/dist/resources/shares.d.ts +40 -0
  86. package/dist/resources/shares.js +19 -0
  87. package/dist/resources/structured-requests.d.ts +14 -0
  88. package/dist/resources/structured-requests.js +19 -0
  89. package/dist/resources/uploads.d.ts +27 -0
  90. package/dist/resources/uploads.js +11 -0
  91. package/dist/schema.d.ts +7 -0
  92. package/dist/schema.js +10 -0
  93. package/dist/stores/postgres.d.ts +32 -0
  94. package/dist/stores/postgres.js +92 -0
  95. package/dist/testing/cloud.d.ts +209 -0
  96. package/dist/testing/cloud.js +880 -0
  97. package/dist/testing/index.d.ts +5 -0
  98. package/dist/testing/index.js +3 -0
  99. package/dist/testing/notices.d.ts +70 -0
  100. package/dist/testing/notices.js +205 -0
  101. package/dist/testing/run.d.ts +172 -0
  102. package/dist/testing/run.js +429 -0
  103. package/dist/testing/users.d.ts +121 -0
  104. package/dist/testing/users.js +70 -0
  105. package/dist/testing/wire.d.ts +62 -0
  106. package/dist/testing/wire.js +148 -0
  107. package/dist/types.d.ts +80 -0
  108. package/dist/types.js +1 -0
  109. package/dist/version.d.ts +2 -0
  110. package/dist/version.js +3 -0
  111. package/package.json +56 -5
@@ -0,0 +1,535 @@
1
+ import { calculateJwkThumbprint, decodeJwt, decodeProtectedHeader, exportJWK, importJWK, importPKCS8, SignJWT } from 'jose';
2
+ import { JwksCache, parseScope, verifyIdToken } from '@north-light/identity';
3
+ import { APIError, CrouterError, mapError } from '../errors.js';
4
+ import { Crouter } from '../client.js';
5
+ import { RunWatcher } from '../resources/run-watcher.js';
6
+ import { GrantWatcher } from '../resources/grant-watcher.js';
7
+ import { attachRefusal, isRefusalCode, refusalKindOf } from './refusal.js';
8
+ export { refusalOf } from './refusal.js';
9
+ /** A connection store for one process (local development and tests): its lock does not reach other processes. */
10
+ export class MemoryConnectionStore {
11
+ connections = new Map();
12
+ locks = new Map();
13
+ savedAt = new Map();
14
+ async load(userId) { return this.connections.get(userId) ?? null; }
15
+ async save(connection) {
16
+ this.connections.set(connection.userId, connection);
17
+ this.savedAt.set(connection.userId, Date.now());
18
+ }
19
+ async remove(userId, refreshToken) {
20
+ if (this.connections.get(userId)?.refreshToken !== refreshToken)
21
+ return false;
22
+ this.connections.delete(userId);
23
+ this.savedAt.delete(userId);
24
+ return true;
25
+ }
26
+ async listStale(before) {
27
+ return [...this.connections.values()].filter((connection) => (this.savedAt.get(connection.userId) ?? Infinity) < before.getTime());
28
+ }
29
+ /** Not reentrant: calling `lock` for the same user from inside `fn` waits forever. */
30
+ async lock(userId, fn) {
31
+ const prior = this.locks.get(userId);
32
+ let release;
33
+ const done = new Promise((resolve) => { release = resolve; });
34
+ this.locks.set(userId, done);
35
+ await prior;
36
+ try {
37
+ return await fn();
38
+ }
39
+ finally {
40
+ release();
41
+ if (this.locks.get(userId) === done)
42
+ this.locks.delete(userId);
43
+ }
44
+ }
45
+ }
46
+ /** The `profile` claims a verified ID token carries, each only when present: use it on `Connection.idToken` after `verifyIdToken` for a sign-in that requested runtime scopes (`exchangeCode`), as `exchangeIdentity` does for a sign-in-only one. */
47
+ export function profileFromClaims(claims) {
48
+ const text = (value) => typeof value === 'string' && value !== '' ? value : undefined;
49
+ const [name, givenName, familyName, picture] = [text(claims.name), text(claims.given_name), text(claims.family_name), httpsUrl(claims.picture)];
50
+ return { ...(name ? { name } : {}), ...(givenName ? { givenName } : {}), ...(familyName ? { familyName } : {}), ...(picture ? { picture } : {}) };
51
+ }
52
+ /** `value` when it is a string parsing as an `https:` URL, else undefined: a `picture` claim is rendered as an image source, so no other scheme gets through. */
53
+ function httpsUrl(value) {
54
+ if (typeof value !== 'string')
55
+ return undefined;
56
+ try {
57
+ return new URL(value).protocol === 'https:' ? value : undefined;
58
+ }
59
+ catch {
60
+ return undefined;
61
+ }
62
+ }
63
+ /** A runtime client on one person's stored connection (`oauth.client(userId)`): a `Crouter` that refreshes its
64
+ * token through the `OAuth2Client`'s store, plus whose connection it is and the runtime it calls. */
65
+ export class ConnectedCrouter extends Crouter {
66
+ /** The person whose connection this client uses (`user:<id>`). */
67
+ userId;
68
+ /** The person's runtime URL this client calls, as the stored connection named it. */
69
+ runtimeUrl;
70
+ constructor(connection, options) {
71
+ super({ ...options, baseURL: connection.runtimeUrl });
72
+ this.userId = connection.userId;
73
+ this.runtimeUrl = connection.runtimeUrl;
74
+ }
75
+ }
76
+ /** How long one discovery read may take before it rejects (and is forgotten). */
77
+ const DISCOVERY_TIMEOUT_MS = 10_000;
78
+ const assertionType = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer';
79
+ let refusalScope;
80
+ function handlerScope() {
81
+ if (refusalScope !== undefined)
82
+ return refusalScope;
83
+ const hooks = globalThis.process?.getBuiltinModule?.('node:async_hooks');
84
+ refusalScope = hooks ? new hooks.AsyncLocalStorage() : null;
85
+ return refusalScope;
86
+ }
87
+ export class OAuth2Client {
88
+ options;
89
+ issuer;
90
+ fetcher;
91
+ store;
92
+ jwks;
93
+ discoveryPromise;
94
+ /** One refresh per (user, refresh token) in this process; a client holding a newer token never joins an older refusal. */
95
+ inFlight = new Map();
96
+ /** Refusals whose connection the SDK removed (or found already replaced) after `onRefusal` resolved. */
97
+ settled = new WeakSet();
98
+ constructor(options) {
99
+ this.options = options;
100
+ if (!options.issuer || !options.issuer.trim())
101
+ throw new CrouterError('OAuth2Client requires an issuer');
102
+ if (!options.store)
103
+ throw new CrouterError('OAuth2Client requires a store (a ConnectionStore such as PostgresConnectionStore)');
104
+ this.issuer = options.issuer;
105
+ this.store = options.store;
106
+ this.fetcher = options.fetch ?? fetch;
107
+ }
108
+ /** The issuer's discovery document. The first successful read is kept for the client's life; a read that fails or
109
+ * takes longer than 10 s rejects and is forgotten, so the next call reads again. */
110
+ discovery() {
111
+ if (this.discoveryPromise)
112
+ return this.discoveryPromise;
113
+ const read = (async () => {
114
+ const response = await this.fetcher(`${this.issuer.replace(/\/$/, '')}/.well-known/openid-configuration`, { signal: AbortSignal.timeout(DISCOVERY_TIMEOUT_MS) });
115
+ if (!response.ok)
116
+ throw new CrouterError(`Directory discovery failed: ${response.status}`);
117
+ const value = await response.json();
118
+ if (!value.authorization_endpoint || !value.token_endpoint || !value.jwks_uri)
119
+ throw new CrouterError('Directory discovery has missing endpoints');
120
+ return value;
121
+ })();
122
+ this.discoveryPromise = read;
123
+ // Only this read's own failure clears the memo, never a newer read started after it.
124
+ read.catch(() => { if (this.discoveryPromise === read)
125
+ this.discoveryPromise = undefined; });
126
+ return read;
127
+ }
128
+ /**
129
+ * The app's page on the signed-in person's crouter cloud account, where they change or remove what the app may do:
130
+ * the discovery document's `account_app_uri_template` with its one `{client_id}` replaced by the encoded client id.
131
+ * Needs no user or connection; its only request is the shared discovery read. Throws `CrouterError` when the field
132
+ * is absent, does not contain `{client_id}` exactly once, or does not expand to a URL on the issuer's origin.
133
+ */
134
+ async accountAppUrl() {
135
+ const template = (await this.discovery()).account_app_uri_template;
136
+ if (typeof template !== 'string' || !template)
137
+ throw new CrouterError('Directory discovery has no account_app_uri_template');
138
+ const parts = template.split('{client_id}');
139
+ if (parts.length !== 2)
140
+ throw new CrouterError("Directory discovery's account_app_uri_template must contain {client_id} exactly once");
141
+ const expanded = parts.join(encodeURIComponent(this.options.clientId));
142
+ const origin = new URL(this.issuer).origin;
143
+ let parsed;
144
+ try {
145
+ parsed = new URL(expanded);
146
+ }
147
+ catch {
148
+ parsed = undefined;
149
+ }
150
+ if (parsed?.origin !== origin)
151
+ throw new CrouterError(`Directory discovery's account_app_uri_template is not a URL on ${origin}`);
152
+ return expanded;
153
+ }
154
+ /** Start a sign-in. When `scopes` includes `openid` the result carries the `nonce` to pass to the exchange; with literal scopes its type is `string`.
155
+ * `prompt: 'consent'` makes the directory show its consent screen even when the person already approved these scopes. */
156
+ async authorizeUrl({ scopes, state, codeVerifier, prompt }) {
157
+ for (const scope of scopes) {
158
+ if (scope === 'openid' || scope === 'email' || scope === 'profile')
159
+ continue;
160
+ try {
161
+ parseScope(scope);
162
+ }
163
+ catch {
164
+ throw new APIError(400, 'invalid_scope', `Unsupported scope: ${scope}`);
165
+ }
166
+ }
167
+ const verifier = codeVerifier ?? randomBase64url(32);
168
+ const nonce = scopes.includes('openid') ? randomBase64url(32) : undefined;
169
+ const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier));
170
+ const challenge = base64url(new Uint8Array(hash));
171
+ const url = new URL((await this.discovery()).authorization_endpoint);
172
+ url.search = new URLSearchParams({
173
+ client_id: this.options.clientId, response_type: 'code', redirect_uri: this.options.redirectUri,
174
+ scope: scopes.join(' '), state, code_challenge: challenge, code_challenge_method: 'S256',
175
+ ...(nonce ? { nonce } : {}), ...(prompt === undefined ? {} : { prompt }),
176
+ }).toString();
177
+ return { url: url.href, codeVerifier: verifier, state, ...(nonce ? { nonce } : {}) };
178
+ }
179
+ /**
180
+ * Finish a sign-in that asked for runtime scopes and return the person's `Connection`. A sign-in-only
181
+ * request (`openid`, `openid email`, `openid profile`, or `openid email profile`) gets no connection from the directory; finish it with
182
+ * `exchangeIdentity`, which this method names when it receives such a response.
183
+ */
184
+ async exchangeCode({ code, codeVerifier, state, expectedState, nonce }) {
185
+ assertState(state, expectedState);
186
+ const response = await this.tokenResponse({ grant_type: 'authorization_code', code, redirect_uri: this.options.redirectUri, code_verifier: codeVerifier });
187
+ if (response.refresh_token === undefined && response.runtimeUrl === undefined && typeof response.id_token === 'string') {
188
+ throw new CrouterError('The directory answered a sign-in-only request (openid, optionally with email and profile) with an identity and no connection; finish a sign-in-only request with exchangeIdentity(), not exchangeCode()', 'oauth_exchange_mismatch');
189
+ }
190
+ const connection = this.connectionFrom(response);
191
+ if (connection.idToken) {
192
+ const claims = await this.verifyIdToken(connection.idToken, { nonce });
193
+ if (typeof claims.nonce !== 'string' || claims.nonce !== nonce)
194
+ throw new CrouterError('ID token nonce is missing or does not match', 'id_token_invalid');
195
+ }
196
+ return connection;
197
+ }
198
+ /**
199
+ * Finish a sign-in-only request (`authorizeUrl` with scopes `openid` or `openid email`) and return the
200
+ * person's verified identity. There is no connection, refresh token, or runtime access on this path.
201
+ * `nonce` is the value `authorizeUrl` returned and is required. A response without an ID token, or with
202
+ * one that fails verification, throws `id_token_invalid`; a response carrying a runtime connection (the
203
+ * request asked for runtime scopes) throws `oauth_exchange_mismatch` and must be finished with `exchangeCode`.
204
+ */
205
+ async exchangeIdentity({ code, codeVerifier, state, expectedState, nonce }) {
206
+ assertState(state, expectedState);
207
+ if (typeof nonce !== 'string' || !nonce)
208
+ throw new CrouterError('exchangeIdentity requires the nonce authorizeUrl returned', 'oauth_nonce_missing');
209
+ const response = await this.tokenResponse({ grant_type: 'authorization_code', code, redirect_uri: this.options.redirectUri, code_verifier: codeVerifier });
210
+ if (response.refresh_token !== undefined || response.runtimeUrl !== undefined) {
211
+ throw new CrouterError('The directory answered with a runtime connection, so the request asked for runtime scopes; finish it with exchangeCode(), or request only openid or openid email for a sign-in-only visitor', 'oauth_exchange_mismatch');
212
+ }
213
+ if (typeof response.id_token !== 'string' || !response.id_token)
214
+ throw new CrouterError('Directory returned no ID token for a sign-in-only request', 'id_token_invalid');
215
+ const claims = await this.verifyIdToken(response.id_token, { nonce });
216
+ return {
217
+ sub: claims.sub,
218
+ ...(typeof claims['email'] === 'string' ? { email: claims['email'] } : {}),
219
+ ...(typeof claims['email_verified'] === 'boolean' ? { emailVerified: claims['email_verified'] } : {}),
220
+ ...profileFromClaims(claims),
221
+ claims,
222
+ };
223
+ }
224
+ /**
225
+ * Refresh the store's idle connections once (the app schedules it, for example daily, with `olderThanMs` of 30
226
+ * days). Each refusal goes through `onRefusal` and the connection's removal, and is counted in `refused`.
227
+ * `failures` holds every connection still stored that could not be refreshed: an error other than a refusal,
228
+ * or a refusal whose `onRefusal` threw (`refusalOf(failure.error)` is set and `failure.error.cause` is what the
229
+ * handler threw); the next run retries those. A connection removed before its turn is skipped.
230
+ */
231
+ async refreshIdleConnections({ olderThanMs }) {
232
+ if (!this.store.listStale)
233
+ throw new CrouterError('ConnectionStore.listStale(before) is required to refresh idle connections');
234
+ if (!Number.isFinite(olderThanMs) || olderThanMs <= 0)
235
+ throw new CrouterError('olderThanMs must be a positive number');
236
+ const stale = await this.store.listStale(new Date(Date.now() - olderThanMs));
237
+ let refreshed = 0;
238
+ let refused = 0;
239
+ const failures = [];
240
+ for (const connection of stale) {
241
+ try {
242
+ // refresh() holds the cross-process store lock and reloads before rotating.
243
+ const current = await this.refresh(connection);
244
+ if (current.refreshToken !== connection.refreshToken)
245
+ refreshed++;
246
+ }
247
+ catch (error) {
248
+ if (error instanceof CrouterError && error.code === 'not_connected')
249
+ continue;
250
+ if (error instanceof APIError && this.settled.has(error))
251
+ refused++;
252
+ else
253
+ failures.push({ userId: connection.userId, error });
254
+ }
255
+ }
256
+ return { refreshed, refused, failures };
257
+ }
258
+ /**
259
+ * Rotate `connection`'s refresh token through the store: under the user's lock it reloads, and when another
260
+ * process already rotated (or the person signed in again) it returns the stored connection without calling the
261
+ * directory. Nothing stored throws `CrouterError` `not_connected`. A refusal runs `onRefusal`, removes the
262
+ * connection, and rejects with the directory's `AuthenticationError` (`refusalOf(error)` set).
263
+ */
264
+ refresh(connection) {
265
+ assertNotInHandler(connection.userId);
266
+ const key = `${connection.userId}\u0000${connection.refreshToken}`;
267
+ const existing = this.inFlight.get(key);
268
+ if (existing)
269
+ return existing;
270
+ const pending = this.rotate(connection);
271
+ this.inFlight.set(key, pending);
272
+ const forget = () => { if (this.inFlight.get(key) === pending)
273
+ this.inFlight.delete(key); };
274
+ pending.then(forget, forget);
275
+ return pending;
276
+ }
277
+ async rotate(connection) {
278
+ const { userId } = connection;
279
+ // C1/M1: the locked closure never throws a refusal (a throw rolls a Postgres store back) and never runs app code.
280
+ const rotation = await this.store.lock(userId, async () => {
281
+ const stored = await this.store.load(userId);
282
+ if (!stored)
283
+ return { kind: 'missing' };
284
+ if (stored.refreshToken !== connection.refreshToken)
285
+ return { kind: 'current', connection: stored };
286
+ let response;
287
+ try {
288
+ response = await this.tokenResponse({ grant_type: 'refresh_token', refresh_token: stored.refreshToken });
289
+ }
290
+ catch (error) {
291
+ if (error instanceof APIError && error.origin === 'directory' && isRefusalCode(error.code))
292
+ return { kind: 'refused', error, stored };
293
+ throw error;
294
+ }
295
+ const updated = this.connectionFrom(response, stored);
296
+ await this.store.save(updated);
297
+ return { kind: 'current', connection: updated };
298
+ });
299
+ if (rotation.kind === 'current')
300
+ return rotation.connection;
301
+ if (rotation.kind === 'missing')
302
+ throw notConnected(userId);
303
+ const { error, stored } = rotation;
304
+ const refusal = { kind: refusalKindOf(error.code), code: error.code, userId, grantId: stored.grantId, error };
305
+ attachRefusal(error, refusal);
306
+ const handler = this.options.onRefusal;
307
+ if (handler) {
308
+ const scope = handlerScope();
309
+ try {
310
+ await (scope ? scope.run(userId, () => handler(refusal)) : handler(refusal));
311
+ }
312
+ catch (handlerError) {
313
+ // The connection stays stored, so the next use is refused again and calls the handler again.
314
+ Object.defineProperty(error, 'cause', { value: handlerError, configurable: true, writable: true });
315
+ throw error;
316
+ }
317
+ }
318
+ try {
319
+ await this.store.lock(userId, () => this.store.remove(userId, stored.refreshToken));
320
+ }
321
+ catch (removeError) {
322
+ Object.defineProperty(error, 'cause', { value: removeError, configurable: true, writable: true });
323
+ throw error;
324
+ }
325
+ this.settled.add(error);
326
+ throw error;
327
+ }
328
+ /**
329
+ * A runtime client on `userId`'s stored connection; `runtimeUrl` on it names the runtime it calls. Nothing
330
+ * stored throws `CrouterError` `not_connected` (show "sign in again"). The client refreshes through the store;
331
+ * once its connection is refused (and `onRefusal` resolved) it throws that same refusal on every later call
332
+ * without asking the directory again. Called for the user whose `onRefusal` is running, it throws
333
+ * `refusal_handler_reentry`.
334
+ */
335
+ async client(userId) {
336
+ assertNotInHandler(userId);
337
+ const connection = await this.store.load(userId);
338
+ if (!connection)
339
+ throw notConnected(userId);
340
+ let current = connection;
341
+ let refused;
342
+ const refresh = async (force) => {
343
+ if (refused)
344
+ throw refused;
345
+ if (!force && current.accessToken && current.accessTokenExpiresAt - Date.now() > 30_000)
346
+ return;
347
+ try {
348
+ current = await this.refresh(current);
349
+ }
350
+ catch (error) {
351
+ if (error instanceof APIError && this.settled.has(error))
352
+ refused = error;
353
+ throw error;
354
+ }
355
+ };
356
+ return new ConnectedCrouter(connection, {
357
+ tokenSource: async () => { await refresh(false); return current.accessToken; },
358
+ onUnauthorized: async () => { await refresh(true); },
359
+ ...(this.options.fetch ? { fetch: this.options.fetch } : {}),
360
+ });
361
+ }
362
+ /**
363
+ * Follow the app's background runs until each comes to rest, and hand each ending to `onEnded` (see
364
+ * `RunWatchOptions`). Runs are opened with `client(userId)`, so refusals reach `onRefusal`. Call `start()`.
365
+ */
366
+ watchRuns(options) {
367
+ return new RunWatcher((userId) => this.client(userId), options);
368
+ }
369
+ /**
370
+ * Follow the grants of the people `users()` names, and hand each change (and each user's first reading in this
371
+ * process) to `onChanged` (see `GrantWatchOptions`). Grants are read with `client(userId)`, so refusals reach
372
+ * `onRefusal`. Call `start()`.
373
+ */
374
+ watchGrants(options) {
375
+ return new GrantWatcher((userId) => this.client(userId), options);
376
+ }
377
+ /** 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
+ async verifyIdToken(idToken, { nonce } = {}) {
379
+ const discovery = await this.discovery();
380
+ this.jwks ??= new JwksCache(this.issuer, this.fetcher, discovery.jwks_uri);
381
+ const invalid = (reason) => new CrouterError(`ID token failed verification: ${reason}`, 'id_token_invalid');
382
+ try {
383
+ decodeProtectedHeader(idToken);
384
+ }
385
+ catch {
386
+ throw invalid('it is not a signed JWT');
387
+ }
388
+ let claims;
389
+ try {
390
+ claims = await verifyIdToken({ token: idToken, jwks: this.jwks, issuer: this.issuer, audience: this.options.clientId });
391
+ }
392
+ catch (error) {
393
+ // A token that fails verification is id_token_invalid; a JWKS that cannot be fetched or is malformed is the
394
+ // directory's fault, not the token's, and is rethrown as is. jose's token errors (bad signature, wrong iss/aud,
395
+ // expired, no matching key) carry an ERR_J* code; ERR_JWKS_INVALID and ERR_JWKS_TIMEOUT describe the key set.
396
+ // The identity package's own checks throw "Invalid ID token …". Duck-typed, since jose may be a separate copy.
397
+ const joseCode = error?.code;
398
+ const message = error instanceof Error ? error.message : String(error);
399
+ const keySetFault = joseCode === 'ERR_JWKS_INVALID' || joseCode === 'ERR_JWKS_TIMEOUT';
400
+ if ((typeof joseCode === 'string' && joseCode.startsWith('ERR_J') && !keySetFault) || /^Invalid ID token/.test(message))
401
+ throw invalid(message);
402
+ throw error;
403
+ }
404
+ if (nonce !== undefined && claims.nonce !== nonce)
405
+ throw new CrouterError('ID token nonce is missing or does not match', 'id_token_invalid');
406
+ return claims;
407
+ }
408
+ async revoke(connection) {
409
+ const metadata = await this.discovery();
410
+ if (!metadata.revocation_endpoint)
411
+ return false;
412
+ await this.post(metadata.revocation_endpoint, { token: connection.refreshToken, token_type_hint: 'refresh_token' }, true);
413
+ return true;
414
+ }
415
+ async tokenResponse(params) {
416
+ const metadata = await this.discovery();
417
+ if (!metadata.token_endpoint_auth_methods_supported?.includes('private_key_jwt'))
418
+ throw new CrouterError('Directory does not support private_key_jwt');
419
+ return await this.post(metadata.token_endpoint, params);
420
+ }
421
+ connectionFrom(response, previous) {
422
+ if (typeof response.access_token !== 'string' || typeof response.refresh_token !== 'string')
423
+ throw new CrouterError('Directory returned an invalid token response');
424
+ const claims = decodeJwt(response.access_token);
425
+ const runtimeUrl = typeof response.runtimeUrl === 'string' && response.runtimeUrl ? response.runtimeUrl : previous?.runtimeUrl;
426
+ if (typeof claims.sub !== 'string' || typeof claims.grant !== 'string' || typeof claims.exp !== 'number' || !runtimeUrl) {
427
+ throw new CrouterError('Directory returned an incomplete connection');
428
+ }
429
+ const idToken = typeof response.id_token === 'string' ? response.id_token : previous?.idToken;
430
+ return {
431
+ userId: claims.sub, runtimeUrl, refreshToken: response.refresh_token,
432
+ grantId: claims.grant,
433
+ accessToken: response.access_token, accessTokenExpiresAt: claims.exp * 1_000,
434
+ ...(idToken ? { idToken } : {}),
435
+ };
436
+ }
437
+ async post(endpoint, values, revocation = false) {
438
+ const key = this.options.privateKey;
439
+ if (typeof key !== 'string' && key.alg !== undefined && key.alg !== 'ES256' && key.alg !== 'RS256')
440
+ throw new CrouterError('privateKey must use ES256 or RS256');
441
+ let alg;
442
+ let imported;
443
+ if (typeof key === 'string') {
444
+ try {
445
+ imported = await importPKCS8(key, 'ES256');
446
+ alg = 'ES256';
447
+ }
448
+ catch {
449
+ imported = await importPKCS8(key, 'RS256');
450
+ alg = 'RS256';
451
+ }
452
+ }
453
+ else {
454
+ alg = key.alg === 'RS256' || key.kty === 'RSA' ? 'RS256' : 'ES256';
455
+ imported = await importJWK(key, alg);
456
+ }
457
+ const kid = this.options.keyId ?? (typeof key === 'string' ? undefined : key.kid);
458
+ for (let attempt = 0;; attempt++) {
459
+ const now = Math.floor(Date.now() / 1000);
460
+ const assertion = await new SignJWT({})
461
+ .setProtectedHeader({ alg, typ: 'JWT', ...(kid ? { kid } : {}) })
462
+ .setIssuer(this.options.clientId).setSubject(this.options.clientId).setAudience(endpoint)
463
+ .setIssuedAt(now).setExpirationTime(now + 60).setJti(crypto.randomUUID()).sign(imported);
464
+ const response = await this.fetcher(endpoint, {
465
+ method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' },
466
+ body: new URLSearchParams({ ...values, client_id: this.options.clientId, client_assertion_type: assertionType, client_assertion: assertion }),
467
+ });
468
+ if (revocation && response.ok)
469
+ return undefined;
470
+ const payload = await response.json();
471
+ if (response.ok)
472
+ return payload;
473
+ const refusal = payload;
474
+ // invalid_grant carries the directory's reason as its error_description; a reason it does not name is grant_revoked.
475
+ const description = refusal.error_description ?? '';
476
+ const code = refusal.error === 'invalid_grant'
477
+ ? description === 'runtime_provisioning' || isRefusalCode(description) ? description : 'grant_revoked'
478
+ : refusal.error === 'invalid_client' ? 'unauthorized' : 'invalid_request';
479
+ const reconnect = isRefusalCode(code);
480
+ if (code === 'runtime_provisioning' && attempt < 2) {
481
+ const retryAfter = refusal.retry_after_s ?? Number(response.headers.get('retry-after'));
482
+ const delay = Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1_000 : 500 * 2 ** attempt * (0.5 + Math.random());
483
+ await new Promise((resolve) => setTimeout(resolve, delay));
484
+ continue;
485
+ }
486
+ const message = refusal.error_description ?? refusal.error ?? 'Directory token request failed';
487
+ const keyHint = code === 'unauthorized' ? await describeSigningKey(key, kid) : undefined;
488
+ // mapError gives the refusal its status class (401 → AuthenticationError) as errors.md documents.
489
+ throw mapError(new APIError(code === 'runtime_provisioning' ? 503 : reconnect || code === 'unauthorized' ? 401 : response.status, code, keyHint ? `${message}: ${keyHint.hint}` : message, code === 'invalid_request' ? { oauth_error: refusal.error }
490
+ : keyHint ? { oauth_error: 'invalid_client', kid: keyHint.kid, ...(keyHint.thumbprint ? { key_thumbprint: keyHint.thumbprint } : {}) } : undefined, response.headers, { origin: 'directory', type: code === 'runtime_provisioning' ? 'server_error' : code === 'invalid_request' ? 'invalid_request_error' : 'authentication_error',
491
+ retryable: code === 'runtime_provisioning', user_action: reconnect ? 'reconnect' : undefined }));
492
+ }
493
+ }
494
+ }
495
+ /**
496
+ * Name the key an `invalid_client` refusal rejected, so a developer who switched key files before
497
+ * uploading the public JWK sees which key the SDK signed with and where it must be registered.
498
+ */
499
+ async function describeSigningKey(key, kid) {
500
+ const question = "is this key's public JWK registered on the app's Sign-in tab in the console?";
501
+ if (kid)
502
+ return { kid, hint: `the directory rejected the client assertion signed with key id (kid) "${kid}"; ${question}` };
503
+ // An assertion without a kid: the console lists such a key by its RFC 7638 thumbprint.
504
+ let thumbprint;
505
+ try {
506
+ thumbprint = typeof key === 'string'
507
+ ? await calculateJwkThumbprint(await exportJWK(await importPKCS8(key, 'ES256', { extractable: true }).catch(() => importPKCS8(key, 'RS256', { extractable: true }))), 'sha256')
508
+ : await calculateJwkThumbprint(key, 'sha256');
509
+ }
510
+ catch {
511
+ thumbprint = undefined;
512
+ }
513
+ const named = thumbprint ? `signed without a kid by the key whose thumbprint is "${thumbprint}"` : 'signed without a kid';
514
+ return { kid: null, ...(thumbprint ? { thumbprint } : {}), hint: `the directory rejected the client assertion ${named}; ${question}` };
515
+ }
516
+ function notConnected(userId) {
517
+ return new CrouterError(`No stored connection for ${userId}: they must sign in with crouter cloud again`, 'not_connected');
518
+ }
519
+ function assertNotInHandler(userId) {
520
+ if (handlerScope()?.getStore() === userId) {
521
+ throw new CrouterError(`onRefusal for ${userId} called that user's runtime; their connection is dead, so act only on the app's own data there`, 'refusal_handler_reentry');
522
+ }
523
+ }
524
+ // expectedState is the state saved by the app at authorizeUrl(), not a value from the callback.
525
+ function assertState(state, expectedState) {
526
+ if (!state || !expectedState || state !== expectedState)
527
+ throw new CrouterError('OAuth state is missing or does not match', 'oauth_state_mismatch');
528
+ }
529
+ function randomBase64url(length) { return base64url(crypto.getRandomValues(new Uint8Array(length))); }
530
+ function base64url(bytes) {
531
+ let binary = '';
532
+ for (const byte of bytes)
533
+ binary += String.fromCharCode(byte);
534
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
535
+ }
@@ -0,0 +1,18 @@
1
+ import { type JWK } from 'jose';
2
+ /** Make an app signing key and the public JWK to register in the directory. */
3
+ export declare function generateKeyPair(): Promise<{
4
+ privateJwk: JWK;
5
+ publicJwk: JWK;
6
+ kid: string;
7
+ }>;
8
+ /** Check that `value` (a JWK object or its JSON text) is a private signing key with a `kid`, and return it as
9
+ * `OAuth2Options.privateKey` takes it. `source` names where it came from in the error, e.g. a file path. */
10
+ export declare function parsePrivateJwk(value: string | JWK, source?: string): JWK & {
11
+ d: string;
12
+ kid: string;
13
+ };
14
+ /** Read a private JWK file, such as the one `crouter-sdk keygen` writes, and check it as `parsePrivateJwk` does. Node only. */
15
+ export declare function privateKeyFromFile(path: string): Promise<JWK & {
16
+ d: string;
17
+ kid: string;
18
+ }>;
@@ -0,0 +1,46 @@
1
+ import { calculateJwkThumbprint, exportJWK, generateKeyPair as generateSigningKeyPair } from 'jose';
2
+ import { CrouterError } from '../errors.js';
3
+ /** Make an app signing key and the public JWK to register in the directory. */
4
+ export async function generateKeyPair() {
5
+ const keys = await generateSigningKeyPair('ES256', { extractable: true });
6
+ const publicKey = await exportJWK(keys.publicKey);
7
+ const kid = await calculateJwkThumbprint(publicKey, 'sha256');
8
+ const properties = { kid, alg: 'ES256', use: 'sig' };
9
+ return {
10
+ privateJwk: { ...await exportJWK(keys.privateKey), ...properties },
11
+ publicJwk: { ...publicKey, ...properties },
12
+ kid,
13
+ };
14
+ }
15
+ const keygenHint = 'make one with: pnpm exec crouter-sdk keygen';
16
+ /** Check that `value` (a JWK object or its JSON text) is a private signing key with a `kid`, and return it as
17
+ * `OAuth2Options.privateKey` takes it. `source` names where it came from in the error, e.g. a file path. */
18
+ export function parsePrivateJwk(value, source = 'The key') {
19
+ let key = value;
20
+ if (typeof value === 'string') {
21
+ try {
22
+ key = JSON.parse(value);
23
+ }
24
+ catch {
25
+ throw new CrouterError(`${source} is not JSON; it must hold a private JWK with a kid (${keygenHint}).`);
26
+ }
27
+ }
28
+ if (!key || typeof key !== 'object' || Array.isArray(key) || typeof key.d !== 'string' || !key.d
29
+ || typeof key.kid !== 'string' || !key.kid) {
30
+ throw new CrouterError(`${source} must hold a private JWK with a kid (${keygenHint}).`);
31
+ }
32
+ return key;
33
+ }
34
+ /** Read a private JWK file, such as the one `crouter-sdk keygen` writes, and check it as `parsePrivateJwk` does. Node only. */
35
+ export async function privateKeyFromFile(path) {
36
+ const load = new Function('specifier', 'return import(specifier)');
37
+ const { readFile } = await load('node:fs/promises');
38
+ let text;
39
+ try {
40
+ text = await readFile(path, 'utf8');
41
+ }
42
+ catch (error) {
43
+ throw new CrouterError(`${path} could not be read (${error instanceof Error ? error.message : String(error)}); it must hold a private JWK with a kid (${keygenHint}).`);
44
+ }
45
+ return parsePrivateJwk(text, path);
46
+ }
@@ -0,0 +1,32 @@
1
+ import { APIError } from '../errors.js';
2
+ /**
3
+ * What a refused refresh means for the app's data:
4
+ * - `removed` (`grant_removed`): the person removed the app or deleted their account. The runtime has
5
+ * deleted the app's runs; delete what the app stores for them.
6
+ * - `paused` (`grant_suspended`): the grant is suspended or expired. Nothing was deleted; keep the
7
+ * person's data and show the app paused until they sign in again.
8
+ * - `reconnect` (`refresh_token_expired`, `refresh_token_reused`, or `grant_revoked` with no reason given):
9
+ * the grant still exists. Nothing was deleted; ask them to sign in again.
10
+ */
11
+ export type RefusalKind = 'removed' | 'paused' | 'reconnect';
12
+ export declare const isRefusalCode: (code: string) => boolean;
13
+ /** A connection the directory refused to refresh: passed to `OAuth2Options.onRefusal`, and read from the
14
+ * refused call's error with `refusalOf(error)`. The SDK removes the stored connection once the handler resolves. */
15
+ export interface Refusal {
16
+ kind: RefusalKind;
17
+ /** The directory's code: `grant_removed`, `grant_suspended`, `refresh_token_expired`, `refresh_token_reused`, or `grant_revoked`. */
18
+ code: string;
19
+ /** The connection's crouter cloud user id (`user:<id>`). */
20
+ userId: string;
21
+ /** The grant the refused connection held. */
22
+ grantId: string;
23
+ /** The `AuthenticationError` (401, `origin: 'directory'`, `userAction: 'reconnect'`) the refusal raised. */
24
+ error: APIError;
25
+ }
26
+ export declare function refusalKindOf(code: string): RefusalKind | null;
27
+ export declare function attachRefusal(error: APIError, refusal: Refusal): void;
28
+ /** The `Refusal` a call failed with when the directory refused its connection's refresh, else `null`. Read it to
29
+ * answer one request (show "sign in again"); act on the connection itself in `OAuth2Options.onRefusal`, which the
30
+ * SDK has already run. Only an error raised by `OAuth2Client` refreshing a stored connection carries one; a 429,
31
+ * `cap_exhausted`, `scope_missing`, or a runtime-origin `grant_revoked` does not. */
32
+ export declare function refusalOf(error: unknown): Refusal | null;
@@ -0,0 +1,17 @@
1
+ import { APIError } from '../errors.js';
2
+ /** The one map from a directory refresh refusal code to what it means. Every other use of these codes reads its keys. */
3
+ const refusalKinds = {
4
+ grant_removed: 'removed', grant_suspended: 'paused',
5
+ refresh_token_expired: 'reconnect', refresh_token_reused: 'reconnect', grant_revoked: 'reconnect',
6
+ };
7
+ export const isRefusalCode = (code) => Object.hasOwn(refusalKinds, code);
8
+ const refusalKey = Symbol('crouter.refusal');
9
+ export function refusalKindOf(code) { return isRefusalCode(code) ? refusalKinds[code] : null; }
10
+ export function attachRefusal(error, refusal) { Object.defineProperty(error, refusalKey, { value: refusal }); }
11
+ /** The `Refusal` a call failed with when the directory refused its connection's refresh, else `null`. Read it to
12
+ * answer one request (show "sign in again"); act on the connection itself in `OAuth2Options.onRefusal`, which the
13
+ * SDK has already run. Only an error raised by `OAuth2Client` refreshing a stored connection carries one; a 429,
14
+ * `cap_exhausted`, `scope_missing`, or a runtime-origin `grant_revoked` does not. */
15
+ export function refusalOf(error) {
16
+ return error instanceof APIError ? error[refusalKey] ?? null : null;
17
+ }