@cortexkit/antigravity-auth-core 3.0.6 → 3.0.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/account-migration.d.ts +1 -1
- package/dist/account-migration.d.ts.map +1 -1
- package/dist/account-migration.js +228 -53
- package/dist/account-migration.js.map +1 -1
- package/dist/agy-transport.d.ts.map +1 -1
- package/dist/agy-transport.js +2 -0
- package/dist/agy-transport.js.map +1 -1
- package/dist/common-auth-embedded/claustrum/availability.d.ts +89 -0
- package/dist/common-auth-embedded/claustrum/availability.js +106 -0
- package/dist/common-auth-embedded/claustrum/consumer.d.ts +41 -1
- package/dist/common-auth-embedded/claustrum/consumer.js +152 -6
- package/dist/common-auth-embedded/claustrum/custody.d.ts +135 -14
- package/dist/common-auth-embedded/claustrum/custody.js +311 -16
- package/dist/common-auth-embedded/claustrum/errors.d.ts +1 -0
- package/dist/common-auth-embedded/claustrum/index.d.ts +5 -3
- package/dist/common-auth-embedded/claustrum/index.js +4 -1
- package/dist/common-auth-embedded/claustrum/insula.d.ts +48 -0
- package/dist/common-auth-embedded/claustrum/insula.js +286 -21
- package/dist/common-auth-embedded/claustrum/watch.d.ts +141 -0
- package/dist/common-auth-embedded/claustrum/watch.js +254 -0
- package/dist/common-auth-embedded/fs/private-files.js +10 -2
- package/dist/common-auth-embedded/fs/refresh-file-lock.d.ts +8 -2
- package/dist/common-auth-embedded/fs/refresh-file-lock.js +6 -1
- package/dist/common-auth-embedded/pi-slot/lock.js +7 -3
- package/dist/common-auth-embedded/quota/format.d.ts +17 -3
- package/dist/common-auth-embedded/quota/format.js +203 -18
- package/dist/common-auth-embedded/quota/index.d.ts +2 -2
- package/dist/common-auth-embedded/quota/index.js +1 -1
- package/dist/common-auth-embedded/quota/projection.d.ts +16 -0
- package/dist/common-auth-embedded/source-output.json +105 -73
- package/dist/common-auth-embedded/store/pool.d.ts +1 -1
- package/dist/common-auth-embedded/store/pool.js +0 -1
- package/dist/common-auth-embedded/store/refresh-lock.d.ts +0 -1
- package/dist/common-auth-embedded/store/refresh-lock.js +0 -1
- package/dist/credential-redaction.d.ts +8 -4
- package/dist/credential-redaction.d.ts.map +1 -1
- package/dist/credential-redaction.js +55 -28
- package/dist/credential-redaction.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/pre-response-deadline.d.ts +41 -0
- package/dist/pre-response-deadline.d.ts.map +1 -0
- package/dist/pre-response-deadline.js +150 -0
- package/dist/pre-response-deadline.js.map +1 -0
- package/dist/quota-presentation.d.ts.map +1 -1
- package/dist/quota-presentation.js +9 -1
- package/dist/quota-presentation.js.map +1 -1
- package/dist/vault-account-source.d.ts +27 -2
- package/dist/vault-account-source.d.ts.map +1 -1
- package/dist/vault-account-source.js +12 -1
- package/dist/vault-account-source.js.map +1 -1
- package/dist/vault-folder-refusal.js +3 -3
- package/dist/vault-grant-reach.d.ts +23 -0
- package/dist/vault-grant-reach.d.ts.map +1 -0
- package/dist/vault-grant-reach.js +15 -0
- package/dist/vault-grant-reach.js.map +1 -0
- package/package.json +3 -3
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
import { type ClaustrumClient, type ClaustrumReporterSource, type EnrollmentTokenFile } from '@cortexkit/claustrum-client';
|
|
2
2
|
import { type ClaustrumLogger } from './errors.js';
|
|
3
3
|
import type { InsulaUsageTransport } from './insula.js';
|
|
4
|
+
import { type ScopedWatchSource, type WatchMode } from './watch.js';
|
|
5
|
+
/**
|
|
6
|
+
* `watchScoped` is present from claustrum-client 0.12.0. Hosts detect it at
|
|
7
|
+
* run time and keep polling without it.
|
|
8
|
+
*/
|
|
4
9
|
export type ClaustrumScopedClient = Pick<ClaustrumClient, 'listScoped' | 'getScoped' | 'reportAuthFailureScoped' | 'close'> & {
|
|
5
10
|
insula?: InsulaUsageTransport;
|
|
6
|
-
};
|
|
11
|
+
} & ScopedWatchSource;
|
|
7
12
|
/** The two credential types a plugin's family can hold; routing differs by type. */
|
|
8
13
|
export type VaultCredentialType = 'oauth' | 'api_key';
|
|
9
14
|
/**
|
|
@@ -44,6 +49,30 @@ export interface SkippedVaultRecord {
|
|
|
44
49
|
readonly credentialId?: string;
|
|
45
50
|
readonly reason: SkippedVaultReason;
|
|
46
51
|
}
|
|
52
|
+
/**
|
|
53
|
+
* One grant this consumer's enrollment holds, as the vault's list reply names
|
|
54
|
+
* it: for example `{ selectorKind: 'category', selector: 'openai-native',
|
|
55
|
+
* operation: 'read' }`. `selectorKind` is `exact` (the selector is a credential
|
|
56
|
+
* id) or `category` (a bare category name); both open strings.
|
|
57
|
+
*/
|
|
58
|
+
export interface VaultGrantTuple {
|
|
59
|
+
readonly selectorKind: string;
|
|
60
|
+
readonly selector: string;
|
|
61
|
+
readonly operation: string;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* What this consumer is granted, independent of what those grants currently
|
|
65
|
+
* reach. It is what tells two empty listings apart: no rows with `grants === 0`
|
|
66
|
+
* means no grant at all (the fix is `ck auth grant`), while no rows with
|
|
67
|
+
* `grants > 0` means a grant that reaches no credential, usually logins that
|
|
68
|
+
* lack the category (`ck auth reclassify --from-registry`); `tuples` names the
|
|
69
|
+
* selector that matched nothing. Never written to the roster file.
|
|
70
|
+
*/
|
|
71
|
+
export interface VaultGrantReach {
|
|
72
|
+
/** How many grants the consumer holds, deposit grants excluded. */
|
|
73
|
+
readonly grants: number;
|
|
74
|
+
readonly tuples: readonly VaultGrantTuple[];
|
|
75
|
+
}
|
|
47
76
|
export interface VaultInventory {
|
|
48
77
|
/**
|
|
49
78
|
* The vault's change cursor: a digest over exactly what this consumer can see
|
|
@@ -53,6 +82,11 @@ export interface VaultInventory {
|
|
|
53
82
|
readonly view: string;
|
|
54
83
|
readonly credentials: readonly VaultCredential[];
|
|
55
84
|
readonly skipped: readonly SkippedVaultRecord[];
|
|
85
|
+
/**
|
|
86
|
+
* The consumer's grants, as the vault's list reply named them. Undefined means unknown
|
|
87
|
+
* (the vault or client predates the fields), never zero grants.
|
|
88
|
+
*/
|
|
89
|
+
readonly grantReach?: VaultGrantReach;
|
|
56
90
|
}
|
|
57
91
|
export interface ClaustrumScopedIdentity {
|
|
58
92
|
readonly credentialId: string;
|
|
@@ -103,6 +137,57 @@ export interface ClaustrumScopedAttempt {
|
|
|
103
137
|
readonly accessToken: string;
|
|
104
138
|
readonly recordVersion: number;
|
|
105
139
|
readonly expiresAtMs: number | null;
|
|
140
|
+
/**
|
|
141
|
+
* True when this receipt was not freshly served but is the last token kept
|
|
142
|
+
* from an earlier read, used because the vault could not be reached. A 401
|
|
143
|
+
* with such a receipt must not be retried and is not reported to the vault,
|
|
144
|
+
* which is unreachable.
|
|
145
|
+
*/
|
|
146
|
+
readonly fromKeptReceipt?: true;
|
|
147
|
+
}
|
|
148
|
+
export interface KeptReceiptState {
|
|
149
|
+
readonly active: boolean;
|
|
150
|
+
readonly since: number | null;
|
|
151
|
+
}
|
|
152
|
+
export interface ClaustrumRosterWatchOptions {
|
|
153
|
+
/**
|
|
154
|
+
* Run the host's own discovery (`custody.discover()`, then its roster
|
|
155
|
+
* commit). Called with the newest notice's view when that view differs from
|
|
156
|
+
* the last discovered one, when a notice shows a sequence gap or a new
|
|
157
|
+
* epoch, and on the first notice of a watch re-opened after a close (the
|
|
158
|
+
* time between two watches was not observed). One call runs at a time;
|
|
159
|
+
* notices that arrive meanwhile earn at most one more call, which runs only
|
|
160
|
+
* if the view still differs. A rejection goes to `onError`.
|
|
161
|
+
*/
|
|
162
|
+
onView: (view: string) => unknown;
|
|
163
|
+
/**
|
|
164
|
+
* The view of the roster the host last committed. Defaults to the view of
|
|
165
|
+
* the last successful `custody.discover()` (outage probes excluded). Pass it
|
|
166
|
+
* when the host can commit a roster it did not list itself (a peer process
|
|
167
|
+
* held the discovery lease) or can fail after listing.
|
|
168
|
+
*/
|
|
169
|
+
view?: () => string | undefined;
|
|
170
|
+
/**
|
|
171
|
+
* Called whenever the mode changes. The watch starts in `polling`. Keep the
|
|
172
|
+
* host's own list timer at its usual pace unless the mode is `watching`.
|
|
173
|
+
*/
|
|
174
|
+
onMode?: (mode: WatchMode) => void;
|
|
175
|
+
/**
|
|
176
|
+
* The vault refused the watch, and its confirming `list_scoped` read it with
|
|
177
|
+
* this error (a revoked enrollment answers `not_found`). Custody has already
|
|
178
|
+
* applied it as it applies a refused discovery (`listFailed`: every kept
|
|
179
|
+
* receipt is dropped); handle it as the host handles that refusal.
|
|
180
|
+
*/
|
|
181
|
+
onRefused?: (error: unknown) => void;
|
|
182
|
+
/** A failed `onView`, or a watch that could not be opened. */
|
|
183
|
+
onError?: (error: unknown) => void;
|
|
184
|
+
}
|
|
185
|
+
/** A vault watch held by a custody; see `ClaustrumScopedCustody.watchRoster`. */
|
|
186
|
+
export interface ClaustrumRosterWatch {
|
|
187
|
+
/** How the host currently learns about changes. */
|
|
188
|
+
readonly mode: WatchMode;
|
|
189
|
+
/** Stop the watch and its timers. Safe to call more than once. */
|
|
190
|
+
close(): void;
|
|
106
191
|
}
|
|
107
192
|
/** Reads the provider identity a served token executes under, when the plugin can tell. */
|
|
108
193
|
export type IdentityParser = (accessToken: string) => string | undefined;
|
|
@@ -122,13 +207,15 @@ export declare function decideScopedRetryAfter401(site: string, served: Claustru
|
|
|
122
207
|
* so it cannot expire while a long request is still using it.
|
|
123
208
|
*/
|
|
124
209
|
export declare const SERVING_MARGIN_MS = 300000;
|
|
210
|
+
/** A kept receipt needs a minute of headroom, not the vault's refresh demand. */
|
|
211
|
+
export declare const KEPT_RECEIPT_MARGIN_MS = 60000;
|
|
125
212
|
/**
|
|
126
213
|
* Reads this consumer's vault credentials (list, fetch, 401 report), with the
|
|
127
|
-
* enrollment token as authorization. Used by every host of a plugin.
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
*
|
|
214
|
+
* enrollment token as authorization. Used by every host of a plugin. Every
|
|
215
|
+
* physical send asks the vault first while reachable. Only an unreachable vault permits
|
|
216
|
+
* reuse of the last validated receipt, in memory and bounded by its expiry.
|
|
217
|
+
* The enrollment token is re-read per operation so revocation and disconnect
|
|
218
|
+
* still take effect without a process restart.
|
|
132
219
|
*/
|
|
133
220
|
export declare class ClaustrumScopedCustody {
|
|
134
221
|
#private;
|
|
@@ -148,7 +235,13 @@ export declare class ClaustrumScopedCustody {
|
|
|
148
235
|
requireAssertion?: boolean;
|
|
149
236
|
now?: () => number;
|
|
150
237
|
logger?: ClaustrumLogger;
|
|
238
|
+
setTimeoutImpl?: typeof setTimeout;
|
|
239
|
+
clearTimeoutImpl?: typeof clearTimeout;
|
|
151
240
|
});
|
|
241
|
+
/** A watch losing its transport is the same outage as an unreachable read. */
|
|
242
|
+
transportLost(error?: unknown): void;
|
|
243
|
+
/** Any vault answer ends transport fallback, even when it refuses access. */
|
|
244
|
+
reachable(): void;
|
|
152
245
|
/**
|
|
153
246
|
* List this consumer's credentials. A record that cannot be used is skipped
|
|
154
247
|
* and warned about rather than failing the whole list, so one bad record
|
|
@@ -157,15 +250,23 @@ export declare class ClaustrumScopedCustody {
|
|
|
157
250
|
*/
|
|
158
251
|
discover(signal?: AbortSignal): Promise<VaultInventory>;
|
|
159
252
|
/**
|
|
160
|
-
*
|
|
161
|
-
*
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
* refuse; the receipt records what the vault asserted separately from what
|
|
165
|
-
* the roster expected. With it, a reply that does not itself name the
|
|
166
|
-
* credential id and the expected account is refused.
|
|
253
|
+
* Apply a failed account listing: any answer from the vault (a refusal such
|
|
254
|
+
* as a revoked enrollment's `not_found`) drops every kept receipt, while an
|
|
255
|
+
* unreachable vault keeps them. A vault watch that ends with the same
|
|
256
|
+
* refusal calls this too, so both paths treat it alike.
|
|
167
257
|
*/
|
|
168
|
-
|
|
258
|
+
listFailed(error: unknown): void;
|
|
259
|
+
/** Discard bearer material when a route is removed, declined or rebound. */
|
|
260
|
+
forget(credentialId: string): void;
|
|
261
|
+
/** Apply an already-held roster; this never fetches credentials or writes it. */
|
|
262
|
+
retain(identities: readonly (ClaustrumScopedIdentity & {
|
|
263
|
+
enabled: boolean;
|
|
264
|
+
state: string;
|
|
265
|
+
})[]): void;
|
|
266
|
+
/** A frozen, non-secret summary for a plugin's status display. */
|
|
267
|
+
keptReceiptState(): KeptReceiptState;
|
|
268
|
+
/** Ask the vault while reachable; retain one validated receipt in memory only. */
|
|
269
|
+
authorize(identity: ClaustrumScopedIdentity, signal?: AbortSignal, routeId?: string): Promise<ClaustrumScopedAttempt>;
|
|
169
270
|
/**
|
|
170
271
|
* Report that a send this consumer actually made was rejected. Only a 401
|
|
171
272
|
* is reported (the vault refuses 429, 402 and 5xx reports), addressed by
|
|
@@ -174,5 +275,25 @@ export declare class ClaustrumScopedCustody {
|
|
|
174
275
|
* custody did not issue is refused rather than reported.
|
|
175
276
|
*/
|
|
176
277
|
reportFailure(attempt: ClaustrumScopedAttempt, status: number, reporterSource: ClaustrumReporterSource): Promise<void>;
|
|
278
|
+
/**
|
|
279
|
+
* Watch this consumer's visible vault accounts instead of listing them on a
|
|
280
|
+
* short timer. Opens one `watchScoped` on this custody's client and calls
|
|
281
|
+
* `onView` when the host must run its discovery (see
|
|
282
|
+
* `ClaustrumRosterWatchOptions`). A claustrum-client without `watchScoped`
|
|
283
|
+
* (before 0.12.0) or a vault that answers `unknown_method` reports
|
|
284
|
+
* `fallback` and opens nothing. A watch that closes before any notice was
|
|
285
|
+
* never accepted: the client reads once to learn why, and the watch reports
|
|
286
|
+
* `fallback` and tries one new watch after `WATCH_RETRY_MS`, never in a
|
|
287
|
+
* loop. A watch that closes after notices is re-opened after
|
|
288
|
+
* `RESUBSCRIBE_BACKOFF_MS`, doubling per close in a row. A lost transport
|
|
289
|
+
* starts this custody's outage, exactly as an unreachable call does, and
|
|
290
|
+
* the first notice of a watch ends it. Timers are unreferenced; `close()`
|
|
291
|
+
* on the returned watch or on this custody stops it.
|
|
292
|
+
*
|
|
293
|
+
* The watch keeps no history, so the host still lists on a slow timer while
|
|
294
|
+
* the mode is `watching`: every `SAFETY_POLL_MS` (five minutes). In any
|
|
295
|
+
* other mode the host keeps its usual list timer.
|
|
296
|
+
*/
|
|
297
|
+
watchRoster(options: ClaustrumRosterWatchOptions): ClaustrumRosterWatch;
|
|
177
298
|
close(): void;
|
|
178
299
|
}
|
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import { ClaustrumCredentialError, } from '@cortexkit/claustrum-client';
|
|
2
2
|
import { createLogger } from '../logger/index.js';
|
|
3
|
+
import { ClaustrumUnreachableError, classifyVaultFailure, } from './availability.js';
|
|
3
4
|
import { readClaustrumEnrollmentToken } from './enrollment.js';
|
|
4
5
|
import { ClaustrumConsumerError } from './errors.js';
|
|
5
6
|
import { CUSTODY_PLACEHOLDER_PREFIX } from './host-slot.js';
|
|
7
|
+
import { ScopedRosterWatch, } from './watch.js';
|
|
6
8
|
/** Only a new version of the same account may replace an in-flight 401. */
|
|
7
9
|
export function isScopedCredentialRotation(served, current) {
|
|
8
10
|
return (current !== undefined &&
|
|
11
|
+
!current.fromKeptReceipt &&
|
|
12
|
+
!served.fromKeptReceipt &&
|
|
9
13
|
current.credentialId === served.credentialId &&
|
|
10
14
|
current.accountIdentity === served.accountIdentity &&
|
|
11
15
|
current.recordVersion !== served.recordVersion);
|
|
@@ -45,6 +49,8 @@ export function decideScopedRetryAfter401(site, served, current, logger = defaul
|
|
|
45
49
|
* so it cannot expire while a long request is still using it.
|
|
46
50
|
*/
|
|
47
51
|
export const SERVING_MARGIN_MS = 300_000;
|
|
52
|
+
/** A kept receipt needs a minute of headroom, not the vault's refresh demand. */
|
|
53
|
+
export const KEPT_RECEIPT_MARGIN_MS = 60_000;
|
|
48
54
|
function invalidMaterial() {
|
|
49
55
|
return new ClaustrumConsumerError('invalid-material', 'Claustrum returned invalid credential material');
|
|
50
56
|
}
|
|
@@ -83,13 +89,28 @@ function credentialTypeOf(row, family) {
|
|
|
83
89
|
return row.refreshAdapter === undefined ? 'api_key' : undefined;
|
|
84
90
|
return undefined;
|
|
85
91
|
}
|
|
92
|
+
/**
|
|
93
|
+
* The grants named in the vault's list reply, or undefined when it names none.
|
|
94
|
+
* Both fields must be present: a reply carrying only one of them is treated as
|
|
95
|
+
* unknown rather than completed with a guess. claustrum-client already refuses
|
|
96
|
+
* a malformed value, so nothing here validates it again.
|
|
97
|
+
*/
|
|
98
|
+
function grantReachOf(listed) {
|
|
99
|
+
const { grants, grantTuples } = listed;
|
|
100
|
+
if (grants === undefined || grantTuples === undefined)
|
|
101
|
+
return undefined;
|
|
102
|
+
return Object.freeze({
|
|
103
|
+
grants,
|
|
104
|
+
tuples: Object.freeze(grantTuples.map(({ selectorKind, selector, operation }) => Object.freeze({ selectorKind, selector, operation }))),
|
|
105
|
+
});
|
|
106
|
+
}
|
|
86
107
|
/**
|
|
87
108
|
* Reads this consumer's vault credentials (list, fetch, 401 report), with the
|
|
88
|
-
* enrollment token as authorization. Used by every host of a plugin.
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
109
|
+
* enrollment token as authorization. Used by every host of a plugin. Every
|
|
110
|
+
* physical send asks the vault first while reachable. Only an unreachable vault permits
|
|
111
|
+
* reuse of the last validated receipt, in memory and bounded by its expiry.
|
|
112
|
+
* The enrollment token is re-read per operation so revocation and disconnect
|
|
113
|
+
* still take effect without a process restart.
|
|
93
114
|
*/
|
|
94
115
|
export class ClaustrumScopedCustody {
|
|
95
116
|
#client;
|
|
@@ -101,7 +122,20 @@ export class ClaustrumScopedCustody {
|
|
|
101
122
|
#logger;
|
|
102
123
|
#provenance = new WeakMap();
|
|
103
124
|
#reports = new WeakMap();
|
|
125
|
+
#kept = new Map();
|
|
126
|
+
#keptAttempts = new WeakMap();
|
|
127
|
+
#setTimeout;
|
|
128
|
+
#clearTimeout;
|
|
129
|
+
#outage = false;
|
|
130
|
+
#outageError = new ClaustrumUnreachableError();
|
|
131
|
+
#probeTimer;
|
|
132
|
+
#probing = false;
|
|
133
|
+
#lastProbeAt = -Infinity;
|
|
134
|
+
#probeDelay = 2_000;
|
|
104
135
|
#closed = false;
|
|
136
|
+
/** The view of the last successful `discover()`, for `watchRoster`. */
|
|
137
|
+
#discoveredView;
|
|
138
|
+
#watches = new Set();
|
|
105
139
|
constructor(options) {
|
|
106
140
|
const tokenPath = options.tokenPath;
|
|
107
141
|
if (options.readToken) {
|
|
@@ -119,6 +153,65 @@ export class ClaustrumScopedCustody {
|
|
|
119
153
|
this.#requireAssertion = options.requireAssertion ?? false;
|
|
120
154
|
this.#now = options.now ?? Date.now;
|
|
121
155
|
this.#logger = options.logger ?? defaultLogger;
|
|
156
|
+
this.#setTimeout = options.setTimeoutImpl ?? setTimeout;
|
|
157
|
+
this.#clearTimeout = options.clearTimeoutImpl ?? clearTimeout;
|
|
158
|
+
}
|
|
159
|
+
/** A watch losing its transport is the same outage as an unreachable read. */
|
|
160
|
+
transportLost(error) {
|
|
161
|
+
if (this.#closed || this.#outage)
|
|
162
|
+
return;
|
|
163
|
+
this.#outage = true;
|
|
164
|
+
this.#outageError =
|
|
165
|
+
error instanceof ClaustrumCredentialError
|
|
166
|
+
? error
|
|
167
|
+
: new ClaustrumUnreachableError();
|
|
168
|
+
this.#logger.warn('serving kept vault receipt', { reason: 'unreachable' });
|
|
169
|
+
this.#scheduleProbe();
|
|
170
|
+
}
|
|
171
|
+
/** Any vault answer ends transport fallback, even when it refuses access. */
|
|
172
|
+
reachable() {
|
|
173
|
+
if (this.#closed || !this.#outage)
|
|
174
|
+
return;
|
|
175
|
+
this.#outage = false;
|
|
176
|
+
if (this.#probeTimer)
|
|
177
|
+
this.#clearTimeout(this.#probeTimer);
|
|
178
|
+
this.#probeTimer = undefined;
|
|
179
|
+
this.#probeDelay = 2_000;
|
|
180
|
+
for (const kept of this.#kept.values())
|
|
181
|
+
kept.since = null;
|
|
182
|
+
(this.#logger.info ?? defaultLogger.info)('vault receipt serving resumed', { reason: 'reachable' });
|
|
183
|
+
}
|
|
184
|
+
#scheduleProbe() {
|
|
185
|
+
if (this.#closed || !this.#outage || this.#probeTimer || this.#probing)
|
|
186
|
+
return;
|
|
187
|
+
this.#probeTimer = this.#setTimeout(() => {
|
|
188
|
+
this.#probeTimer = undefined;
|
|
189
|
+
this.#probe(true);
|
|
190
|
+
}, this.#probeDelay);
|
|
191
|
+
this.#probeTimer.unref?.();
|
|
192
|
+
}
|
|
193
|
+
#probe(idle = false) {
|
|
194
|
+
if (this.#closed || !this.#outage || this.#probing)
|
|
195
|
+
return;
|
|
196
|
+
if (this.#now() - this.#lastProbeAt < 1_000) {
|
|
197
|
+
this.#scheduleProbe();
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
this.#lastProbeAt = this.#now();
|
|
201
|
+
this.#probing = true;
|
|
202
|
+
// Both triggers share list_scoped discovery, including its receipt-drop rules.
|
|
203
|
+
// Sends never await discovery or let idle backoff delay the next send probe.
|
|
204
|
+
// A probe commits no roster, so it is not the host's discovery and does
|
|
205
|
+
// not move the view a roster watch compares notices against.
|
|
206
|
+
void this.#list()
|
|
207
|
+
.catch(() => {
|
|
208
|
+
if (idle && this.#outage)
|
|
209
|
+
this.#probeDelay = Math.min(this.#probeDelay * 2, 30_000);
|
|
210
|
+
})
|
|
211
|
+
.finally(() => {
|
|
212
|
+
this.#probing = false;
|
|
213
|
+
this.#scheduleProbe();
|
|
214
|
+
});
|
|
122
215
|
}
|
|
123
216
|
#check(signal) {
|
|
124
217
|
if (this.#closed)
|
|
@@ -157,17 +250,24 @@ export class ClaustrumScopedCustody {
|
|
|
157
250
|
}
|
|
158
251
|
catch (error) {
|
|
159
252
|
this.#check(signal);
|
|
253
|
+
if (classifyVaultFailure(error) === 'unreachable')
|
|
254
|
+
this.transportLost(error);
|
|
255
|
+
else
|
|
256
|
+
this.reachable();
|
|
160
257
|
// Keep the vault's own refusals (ClaustrumCredentialError, carrying
|
|
161
258
|
// code, class and action); replace any other error, whose text may echo
|
|
162
259
|
// request params that include the enrollment token.
|
|
163
260
|
if (error instanceof ClaustrumCredentialError)
|
|
164
261
|
throw error;
|
|
262
|
+
if (classifyVaultFailure(error) === 'unreachable')
|
|
263
|
+
throw new ClaustrumUnreachableError();
|
|
165
264
|
throw new ClaustrumConsumerError('unavailable', 'Claustrum scoped operation unavailable');
|
|
166
265
|
}
|
|
167
266
|
finally {
|
|
168
267
|
removeAbortListener?.();
|
|
169
268
|
}
|
|
170
269
|
this.#check(signal);
|
|
270
|
+
this.reachable();
|
|
171
271
|
return result;
|
|
172
272
|
}
|
|
173
273
|
/**
|
|
@@ -177,8 +277,27 @@ export class ClaustrumScopedCustody {
|
|
|
177
277
|
* skipped records: they simply are not this consumer's.
|
|
178
278
|
*/
|
|
179
279
|
async discover(signal) {
|
|
180
|
-
const
|
|
181
|
-
|
|
280
|
+
const inventory = await this.#list(signal);
|
|
281
|
+
this.#discoveredView = inventory.view;
|
|
282
|
+
return inventory;
|
|
283
|
+
}
|
|
284
|
+
async #list(signal) {
|
|
285
|
+
let inventory;
|
|
286
|
+
let token;
|
|
287
|
+
try {
|
|
288
|
+
token = await this.#token(signal);
|
|
289
|
+
}
|
|
290
|
+
catch (error) {
|
|
291
|
+
this.#kept.clear();
|
|
292
|
+
throw error;
|
|
293
|
+
}
|
|
294
|
+
try {
|
|
295
|
+
inventory = await this.#call(() => this.#client.listScoped(token), signal);
|
|
296
|
+
}
|
|
297
|
+
catch (error) {
|
|
298
|
+
this.listFailed(error);
|
|
299
|
+
throw error;
|
|
300
|
+
}
|
|
182
301
|
const skipped = [];
|
|
183
302
|
const candidates = [];
|
|
184
303
|
const counts = new Map();
|
|
@@ -217,29 +336,128 @@ export class ClaustrumScopedCustody {
|
|
|
217
336
|
}
|
|
218
337
|
for (const record of skipped)
|
|
219
338
|
this.#logger.warn('skipped malformed vault record', record);
|
|
339
|
+
const grantReach = grantReachOf(inventory);
|
|
220
340
|
return {
|
|
221
341
|
view: inventory.view,
|
|
222
342
|
credentials: Object.freeze(credentials),
|
|
223
343
|
skipped: Object.freeze(skipped),
|
|
344
|
+
...(grantReach && { grantReach }),
|
|
224
345
|
};
|
|
225
346
|
}
|
|
226
347
|
/**
|
|
227
|
-
*
|
|
228
|
-
*
|
|
229
|
-
*
|
|
230
|
-
*
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
348
|
+
* Apply a failed account listing: any answer from the vault (a refusal such
|
|
349
|
+
* as a revoked enrollment's `not_found`) drops every kept receipt, while an
|
|
350
|
+
* unreachable vault keeps them. A vault watch that ends with the same
|
|
351
|
+
* refusal calls this too, so both paths treat it alike.
|
|
352
|
+
*/
|
|
353
|
+
listFailed(error) {
|
|
354
|
+
if (classifyVaultFailure(error) === 'unreachable')
|
|
355
|
+
this.transportLost(error);
|
|
356
|
+
else {
|
|
357
|
+
this.reachable();
|
|
358
|
+
this.#kept.clear();
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
/** Discard bearer material when a route is removed, declined or rebound. */
|
|
362
|
+
forget(credentialId) {
|
|
363
|
+
this.#kept.delete(credentialId);
|
|
364
|
+
}
|
|
365
|
+
/** Apply an already-held roster; this never fetches credentials or writes it. */
|
|
366
|
+
retain(identities) {
|
|
367
|
+
const current = new Map(identities.map((identity) => [identity.credentialId, identity]));
|
|
368
|
+
for (const [id, kept] of this.#kept) {
|
|
369
|
+
const identity = current.get(id);
|
|
370
|
+
if (!identity?.enabled ||
|
|
371
|
+
identity.state !== 'active' ||
|
|
372
|
+
identity.credentialType !== kept.attempt.credentialType ||
|
|
373
|
+
identity.accountIdentity !== kept.attempt.expectedAccountIdentity)
|
|
374
|
+
this.forget(id);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
/** A frozen, non-secret summary for a plugin's status display. */
|
|
378
|
+
keptReceiptState() {
|
|
379
|
+
let since = null;
|
|
380
|
+
for (const [id, kept] of this.#kept) {
|
|
381
|
+
if (!this.#usable(kept.attempt)) {
|
|
382
|
+
this.forget(id);
|
|
383
|
+
continue;
|
|
384
|
+
}
|
|
385
|
+
if (kept.since !== null)
|
|
386
|
+
since = Math.min(since ?? kept.since, kept.since);
|
|
387
|
+
}
|
|
388
|
+
return Object.freeze({ active: since !== null, since });
|
|
389
|
+
}
|
|
390
|
+
#usable(attempt) {
|
|
391
|
+
return attempt.expiresAtMs === null
|
|
392
|
+
? attempt.credentialType === 'api_key'
|
|
393
|
+
: attempt.expiresAtMs - this.#now() > KEPT_RECEIPT_MARGIN_MS;
|
|
394
|
+
}
|
|
395
|
+
/** Ask the vault while reachable; retain one validated receipt in memory only. */
|
|
396
|
+
async authorize(identity, signal, routeId = 'vault') {
|
|
397
|
+
this.#check(signal);
|
|
398
|
+
const { credentialId, credentialType, accountIdentity } = identity;
|
|
399
|
+
let kept = this.#kept.get(credentialId);
|
|
400
|
+
if (kept &&
|
|
401
|
+
(kept.attempt.credentialType !== credentialType ||
|
|
402
|
+
kept.attempt.expectedAccountIdentity !== accountIdentity)) {
|
|
403
|
+
this.forget(credentialId);
|
|
404
|
+
kept = undefined;
|
|
405
|
+
}
|
|
406
|
+
// A missing or invalid enrollment is not a transport outage.
|
|
407
|
+
let token;
|
|
408
|
+
try {
|
|
409
|
+
token = await this.#token(signal);
|
|
410
|
+
}
|
|
411
|
+
catch (error) {
|
|
412
|
+
this.#kept.clear();
|
|
413
|
+
throw error;
|
|
414
|
+
}
|
|
415
|
+
if (this.#outage && kept) {
|
|
416
|
+
this.#probe();
|
|
417
|
+
return this.#serveKept(credentialId, kept, token, routeId);
|
|
418
|
+
}
|
|
419
|
+
try {
|
|
420
|
+
const attempt = await this.#authorizeFresh({ credentialId, credentialType, accountIdentity }, token, signal);
|
|
421
|
+
this.#kept.set(credentialId, { attempt, routeId, since: null });
|
|
422
|
+
return attempt;
|
|
423
|
+
}
|
|
424
|
+
catch (error) {
|
|
425
|
+
this.#check(signal);
|
|
426
|
+
if (classifyVaultFailure(error) !== 'unreachable') {
|
|
427
|
+
this.forget(credentialId);
|
|
428
|
+
throw error;
|
|
429
|
+
}
|
|
430
|
+
return this.#serveKept(credentialId, kept, token, routeId, error);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
#serveKept(credentialId, kept, token, routeId, error = this.#outageError) {
|
|
434
|
+
// A concurrent refusal or 401 may already have discarded this entry.
|
|
435
|
+
if (!kept ||
|
|
436
|
+
this.#kept.get(credentialId) !== kept ||
|
|
437
|
+
!this.#usable(kept.attempt)) {
|
|
438
|
+
this.forget(credentialId);
|
|
439
|
+
throw error;
|
|
440
|
+
}
|
|
441
|
+
if (kept.since === null)
|
|
442
|
+
kept.since = this.#now();
|
|
443
|
+
const attempt = Object.freeze(Object.defineProperty({ ...kept.attempt, fromKeptReceipt: true }, 'accessToken', { value: kept.attempt.accessToken, enumerable: false }));
|
|
444
|
+
this.#provenance.set(attempt, token);
|
|
445
|
+
this.#keptAttempts.set(attempt, routeId);
|
|
446
|
+
return attempt;
|
|
447
|
+
}
|
|
448
|
+
/**
|
|
449
|
+
* Fetch and validate a fresh receipt before retaining it. The vault assertion
|
|
450
|
+
* (or the plugin's token parse) must agree with the roster when both exist.
|
|
451
|
+
* With requireAssertion, an absent assertion also refuses rather than
|
|
452
|
+
* treating the roster's expectation as proof of the served account.
|
|
234
453
|
*/
|
|
235
|
-
async
|
|
454
|
+
async #authorizeFresh(identity, token, signal) {
|
|
236
455
|
if (!identity.credentialId)
|
|
237
456
|
throw new ClaustrumConsumerError('route-unavailable', 'Claustrum dispatch requires a credential id');
|
|
238
457
|
// Capture the caller's fields before yielding so a later mutation cannot move the fence.
|
|
239
458
|
const { credentialId, credentialType, accountIdentity } = identity;
|
|
240
459
|
if (this.#requireAssertion && accountIdentity === undefined)
|
|
241
460
|
throw new ClaustrumConsumerError('identity-unasserted', 'Claustrum dispatch requires a known account identity');
|
|
242
|
-
const token = await this.#token(signal);
|
|
243
461
|
const served = await this.#call(() => this.#client.getScoped({
|
|
244
462
|
credentialId,
|
|
245
463
|
enrollmentToken: token,
|
|
@@ -321,6 +539,21 @@ export class ClaustrumScopedCustody {
|
|
|
321
539
|
const token = this.#provenance.get(attempt);
|
|
322
540
|
if (!token)
|
|
323
541
|
throw new ClaustrumConsumerError('no-receipt', 'Claustrum failure report requires an original dispatch receipt');
|
|
542
|
+
const keptRoute = this.#keptAttempts.get(attempt);
|
|
543
|
+
if (keptRoute !== undefined) {
|
|
544
|
+
this.forget(attempt.credentialId);
|
|
545
|
+
this.#logger.debug('scoped 401 not reported', {
|
|
546
|
+
routeId: keptRoute,
|
|
547
|
+
reason: 'kept-receipt',
|
|
548
|
+
});
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
// The provider rejected this token, so it must not come back as a kept
|
|
552
|
+
// receipt during a later outage. Drop it before reporting, so a report that
|
|
553
|
+
// fails because the vault is unreachable still drops it.
|
|
554
|
+
const kept = this.#kept.get(attempt.credentialId);
|
|
555
|
+
if (kept && kept.attempt.recordVersion === attempt.recordVersion)
|
|
556
|
+
this.forget(attempt.credentialId);
|
|
324
557
|
const pending = this.#reports.get(attempt);
|
|
325
558
|
if (pending)
|
|
326
559
|
return pending;
|
|
@@ -345,10 +578,72 @@ export class ClaustrumScopedCustody {
|
|
|
345
578
|
throw error;
|
|
346
579
|
}
|
|
347
580
|
}
|
|
581
|
+
/**
|
|
582
|
+
* Watch this consumer's visible vault accounts instead of listing them on a
|
|
583
|
+
* short timer. Opens one `watchScoped` on this custody's client and calls
|
|
584
|
+
* `onView` when the host must run its discovery (see
|
|
585
|
+
* `ClaustrumRosterWatchOptions`). A claustrum-client without `watchScoped`
|
|
586
|
+
* (before 0.12.0) or a vault that answers `unknown_method` reports
|
|
587
|
+
* `fallback` and opens nothing. A watch that closes before any notice was
|
|
588
|
+
* never accepted: the client reads once to learn why, and the watch reports
|
|
589
|
+
* `fallback` and tries one new watch after `WATCH_RETRY_MS`, never in a
|
|
590
|
+
* loop. A watch that closes after notices is re-opened after
|
|
591
|
+
* `RESUBSCRIBE_BACKOFF_MS`, doubling per close in a row. A lost transport
|
|
592
|
+
* starts this custody's outage, exactly as an unreachable call does, and
|
|
593
|
+
* the first notice of a watch ends it. Timers are unreferenced; `close()`
|
|
594
|
+
* on the returned watch or on this custody stops it.
|
|
595
|
+
*
|
|
596
|
+
* The watch keeps no history, so the host still lists on a slow timer while
|
|
597
|
+
* the mode is `watching`: every `SAFETY_POLL_MS` (five minutes). In any
|
|
598
|
+
* other mode the host keeps its usual list timer.
|
|
599
|
+
*/
|
|
600
|
+
watchRoster(options) {
|
|
601
|
+
this.#check();
|
|
602
|
+
const watch = new ScopedRosterWatch({
|
|
603
|
+
client: this.#client,
|
|
604
|
+
enrollmentToken: () => this.#token(),
|
|
605
|
+
view: options.view ?? (() => this.#discoveredView),
|
|
606
|
+
discover: async (view) => {
|
|
607
|
+
await options.onView(view);
|
|
608
|
+
},
|
|
609
|
+
onRefused: (error) => {
|
|
610
|
+
// Apply a refused watch as discover() applies a refused account
|
|
611
|
+
// listing: every kept receipt is dropped.
|
|
612
|
+
this.listFailed(error);
|
|
613
|
+
options.onRefused?.(error);
|
|
614
|
+
},
|
|
615
|
+
onTransportLost: () => this.transportLost(),
|
|
616
|
+
onReachable: () => this.reachable(),
|
|
617
|
+
onError: options.onError,
|
|
618
|
+
onMode: options.onMode,
|
|
619
|
+
logger: this.#logger,
|
|
620
|
+
now: this.#now,
|
|
621
|
+
setTimeoutImpl: this.#setTimeout,
|
|
622
|
+
clearTimeoutImpl: this.#clearTimeout,
|
|
623
|
+
});
|
|
624
|
+
this.#watches.add(watch);
|
|
625
|
+
watch.start();
|
|
626
|
+
return {
|
|
627
|
+
get mode() {
|
|
628
|
+
return watch.mode;
|
|
629
|
+
},
|
|
630
|
+
close: () => {
|
|
631
|
+
this.#watches.delete(watch);
|
|
632
|
+
watch.stop();
|
|
633
|
+
},
|
|
634
|
+
};
|
|
635
|
+
}
|
|
348
636
|
close() {
|
|
349
637
|
if (this.#closed)
|
|
350
638
|
return;
|
|
351
639
|
this.#closed = true;
|
|
640
|
+
if (this.#probeTimer)
|
|
641
|
+
this.#clearTimeout(this.#probeTimer);
|
|
642
|
+
this.#probeTimer = undefined;
|
|
643
|
+
for (const watch of this.#watches)
|
|
644
|
+
watch.stop();
|
|
645
|
+
this.#watches.clear();
|
|
646
|
+
this.#kept.clear();
|
|
352
647
|
this.#client.close();
|
|
353
648
|
}
|
|
354
649
|
}
|
|
@@ -11,6 +11,7 @@ export declare class ClaustrumConsumerError extends Error {
|
|
|
11
11
|
}
|
|
12
12
|
/** The structural logger every class here accepts; `/logger`'s `createLogger` satisfies it. */
|
|
13
13
|
export interface ClaustrumLogger {
|
|
14
|
+
info?(message: string, data?: unknown): void;
|
|
14
15
|
warn(message: string, data?: unknown): void;
|
|
15
16
|
debug(message: string, data?: unknown): void;
|
|
16
17
|
}
|