@crouter/sdk 0.3.393 → 0.3.395

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/README.md CHANGED
@@ -145,7 +145,7 @@ console.log(source.content, command.exit_code);
145
145
 
146
146
  ## PostgreSQL connection store (Node)
147
147
 
148
- For a multi-process OAuth app, install `pg` in the app (`npm install pg`) and import the optional `@crouter/sdk/stores/postgres` subpath; the SDK's main entry never loads `pg`. `new PostgresConnectionStore(pool, { table: 'connection' })` implements `load`, `save`, `lock`, and `listStale(before: Date)`. Use one shared database and the same table for all workers. The lock keeps one PostgreSQL transaction and client open across the entire refresh callback (including the token request), so provision the pool for concurrent refreshes and avoid a transaction timeout shorter than the request. The table name accepts one or two lowercase SQL identifiers (schema.table), never SQL fragments.
148
+ For a multi-process OAuth app, install `pg` in the app (`npm install pg`) and import the optional `@crouter/sdk/stores/postgres` subpath; the SDK's main entry never loads `pg`. `new PostgresConnectionStore(pool, { table: 'connection' })` implements `load`, `save`, `lock`, `remove(userId, refreshToken)`, and `listStale(before: Date)`. Pass it as `new OAuth2Client({..., store})`. Use one shared database and the same table for all workers. The lock keeps one PostgreSQL transaction and client open across the entire refresh callback (including the token request), so provision the pool for concurrent refreshes and avoid a transaction timeout shorter than the request. The table name accepts one or two lowercase SQL identifiers (schema.table), never SQL fragments.
149
149
 
150
150
  Create the table before using the store (or adapt this schema in your migration):
151
151
 
@@ -163,7 +163,7 @@ CREATE TABLE connection (
163
163
  CREATE INDEX connection_updated_at_idx ON connection (updated_at, user_id);
164
164
  ```
165
165
 
166
- `save` upserts all fields and sets `updated_at` to the database clock. `listStale(before)` selects connections with `updated_at < before`, oldest first; it is a **last save** timestamp, not necessarily last successful refresh if an app also calls `save` for another reason. For idle-token maintenance, schedule `oauth.refreshIdleConnections(store, {olderThanMs: 30 * 24 * 60 * 60 * 1000})` (for example, daily). It selects stale connections, refreshes under the store lock, reloads there to avoid rotating an already-used token, saves a rotated connection, and reports `{refreshed, failures}` for the worker to inspect; each failure's `reason` is `refreshRefusal(error)`. `refreshRefusal(error)` classifies any refused refresh as `'removed'` (`grant_removed`: the runtime deleted the app's runs; delete what you store for the person), `'paused'` (`grant_suspended`: nothing was deleted; keep the data until they sign in again), `'reconnect'` (`refresh_token_expired`, `refresh_token_reused`, `grant_revoked`: nothing was deleted; ask them to sign in again), or `null` when the error is not the directory refusing a refresh. Load the signing key `crouter-sdk keygen` wrote with `privateKey: await privateKeyFromFile('local/app-key.json')` (Node), or check one from an environment variable with `parsePrivateJwk(text)`; both refuse anything but a private JWK with a `kid`. The SDK does not start a timer. `listStale` returns connections, not timestamps, so a worker needing a strict cutoff recheck must maintain that policy separately. The table contains credentials: restrict database access, encrypt backups, and delete a row when its user disconnects. PostgreSQL `timestamptz` must be returned as a JavaScript `Date` (the `pg` default parser).
166
+ `save` upserts all fields and sets `updated_at` to the database clock. `listStale(before)` selects connections with `updated_at < before`, oldest first; it is a **last save** timestamp, not necessarily last successful refresh if an app also calls `save` for another reason. For idle-token maintenance, schedule `oauth.refreshIdleConnections({olderThanMs: 30 * 24 * 60 * 60 * 1000})` (for example, daily). It selects stale connections, refreshes under the store lock, reloads there to avoid rotating an already-used token, saves a rotated connection, and reports `{refreshed, refused, failures}`. A connection the directory refuses goes to the client's `onRefusal(refusal)` and is then removed (`remove` deletes the row only while it still holds the refused token); `refusal.kind` is `'removed'` (`grant_removed`: the runtime deleted the app's runs; delete what you store for the person), `'paused'` (`grant_suspended`: nothing was deleted; keep the data until they sign in again), or `'reconnect'` (`refresh_token_expired`, `refresh_token_reused`, `grant_revoked`: nothing was deleted; ask them to sign in again). `refusalOf(error)` returns the same `Refusal` from a refused call's error. Load the signing key `crouter-sdk keygen` wrote with `privateKey: await privateKeyFromFile('local/app-key.json')` (Node), or check one from an environment variable with `parsePrivateJwk(text)`; both refuse anything but a private JWK with a `kid`. The SDK does not start a timer. `listStale` returns connections, not timestamps, so a worker needing a strict cutoff recheck must maintain that policy separately. The table contains credentials: restrict database access, encrypt backups, and delete a row when its user disconnects. PostgreSQL `timestamptz` must be returned as a JavaScript `Date` (the `pg` default parser).
167
167
 
168
168
  ## Example application
169
169
 
@@ -2,6 +2,6 @@ export { errorCodes } from '@crouter/api';
2
2
  export type { ErrorCode, ErrorEnvelope, ErrorOrigin, ErrorType } from '@crouter/api';
3
3
  export { mapError } from './errors.js';
4
4
  /** Codes the SDK raises itself rather than reading from a daemon or directory response. */
5
- export declare const sdkErrorCodes: readonly ["crouter_error", "id_token_invalid", "oauth_nonce_missing", "oauth_state_mismatch", "oauth_exchange_mismatch", "run_idle", "waiting_on_user", "request_aborted", "request_timeout", "connection_error", "transport_error", "daemon_unavailable", "daemon_request_interrupted", "daemon_health_unavailable", "runtime_version_unsupported", "invalid_response", "stream_error", "stream_ended"];
5
+ export declare const sdkErrorCodes: readonly ["crouter_error", "id_token_invalid", "oauth_nonce_missing", "oauth_state_mismatch", "oauth_exchange_mismatch", "not_connected", "refusal_handler_reentry", "run_idle", "waiting_on_user", "request_aborted", "request_timeout", "connection_error", "transport_error", "daemon_unavailable", "daemon_request_interrupted", "daemon_health_unavailable", "runtime_version_unsupported", "invalid_response", "stream_error", "stream_ended"];
6
6
  /** A code the SDK raises itself. An `APIError`'s `code` is one of these or an `ErrorCode` from the contract. */
7
7
  export type SdkErrorCode = typeof sdkErrorCodes[number];
@@ -13,6 +13,10 @@ export const sdkErrorCodes = [
13
13
  'oauth_state_mismatch',
14
14
  /** `exchangeCode` got a sign-in-only answer, or `exchangeIdentity` got one carrying a connection. */
15
15
  'oauth_exchange_mismatch',
16
+ /** `oauth.client(userId)` or `refresh` found no stored connection for the person: they must sign in again. */
17
+ 'not_connected',
18
+ /** `onRefusal` called `oauth.client` (or `refresh`) for the user whose dead connection it is handling. */
19
+ 'refusal_handler_reentry',
16
20
  /** `runs.parse`: the run stopped without settling. */
17
21
  'run_idle',
18
22
  /** `runs.parse`: the run waits on a question and no `onQuestion` was given. */
package/dist/index.d.ts CHANGED
@@ -15,9 +15,11 @@ export type { Share, ShareCreated, ShareCreateParams } from './resources/shares.
15
15
  export type { MemoryDocument, MemoryGetParams, MemoryListItem, MemoryListPage, MemoryListParams } from './resources/memory.js';
16
16
  export type { Question, QuestionAnswered, QuestionFields, QuestionResponses, QuestionSlot, QuestionStatus } from './resources/questions.js';
17
17
  export type { ProviderToolEntry } from './resources/providers.js';
18
- export { OAuth2Client, MemoryConnectionStore, profileFromClaims } from './oauth/index.js';
19
- export { refreshRefusal } from './oauth/index.js';
20
- export type { RefreshFailure, RefreshRefusal } from './oauth/index.js';
18
+ export { OAuth2Client, ConnectedCrouter, MemoryConnectionStore, profileFromClaims } from './oauth/index.js';
19
+ export { refusalOf } from './oauth/index.js';
20
+ export type { RefreshFailure, Refusal, RefusalKind } from './oauth/index.js';
21
+ export { RunWatcher } from './resources/run-watcher.js';
22
+ export type { PendingRun, RunEnded, RunEnding, RunWatchOptions } from './resources/run-watcher.js';
21
23
  export { generateKeyPair } from './oauth/keygen.js';
22
24
  export { parsePrivateJwk, privateKeyFromFile } from './oauth/keygen.js';
23
25
  export type { AuthorizeNonce, Connection, ConnectionStore, Identity, OAuth2Options } from './oauth/index.js';
package/dist/index.js CHANGED
@@ -3,8 +3,9 @@ export { NodeStream } from './resources/node-stream.js';
3
3
  export { RunStream } from './resources/run-stream.js';
4
4
  export { CustomObjects } from './resources/custom-objects.js';
5
5
  export { RunReply } from './resources/run-reply.js';
6
- export { OAuth2Client, MemoryConnectionStore, profileFromClaims } from './oauth/index.js';
7
- export { refreshRefusal } from './oauth/index.js';
6
+ export { OAuth2Client, ConnectedCrouter, MemoryConnectionStore, profileFromClaims } from './oauth/index.js';
7
+ export { refusalOf } from './oauth/index.js';
8
+ export { RunWatcher } from './resources/run-watcher.js';
8
9
  export { generateKeyPair } from './oauth/keygen.js';
9
10
  export { parsePrivateJwk, privateKeyFromFile } from './oauth/keygen.js';
10
11
  export { cursorFromRequest, forwardRunEvents } from './resources/forward.js';
@@ -1,24 +1,15 @@
1
1
  import { type JWK } from 'jose';
2
2
  import { type IdTokenClaims } from '@crouter/identity';
3
- import { Crouter } from '../client.js';
4
- /**
5
- * Why the directory refused a connection's refresh token, and what it means for the app's data:
6
- * - `removed` (`grant_removed`): the person removed the app or deleted their account. The runtime has
7
- * deleted the app's runs; delete what the app stores for them.
8
- * - `paused` (`grant_suspended`): the grant is suspended or expired. Nothing was deleted; keep the
9
- * person's data and show the app paused until they sign in again.
10
- * - `reconnect` (`refresh_token_expired`, `refresh_token_reused`, or `grant_revoked` with no reason given):
11
- * the grant still exists. Nothing was deleted; drop the dead connection and ask them to sign in again.
12
- */
13
- export type RefreshRefusal = 'removed' | 'paused' | 'reconnect';
14
- /** What a refused refresh means (`RefreshRefusal`), or `null` when `error` is not the directory refusing one.
15
- * Takes any error a connected client or `refresh` threw; only `APIError` with `origin: 'directory'` counts. */
16
- export declare function refreshRefusal(error: unknown): RefreshRefusal | null;
17
- /** One connection `refreshIdleConnections` could not refresh. `reason` is `refreshRefusal(error)`. */
3
+ import { Crouter, type CrouterOptions } from '../client.js';
4
+ import { RunWatcher, type RunWatchOptions } from '../resources/run-watcher.js';
5
+ import { type Refusal } from './refusal.js';
6
+ export { refusalOf } from './refusal.js';
7
+ export type { Refusal, RefusalKind } from './refusal.js';
8
+ /** One connection `refreshIdleConnections` could not refresh and that is still stored: an error other than a
9
+ * refusal, or a refusal whose `onRefusal` threw (then `refusalOf(error)` is set and `error.cause` is what it threw). */
18
10
  export interface RefreshFailure {
19
11
  userId: string;
20
12
  error: unknown;
21
- reason: RefreshRefusal | null;
22
13
  }
23
14
  export interface Connection {
24
15
  userId: string;
@@ -32,17 +23,26 @@ export interface Connection {
32
23
  export interface ConnectionStore {
33
24
  load(userId: string): Promise<Connection | null>;
34
25
  save(connection: Connection): Promise<void>;
35
- lock?<T>(userId: string, fn: () => Promise<T>): Promise<T>;
26
+ /** Run `fn` holding `userId`'s lock across every process that shares the store, and commit what it wrote when
27
+ * it resolves. `OAuth2Client` reloads, refreshes and saves inside it, so two processes never spend one refresh token. */
28
+ lock<T>(userId: string, fn: () => Promise<T>): Promise<T>;
29
+ /** Delete `userId`'s connection only while its refresh token is still `refreshToken`, and answer whether a row
30
+ * was deleted. The SDK calls it, under `lock`, after a refused refresh's `onRefusal` resolves; a connection a
31
+ * fresh sign-in saved meanwhile carries another token and is kept. */
32
+ remove(userId: string, refreshToken: string): Promise<boolean>;
36
33
  /** Connections last saved before this instant. A save must update the store's activity timestamp. */
37
34
  listStale?(before: Date): Promise<Connection[]>;
38
35
  }
36
+ /** A connection store for one process (local development and tests): its lock does not reach other processes. */
39
37
  export declare class MemoryConnectionStore implements ConnectionStore {
40
38
  private readonly connections;
41
39
  private readonly locks;
42
40
  private readonly savedAt;
43
41
  load(userId: string): Promise<Connection | null>;
44
42
  save(connection: Connection): Promise<void>;
43
+ remove(userId: string, refreshToken: string): Promise<boolean>;
45
44
  listStale(before: Date): Promise<Connection[]>;
45
+ /** Not reentrant: calling `lock` for the same user from inside `fn` waits forever. */
46
46
  lock<T>(userId: string, fn: () => Promise<T>): Promise<T>;
47
47
  }
48
48
  /**
@@ -76,15 +76,44 @@ export interface OAuth2Options {
76
76
  keyId?: string;
77
77
  redirectUri: string;
78
78
  issuer: string;
79
+ /** Where the app keeps each person's `Connection`. Save `exchangeCode`'s result here; every refresh reloads,
80
+ * rotates and saves under its `lock`, and a refused connection is removed from it. */
81
+ store: ConnectionStore;
82
+ /**
83
+ * Called when the directory refuses to refresh a stored connection (`grant_removed`, `grant_suspended`,
84
+ * `refresh_token_expired`, `refresh_token_reused`, `grant_revoked`): act on the person's data here, once, for
85
+ * every caller (a request, `refreshIdleConnections`, a `RunWatcher`). `refusal.kind` says what it means.
86
+ *
87
+ * At least once per dead connection, so make it idempotent: it runs after the store lock is released, and the
88
+ * SDK removes the stored connection (only while it still holds the refused token) after it resolves. If it
89
+ * throws, the connection stays stored and the next use of it refreshes, is refused, and calls it again.
90
+ * It must not call the runtime for `refusal.userId` (that connection is dead): `oauth.client(userId)` for that
91
+ * user inside it throws `CrouterError` `refusal_handler_reentry`.
92
+ */
93
+ onRefusal?: (refusal: Refusal) => void | Promise<void>;
94
+ /** Transport override for the directory requests and for the runtime calls of every `client(userId)`. */
79
95
  fetch?: typeof fetch;
80
96
  }
97
+ /** A runtime client on one person's stored connection (`oauth.client(userId)`): a `Crouter` that refreshes its
98
+ * token through the `OAuth2Client`'s store, plus whose connection it is and the runtime it calls. */
99
+ export declare class ConnectedCrouter extends Crouter {
100
+ /** The person whose connection this client uses (`user:<id>`). */
101
+ readonly userId: string;
102
+ /** The person's runtime URL this client calls, as the stored connection named it. */
103
+ readonly runtimeUrl: string;
104
+ constructor(connection: Pick<Connection, 'userId' | 'runtimeUrl'>, options: Pick<CrouterOptions, 'tokenSource' | 'onUnauthorized' | 'fetch'>);
105
+ }
81
106
  export declare class OAuth2Client {
82
107
  private readonly options;
83
108
  private readonly issuer;
84
109
  private readonly fetcher;
110
+ private readonly store;
85
111
  private jwks?;
86
112
  private discoveryPromise?;
113
+ /** One refresh per (user, refresh token) in this process; a client holding a newer token never joins an older refusal. */
87
114
  private readonly inFlight;
115
+ /** Refusals whose connection the SDK removed (or found already replaced) after `onRefusal` resolved. */
116
+ private readonly settled;
88
117
  constructor(options: OAuth2Options);
89
118
  private discovery;
90
119
  /** 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`.
@@ -126,16 +155,41 @@ export declare class OAuth2Client {
126
155
  expectedState: string;
127
156
  nonce: string;
128
157
  }): Promise<Identity>;
129
- /** Refresh a store's idle connections once. The app owns scheduling; failures remain visible to its worker,
130
- * each with `reason` set when the directory refused that connection's refresh (see `RefreshRefusal`). */
131
- refreshIdleConnections(store: ConnectionStore, { olderThanMs }: {
158
+ /**
159
+ * Refresh the store's idle connections once (the app schedules it, for example daily, with `olderThanMs` of 30
160
+ * days). Each refusal goes through `onRefusal` and the connection's removal, and is counted in `refused`.
161
+ * `failures` holds every connection still stored that could not be refreshed: an error other than a refusal,
162
+ * or a refusal whose `onRefusal` threw (`refusalOf(failure.error)` is set and `failure.error.cause` is what the
163
+ * handler threw); the next run retries those. A connection removed before its turn is skipped.
164
+ */
165
+ refreshIdleConnections({ olderThanMs }: {
132
166
  olderThanMs: number;
133
167
  }): Promise<{
134
168
  refreshed: number;
169
+ refused: number;
135
170
  failures: RefreshFailure[];
136
171
  }>;
137
- refresh(connection: Connection, store?: ConnectionStore): Promise<Connection>;
138
- client(connection: Connection, store: ConnectionStore): Crouter;
172
+ /**
173
+ * Rotate `connection`'s refresh token through the store: under the user's lock it reloads, and when another
174
+ * process already rotated (or the person signed in again) it returns the stored connection without calling the
175
+ * directory. Nothing stored throws `CrouterError` `not_connected`. A refusal runs `onRefusal`, removes the
176
+ * connection, and rejects with the directory's `AuthenticationError` (`refusalOf(error)` set).
177
+ */
178
+ refresh(connection: Connection): Promise<Connection>;
179
+ private rotate;
180
+ /**
181
+ * A runtime client on `userId`'s stored connection; `runtimeUrl` on it names the runtime it calls. Nothing
182
+ * stored throws `CrouterError` `not_connected` (show "sign in again"). The client refreshes through the store;
183
+ * once its connection is refused (and `onRefusal` resolved) it throws that same refusal on every later call
184
+ * without asking the directory again. Called for the user whose `onRefusal` is running, it throws
185
+ * `refusal_handler_reentry`.
186
+ */
187
+ client(userId: string): Promise<ConnectedCrouter>;
188
+ /**
189
+ * Follow the app's background runs until each comes to rest, and hand each ending to `onEnded` (see
190
+ * `RunWatchOptions`). Runs are opened with `client(userId)`, so refusals reach `onRefusal`. Call `start()`.
191
+ */
192
+ watchRuns(options: RunWatchOptions): RunWatcher;
139
193
  /** 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. */
140
194
  verifyIdToken(idToken: string, { nonce }?: {
141
195
  nonce?: string;
@@ -2,19 +2,10 @@ import { calculateJwkThumbprint, decodeJwt, decodeProtectedHeader, exportJWK, im
2
2
  import { JwksCache, parseScope, verifyIdToken } from '@crouter/identity';
3
3
  import { APIError, CrouterError, mapError } from '../errors.js';
4
4
  import { Crouter } from '../client.js';
5
- /** Refresh refusals the app must answer by reconnecting; each latches the connected client (SDK-AUTH-17). */
6
- const reconnectCodes = new Set(['grant_revoked', 'grant_removed', 'grant_suspended', 'refresh_token_expired', 'refresh_token_reused']);
7
- const refusals = {
8
- grant_removed: 'removed', grant_suspended: 'paused',
9
- refresh_token_expired: 'reconnect', refresh_token_reused: 'reconnect', grant_revoked: 'reconnect',
10
- };
11
- /** What a refused refresh means (`RefreshRefusal`), or `null` when `error` is not the directory refusing one.
12
- * Takes any error a connected client or `refresh` threw; only `APIError` with `origin: 'directory'` counts. */
13
- export function refreshRefusal(error) {
14
- if (!(error instanceof APIError) || error.origin !== 'directory')
15
- return null;
16
- return Object.hasOwn(refusals, error.code) ? refusals[error.code] : null;
17
- }
5
+ import { RunWatcher } from '../resources/run-watcher.js';
6
+ import { attachRefusal, isRefusalCode, refusalKindOf } from './refusal.js';
7
+ export { refusalOf } from './refusal.js';
8
+ /** A connection store for one process (local development and tests): its lock does not reach other processes. */
18
9
  export class MemoryConnectionStore {
19
10
  connections = new Map();
20
11
  locks = new Map();
@@ -24,9 +15,17 @@ export class MemoryConnectionStore {
24
15
  this.connections.set(connection.userId, connection);
25
16
  this.savedAt.set(connection.userId, Date.now());
26
17
  }
18
+ async remove(userId, refreshToken) {
19
+ if (this.connections.get(userId)?.refreshToken !== refreshToken)
20
+ return false;
21
+ this.connections.delete(userId);
22
+ this.savedAt.delete(userId);
23
+ return true;
24
+ }
27
25
  async listStale(before) {
28
26
  return [...this.connections.values()].filter((connection) => (this.savedAt.get(connection.userId) ?? Infinity) < before.getTime());
29
27
  }
28
+ /** Not reentrant: calling `lock` for the same user from inside `fn` waits forever. */
30
29
  async lock(userId, fn) {
31
30
  const prior = this.locks.get(userId);
32
31
  let release;
@@ -60,19 +59,47 @@ function httpsUrl(value) {
60
59
  return undefined;
61
60
  }
62
61
  }
62
+ /** A runtime client on one person's stored connection (`oauth.client(userId)`): a `Crouter` that refreshes its
63
+ * token through the `OAuth2Client`'s store, plus whose connection it is and the runtime it calls. */
64
+ export class ConnectedCrouter extends Crouter {
65
+ /** The person whose connection this client uses (`user:<id>`). */
66
+ userId;
67
+ /** The person's runtime URL this client calls, as the stored connection named it. */
68
+ runtimeUrl;
69
+ constructor(connection, options) {
70
+ super({ ...options, baseURL: connection.runtimeUrl });
71
+ this.userId = connection.userId;
72
+ this.runtimeUrl = connection.runtimeUrl;
73
+ }
74
+ }
63
75
  const assertionType = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer';
76
+ let refusalScope;
77
+ function handlerScope() {
78
+ if (refusalScope !== undefined)
79
+ return refusalScope;
80
+ const hooks = globalThis.process?.getBuiltinModule?.('node:async_hooks');
81
+ refusalScope = hooks ? new hooks.AsyncLocalStorage() : null;
82
+ return refusalScope;
83
+ }
64
84
  export class OAuth2Client {
65
85
  options;
66
86
  issuer;
67
87
  fetcher;
88
+ store;
68
89
  jwks;
69
90
  discoveryPromise;
91
+ /** One refresh per (user, refresh token) in this process; a client holding a newer token never joins an older refusal. */
70
92
  inFlight = new Map();
93
+ /** Refusals whose connection the SDK removed (or found already replaced) after `onRefusal` resolved. */
94
+ settled = new WeakSet();
71
95
  constructor(options) {
72
96
  this.options = options;
73
97
  if (!options.issuer || !options.issuer.trim())
74
98
  throw new CrouterError('OAuth2Client requires an issuer');
99
+ if (!options.store)
100
+ throw new CrouterError('OAuth2Client requires a store (a ConnectionStore such as PostgresConnectionStore)');
75
101
  this.issuer = options.issuer;
102
+ this.store = options.store;
76
103
  this.fetcher = options.fetch ?? fetch;
77
104
  }
78
105
  discovery() {
@@ -157,73 +184,151 @@ export class OAuth2Client {
157
184
  claims,
158
185
  };
159
186
  }
160
- /** Refresh a store's idle connections once. The app owns scheduling; failures remain visible to its worker,
161
- * each with `reason` set when the directory refused that connection's refresh (see `RefreshRefusal`). */
162
- async refreshIdleConnections(store, { olderThanMs }) {
163
- if (!store.listStale)
187
+ /**
188
+ * Refresh the store's idle connections once (the app schedules it, for example daily, with `olderThanMs` of 30
189
+ * days). Each refusal goes through `onRefusal` and the connection's removal, and is counted in `refused`.
190
+ * `failures` holds every connection still stored that could not be refreshed: an error other than a refusal,
191
+ * or a refusal whose `onRefusal` threw (`refusalOf(failure.error)` is set and `failure.error.cause` is what the
192
+ * handler threw); the next run retries those. A connection removed before its turn is skipped.
193
+ */
194
+ async refreshIdleConnections({ olderThanMs }) {
195
+ if (!this.store.listStale)
164
196
  throw new CrouterError('ConnectionStore.listStale(before) is required to refresh idle connections');
165
197
  if (!Number.isFinite(olderThanMs) || olderThanMs <= 0)
166
198
  throw new CrouterError('olderThanMs must be a positive number');
167
- const stale = await store.listStale(new Date(Date.now() - olderThanMs));
199
+ const stale = await this.store.listStale(new Date(Date.now() - olderThanMs));
168
200
  let refreshed = 0;
201
+ let refused = 0;
169
202
  const failures = [];
170
203
  for (const connection of stale) {
171
204
  try {
172
205
  // refresh() holds the cross-process store lock and reloads before rotating.
173
- const current = await this.refresh(connection, store);
206
+ const current = await this.refresh(connection);
174
207
  if (current.refreshToken !== connection.refreshToken)
175
208
  refreshed++;
176
209
  }
177
210
  catch (error) {
178
- failures.push({ userId: connection.userId, error, reason: refreshRefusal(error) });
211
+ if (error instanceof CrouterError && error.code === 'not_connected')
212
+ continue;
213
+ if (error instanceof APIError && this.settled.has(error))
214
+ refused++;
215
+ else
216
+ failures.push({ userId: connection.userId, error });
179
217
  }
180
218
  }
181
- return { refreshed, failures };
219
+ return { refreshed, refused, failures };
182
220
  }
183
- async refresh(connection, store) {
184
- const existing = this.inFlight.get(connection.userId);
221
+ /**
222
+ * Rotate `connection`'s refresh token through the store: under the user's lock it reloads, and when another
223
+ * process already rotated (or the person signed in again) it returns the stored connection without calling the
224
+ * directory. Nothing stored throws `CrouterError` `not_connected`. A refusal runs `onRefusal`, removes the
225
+ * connection, and rejects with the directory's `AuthenticationError` (`refusalOf(error)` set).
226
+ */
227
+ refresh(connection) {
228
+ assertNotInHandler(connection.userId);
229
+ const key = `${connection.userId}\u0000${connection.refreshToken}`;
230
+ const existing = this.inFlight.get(key);
185
231
  if (existing)
186
232
  return existing;
187
- const rotate = async () => {
188
- const stored = await store?.load(connection.userId);
189
- if (stored && stored.refreshToken !== connection.refreshToken)
190
- return stored;
191
- const updated = this.connectionFrom(await this.tokenResponse({ grant_type: 'refresh_token', refresh_token: connection.refreshToken }), connection);
192
- await store?.save(updated);
193
- return updated;
194
- };
195
- const pending = store?.lock ? store.lock(connection.userId, rotate) : rotate();
196
- this.inFlight.set(connection.userId, pending);
233
+ const pending = this.rotate(connection);
234
+ this.inFlight.set(key, pending);
235
+ const forget = () => { if (this.inFlight.get(key) === pending)
236
+ this.inFlight.delete(key); };
237
+ pending.then(forget, forget);
238
+ return pending;
239
+ }
240
+ async rotate(connection) {
241
+ const { userId } = connection;
242
+ // C1/M1: the locked closure never throws a refusal (a throw rolls a Postgres store back) and never runs app code.
243
+ const rotation = await this.store.lock(userId, async () => {
244
+ const stored = await this.store.load(userId);
245
+ if (!stored)
246
+ return { kind: 'missing' };
247
+ if (stored.refreshToken !== connection.refreshToken)
248
+ return { kind: 'current', connection: stored };
249
+ let response;
250
+ try {
251
+ response = await this.tokenResponse({ grant_type: 'refresh_token', refresh_token: stored.refreshToken });
252
+ }
253
+ catch (error) {
254
+ if (error instanceof APIError && error.origin === 'directory' && isRefusalCode(error.code))
255
+ return { kind: 'refused', error, stored };
256
+ throw error;
257
+ }
258
+ const updated = this.connectionFrom(response, stored);
259
+ await this.store.save(updated);
260
+ return { kind: 'current', connection: updated };
261
+ });
262
+ if (rotation.kind === 'current')
263
+ return rotation.connection;
264
+ if (rotation.kind === 'missing')
265
+ throw notConnected(userId);
266
+ const { error, stored } = rotation;
267
+ const refusal = { kind: refusalKindOf(error.code), code: error.code, userId, grantId: stored.grantId, error };
268
+ attachRefusal(error, refusal);
269
+ const handler = this.options.onRefusal;
270
+ if (handler) {
271
+ const scope = handlerScope();
272
+ try {
273
+ await (scope ? scope.run(userId, () => handler(refusal)) : handler(refusal));
274
+ }
275
+ catch (handlerError) {
276
+ // The connection stays stored, so the next use is refused again and calls the handler again.
277
+ Object.defineProperty(error, 'cause', { value: handlerError, configurable: true, writable: true });
278
+ throw error;
279
+ }
280
+ }
197
281
  try {
198
- return await pending;
282
+ await this.store.lock(userId, () => this.store.remove(userId, stored.refreshToken));
199
283
  }
200
- finally {
201
- this.inFlight.delete(connection.userId);
284
+ catch (removeError) {
285
+ Object.defineProperty(error, 'cause', { value: removeError, configurable: true, writable: true });
286
+ throw error;
202
287
  }
288
+ this.settled.add(error);
289
+ throw error;
203
290
  }
204
- client(connection, store) {
291
+ /**
292
+ * A runtime client on `userId`'s stored connection; `runtimeUrl` on it names the runtime it calls. Nothing
293
+ * stored throws `CrouterError` `not_connected` (show "sign in again"). The client refreshes through the store;
294
+ * once its connection is refused (and `onRefusal` resolved) it throws that same refusal on every later call
295
+ * without asking the directory again. Called for the user whose `onRefusal` is running, it throws
296
+ * `refusal_handler_reentry`.
297
+ */
298
+ async client(userId) {
299
+ assertNotInHandler(userId);
300
+ const connection = await this.store.load(userId);
301
+ if (!connection)
302
+ throw notConnected(userId);
205
303
  let current = connection;
206
- let revoked;
304
+ let refused;
207
305
  const refresh = async (force) => {
208
- if (revoked)
209
- throw revoked;
306
+ if (refused)
307
+ throw refused;
210
308
  if (!force && current.accessToken && current.accessTokenExpiresAt - Date.now() > 30_000)
211
309
  return;
212
310
  try {
213
- current = await this.refresh(current, store);
311
+ current = await this.refresh(current);
214
312
  }
215
313
  catch (error) {
216
- if (error instanceof APIError && reconnectCodes.has(error.code))
217
- revoked = error;
314
+ if (error instanceof APIError && this.settled.has(error))
315
+ refused = error;
218
316
  throw error;
219
317
  }
220
318
  };
221
- return new Crouter({
222
- baseURL: connection.runtimeUrl,
319
+ return new ConnectedCrouter(connection, {
223
320
  tokenSource: async () => { await refresh(false); return current.accessToken; },
224
321
  onUnauthorized: async () => { await refresh(true); },
322
+ ...(this.options.fetch ? { fetch: this.options.fetch } : {}),
225
323
  });
226
324
  }
325
+ /**
326
+ * Follow the app's background runs until each comes to rest, and hand each ending to `onEnded` (see
327
+ * `RunWatchOptions`). Runs are opened with `client(userId)`, so refusals reach `onRefusal`. Call `start()`.
328
+ */
329
+ watchRuns(options) {
330
+ return new RunWatcher((userId) => this.client(userId), options);
331
+ }
227
332
  /** 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. */
228
333
  async verifyIdToken(idToken, { nonce } = {}) {
229
334
  const discovery = await this.discovery();
@@ -324,9 +429,9 @@ export class OAuth2Client {
324
429
  // invalid_grant carries the directory's reason as its error_description; a reason it does not name is grant_revoked.
325
430
  const description = refusal.error_description ?? '';
326
431
  const code = refusal.error === 'invalid_grant'
327
- ? description === 'runtime_provisioning' || reconnectCodes.has(description) ? description : 'grant_revoked'
432
+ ? description === 'runtime_provisioning' || isRefusalCode(description) ? description : 'grant_revoked'
328
433
  : refusal.error === 'invalid_client' ? 'unauthorized' : 'invalid_request';
329
- const reconnect = reconnectCodes.has(code);
434
+ const reconnect = isRefusalCode(code);
330
435
  if (code === 'runtime_provisioning' && attempt < 2) {
331
436
  const retryAfter = refusal.retry_after_s ?? Number(response.headers.get('retry-after'));
332
437
  const delay = Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1_000 : 500 * 2 ** attempt * (0.5 + Math.random());
@@ -363,6 +468,14 @@ async function describeSigningKey(key, kid) {
363
468
  const named = thumbprint ? `signed without a kid by the key whose thumbprint is "${thumbprint}"` : 'signed without a kid';
364
469
  return { kid: null, ...(thumbprint ? { thumbprint } : {}), hint: `the directory rejected the client assertion ${named}; ${question}` };
365
470
  }
471
+ function notConnected(userId) {
472
+ return new CrouterError(`No stored connection for ${userId}: they must sign in with crouter cloud again`, 'not_connected');
473
+ }
474
+ function assertNotInHandler(userId) {
475
+ if (handlerScope()?.getStore() === userId) {
476
+ 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');
477
+ }
478
+ }
366
479
  // expectedState is the state saved by the app at authorizeUrl(), not a value from the callback.
367
480
  function assertState(state, expectedState) {
368
481
  if (!state || !expectedState || state !== expectedState)
@@ -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
+ }
@@ -0,0 +1,86 @@
1
+ import type { Crouter } from '../client.js';
2
+ import type { RunObject } from './runs.js';
3
+ /** A run the app is still waiting on: whose runtime holds it, and its id. `pending()` returns these from the app's own rows. */
4
+ export interface PendingRun {
5
+ userId: string;
6
+ runId: string;
7
+ }
8
+ /** How a watched run came to rest. `gone`: the runtime answered 404 `not_found` for it (the run was deleted). */
9
+ export type RunEnding = 'settled' | 'idle' | 'waiting_on_user' | 'gone';
10
+ /** One ending `RunWatcher` hands to `onEnded`. `run` is the `runs.get` answer that came to rest, or `null` when `gone`. */
11
+ export type RunEnded = {
12
+ userId: string;
13
+ runId: string;
14
+ ended: Exclude<RunEnding, 'gone'>;
15
+ run: RunObject;
16
+ } | {
17
+ userId: string;
18
+ runId: string;
19
+ ended: 'gone';
20
+ run: null;
21
+ };
22
+ export interface RunWatchOptions {
23
+ /** Every run the app still waits on, read from its own rows (the durable record). Called at `start()`, every
24
+ * `sweepMs`, after each delivery, and on `nudge()`. Stop returning a run once `onEnded` has landed it. */
25
+ pending: () => Promise<PendingRun[]> | PendingRun[];
26
+ /** Land one ending. At least once per `(runId, status, latest_sequence)`: make it idempotent (guard the write on
27
+ * the row still waiting on that run). If it throws, the same ending is retried after 5 s, doubling to 5 min. */
28
+ onEnded: (event: RunEnded) => Promise<void> | void;
29
+ /** Told about every failure the watcher recovers from itself: a throwing `pending()` or `onEnded`, a transient
30
+ * runtime error, a missing (`not_connected`) or refused connection. Defaults to ignoring them. */
31
+ onError?: (error: unknown, run: PendingRun | null) => void;
32
+ /** Milliseconds between sweeps of `pending()`. Default 30 000. */
33
+ sweepMs?: number;
34
+ /** Most runs followed with an open long-poll at once. Default 50; the rest wait their turn. */
35
+ concurrency?: number;
36
+ }
37
+ /**
38
+ * Follows an app's background runs until each comes to rest (`settled`, `idle`, `waiting_on_user`, or deleted),
39
+ * and hands each ending to `onEnded` at least once. Build it with `oauth.watchRuns(...)`, then `start()`.
40
+ *
41
+ * Delivery is keyed in memory on `(runId, status, latest_sequence)`: a run still in `pending()` after delivery is
42
+ * re-read once per sweep without `wait` and delivered again only when that key changes (it worked again, or an
43
+ * `idle` run was later canceled to `settled`); a sweep that reads `running` goes back to the long-poll. The app's
44
+ * rows are the durable record, so a restarted watcher re-derives everything from `pending()`.
45
+ */
46
+ export declare class RunWatcher {
47
+ private readonly open;
48
+ private readonly options;
49
+ private readonly sweepMs;
50
+ private readonly concurrency;
51
+ /** Followed runs by runId (one open loop each). */
52
+ private readonly following;
53
+ /** The last key delivered per runId: `${status}:${latest_sequence}`, or `gone`. */
54
+ private readonly delivered;
55
+ /** Runs that rested and were delivered: re-read only on sweeps. */
56
+ private readonly resting;
57
+ /** Runs backing off after a failure, until this instant. */
58
+ private readonly retryAt;
59
+ private readonly backoff;
60
+ private readonly queue;
61
+ private active;
62
+ private running;
63
+ private timer;
64
+ private sweeping;
65
+ private sweepAgain;
66
+ private readonly lifetime;
67
+ constructor(open: (userId: string) => Promise<Crouter>, options: RunWatchOptions);
68
+ /** Read `pending()` and begin following; sweeps repeat every `sweepMs` until `stop()`. Resolves after the first sweep. */
69
+ start(): Promise<void>;
70
+ /** Sweep now instead of waiting for `sweepMs`, for example right after the app starts a run it will watch. */
71
+ nudge(): void;
72
+ /** Stop sweeping, abort every open long-poll, and resolve once every loop has ended. No handler starts afterwards. */
73
+ stop(): Promise<void>;
74
+ private sweep;
75
+ private sweepOnce;
76
+ private forget;
77
+ private drain;
78
+ /** One loop: read the run until it rests, deliver once per key, then leave it to the sweeps. */
79
+ private follow;
80
+ private deliver;
81
+ /** Back off this run: 5 s doubling to 5 min, then a sweep picks it up again. */
82
+ private fail;
83
+ /** No stored connection, or a refusal `OAuth2Client` already handled: drop the loop; the next sweep decides. */
84
+ private connectionTrouble;
85
+ private report;
86
+ }
@@ -0,0 +1,242 @@
1
+ import { APIError, CrouterError } from '../errors.js';
2
+ import { refusalOf } from '../oauth/refusal.js';
3
+ const restingStatuses = new Set(['settled', 'idle', 'waiting_on_user']);
4
+ const firstBackoffMs = 5_000;
5
+ const maxBackoffMs = 300_000;
6
+ /**
7
+ * Follows an app's background runs until each comes to rest (`settled`, `idle`, `waiting_on_user`, or deleted),
8
+ * and hands each ending to `onEnded` at least once. Build it with `oauth.watchRuns(...)`, then `start()`.
9
+ *
10
+ * Delivery is keyed in memory on `(runId, status, latest_sequence)`: a run still in `pending()` after delivery is
11
+ * re-read once per sweep without `wait` and delivered again only when that key changes (it worked again, or an
12
+ * `idle` run was later canceled to `settled`); a sweep that reads `running` goes back to the long-poll. The app's
13
+ * rows are the durable record, so a restarted watcher re-derives everything from `pending()`.
14
+ */
15
+ export class RunWatcher {
16
+ open;
17
+ options;
18
+ sweepMs;
19
+ concurrency;
20
+ /** Followed runs by runId (one open loop each). */
21
+ following = new Map();
22
+ /** The last key delivered per runId: `${status}:${latest_sequence}`, or `gone`. */
23
+ delivered = new Map();
24
+ /** Runs that rested and were delivered: re-read only on sweeps. */
25
+ resting = new Set();
26
+ /** Runs backing off after a failure, until this instant. */
27
+ retryAt = new Map();
28
+ backoff = new Map();
29
+ queue = [];
30
+ active = 0;
31
+ running = false;
32
+ timer;
33
+ sweeping;
34
+ sweepAgain = false;
35
+ lifetime = new AbortController();
36
+ constructor(open, options) {
37
+ this.open = open;
38
+ this.options = options;
39
+ this.sweepMs = options.sweepMs ?? 30_000;
40
+ this.concurrency = options.concurrency ?? 50;
41
+ if (!Number.isFinite(this.sweepMs) || this.sweepMs <= 0)
42
+ throw new CrouterError('sweepMs must be a positive number');
43
+ if (!Number.isInteger(this.concurrency) || this.concurrency < 1)
44
+ throw new CrouterError('concurrency must be a positive integer');
45
+ }
46
+ /** Read `pending()` and begin following; sweeps repeat every `sweepMs` until `stop()`. Resolves after the first sweep. */
47
+ async start() {
48
+ if (this.running)
49
+ return;
50
+ if (this.lifetime.signal.aborted)
51
+ throw new CrouterError('A stopped RunWatcher cannot be started again; build a new one');
52
+ this.running = true;
53
+ await this.sweep();
54
+ }
55
+ /** Sweep now instead of waiting for `sweepMs`, for example right after the app starts a run it will watch. */
56
+ nudge() { if (this.running)
57
+ void this.sweep(); }
58
+ /** Stop sweeping, abort every open long-poll, and resolve once every loop has ended. No handler starts afterwards. */
59
+ async stop() {
60
+ this.running = false;
61
+ if (this.timer)
62
+ clearTimeout(this.timer);
63
+ this.lifetime.abort();
64
+ this.queue.length = 0;
65
+ await Promise.allSettled([...this.following.values()].map((follow) => follow.done));
66
+ }
67
+ sweep() {
68
+ if (this.sweeping) {
69
+ this.sweepAgain = true;
70
+ return this.sweeping;
71
+ }
72
+ this.sweeping = (async () => {
73
+ do {
74
+ this.sweepAgain = false;
75
+ await this.sweepOnce();
76
+ } while (this.sweepAgain && this.running);
77
+ })().finally(() => {
78
+ this.sweeping = undefined;
79
+ if (this.running) {
80
+ if (this.timer)
81
+ clearTimeout(this.timer);
82
+ this.timer = setTimeout(() => void this.sweep(), this.sweepMs);
83
+ this.timer.unref?.();
84
+ }
85
+ });
86
+ return this.sweeping;
87
+ }
88
+ async sweepOnce() {
89
+ if (!this.running)
90
+ return;
91
+ let runs;
92
+ try {
93
+ runs = await this.options.pending();
94
+ }
95
+ catch (error) {
96
+ this.report(error, null);
97
+ return;
98
+ }
99
+ if (!this.running)
100
+ return;
101
+ const wanted = new Map(runs.map((run) => [run.runId, run]));
102
+ // Forget runs the app no longer waits on, so a later reuse of the id starts clean.
103
+ for (const runId of [...this.delivered.keys()])
104
+ if (!wanted.has(runId))
105
+ this.forget(runId);
106
+ for (let i = this.queue.length - 1; i >= 0; i--)
107
+ if (!wanted.has(this.queue[i].runId))
108
+ this.queue.splice(i, 1);
109
+ const now = Date.now();
110
+ for (const run of wanted.values()) {
111
+ if (this.following.has(run.runId) || this.queue.some((queued) => queued.runId === run.runId))
112
+ continue;
113
+ if ((this.retryAt.get(run.runId) ?? 0) > now)
114
+ continue;
115
+ // A deleted run cannot come back: once `gone` is delivered it is not read again while pending() returns it.
116
+ if (this.delivered.get(run.runId) === 'gone')
117
+ continue;
118
+ this.queue.push(run);
119
+ }
120
+ this.drain();
121
+ }
122
+ forget(runId) {
123
+ this.delivered.delete(runId);
124
+ this.resting.delete(runId);
125
+ this.retryAt.delete(runId);
126
+ this.backoff.delete(runId);
127
+ }
128
+ drain() {
129
+ while (this.running && this.active < this.concurrency && this.queue.length > 0) {
130
+ const run = this.queue.shift();
131
+ const controller = new AbortController();
132
+ const abort = () => controller.abort(this.lifetime.signal.reason);
133
+ this.lifetime.signal.addEventListener('abort', abort, { once: true });
134
+ this.active++;
135
+ const follow = { run, controller, done: Promise.resolve() };
136
+ this.following.set(run.runId, follow);
137
+ follow.done = this.follow(run, controller.signal).finally(() => {
138
+ this.lifetime.signal.removeEventListener('abort', abort);
139
+ this.active--;
140
+ if (this.following.get(run.runId) === follow)
141
+ this.following.delete(run.runId);
142
+ this.drain();
143
+ });
144
+ }
145
+ }
146
+ /** One loop: read the run until it rests, deliver once per key, then leave it to the sweeps. */
147
+ async follow(run, signal) {
148
+ let client;
149
+ try {
150
+ client = await this.open(run.userId);
151
+ }
152
+ catch (error) {
153
+ this.connectionTrouble(error, run);
154
+ return;
155
+ }
156
+ // A run delivered before is re-read once without waiting: waitRun answers a resting run at once, so a
157
+ // long-poll would spin. Only a run seen running (or never delivered) long-polls.
158
+ let plain = this.resting.has(run.runId);
159
+ for (;;) {
160
+ if (signal.aborted || !this.running)
161
+ return;
162
+ let current;
163
+ try {
164
+ current = await client.runs.get(run.runId, plain ? { signal } : { signal, wait: 25 });
165
+ }
166
+ catch (error) {
167
+ if (signal.aborted || !this.running)
168
+ return;
169
+ if (error instanceof APIError && error.status === 404 && error.code === 'not_found' && error.origin === 'runtime') {
170
+ await this.deliver(run, 'gone', { userId: run.userId, runId: run.runId, ended: 'gone', run: null });
171
+ return;
172
+ }
173
+ if (isConnectionTrouble(error)) {
174
+ this.connectionTrouble(error, run);
175
+ return;
176
+ }
177
+ this.fail(run, error);
178
+ return;
179
+ }
180
+ if (!restingStatuses.has(current.status)) {
181
+ plain = false;
182
+ this.resting.delete(run.runId);
183
+ continue;
184
+ }
185
+ const key = `${current.status}:${current.latest_sequence}`;
186
+ if (this.delivered.get(run.runId) === key) {
187
+ this.resting.add(run.runId);
188
+ return;
189
+ }
190
+ await this.deliver(run, key, { userId: run.userId, runId: run.runId, ended: current.status, run: current });
191
+ return;
192
+ }
193
+ }
194
+ async deliver(run, key, event) {
195
+ if (!this.running)
196
+ return;
197
+ try {
198
+ await this.options.onEnded(event);
199
+ }
200
+ catch (error) {
201
+ this.fail(run, error);
202
+ return;
203
+ }
204
+ this.delivered.set(run.runId, key);
205
+ this.resting.add(run.runId);
206
+ this.retryAt.delete(run.runId);
207
+ this.backoff.delete(run.runId);
208
+ // The app has likely landed it; re-read pending() so the set shrinks (and the next run gets a slot) promptly.
209
+ if (this.running)
210
+ void this.sweep();
211
+ }
212
+ /** Back off this run: 5 s doubling to 5 min, then a sweep picks it up again. */
213
+ fail(run, error) {
214
+ this.report(error, run);
215
+ const delay = Math.min(maxBackoffMs, this.backoff.get(run.runId) ?? firstBackoffMs);
216
+ this.backoff.set(run.runId, Math.min(maxBackoffMs, delay * 2));
217
+ this.retryAt.set(run.runId, Date.now() + delay);
218
+ if (!this.running)
219
+ return;
220
+ const timer = setTimeout(() => { if (this.running)
221
+ void this.sweep(); }, delay);
222
+ timer.unref?.();
223
+ this.lifetime.signal.addEventListener('abort', () => clearTimeout(timer), { once: true });
224
+ }
225
+ /** No stored connection, or a refusal `OAuth2Client` already handled: drop the loop; the next sweep decides. */
226
+ connectionTrouble(error, run) {
227
+ if (isConnectionTrouble(error)) {
228
+ this.report(error, run);
229
+ return;
230
+ }
231
+ this.fail(run, error);
232
+ }
233
+ report(error, run) {
234
+ try {
235
+ this.options.onError?.(error, run);
236
+ }
237
+ catch { /* A throwing onError must not stop the watcher. */ }
238
+ }
239
+ }
240
+ function isConnectionTrouble(error) {
241
+ return refusalOf(error) !== null || (error instanceof CrouterError && (error.code === 'not_connected' || error.code === 'refusal_handler_reentry'));
242
+ }
@@ -23,6 +23,9 @@ export declare class PostgresConnectionStore implements ConnectionStore {
23
23
  lock<T>(userId: string, fn: () => Promise<T>): Promise<T>;
24
24
  load(userId: string): Promise<Connection | null>;
25
25
  save(connection: Connection): Promise<void>;
26
+ /** Delete `userId`'s row only while it still holds `refreshToken`; answers whether one was deleted. Inside
27
+ * `lock` it runs in that transaction, so it commits when the locked callback resolves. */
28
+ remove(userId: string, refreshToken: string): Promise<boolean>;
26
29
  /** Connections not saved since `before`, oldest first. Save activity is not necessarily refresh activity. */
27
30
  listStale(before: Date): Promise<Connection[]>;
28
31
  }
@@ -75,6 +75,12 @@ export class PostgresConnectionStore {
75
75
  access_token_expires_at = EXCLUDED.access_token_expires_at, id_token = EXCLUDED.id_token, updated_at = clock_timestamp()`, [connection.userId, connection.runtimeUrl, connection.refreshToken, connection.grantId,
76
76
  connection.accessToken, expiry, connection.idToken ?? null]);
77
77
  }
78
+ /** Delete `userId`'s row only while it still holds `refreshToken`; answers whether one was deleted. Inside
79
+ * `lock` it runs in that transaction, so it commits when the locked callback resolves. */
80
+ async remove(userId, refreshToken) {
81
+ const result = await (this.transaction.getStore() ?? this.pool).query(`DELETE FROM ${this.table} WHERE user_id = $1 AND refresh_token = $2 RETURNING user_id`, [userId, refreshToken]);
82
+ return result.rows.length > 0;
83
+ }
78
84
  /** Connections not saved since `before`, oldest first. Save activity is not necessarily refresh activity. */
79
85
  async listStale(before) {
80
86
  if (!Number.isFinite(before.getTime()))
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.393";
2
+ export declare const SDK_VERSION = "0.3.395";
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.393';
3
+ export const SDK_VERSION = '0.3.395';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crouter/sdk",
3
- "version": "0.3.393",
3
+ "version": "0.3.395",
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",
@@ -31,8 +31,8 @@
31
31
  "build": "tsc -p tsconfig.json && chmod 755 dist/keygen-cli.js"
32
32
  },
33
33
  "dependencies": {
34
- "@crouter/api": "0.3.393",
35
- "@crouter/identity": "0.3.393",
34
+ "@crouter/api": "0.3.395",
35
+ "@crouter/identity": "0.3.395",
36
36
  "jose": "^6.2.1"
37
37
  },
38
38
  "peerDependencies": {