@cortexkit/common-auth 0.2.5 → 0.2.7

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.
@@ -0,0 +1,93 @@
1
+ import type { ClaustrumReporterSource } from '@cortexkit/claustrum-client';
2
+ import type { QuotaObservation } from '../quota/index.js';
3
+ import type { RoutingRow } from '../routing/index.js';
4
+ import { type ClaustrumFamily, type ClaustrumScopedAttempt, type ClaustrumScopedClient, type IdentityParser } from './custody.js';
5
+ import { type ClaustrumLogger } from './errors.js';
6
+ import { type AccountMapper, type VaultRosterFile } from './roster.js';
7
+ export interface ClaustrumConsumerOptions {
8
+ /**
9
+ * The roster file: one row per vault account (route id, credential id,
10
+ * account identity, state, quota), plus the accounts the user declined. It
11
+ * never holds a token.
12
+ */
13
+ rosterPath: string;
14
+ /** This host's enrollment token, written by setup. */
15
+ tokenPath: string;
16
+ family: ClaustrumFamily;
17
+ connect: () => Promise<ClaustrumScopedClient>;
18
+ /**
19
+ * Whether the plugin currently takes its accounts from the vault (custody
20
+ * mode) rather than from local logins. Checked before every discovery,
21
+ * commit and dispatch; false closes the vault connection and refuses sends.
22
+ */
23
+ isCustodyActive?: () => boolean | Promise<boolean>;
24
+ /** Ids of the plugin's local pool rows; no vault route id may equal one. */
25
+ reservedRouteIds?: () => Iterable<string>;
26
+ routePrefix?: string;
27
+ mapAccount?: AccountMapper;
28
+ parseIdentity?: IdentityParser;
29
+ /**
30
+ * Fired once per change of the vault's view cursor, including the first
31
+ * roster. A poll that sees the same view does not fire, and neither does a
32
+ * routine token refresh, which never moves the view.
33
+ */
34
+ onRoster?: (roster: VaultRosterFile) => void;
35
+ onError?: (error: unknown) => void;
36
+ pollIntervalMs?: number;
37
+ setTimeoutImpl?: typeof setTimeout;
38
+ clearTimeoutImpl?: typeof clearTimeout;
39
+ now?: () => number;
40
+ logger?: ClaustrumLogger;
41
+ }
42
+ export interface SendOptions {
43
+ /**
44
+ * What kind of request this is (for example `model`, `quota`, `profile`),
45
+ * recorded in the 401 retry log line so a retry can be traced to its caller.
46
+ */
47
+ site: string;
48
+ signal?: AbortSignal;
49
+ reporterSource?: ClaustrumReporterSource;
50
+ }
51
+ /**
52
+ * One plugin host's view of its vault accounts. It turns vault credentials
53
+ * into rows for `/routing`, authorizes each physical send with the vault, and
54
+ * reports a 401 against the exact record version that send used. Metadata
55
+ * (the connection and discovery) is shared and coalesced; credential reads
56
+ * never are. It never enrolls: a missing token is an error here, and setup
57
+ * is where enrollment happens.
58
+ */
59
+ export declare class ClaustrumConsumer {
60
+ #private;
61
+ constructor(options: ClaustrumConsumerOptions);
62
+ /** The last committed roster this instance saw, without any I/O. */
63
+ snapshot(): VaultRosterFile | undefined;
64
+ /** Rows for `/routing`, from the last committed roster. */
65
+ routingRows(): RoutingRow[];
66
+ refresh(): Promise<VaultRosterFile | undefined>;
67
+ start(): void;
68
+ /**
69
+ * Authorize one physical send on a vault route. The roster file is re-read
70
+ * first, so a decline committed by another process applies before the next
71
+ * poll, and a route whose credential or account changed is refused.
72
+ */
73
+ authorize(routeId: string, signal?: AbortSignal): Promise<ClaustrumScopedAttempt>;
74
+ reportFailure(attempt: ClaustrumScopedAttempt, status: number, source: ClaustrumReporterSource): Promise<void>;
75
+ /**
76
+ * Send on a vault route. `dispatch` builds and sends the request with the
77
+ * receipt's token and may be called twice, so it must be able to rebuild
78
+ * its body. A 401 is retried once, and only when the vault now serves a new
79
+ * record version of the same credential and account; the final 401 is
80
+ * reported against the version that send actually used. Each call to
81
+ * `dispatch` gets its own receipt.
82
+ */
83
+ send(routeId: string, dispatch: (attempt: ClaustrumScopedAttempt, signal?: AbortSignal) => Promise<Response>, options: SendOptions): Promise<Response>;
84
+ /** Decline a vault account: it stays listed and never routes until accepted. */
85
+ decline(routeId: string): Promise<void>;
86
+ accept(routeId: string): Promise<void>;
87
+ /**
88
+ * Store a quota observation for a vault route. Pass the receipt the reading
89
+ * was taken with, so an observation for a replaced account is dropped.
90
+ */
91
+ recordQuota(routeId: string, observation: QuotaObservation, attempt?: Pick<ClaustrumScopedAttempt, 'accountIdentity'>): Promise<boolean>;
92
+ close(): void;
93
+ }
@@ -0,0 +1,276 @@
1
+ import { createLogger } from '../logger/index.js';
2
+ import { ClaustrumScopedCustody, decideScopedRetryAfter401, } from './custody.js';
3
+ import { ClaustrumConsumerError } from './errors.js';
4
+ import { acceptVaultRoute, declineVaultRoute, readVaultRoster, recordVaultQuota, refreshVaultRoster, vaultRoutingRows, } from './roster.js';
5
+ /**
6
+ * One plugin host's view of its vault accounts. It turns vault credentials
7
+ * into rows for `/routing`, authorizes each physical send with the vault, and
8
+ * reports a 401 against the exact record version that send used. Metadata
9
+ * (the connection and discovery) is shared and coalesced; credential reads
10
+ * never are. It never enrolls: a missing token is an error here, and setup
11
+ * is where enrollment happens.
12
+ */
13
+ export class ClaustrumConsumer {
14
+ #options;
15
+ #shutdown = new AbortController();
16
+ #logger;
17
+ #custody;
18
+ #connecting;
19
+ #refreshing;
20
+ #roster;
21
+ #timer;
22
+ #started = false;
23
+ constructor(options) {
24
+ this.#options = options;
25
+ this.#logger = options.logger ?? createLogger('claustrum');
26
+ }
27
+ #assertOpen(signal) {
28
+ this.#shutdown.signal.throwIfAborted();
29
+ signal?.throwIfAborted();
30
+ }
31
+ async #active() {
32
+ return (await this.#options.isCustodyActive?.()) ?? true;
33
+ }
34
+ #wait(pending, signal) {
35
+ const combined = AbortSignal.any([
36
+ this.#shutdown.signal,
37
+ ...(signal ? [signal] : []),
38
+ ]);
39
+ return new Promise((resolve, reject) => {
40
+ const cleanup = () => combined.removeEventListener('abort', abort);
41
+ const abort = () => {
42
+ cleanup();
43
+ reject(combined.reason);
44
+ };
45
+ combined.addEventListener('abort', abort, { once: true });
46
+ // Observe the operation even after cancellation: shared metadata work may
47
+ // finish for other callers, and a late connection must still be closed.
48
+ pending.then((value) => {
49
+ cleanup();
50
+ if (combined.aborted)
51
+ reject(combined.reason);
52
+ else
53
+ resolve(value);
54
+ }, (error) => {
55
+ cleanup();
56
+ reject(error);
57
+ });
58
+ if (combined.aborted)
59
+ abort();
60
+ });
61
+ }
62
+ async #getCustody() {
63
+ this.#assertOpen();
64
+ if (this.#custody)
65
+ return this.#custody;
66
+ if (!this.#connecting) {
67
+ this.#connecting = this.#options
68
+ .connect()
69
+ .then((client) => {
70
+ if (this.#shutdown.signal.aborted) {
71
+ client.close();
72
+ this.#assertOpen();
73
+ }
74
+ this.#custody = new ClaustrumScopedCustody({
75
+ client,
76
+ family: this.#options.family,
77
+ tokenPath: this.#options.tokenPath,
78
+ parseIdentity: this.#options.parseIdentity,
79
+ now: this.#options.now,
80
+ logger: this.#logger,
81
+ });
82
+ return this.#custody;
83
+ })
84
+ .finally(() => {
85
+ this.#connecting = undefined;
86
+ });
87
+ }
88
+ return this.#wait(this.#connecting);
89
+ }
90
+ /** The last committed roster this instance saw, without any I/O. */
91
+ snapshot() {
92
+ return this.#roster;
93
+ }
94
+ /** Rows for `/routing`, from the last committed roster. */
95
+ routingRows() {
96
+ return vaultRoutingRows(this.#roster);
97
+ }
98
+ refresh() {
99
+ this.#assertOpen();
100
+ if (this.#refreshing)
101
+ return this.#refreshing;
102
+ this.#refreshing = (async () => {
103
+ if (!(await this.#active())) {
104
+ this.#roster = undefined;
105
+ this.#custody?.close();
106
+ this.#custody = undefined;
107
+ return undefined;
108
+ }
109
+ const custody = await this.#getCustody();
110
+ const roster = await refreshVaultRoster({
111
+ path: this.#options.rosterPath,
112
+ custody,
113
+ isActive: () => this.#active(),
114
+ signal: this.#shutdown.signal,
115
+ projection: () => ({
116
+ reservedRouteIds: new Set(this.#options.reservedRouteIds?.() ?? []),
117
+ routePrefix: this.#options.routePrefix,
118
+ mapAccount: this.#options.mapAccount,
119
+ now: (this.#options.now ?? Date.now)(),
120
+ }),
121
+ });
122
+ this.#assertOpen();
123
+ const changed = roster?.view !== this.#roster?.view;
124
+ this.#roster = roster;
125
+ // Notify on view changes only: subscribers rewrite shared state on each
126
+ // notification, and the poll runs every few seconds.
127
+ if (roster && changed)
128
+ this.#options.onRoster?.(roster);
129
+ return roster;
130
+ })().finally(() => {
131
+ this.#refreshing = undefined;
132
+ });
133
+ return this.#refreshing;
134
+ }
135
+ start() {
136
+ if (this.#started)
137
+ return;
138
+ this.#assertOpen();
139
+ this.#started = true;
140
+ const tick = async () => {
141
+ try {
142
+ await this.refresh();
143
+ }
144
+ catch (error) {
145
+ if (!this.#shutdown.signal.aborted)
146
+ this.#options.onError?.(error);
147
+ }
148
+ if (this.#shutdown.signal.aborted)
149
+ return;
150
+ const delay = this.#options.pollIntervalMs ?? 5_000;
151
+ if (delay <= 0)
152
+ return;
153
+ this.#timer = (this.#options.setTimeoutImpl ?? setTimeout)(() => {
154
+ this.#timer = undefined;
155
+ void tick();
156
+ }, delay);
157
+ this.#timer.unref?.();
158
+ };
159
+ void tick();
160
+ }
161
+ /**
162
+ * Authorize one physical send on a vault route. The roster file is re-read
163
+ * first, so a decline committed by another process applies before the next
164
+ * poll, and a route whose credential or account changed is refused.
165
+ */
166
+ async authorize(routeId, signal) {
167
+ this.#assertOpen(signal);
168
+ const roster = this.#roster ?? (await this.#wait(this.refresh(), signal));
169
+ if (!roster || !(await this.#active()))
170
+ throw new ClaustrumConsumerError('not-active', 'Claustrum scoped custody is not active');
171
+ const current = await readVaultRoster(this.#options.rosterPath);
172
+ this.#assertOpen(signal);
173
+ const observed = roster.rows.find((row) => row.routeId === routeId);
174
+ const configured = current?.rows.find((row) => row.routeId === routeId);
175
+ if (configured && !configured.enabled)
176
+ throw new ClaustrumConsumerError('route-declined', 'Claustrum route is disabled');
177
+ if (!observed ||
178
+ !configured ||
179
+ configured.state !== 'active' ||
180
+ configured.credentialId !== observed.credentialId ||
181
+ configured.accountIdentity !== observed.accountIdentity)
182
+ throw new ClaustrumConsumerError('route-unavailable', 'Claustrum route is disabled, removed or changed');
183
+ return (await this.#wait(this.#getCustody(), signal)).authorize({
184
+ credentialId: configured.credentialId,
185
+ credentialType: configured.credentialType,
186
+ ...(configured.accountIdentity !== undefined && {
187
+ accountIdentity: configured.accountIdentity,
188
+ }),
189
+ }, AbortSignal.any([this.#shutdown.signal, ...(signal ? [signal] : [])]));
190
+ }
191
+ async reportFailure(attempt, status, source) {
192
+ this.#assertOpen();
193
+ if (!this.#custody)
194
+ throw new ClaustrumConsumerError('no-receipt', 'Claustrum dispatch receipt has no active owner');
195
+ await this.#custody.reportFailure(attempt, status, source);
196
+ }
197
+ /**
198
+ * Send on a vault route. `dispatch` builds and sends the request with the
199
+ * receipt's token and may be called twice, so it must be able to rebuild
200
+ * its body. A 401 is retried once, and only when the vault now serves a new
201
+ * record version of the same credential and account; the final 401 is
202
+ * reported against the version that send actually used. Each call to
203
+ * `dispatch` gets its own receipt.
204
+ */
205
+ async send(routeId, dispatch, options) {
206
+ const signal = options.signal;
207
+ let served = await this.authorize(routeId, signal);
208
+ let response = await dispatch(served, signal);
209
+ if (response.status === 401 && !signal?.aborted) {
210
+ let current;
211
+ try {
212
+ current = await this.authorize(routeId, signal);
213
+ }
214
+ catch {
215
+ // Re-authorization failed: keep this 401 and report it against the
216
+ // receipt (served record version) this request was sent with.
217
+ }
218
+ if (decideScopedRetryAfter401(options.site, served, current, this.#logger)) {
219
+ await response.body?.cancel().catch(() => { });
220
+ served = current;
221
+ response = await dispatch(current, signal);
222
+ }
223
+ }
224
+ if (response.status === 401) {
225
+ await this.reportFailure(served, 401, options.reporterSource ?? 'direct').catch((error) => this.#options.onError?.(error));
226
+ // After the report the vault may mark the account as needing a new
227
+ // login; refresh now so routing drops it without waiting for the next poll. Deferred so a consumer closed in
228
+ // the meantime reports to onError instead of throwing from this send.
229
+ Promise.resolve()
230
+ .then(() => this.refresh())
231
+ .catch((error) => {
232
+ if (!this.#shutdown.signal.aborted)
233
+ this.#options.onError?.(error);
234
+ });
235
+ }
236
+ return response;
237
+ }
238
+ /** Decline a vault account: it stays listed and never routes until accepted. */
239
+ async decline(routeId) {
240
+ this.#assertOpen();
241
+ await declineVaultRoute(this.#options.rosterPath, routeId);
242
+ this.#roster = await readVaultRoster(this.#options.rosterPath);
243
+ }
244
+ async accept(routeId) {
245
+ this.#assertOpen();
246
+ await acceptVaultRoute(this.#options.rosterPath, routeId);
247
+ this.#roster = await readVaultRoster(this.#options.rosterPath);
248
+ }
249
+ /**
250
+ * Store a quota observation for a vault route. Pass the receipt the reading
251
+ * was taken with, so an observation for a replaced account is dropped.
252
+ */
253
+ async recordQuota(routeId, observation, attempt) {
254
+ this.#assertOpen();
255
+ const kept = await recordVaultQuota(this.#options.rosterPath, {
256
+ routeId,
257
+ observation,
258
+ ...(attempt?.accountIdentity !== undefined && {
259
+ accountIdentity: attempt.accountIdentity,
260
+ }),
261
+ });
262
+ if (kept)
263
+ this.#roster = await readVaultRoster(this.#options.rosterPath);
264
+ return kept;
265
+ }
266
+ close() {
267
+ if (this.#shutdown.signal.aborted)
268
+ return;
269
+ this.#shutdown.abort(new ClaustrumConsumerError('closed', 'Claustrum consumer is closed'));
270
+ if (this.#timer)
271
+ (this.#options.clearTimeoutImpl ?? clearTimeout)(this.#timer);
272
+ this.#timer = undefined;
273
+ this.#custody?.close();
274
+ this.#roster = undefined;
275
+ }
276
+ }
@@ -0,0 +1,128 @@
1
+ import { type ClaustrumClient, type ClaustrumReporterSource, type EnrollmentTokenFile } from '@cortexkit/claustrum-client';
2
+ import { type ClaustrumLogger } from './errors.js';
3
+ export type ClaustrumScopedClient = Pick<ClaustrumClient, 'listScoped' | 'getScoped' | 'reportAuthFailureScoped' | 'close'>;
4
+ /** The two credential types a plugin's family can hold; routing differs by type. */
5
+ export type VaultCredentialType = 'oauth' | 'api_key';
6
+ /**
7
+ * Which vault rows belong to this plugin. `refreshAdapter` classifies an OAuth
8
+ * row by the protocol it speaks; `category` is the grant that authorizes this
9
+ * consumer to read it. Static API keys carry no refresh adapter, so they are
10
+ * admitted only when `apiKeys` is set and only by category.
11
+ */
12
+ export interface ClaustrumFamily {
13
+ refreshAdapter: string;
14
+ category: string;
15
+ apiKeys?: boolean;
16
+ }
17
+ /** One vault credential this consumer may serve. Never carries bearer material. */
18
+ export interface VaultCredential {
19
+ readonly credentialId: string;
20
+ readonly credentialType: VaultCredentialType;
21
+ /**
22
+ * The provider account the credential logs into, when the vault's adapter
23
+ * claims one. Absent means the adapter makes no claim, not a mismatch.
24
+ */
25
+ readonly accountIdentity?: string;
26
+ readonly state: string;
27
+ readonly email?: string;
28
+ readonly orgName?: string;
29
+ }
30
+ /** A vault record this consumer could not use, kept for the warning it raises. */
31
+ export interface SkippedVaultRecord {
32
+ readonly credentialId?: string;
33
+ readonly reason: string;
34
+ }
35
+ export interface VaultInventory {
36
+ /**
37
+ * The vault's change cursor: a digest over exactly what this consumer can see
38
+ * (ids, grants, state, identity), never over record versions, so a routine
39
+ * token refresh does not move it. Only equality is meaningful.
40
+ */
41
+ readonly view: string;
42
+ readonly credentials: readonly VaultCredential[];
43
+ readonly skipped: readonly SkippedVaultRecord[];
44
+ }
45
+ export interface ClaustrumScopedIdentity {
46
+ readonly credentialId: string;
47
+ readonly credentialType: VaultCredentialType;
48
+ readonly accountIdentity?: string;
49
+ }
50
+ /**
51
+ * A receipt: what the vault served for one physical send. Each attempt gets
52
+ * its own. It records the exact record version served, because a 401 for
53
+ * this send is reported to the vault against that version.
54
+ */
55
+ export interface ClaustrumScopedAttempt {
56
+ readonly credentialId: string;
57
+ readonly credentialType: VaultCredentialType;
58
+ readonly accountIdentity?: string;
59
+ /**
60
+ * Kept in memory only and hidden from JSON.stringify and object spreads, so
61
+ * logging a receipt never leaks it. Authorize again for every dispatch and retry.
62
+ */
63
+ readonly accessToken: string;
64
+ readonly recordVersion: number;
65
+ readonly expiresAtMs: number | null;
66
+ }
67
+ /** Reads the provider identity a served token executes under, when the plugin can tell. */
68
+ export type IdentityParser = (accessToken: string) => string | undefined;
69
+ /** Only a new version of the same account may replace an in-flight 401. */
70
+ export declare function isScopedCredentialRotation(served: ClaustrumScopedAttempt, current: ClaustrumScopedAttempt | undefined): current is ClaustrumScopedAttempt;
71
+ /** Why a scoped 401 did or did not retry. Never carries credential material. */
72
+ export type ScopedRetryReason = 'rotated' | 'reauthorize-failed' | 'credential-changed' | 'account-changed' | 'version-unchanged';
73
+ /**
74
+ * Decide whether a request that got a 401 should retry with the freshly
75
+ * re-authorized receipt, and log the decision. The vault can refresh a
76
+ * credential between the send and the 401; the log line lets that refresh be
77
+ * matched against the consumer's retry. `site` names the kind of request.
78
+ */
79
+ export declare function decideScopedRetryAfter401(site: string, served: ClaustrumScopedAttempt, current: ClaustrumScopedAttempt | undefined, logger?: ClaustrumLogger): current is ClaustrumScopedAttempt;
80
+ /**
81
+ * How long a served OAuth token must stay valid after the vault hands it out,
82
+ * so it cannot expire while a long request is still using it.
83
+ */
84
+ export declare const SERVING_MARGIN_MS = 300000;
85
+ /**
86
+ * Reads this consumer's vault credentials (list, fetch, 401 report), with the
87
+ * enrollment token as authorization. Used by every host of a plugin. There is
88
+ * deliberately no credential cache and no single-flight of credential reads:
89
+ * each physical send is authorized by the vault, so a revoked enrollment or a
90
+ * changed record takes effect on the next send. The enrollment token is
91
+ * re-read per operation so an operator's reissue on disk is picked up.
92
+ */
93
+ export declare class ClaustrumScopedCustody {
94
+ #private;
95
+ constructor(options: {
96
+ client: ClaustrumScopedClient;
97
+ family: ClaustrumFamily;
98
+ tokenPath?: string;
99
+ readToken?: () => Promise<EnrollmentTokenFile>;
100
+ parseIdentity?: IdentityParser;
101
+ now?: () => number;
102
+ logger?: ClaustrumLogger;
103
+ });
104
+ /**
105
+ * List this consumer's credentials. A record that cannot be used is skipped
106
+ * and warned about rather than failing the whole list, so one bad record
107
+ * never hides every other account. Records outside the family are not
108
+ * skipped records: they simply are not this consumer's.
109
+ */
110
+ discover(signal?: AbortSignal): Promise<VaultInventory>;
111
+ /**
112
+ * Fetch the credential for one physical send and wrap it in a fresh receipt.
113
+ * Identity is checked only where both sides assert one: the vault's served
114
+ * identity (or, without one, the plugin's parse of the token) must equal the
115
+ * roster's when both are present; absence on either side proves nothing and
116
+ * does not refuse.
117
+ */
118
+ authorize(identity: ClaustrumScopedIdentity, signal?: AbortSignal): Promise<ClaustrumScopedAttempt>;
119
+ /**
120
+ * Report that a send this consumer actually made was rejected. Only a 401
121
+ * is reported (the vault refuses 429, 402 and 5xx reports), addressed by
122
+ * credential id with the exact record version that send was served, and
123
+ * signed with the enrollment token that authorized it. A receipt this
124
+ * custody did not issue is refused rather than reported.
125
+ */
126
+ reportFailure(attempt: ClaustrumScopedAttempt, status: number, reporterSource: ClaustrumReporterSource): Promise<void>;
127
+ close(): void;
128
+ }