@cortexkit/common-auth 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/dist/cachekeep/manager.d.ts +18 -6
  2. package/dist/cachekeep/manager.js +40 -10
  3. package/dist/claustrum/consumer.d.ts +13 -4
  4. package/dist/claustrum/consumer.js +11 -3
  5. package/dist/claustrum/custody.d.ts +47 -6
  6. package/dist/claustrum/custody.js +37 -7
  7. package/dist/claustrum/errors.d.ts +1 -1
  8. package/dist/claustrum/index.d.ts +3 -3
  9. package/dist/claustrum/index.js +2 -2
  10. package/dist/claustrum/interlock.d.ts +15 -17
  11. package/dist/claustrum/interlock.js +19 -26
  12. package/dist/claustrum/roster.d.ts +96 -6
  13. package/dist/claustrum/roster.js +237 -45
  14. package/dist/commands/builtins.d.ts +1 -1
  15. package/dist/commands/builtins.js +6 -1
  16. package/dist/commands/index.d.ts +2 -2
  17. package/dist/commands/index.js +1 -1
  18. package/dist/commands/menu.d.ts +8 -0
  19. package/dist/commands/menu.js +34 -13
  20. package/dist/commands/model.d.ts +9 -0
  21. package/dist/commands/seam.d.ts +40 -4
  22. package/dist/commands/seam.js +132 -19
  23. package/dist/dump/index.d.ts +94 -0
  24. package/dist/dump/index.js +236 -9
  25. package/dist/logger/engine.d.ts +52 -17
  26. package/dist/logger/engine.js +178 -135
  27. package/dist/logger/index.d.ts +2 -2
  28. package/dist/logger/index.js +1 -1
  29. package/dist/opencode2/install.d.ts +8 -3
  30. package/dist/opencode2/install.js +18 -9
  31. package/dist/opencode2/types.d.ts +22 -3
  32. package/dist/quota/projection.d.ts +11 -4
  33. package/dist/quota/projection.js +11 -4
  34. package/dist/routing/admission.js +3 -1
  35. package/dist/routing/index.d.ts +2 -2
  36. package/dist/routing/index.js +1 -1
  37. package/dist/routing/sticky.d.ts +19 -6
  38. package/dist/routing/sticky.js +34 -23
  39. package/dist/rpc/notifications.d.ts +20 -0
  40. package/dist/rpc/notifications.js +21 -0
  41. package/dist/rpc/rpc-server.d.ts +9 -1
  42. package/dist/rpc/rpc-server.js +8 -1
  43. package/dist/sidebar-file/index.d.ts +1 -1
  44. package/dist/sidebar-file/sidebar-file.d.ts +50 -2
  45. package/dist/sidebar-file/sidebar-file.js +92 -21
  46. package/dist/store/attribution.js +14 -2
  47. package/dist/store/errors.d.ts +6 -3
  48. package/dist/store/identity.d.ts +13 -4
  49. package/dist/store/index.d.ts +1 -1
  50. package/dist/store/mutate.d.ts +27 -4
  51. package/dist/store/mutate.js +43 -26
  52. package/dist/store/pool.d.ts +16 -3
  53. package/dist/store/pool.js +7 -2
  54. package/dist/store/rows.d.ts +20 -6
  55. package/dist/store/rows.js +141 -46
  56. package/dist/store/schema.d.ts +74 -5
  57. package/dist/store/schema.js +112 -10
  58. package/dist/store/torn.d.ts +29 -0
  59. package/dist/store/torn.js +113 -0
  60. package/package.json +1 -1
@@ -151,12 +151,18 @@ export interface CacheKeepStatus {
151
151
  * Keeps idle prompt caches warm: one target per session holding the latest
152
152
  * request body, replayed just before the provider's cache would expire.
153
153
  *
154
- * Bounds, in the order they act: the clock window gates capture and warming
155
- * (targets captured earlier survive outside it); idle caps prune a target its
156
- * session stopped using (`sustain` lifts only the main-session cap); the
157
- * target-count and byte caps evict the least recently touched target; a
158
- * per-target warm cap retires short-lived sessions; and a failed warm backs
159
- * that target off without touching the others.
154
+ * Bounds, in the order they act: a target retires once its last confirmed
155
+ * cache lifetime ends (`cacheExpiresAt`, set by its capture or its last
156
+ * successful warm), whatever else holds, because replaying a history whose
157
+ * cache has expired rebuilds a cold cache instead of keeping a warm one
158
+ * alive; the clock window gates capture and warming (targets captured earlier
159
+ * survive outside it until their lifetime ends); idle caps prune a target its
160
+ * session stopped using (`sustain` lifts only the main-session idle cap, not
161
+ * the lifetime); the target-count and byte caps evict the least recently
162
+ * touched target; a per-target warm cap retires short-lived sessions; and a
163
+ * failed warm backs that target off without touching the others. A retry
164
+ * after a failure is sent only while the lifetime lasts, so a backoff that
165
+ * runs past it retires the target instead.
160
166
  */
161
167
  export declare class CacheKeepManager<M = undefined> {
162
168
  private readonly targets;
@@ -203,6 +209,12 @@ export declare class CacheKeepManager<M = undefined> {
203
209
  */
204
210
  tick(): Promise<void>;
205
211
  private runTick;
212
+ /**
213
+ * Retires `target` when its last confirmed cache lifetime has ended; true
214
+ * when it did. Called before every send, since earlier warms in the same
215
+ * tick, the adapter's account lookup and body build all take time.
216
+ */
217
+ private retireIfExpired;
206
218
  private isCurrent;
207
219
  private view;
208
220
  private fail;
@@ -19,12 +19,18 @@ function errorMessage(error) {
19
19
  * Keeps idle prompt caches warm: one target per session holding the latest
20
20
  * request body, replayed just before the provider's cache would expire.
21
21
  *
22
- * Bounds, in the order they act: the clock window gates capture and warming
23
- * (targets captured earlier survive outside it); idle caps prune a target its
24
- * session stopped using (`sustain` lifts only the main-session cap); the
25
- * target-count and byte caps evict the least recently touched target; a
26
- * per-target warm cap retires short-lived sessions; and a failed warm backs
27
- * that target off without touching the others.
22
+ * Bounds, in the order they act: a target retires once its last confirmed
23
+ * cache lifetime ends (`cacheExpiresAt`, set by its capture or its last
24
+ * successful warm), whatever else holds, because replaying a history whose
25
+ * cache has expired rebuilds a cold cache instead of keeping a warm one
26
+ * alive; the clock window gates capture and warming (targets captured earlier
27
+ * survive outside it until their lifetime ends); idle caps prune a target its
28
+ * session stopped using (`sustain` lifts only the main-session idle cap, not
29
+ * the lifetime); the target-count and byte caps evict the least recently
30
+ * touched target; a per-target warm cap retires short-lived sessions; and a
31
+ * failed warm backs that target off without touching the others. A retry
32
+ * after a failure is sent only while the lifetime lasts, so a backoff that
33
+ * runs past it retires the target instead.
28
34
  */
29
35
  export class CacheKeepManager {
30
36
  targets = new Map();
@@ -233,8 +239,9 @@ export class CacheKeepManager {
233
239
  }
234
240
  async runTick() {
235
241
  this.pruneStale();
236
- // Outside the window nothing fires, but captured targets stay so they
237
- // warm again when it reopens.
242
+ // Outside the window nothing fires, but captured targets stay until
243
+ // their cache lifetime ends, so one still alive when the window reopens
244
+ // is warmed again.
238
245
  const window = this.getWindow?.();
239
246
  if (window && !isWithinCacheKeepWindow(window, new Date(this.now()))) {
240
247
  return;
@@ -254,6 +261,23 @@ export class CacheKeepManager {
254
261
  await this.warm(sessionKey, target);
255
262
  }
256
263
  }
264
+ /**
265
+ * Retires `target` when its last confirmed cache lifetime has ended; true
266
+ * when it did. Called before every send, since earlier warms in the same
267
+ * tick, the adapter's account lookup and body build all take time.
268
+ */
269
+ retireIfExpired(sessionKey, target) {
270
+ if (this.now() < target.cacheExpiresAt)
271
+ return false;
272
+ this.log?.debug('cachekeep retired target (cache lifetime ended)', {
273
+ sessionKey,
274
+ accountId: target.accountId,
275
+ cacheExpiresAt: target.cacheExpiresAt,
276
+ failures: target.failures,
277
+ });
278
+ this.drop(sessionKey);
279
+ return true;
280
+ }
257
281
  isCurrent(sessionKey, target) {
258
282
  return !this.disposed && this.targets.get(sessionKey) === target;
259
283
  }
@@ -286,6 +310,8 @@ export class CacheKeepManager {
286
310
  });
287
311
  }
288
312
  async warm(sessionKey, target) {
313
+ if (this.retireIfExpired(sessionKey, target))
314
+ return;
289
315
  const view = this.view(sessionKey, target);
290
316
  if (this.adapter.activeAccount) {
291
317
  let active;
@@ -322,6 +348,8 @@ export class CacheKeepManager {
322
348
  }
323
349
  if (!this.isCurrent(sessionKey, target))
324
350
  return;
351
+ if (this.retireIfExpired(sessionKey, target))
352
+ return;
325
353
  const signal = AbortSignal.any([
326
354
  AbortSignal.timeout(this.warmTimeoutMs),
327
355
  this.abortController.signal,
@@ -383,8 +411,10 @@ export class CacheKeepManager {
383
411
  const now = this.now();
384
412
  const sustain = this.getSustain?.() === true;
385
413
  for (const [sessionKey, target] of [...this.targets]) {
386
- // Sustain exempts main sessions from the idle cap only; the window,
387
- // count, byte and warm caps still apply to them.
414
+ if (this.retireIfExpired(sessionKey, target))
415
+ continue;
416
+ // Sustain exempts main sessions from the idle cap only; the lifetime,
417
+ // window, count, byte and warm caps still apply to them.
388
418
  if (sustain && !target.isSubagent)
389
419
  continue;
390
420
  const maxIdleMs = target.maxIdleMs ?? this.idleDefault(target.isSubagent);
@@ -3,7 +3,7 @@ import type { QuotaObservation } from '../quota/index.js';
3
3
  import type { RoutingRow } from '../routing/index.js';
4
4
  import { type ClaustrumFamily, type ClaustrumScopedAttempt, type ClaustrumScopedClient, type IdentityParser } from './custody.js';
5
5
  import { type ClaustrumLogger } from './errors.js';
6
- import { type AccountMapper, type VaultRosterFile } from './roster.js';
6
+ import { type AccountMapper, type QuotaReceipt, type VaultRosterFile } from './roster.js';
7
7
  export interface ClaustrumConsumerOptions {
8
8
  /**
9
9
  * The roster file: one row per vault account (route id, credential id,
@@ -26,6 +26,13 @@ export interface ClaustrumConsumerOptions {
26
26
  routePrefix?: string;
27
27
  mapAccount?: AccountMapper;
28
28
  parseIdentity?: IdentityParser;
29
+ /**
30
+ * Issue a receipt only when the vault's served reply itself names the
31
+ * credential id and the roster's account identity (see
32
+ * `ClaustrumScopedCustody`). Set it for providers whose tokens do not reveal
33
+ * their account; leave it unset to accept a reply that asserts no identity.
34
+ */
35
+ requireAssertion?: boolean;
29
36
  /**
30
37
  * Fired once per change of the vault's view cursor, including the first
31
38
  * roster. A poll that sees the same view does not fire, and neither does a
@@ -85,9 +92,11 @@ export declare class ClaustrumConsumer {
85
92
  decline(routeId: string): Promise<void>;
86
93
  accept(routeId: string): Promise<void>;
87
94
  /**
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.
95
+ * Store a quota or profile observation for a vault route. The receipt the
96
+ * reading was taken with is required: the observation lands only while the
97
+ * route still holds the credential and account that receipt was served for
98
+ * (see `recordVaultQuota`), so a reading for a replaced account is dropped.
90
99
  */
91
- recordQuota(routeId: string, observation: QuotaObservation, attempt?: Pick<ClaustrumScopedAttempt, 'accountIdentity'>): Promise<boolean>;
100
+ recordQuota(routeId: string, observation: QuotaObservation, attempt: QuotaReceipt): Promise<boolean>;
92
101
  close(): void;
93
102
  }
@@ -76,6 +76,7 @@ export class ClaustrumConsumer {
76
76
  family: this.#options.family,
77
77
  tokenPath: this.#options.tokenPath,
78
78
  parseIdentity: this.#options.parseIdentity,
79
+ requireAssertion: this.#options.requireAssertion,
79
80
  now: this.#options.now,
80
81
  logger: this.#logger,
81
82
  });
@@ -247,17 +248,24 @@ export class ClaustrumConsumer {
247
248
  this.#roster = await readVaultRoster(this.#options.rosterPath);
248
249
  }
249
250
  /**
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.
251
+ * Store a quota or profile observation for a vault route. The receipt the
252
+ * reading was taken with is required: the observation lands only while the
253
+ * route still holds the credential and account that receipt was served for
254
+ * (see `recordVaultQuota`), so a reading for a replaced account is dropped.
252
255
  */
253
256
  async recordQuota(routeId, observation, attempt) {
254
257
  this.#assertOpen();
255
258
  const kept = await recordVaultQuota(this.#options.rosterPath, {
256
259
  routeId,
257
260
  observation,
258
- ...(attempt?.accountIdentity !== undefined && {
261
+ credentialId: attempt.credentialId,
262
+ accountIdentitySource: attempt.accountIdentitySource,
263
+ ...(attempt.accountIdentity !== undefined && {
259
264
  accountIdentity: attempt.accountIdentity,
260
265
  }),
266
+ ...(attempt.expectedAccountIdentity !== undefined && {
267
+ expectedAccountIdentity: attempt.expectedAccountIdentity,
268
+ }),
261
269
  });
262
270
  if (kept)
263
271
  this.#roster = await readVaultRoster(this.#options.rosterPath);
@@ -27,10 +27,19 @@ export interface VaultCredential {
27
27
  readonly email?: string;
28
28
  readonly orgName?: string;
29
29
  }
30
- /** A vault record this consumer could not use, kept for the warning it raises. */
30
+ /**
31
+ * Why a listed vault record could not be used. A closed set of fixed codes, so
32
+ * a log line or a roster file that carries one never echoes vault data.
33
+ */
34
+ export type SkippedVaultReason = 'empty credential id' | 'duplicate credential id' | 'blank account identity' | 'empty state';
35
+ /**
36
+ * A vault record this consumer could not use. `credentialId` is absent when
37
+ * the record's id itself was unusable, so nothing can say which account the
38
+ * record was.
39
+ */
31
40
  export interface SkippedVaultRecord {
32
41
  readonly credentialId?: string;
33
- readonly reason: string;
42
+ readonly reason: SkippedVaultReason;
34
43
  }
35
44
  export interface VaultInventory {
36
45
  /**
@@ -47,6 +56,13 @@ export interface ClaustrumScopedIdentity {
47
56
  readonly credentialType: VaultCredentialType;
48
57
  readonly accountIdentity?: string;
49
58
  }
59
+ /**
60
+ * Where a receipt's `accountIdentity` came from: the vault asserted it in the
61
+ * served reply, the plugin's `parseIdentity` read it from the served token, or
62
+ * neither did and it is only the roster's expectation (`none`: no identity at
63
+ * all).
64
+ */
65
+ export type AccountIdentitySource = 'asserted' | 'parsed' | 'expected' | 'none';
50
66
  /**
51
67
  * A receipt: what the vault served for one physical send. Each attempt gets
52
68
  * its own. It records the exact record version served, because a 401 for
@@ -55,7 +71,22 @@ export interface ClaustrumScopedIdentity {
55
71
  export interface ClaustrumScopedAttempt {
56
72
  readonly credentialId: string;
57
73
  readonly credentialType: VaultCredentialType;
74
+ /**
75
+ * The account this receipt is bound to: the vault's assertion, else the
76
+ * plugin's parse of the token, else the roster's expectation. Check
77
+ * `accountIdentitySource` before treating it as proof.
78
+ */
58
79
  readonly accountIdentity?: string;
80
+ readonly accountIdentitySource: AccountIdentitySource;
81
+ /** The account the roster row named when this receipt was requested. */
82
+ readonly expectedAccountIdentity?: string;
83
+ /** The credential id the vault itself put in the served reply, if any. */
84
+ readonly assertedCredentialId?: string;
85
+ /**
86
+ * The account the vault itself put in the served reply, if any. Never filled
87
+ * in from the roster or from a token parse.
88
+ */
89
+ readonly assertedAccountIdentity?: string;
59
90
  /**
60
91
  * Kept in memory only and hidden from JSON.stringify and object spreads, so
61
92
  * logging a receipt never leaks it. Authorize again for every dispatch and retry.
@@ -98,6 +129,14 @@ export declare class ClaustrumScopedCustody {
98
129
  tokenPath?: string;
99
130
  readToken?: () => Promise<EnrollmentTokenFile>;
100
131
  parseIdentity?: IdentityParser;
132
+ /**
133
+ * Issue a receipt only when the vault's served reply itself names the
134
+ * requested credential id and the roster's (known) account identity. For
135
+ * providers whose tokens are opaque, where nothing else can prove which
136
+ * account a token belongs to. Off by default: then an absent assertion is
137
+ * no claim, and the receipt says where its identity came from.
138
+ */
139
+ requireAssertion?: boolean;
101
140
  now?: () => number;
102
141
  logger?: ClaustrumLogger;
103
142
  });
@@ -110,10 +149,12 @@ export declare class ClaustrumScopedCustody {
110
149
  discover(signal?: AbortSignal): Promise<VaultInventory>;
111
150
  /**
112
151
  * 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.
152
+ * The vault's served identity (or, without one, the plugin's parse of the
153
+ * token) must equal the roster's when both are present. Without
154
+ * `requireAssertion`, absence on either side proves nothing and does not
155
+ * refuse; the receipt records what the vault asserted separately from what
156
+ * the roster expected. With it, a reply that does not itself name the
157
+ * credential id and the expected account is refused.
117
158
  */
118
159
  authorize(identity: ClaustrumScopedIdentity, signal?: AbortSignal): Promise<ClaustrumScopedAttempt>;
119
160
  /**
@@ -97,6 +97,7 @@ export class ClaustrumScopedCustody {
97
97
  #now;
98
98
  #family;
99
99
  #parseIdentity;
100
+ #requireAssertion;
100
101
  #logger;
101
102
  #provenance = new WeakMap();
102
103
  #reports = new WeakMap();
@@ -115,6 +116,7 @@ export class ClaustrumScopedCustody {
115
116
  this.#client = options.client;
116
117
  this.#family = options.family;
117
118
  this.#parseIdentity = options.parseIdentity;
119
+ this.#requireAssertion = options.requireAssertion ?? false;
118
120
  this.#now = options.now ?? Date.now;
119
121
  this.#logger = options.logger ?? defaultLogger;
120
122
  }
@@ -223,16 +225,20 @@ export class ClaustrumScopedCustody {
223
225
  }
224
226
  /**
225
227
  * Fetch the credential for one physical send and wrap it in a fresh receipt.
226
- * Identity is checked only where both sides assert one: the vault's served
227
- * identity (or, without one, the plugin's parse of the token) must equal the
228
- * roster's when both are present; absence on either side proves nothing and
229
- * does not refuse.
228
+ * The vault's served identity (or, without one, the plugin's parse of the
229
+ * token) must equal the roster's when both are present. Without
230
+ * `requireAssertion`, absence on either side proves nothing and does not
231
+ * refuse; the receipt records what the vault asserted separately from what
232
+ * the roster expected. With it, a reply that does not itself name the
233
+ * credential id and the expected account is refused.
230
234
  */
231
235
  async authorize(identity, signal) {
232
236
  if (!identity.credentialId)
233
237
  throw new ClaustrumConsumerError('route-unavailable', 'Claustrum dispatch requires a credential id');
234
238
  // Capture the caller's fields before yielding so a later mutation cannot move the fence.
235
239
  const { credentialId, credentialType, accountIdentity } = identity;
240
+ if (this.#requireAssertion && accountIdentity === undefined)
241
+ throw new ClaustrumConsumerError('identity-unasserted', 'Claustrum dispatch requires a known account identity');
236
242
  const token = await this.#token(signal);
237
243
  const served = await this.#call(() => this.#client.getScoped({
238
244
  credentialId,
@@ -253,21 +259,45 @@ export class ClaustrumScopedCustody {
253
259
  throw new ClaustrumConsumerError('insufficient-validity', 'Claustrum served credential has insufficient validity');
254
260
  }
255
261
  const accessToken = accessTokenFromMaterial(served.material);
256
- const servedIdentity = served.accountId?.trim()
262
+ const assertedIdentity = served.accountId?.trim()
257
263
  ? served.accountId
258
- : this.#parseIdentity?.(accessToken);
264
+ : undefined;
265
+ if (this.#requireAssertion &&
266
+ (served.credentialId === undefined || assertedIdentity === undefined))
267
+ throw new ClaustrumConsumerError('identity-unasserted', 'Claustrum served credential did not assert its identity');
268
+ const parsedIdentity = assertedIdentity === undefined
269
+ ? this.#parseIdentity?.(accessToken)
270
+ : undefined;
271
+ const servedIdentity = assertedIdentity ?? parsedIdentity;
259
272
  if (accountIdentity !== undefined &&
260
273
  servedIdentity !== undefined &&
261
274
  servedIdentity !== accountIdentity) {
262
275
  throw new ClaustrumConsumerError('identity-changed', 'Claustrum served credential identity changed');
263
276
  }
264
- const resolvedIdentity = accountIdentity ?? servedIdentity;
277
+ const resolvedIdentity = servedIdentity ?? accountIdentity;
278
+ const source = assertedIdentity !== undefined
279
+ ? 'asserted'
280
+ : parsedIdentity !== undefined
281
+ ? 'parsed'
282
+ : accountIdentity !== undefined
283
+ ? 'expected'
284
+ : 'none';
265
285
  const attempt = Object.freeze(Object.defineProperty({
266
286
  credentialId,
267
287
  credentialType,
268
288
  ...(resolvedIdentity !== undefined && {
269
289
  accountIdentity: resolvedIdentity,
270
290
  }),
291
+ accountIdentitySource: source,
292
+ ...(accountIdentity !== undefined && {
293
+ expectedAccountIdentity: accountIdentity,
294
+ }),
295
+ ...(served.credentialId !== undefined && {
296
+ assertedCredentialId: served.credentialId,
297
+ }),
298
+ ...(assertedIdentity !== undefined && {
299
+ assertedAccountIdentity: assertedIdentity,
300
+ }),
271
301
  recordVersion: served.recordVersion,
272
302
  expiresAtMs,
273
303
  }, 'accessToken', { value: accessToken, enumerable: false }));
@@ -4,7 +4,7 @@
4
4
  * `code`, a `class` and an `action`), so callers can tell the vault's verdict
5
5
  * apart from a check made on this side.
6
6
  */
7
- export type ClaustrumConsumerFailureKind = 'closed' | 'not-enrolled' | 'invalid-token' | 'unavailable' | 'identity-changed' | 'insufficient-validity' | 'invalid-material' | 'not-active' | 'route-unavailable' | 'route-declined' | 'no-receipt' | 'unsafe-file' | 'invalid-state' | 'wrong-consumer' | 'roster-busy' | 'host-slot-login' | 'host-slot-placeholder' | 'placeholder-refresh';
7
+ export type ClaustrumConsumerFailureKind = 'closed' | 'not-enrolled' | 'invalid-token' | 'unavailable' | 'identity-changed' | 'identity-unasserted' | 'insufficient-validity' | 'invalid-material' | 'not-active' | 'route-unavailable' | 'route-declined' | 'no-receipt' | 'unsafe-file' | 'invalid-state' | 'wrong-consumer' | 'roster-busy' | 'host-slot-login' | 'host-slot-placeholder' | 'placeholder-refresh';
8
8
  export declare class ClaustrumConsumerError extends Error {
9
9
  readonly kind: ClaustrumConsumerFailureKind;
10
10
  constructor(kind: ClaustrumConsumerFailureKind, message: string);
@@ -2,12 +2,12 @@ import { type ClaustrumClientOptions } from '@cortexkit/claustrum-client';
2
2
  import type { ClaustrumScopedClient } from './custody.js';
3
3
  export { ClaustrumCredentialError, type ClaustrumReporterSource, type EnrollmentTokenFile, } from '@cortexkit/claustrum-client';
4
4
  export { ClaustrumConsumer, type ClaustrumConsumerOptions, type SendOptions, } from './consumer.js';
5
- export { type ClaustrumFamily, type ClaustrumScopedAttempt, type ClaustrumScopedClient, ClaustrumScopedCustody, type ClaustrumScopedIdentity, decideScopedRetryAfter401, type IdentityParser, isScopedCredentialRotation, type ScopedRetryReason, SERVING_MARGIN_MS, type SkippedVaultRecord, type VaultCredential, type VaultCredentialType, type VaultInventory, } from './custody.js';
5
+ export { type AccountIdentitySource, type ClaustrumFamily, type ClaustrumScopedAttempt, type ClaustrumScopedClient, ClaustrumScopedCustody, type ClaustrumScopedIdentity, decideScopedRetryAfter401, type IdentityParser, isScopedCredentialRotation, type ScopedRetryReason, SERVING_MARGIN_MS, type SkippedVaultReason, type SkippedVaultRecord, type VaultCredential, type VaultCredentialType, type VaultInventory, } from './custody.js';
6
6
  export { type ClaustrumEnrollmentClient, type ClaustrumEnrollmentConnection, ClaustrumEnrollmentManager, type ClaustrumEnrollmentPaths, type ClaustrumEnrollmentResetResult, type ClaustrumEnrollmentStatus, classifyEnrollmentError, connectClaustrumEnrollmentClient, type EnrollmentDisposition, enrollmentName, getClaustrumEnrollmentPaths, hostEnrollmentPaths, RETRYABLE_ENROLLMENT_CODES, readClaustrumEnrollmentStatus, readClaustrumEnrollmentToken, resetClaustrumEnrollmentState, TERMINAL_ENROLLMENT_CODES, } from './enrollment.js';
7
7
  export { ClaustrumConsumerError, type ClaustrumConsumerFailureKind, type ClaustrumLogger, } from './errors.js';
8
8
  export { assertHostSlotMatchesMode, assertNotCustodyPlaceholder, CUSTODY_PLACEHOLDER_PREFIX, classifyHostSlot, custodyPlaceholder, custodyPlaceholderKey, type HostSlotContent, isCustodyPlaceholder, isCustodyPlaceholderValue, } from './host-slot.js';
9
- export { acceptAccount, type DeclinedAccount, declineAccount, isDeclined, pruneDeclined, } from './interlock.js';
10
- export { type AccountMapper, acceptVaultRoute, DEFAULT_ROUTE_PREFIX, declineVaultRoute, mutateVaultRoster, type ProjectionOptions, projectVaultRoster, readVaultRoster, recordVaultQuota, refreshVaultRoster, type VaultRosterFile, type VaultRosterRow, vaultRoutingRows, } from './roster.js';
9
+ export { acceptAccount, type DeclinedAccount, declineAccount, isDeclined, } from './interlock.js';
10
+ export { type AccountMapper, acceptVaultRoute, DEFAULT_ROUTE_PREFIX, declineVaultRoute, mutateVaultRoster, type ProjectionOptions, projectVaultRoster, type QuotaReceipt, readVaultRoster, recordVaultQuota, refreshVaultRoster, resolveVaultPrimary, type VaultPrimary, type VaultPrimaryBinding, type VaultPrimaryUnavailableReason, type VaultRosterFile, type VaultRosterRow, vaultRoutingRows, } from './roster.js';
11
11
  /**
12
12
  * Connect the client that lists and fetches this consumer's vault credentials
13
13
  * on the request path. `connectionFile` is required: this library
@@ -10,8 +10,8 @@ export { ClaustrumScopedCustody, decideScopedRetryAfter401, isScopedCredentialRo
10
10
  export { ClaustrumEnrollmentManager, classifyEnrollmentError, connectClaustrumEnrollmentClient, enrollmentName, getClaustrumEnrollmentPaths, hostEnrollmentPaths, RETRYABLE_ENROLLMENT_CODES, readClaustrumEnrollmentStatus, readClaustrumEnrollmentToken, resetClaustrumEnrollmentState, TERMINAL_ENROLLMENT_CODES, } from './enrollment.js';
11
11
  export { ClaustrumConsumerError, } from './errors.js';
12
12
  export { assertHostSlotMatchesMode, assertNotCustodyPlaceholder, CUSTODY_PLACEHOLDER_PREFIX, classifyHostSlot, custodyPlaceholder, custodyPlaceholderKey, isCustodyPlaceholder, isCustodyPlaceholderValue, } from './host-slot.js';
13
- export { acceptAccount, declineAccount, isDeclined, pruneDeclined, } from './interlock.js';
14
- export { acceptVaultRoute, DEFAULT_ROUTE_PREFIX, declineVaultRoute, mutateVaultRoster, projectVaultRoster, readVaultRoster, recordVaultQuota, refreshVaultRoster, vaultRoutingRows, } from './roster.js';
13
+ export { acceptAccount, declineAccount, isDeclined, } from './interlock.js';
14
+ export { acceptVaultRoute, DEFAULT_ROUTE_PREFIX, declineVaultRoute, mutateVaultRoster, projectVaultRoster, readVaultRoster, recordVaultQuota, refreshVaultRoster, resolveVaultPrimary, vaultRoutingRows, } from './roster.js';
15
15
  /**
16
16
  * Connect the client that lists and fetches this consumer's vault credentials
17
17
  * on the request path. `connectionFile` is required: this library
@@ -2,28 +2,26 @@
2
2
  * The declined-account interlock: the user's "do not use this vault account",
3
3
  * kept on the consumer side because the vault has no notion of it.
4
4
  *
5
- * An entry is keyed on the credential id plus the provider identity the
6
- * credential logged into, never on the record version, so a token refresh
7
- * cannot lift it. It is sticky whenever either side's identity is unknown: an
8
- * adapter that makes no identity claim cannot prove the account changed. It
9
- * lifts on its own only when both identities are known and differ, which
10
- * means the credential now logs into a different account than the one the
11
- * user declined.
5
+ * A decline follows the provider account, not the vault record: an entry made
6
+ * while the account was known matches any credential id or alias that logs
7
+ * into that same account, so relabelling, adding an alias or removing and
8
+ * re-adding the account never lifts it. A credential id that now logs into a
9
+ * different known account does not match, because that account has its own
10
+ * policy. While either side's account is unknown the entry falls back to the
11
+ * credential id, so an adapter that makes no identity claim cannot slip past
12
+ * it. Never keyed on the record version, so a token refresh cannot lift it.
12
13
  */
13
14
  export interface DeclinedAccount {
14
15
  readonly credentialId: string;
15
16
  readonly accountIdentity?: string;
16
17
  }
17
18
  export declare function isDeclined(entries: readonly DeclinedAccount[], credentialId: string, accountIdentity?: string): boolean;
19
+ export declare function declineAccount(entries: readonly DeclinedAccount[], credentialId: string, accountIdentity?: string): DeclinedAccount[];
18
20
  /**
19
- * Drop entries the vault has proven stale: the same credential id now listed
20
- * with a known identity that differs from the declined one. Entries for
21
- * credentials that are not listed stay, so an account that leaves and comes
22
- * back is still declined.
21
+ * Lift every entry that declines a row: the row's credential ids (the
22
+ * representative and its aliases) under the row's account. An entry for a
23
+ * different known account is left alone even when it names one of these
24
+ * credential ids, so accepting a replacement never re-enables the account the
25
+ * user declined.
23
26
  */
24
- export declare function pruneDeclined(entries: readonly DeclinedAccount[], listed: readonly {
25
- credentialId: string;
26
- accountIdentity?: string;
27
- }[]): DeclinedAccount[];
28
- export declare function declineAccount(entries: readonly DeclinedAccount[], credentialId: string, accountIdentity?: string): DeclinedAccount[];
29
- export declare function acceptAccount(entries: readonly DeclinedAccount[], credentialIds: readonly string[]): DeclinedAccount[];
27
+ export declare function acceptAccount(entries: readonly DeclinedAccount[], credentialIds: readonly string[], accountIdentity?: string): DeclinedAccount[];
@@ -1,36 +1,29 @@
1
1
  function matches(entry, credentialId, accountIdentity) {
2
- return (entry.credentialId === credentialId &&
3
- (entry.accountIdentity === undefined ||
4
- accountIdentity === undefined ||
5
- entry.accountIdentity === accountIdentity));
2
+ if (entry.accountIdentity !== undefined && accountIdentity !== undefined)
3
+ return entry.accountIdentity === accountIdentity;
4
+ return entry.credentialId === credentialId;
6
5
  }
7
6
  export function isDeclined(entries, credentialId, accountIdentity) {
8
7
  return entries.some((entry) => matches(entry, credentialId, accountIdentity));
9
8
  }
10
- /**
11
- * Drop entries the vault has proven stale: the same credential id now listed
12
- * with a known identity that differs from the declined one. Entries for
13
- * credentials that are not listed stay, so an account that leaves and comes
14
- * back is still declined.
15
- */
16
- export function pruneDeclined(entries, listed) {
17
- return entries.filter((entry) => {
18
- const current = listed.find((credential) => credential.credentialId === entry.credentialId);
19
- return !(current &&
20
- entry.accountIdentity !== undefined &&
21
- current.accountIdentity !== undefined &&
22
- current.accountIdentity !== entry.accountIdentity);
23
- });
24
- }
25
9
  export function declineAccount(entries, credentialId, accountIdentity) {
10
+ const entry = {
11
+ credentialId,
12
+ ...(accountIdentity !== undefined && { accountIdentity }),
13
+ };
26
14
  return [
27
- ...entries.filter((entry) => entry.credentialId !== credentialId),
28
- {
29
- credentialId,
30
- ...(accountIdentity !== undefined && { accountIdentity }),
31
- },
15
+ ...entries.filter((existing) => existing.credentialId !== credentialId ||
16
+ existing.accountIdentity !== accountIdentity),
17
+ entry,
32
18
  ];
33
19
  }
34
- export function acceptAccount(entries, credentialIds) {
35
- return entries.filter((entry) => !credentialIds.includes(entry.credentialId));
20
+ /**
21
+ * Lift every entry that declines a row: the row's credential ids (the
22
+ * representative and its aliases) under the row's account. An entry for a
23
+ * different known account is left alone even when it names one of these
24
+ * credential ids, so accepting a replacement never re-enables the account the
25
+ * user declined.
26
+ */
27
+ export function acceptAccount(entries, credentialIds, accountIdentity) {
28
+ return entries.filter((entry) => !credentialIds.some((id) => matches(entry, id, accountIdentity)));
36
29
  }