@cortexkit/antigravity-auth-core 3.0.7 → 3.0.9

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 (34) hide show
  1. package/dist/common-auth-embedded/auth-menu/login.d.ts +6 -10
  2. package/dist/common-auth-embedded/auth-menu/login.js +14 -11
  3. package/dist/common-auth-embedded/claustrum/availability.d.ts +89 -0
  4. package/dist/common-auth-embedded/claustrum/availability.js +106 -0
  5. package/dist/common-auth-embedded/claustrum/consumer.d.ts +36 -2
  6. package/dist/common-auth-embedded/claustrum/consumer.js +154 -6
  7. package/dist/common-auth-embedded/claustrum/custody.d.ts +106 -14
  8. package/dist/common-auth-embedded/claustrum/custody.js +294 -16
  9. package/dist/common-auth-embedded/claustrum/errors.d.ts +1 -0
  10. package/dist/common-auth-embedded/claustrum/index.d.ts +5 -3
  11. package/dist/common-auth-embedded/claustrum/index.js +4 -1
  12. package/dist/common-auth-embedded/claustrum/insula.d.ts +71 -0
  13. package/dist/common-auth-embedded/claustrum/insula.js +348 -21
  14. package/dist/common-auth-embedded/claustrum/watch.d.ts +141 -0
  15. package/dist/common-auth-embedded/claustrum/watch.js +254 -0
  16. package/dist/common-auth-embedded/fs/private-files.js +10 -2
  17. package/dist/common-auth-embedded/quota/format.d.ts +3 -0
  18. package/dist/common-auth-embedded/quota/format.js +69 -1
  19. package/dist/common-auth-embedded/quota/index.d.ts +1 -1
  20. package/dist/common-auth-embedded/quota/projection.d.ts +16 -0
  21. package/dist/common-auth-embedded/source-output.json +87 -55
  22. package/dist/constants.d.ts +5 -0
  23. package/dist/constants.d.ts.map +1 -1
  24. package/dist/constants.js +5 -0
  25. package/dist/constants.js.map +1 -1
  26. package/dist/quota-presentation.d.ts.map +1 -1
  27. package/dist/quota-presentation.js +9 -1
  28. package/dist/quota-presentation.js.map +1 -1
  29. package/dist/vault-account-source.d.ts +20 -3
  30. package/dist/vault-account-source.d.ts.map +1 -1
  31. package/dist/vault-account-source.js +45 -1
  32. package/dist/vault-account-source.js.map +1 -1
  33. package/dist/vault-folder-refusal.js +3 -3
  34. package/package.json +3 -3
@@ -26,28 +26,24 @@ export interface MenuLogin {
26
26
  signal?: AbortSignal;
27
27
  }): Promise<LoginFlow>;
28
28
  /**
29
- * Opens the URL in a browser; false or a throw means no browser could be
30
- * opened. Defaults to `openBrowserForMenu`.
29
+ * Opens the URL in a browser; false or a throw means its opener could not be
30
+ * started. Defaults to `openBrowserForMenu`.
31
31
  */
32
32
  openBrowser?(url: string): boolean | undefined | Promise<boolean | undefined>;
33
33
  }
34
- type BrowserExec = (file: string, args: string[], options: {
35
- stdio: 'ignore';
36
- timeout: number;
37
- }) => unknown;
38
34
  type BrowserSpawn = (file: string, args: string[], options: {
39
35
  stdio: 'ignore';
40
- windowsHide: true;
36
+ windowsHide?: true;
41
37
  }) => {
42
38
  pid?: number;
43
39
  unref(): void;
44
40
  on(event: 'error', listener: () => void): unknown;
45
41
  };
46
- /** Opens a URL with the platform's opener; false when that fails. */
47
- export declare function openBrowserForMenu(url: string, platform?: NodeJS.Platform, execFileSync?: BrowserExec, spawn?: BrowserSpawn): boolean;
42
+ /** Opens a URL with the platform's opener; false when it could not start. */
43
+ export declare function openBrowserForMenu(url: string, platform?: NodeJS.Platform, spawn?: BrowserSpawn): boolean;
48
44
  /**
49
45
  * Runs a login for the menu: a browser login first and, when no browser can
50
- * be opened, a device-code login instead, so a headless machine can still
46
+ * be started, a device-code login instead, so a headless machine can still
51
47
  * add an account. The URL is always printed, so an operator can open it by
52
48
  * hand when the opener reports success but nothing appears.
53
49
  */
@@ -1,6 +1,6 @@
1
- import { execFileSync as defaultExecFileSync, spawn as defaultSpawn, } from 'node:child_process';
2
- /** Opens a URL with the platform's opener; false when that fails. */
3
- export function openBrowserForMenu(url, platform = process.platform, execFileSync = defaultExecFileSync, spawn = defaultSpawn) {
1
+ import { spawn as defaultSpawn } from 'node:child_process';
2
+ /** Opens a URL with the platform's opener; false when it could not start. */
3
+ export function openBrowserForMenu(url, platform = process.platform, spawn = defaultSpawn) {
4
4
  try {
5
5
  if (platform === 'win32') {
6
6
  // Start-Process opens the URL with its associated app. Base64 UTF-16LE
@@ -28,15 +28,18 @@ export function openBrowserForMenu(url, platform = process.platform, execFileSyn
28
28
  // Node leaves pid undefined when the process could not be started.
29
29
  return child.pid !== undefined;
30
30
  }
31
- else {
32
- execFileSync(platform === 'darwin' ? 'open' : 'xdg-open', [url], {
33
- stdio: 'ignore',
34
- timeout: 3000,
35
- });
36
- }
37
- return true;
31
+ const child = spawn(platform === 'darwin' ? 'open' : 'xdg-open', [url], {
32
+ stdio: 'ignore',
33
+ });
34
+ // A failed launch reports asynchronously; without a listener it would
35
+ // become an uncaught exception after this function has returned.
36
+ child.on('error', () => { });
37
+ child.unref();
38
+ // The URL handler's process may outlive the caller; a pid means launch began.
39
+ return child.pid !== undefined;
38
40
  }
39
41
  catch {
42
+ // Browser launch is best effort; the printed URL remains the fallback.
40
43
  return false;
41
44
  }
42
45
  }
@@ -53,7 +56,7 @@ function printFlow(context, flow) {
53
56
  }
54
57
  /**
55
58
  * Runs a login for the menu: a browser login first and, when no browser can
56
- * be opened, a device-code login instead, so a headless machine can still
59
+ * be started, a device-code login instead, so a headless machine can still
57
60
  * add an account. The URL is always printed, so an operator can open it by
58
61
  * hand when the opener reports success but nothing appears.
59
62
  */
@@ -0,0 +1,89 @@
1
+ import { ClaustrumConsumerError } from './errors.js';
2
+ export type VaultFailureClass = 'unreachable' | 'answered';
3
+ /**
4
+ * Codes from claustrum-client's decoder and its subc transport. A retry action
5
+ * alone is not evidence of an outage: even transient vault refusals must win
6
+ * over a previously served credential. New codes fail closed until reviewed.
7
+ */
8
+ export declare const VAULT_FAILURE_CLASSES: Readonly<{
9
+ ECONNREFUSED: "unreachable";
10
+ ENOENT: "unreachable";
11
+ ECONNRESET: "unreachable";
12
+ EPIPE: "unreachable";
13
+ ETIMEDOUT: "unreachable";
14
+ transport_error: "unreachable";
15
+ unavailable: "unreachable";
16
+ timeout: "unreachable";
17
+ route_closed: "unreachable";
18
+ daemon_draining: "unreachable";
19
+ connection_closed: "unreachable";
20
+ connection_dropped: "unreachable";
21
+ request_deadline: "unreachable";
22
+ deadline_exceeded_no_drop_observed: "unreachable";
23
+ module_reloading: "unreachable";
24
+ module_warming: "unreachable";
25
+ target_unavailable: "unreachable";
26
+ module_timeout: "unreachable";
27
+ invalid_response: "answered";
28
+ invalid_record_version: "answered";
29
+ invalid_expiry: "answered";
30
+ invalid_status: "answered";
31
+ invalid_params: "answered";
32
+ ttl_unsatisfiable: "answered";
33
+ not_found: "answered";
34
+ enrollment_revoked: "answered";
35
+ enrollment_invalid: "answered";
36
+ grant_denied: "answered";
37
+ credential_unavailable: "answered";
38
+ needs_reauth: "answered";
39
+ declined: "answered";
40
+ disabled: "answered";
41
+ identity_changed: "answered";
42
+ closed: "answered";
43
+ client_closed: "answered";
44
+ pending_exists: "answered";
45
+ pending_queue_full: "answered";
46
+ already_consumed: "answered";
47
+ superseded: "answered";
48
+ stale_generation: "answered";
49
+ missing_identity: "answered";
50
+ invalid_control_body: "answered";
51
+ unknown_module: "answered";
52
+ module_removed: "answered";
53
+ capability_denied: "answered";
54
+ unknown_channel: "unreachable";
55
+ stale_route_epoch: "unreachable";
56
+ capability_forbidden: "answered";
57
+ capability_unprovided: "answered";
58
+ capability_ambiguous: "answered";
59
+ invalid_capability_identifier: "answered";
60
+ binary_body_required: "answered";
61
+ binary_call_requires_call_binary: "answered";
62
+ corr_exhausted: "answered";
63
+ late_route_cleanup_failed: "answered";
64
+ reverse_request_capability_not_declared: "answered";
65
+ stale_route_handle: "answered";
66
+ reverse_request_unhandled: "answered";
67
+ handler_error: "answered";
68
+ health_error: "answered";
69
+ duplicate_module_id: "answered";
70
+ route_rejected: "answered";
71
+ lock_busy: "answered";
72
+ owner_invalid: "answered";
73
+ renewal_failed: "answered";
74
+ unsupported_platform: "answered";
75
+ EACCES: "answered";
76
+ EPERM: "answered";
77
+ ERR_PRIVATE_FILES_SECURITY: "answered";
78
+ }>;
79
+ /**
80
+ * An injected adapter may throw this ONLY when its connection/transport is
81
+ * unreachable, including a failed connect before getScoped. Do not wrap an
82
+ * adapter shutdown, cancellation, validation error or vault refusal in it.
83
+ * Its fixed message cannot echo enrollment or credential material, and its
84
+ * kind stays `unavailable` for callers handling ClaustrumConsumerError.
85
+ */
86
+ export declare class ClaustrumUnreachableError extends ClaustrumConsumerError {
87
+ constructor();
88
+ }
89
+ export declare function classifyVaultFailure(error: unknown): VaultFailureClass;
@@ -0,0 +1,106 @@
1
+ import { ClaustrumCredentialError } from '@cortexkit/claustrum-client';
2
+ import { SocketClosedError, SocketTimeoutError, SubcCallError, } from '@cortexkit/subc-client';
3
+ import { ClaustrumConsumerError } from './errors.js';
4
+ /**
5
+ * Codes from claustrum-client's decoder and its subc transport. A retry action
6
+ * alone is not evidence of an outage: even transient vault refusals must win
7
+ * over a previously served credential. New codes fail closed until reviewed.
8
+ */
9
+ export const VAULT_FAILURE_CLASSES = Object.freeze({
10
+ ECONNREFUSED: 'unreachable',
11
+ ENOENT: 'unreachable',
12
+ ECONNRESET: 'unreachable',
13
+ EPIPE: 'unreachable',
14
+ ETIMEDOUT: 'unreachable',
15
+ transport_error: 'unreachable',
16
+ unavailable: 'unreachable',
17
+ timeout: 'unreachable',
18
+ route_closed: 'unreachable',
19
+ daemon_draining: 'unreachable',
20
+ connection_closed: 'unreachable',
21
+ connection_dropped: 'unreachable',
22
+ request_deadline: 'unreachable',
23
+ deadline_exceeded_no_drop_observed: 'unreachable',
24
+ module_reloading: 'unreachable',
25
+ module_warming: 'unreachable',
26
+ target_unavailable: 'unreachable',
27
+ module_timeout: 'unreachable',
28
+ invalid_response: 'answered',
29
+ invalid_record_version: 'answered',
30
+ invalid_expiry: 'answered',
31
+ invalid_status: 'answered',
32
+ invalid_params: 'answered',
33
+ ttl_unsatisfiable: 'answered',
34
+ not_found: 'answered',
35
+ enrollment_revoked: 'answered',
36
+ enrollment_invalid: 'answered',
37
+ grant_denied: 'answered',
38
+ credential_unavailable: 'answered',
39
+ needs_reauth: 'answered',
40
+ declined: 'answered',
41
+ disabled: 'answered',
42
+ identity_changed: 'answered',
43
+ closed: 'answered',
44
+ client_closed: 'answered',
45
+ pending_exists: 'answered',
46
+ pending_queue_full: 'answered',
47
+ already_consumed: 'answered',
48
+ superseded: 'answered',
49
+ stale_generation: 'answered',
50
+ missing_identity: 'answered',
51
+ invalid_control_body: 'answered',
52
+ unknown_module: 'answered',
53
+ module_removed: 'answered',
54
+ capability_denied: 'answered',
55
+ unknown_channel: 'unreachable',
56
+ stale_route_epoch: 'unreachable',
57
+ capability_forbidden: 'answered',
58
+ capability_unprovided: 'answered',
59
+ capability_ambiguous: 'answered',
60
+ invalid_capability_identifier: 'answered',
61
+ binary_body_required: 'answered',
62
+ binary_call_requires_call_binary: 'answered',
63
+ corr_exhausted: 'answered',
64
+ late_route_cleanup_failed: 'answered',
65
+ reverse_request_capability_not_declared: 'answered',
66
+ stale_route_handle: 'answered',
67
+ reverse_request_unhandled: 'answered',
68
+ handler_error: 'answered',
69
+ health_error: 'answered',
70
+ duplicate_module_id: 'answered',
71
+ route_rejected: 'answered',
72
+ lock_busy: 'answered',
73
+ owner_invalid: 'answered',
74
+ renewal_failed: 'answered',
75
+ unsupported_platform: 'answered',
76
+ EACCES: 'answered',
77
+ EPERM: 'answered',
78
+ ERR_PRIVATE_FILES_SECURITY: 'answered',
79
+ });
80
+ /**
81
+ * An injected adapter may throw this ONLY when its connection/transport is
82
+ * unreachable, including a failed connect before getScoped. Do not wrap an
83
+ * adapter shutdown, cancellation, validation error or vault refusal in it.
84
+ * Its fixed message cannot echo enrollment or credential material, and its
85
+ * kind stays `unavailable` for callers handling ClaustrumConsumerError.
86
+ */
87
+ export class ClaustrumUnreachableError extends ClaustrumConsumerError {
88
+ constructor() {
89
+ super('unavailable', 'Claustrum scoped operation unavailable');
90
+ this.name = 'ClaustrumUnreachableError';
91
+ }
92
+ }
93
+ export function classifyVaultFailure(error) {
94
+ if (error instanceof ClaustrumUnreachableError ||
95
+ error instanceof SocketClosedError ||
96
+ error instanceof SocketTimeoutError)
97
+ return 'unreachable';
98
+ const code = error instanceof ClaustrumCredentialError || error instanceof SubcCallError
99
+ ? error.code
100
+ : error instanceof Error && 'code' in error
101
+ ? error.code
102
+ : undefined;
103
+ if (typeof code !== 'string' || !Object.hasOwn(VAULT_FAILURE_CLASSES, code))
104
+ return 'answered';
105
+ return VAULT_FAILURE_CLASSES[code];
106
+ }
@@ -1,10 +1,11 @@
1
1
  import type { ClaustrumReporterSource } from '@cortexkit/claustrum-client';
2
2
  import type { QuotaObservation } from '../quota/index.js';
3
3
  import type { RoutingRow } from '../routing/index.js';
4
- import { type ClaustrumFamily, type ClaustrumScopedAttempt, type ClaustrumScopedClient, type IdentityParser, type VaultGrantReach } from './custody.js';
4
+ import { type ClaustrumFamily, type ClaustrumScopedAttempt, type ClaustrumScopedClient, type IdentityParser, type KeptReceiptState, type VaultGrantReach } from './custody.js';
5
5
  import { type ClaustrumLogger } from './errors.js';
6
- import { type InsulaQuotaOptions } from './insula.js';
6
+ import { type InsulaQuotaOptions, type InsulaQuotaResult } from './insula.js';
7
7
  import { type AccountMapper, type QuotaReceipt, type VaultRosterFile } from './roster.js';
8
+ import { type WatchMode } from './watch.js';
8
9
  export interface ClaustrumConsumerOptions<Q = QuotaObservation> {
9
10
  /**
10
11
  * The roster file: one row per vault account (route id, credential id,
@@ -41,6 +42,12 @@ export interface ClaustrumConsumerOptions<Q = QuotaObservation> {
41
42
  */
42
43
  onRoster?: (roster: VaultRosterFile) => void;
43
44
  onError?: (error: unknown) => void;
45
+ /**
46
+ * How often `start()` ticks. A tick lists the vault when no vault watch is
47
+ * open (before it is accepted, or when the vault or the client cannot
48
+ * watch), and otherwise only every five minutes, as a safety net. A tick
49
+ * polls insula (at most once a minute) only while its watch is not open.
50
+ */
44
51
  pollIntervalMs?: number;
45
52
  setTimeoutImpl?: typeof setTimeout;
46
53
  clearTimeoutImpl?: typeof clearTimeout;
@@ -49,6 +56,15 @@ export interface ClaustrumConsumerOptions<Q = QuotaObservation> {
49
56
  /** Optional background quota source. In-band readings still use recordQuota. */
50
57
  insula?: InsulaQuotaOptions<Q>;
51
58
  }
59
+ /**
60
+ * How a started consumer learns about changes, for logs and status displays.
61
+ * `insula` is present when the consumer was given an insula source. Never
62
+ * carries secrets.
63
+ */
64
+ export interface ClaustrumWatchState {
65
+ readonly vault: WatchMode;
66
+ readonly insula?: WatchMode;
67
+ }
52
68
  export interface SendOptions {
53
69
  /**
54
70
  * What kind of request this is (for example `model`, `quota`, `profile`),
@@ -81,9 +97,23 @@ export declare class ClaustrumConsumer<Q = QuotaObservation> {
81
97
  * held the discovery lease and its newer roster was served instead).
82
98
  */
83
99
  grantReach(): VaultGrantReach | undefined;
100
+ /** How this consumer currently learns about changes, without I/O. */
101
+ watchState(): ClaustrumWatchState;
102
+ /** Whether any account is using an in-memory outage receipt, without I/O. */
103
+ keptReceiptState(): KeptReceiptState;
84
104
  /** Rows for `/routing`, from the last committed roster. */
85
105
  routingRows(): RoutingRow[];
86
106
  refresh(): Promise<VaultRosterFile | undefined>;
107
+ /**
108
+ * Poll in the background and open the vault and insula watches. Until the
109
+ * vault watch is accepted, and whenever it is unavailable, every tick lists
110
+ * the vault exactly as before watches existed. While it is open a tick
111
+ * lists only when five minutes have passed since the last discovery, since
112
+ * the watch keeps no history; it still checks `isCustodyActive`, so a switch
113
+ * to local mode closes the connection as quickly as before. Insula is
114
+ * polled on the tick only while its own watch is not open. A host not in
115
+ * vault mode makes no request.
116
+ */
87
117
  start(): void;
88
118
  /**
89
119
  * Fetch on a background tick, at most once per minute across projects with
@@ -104,6 +134,10 @@ export declare class ClaustrumConsumer<Q = QuotaObservation> {
104
134
  * A route without a known provider account cannot join an insula entry.
105
135
  */
106
136
  cachedInsulaQuotaForRoute(routeId: string): Promise<Q | undefined>;
137
+ /** Cache-only background lookup, preserving the reason a route has no reading. */
138
+ lookupInsulaQuotaForRoute(routeId: string): Promise<InsulaQuotaResult<Q>>;
139
+ /** Skip the fallback poll while insula is cold or its upstream fetch failed. */
140
+ pollInsulaQuotaForRoute(routeId: string, fallback: () => Promise<Q | undefined>): Promise<Q | undefined>;
107
141
  /**
108
142
  * Authorize one physical send on a vault route. The roster file is re-read
109
143
  * first, so a decline committed by another process applies before the next
@@ -3,8 +3,9 @@ import { combineAbortSignals } from '../util/any-signal.js';
3
3
  import { ClaustrumScopedCustody, decideScopedRetryAfter401, } from './custody.js';
4
4
  import { readClaustrumEnrollmentToken } from './enrollment.js';
5
5
  import { ClaustrumConsumerError } from './errors.js';
6
- import { acquireInsulaQuotaSource } from './insula.js';
6
+ import { acquireInsulaQuotaSource, } from './insula.js';
7
7
  import { acceptVaultRoute, declineVaultRoute, readVaultRoster, recordVaultAccountQuota, recordVaultQuota, refreshVaultRoster, vaultRoutingRows, } from './roster.js';
8
+ import { SAFETY_POLL_MS } from './watch.js';
8
9
  /**
9
10
  * One plugin host's view of its vault accounts. It turns vault credentials
10
11
  * into rows for `/routing`, authorizes each physical send with the vault, and
@@ -29,6 +30,11 @@ export class ClaustrumConsumer {
29
30
  #timer;
30
31
  #started = false;
31
32
  #insula;
33
+ #vaultWatch;
34
+ #releaseInsulaWatch;
35
+ #insulaSkips = new Map();
36
+ /** When this instance last completed a discovery, by `now`. */
37
+ #discoveredAt;
32
38
  constructor(options) {
33
39
  this.#options = options;
34
40
  this.#logger = options.logger ?? createLogger('claustrum');
@@ -91,6 +97,8 @@ export class ClaustrumConsumer {
91
97
  requireAssertion: this.#options.requireAssertion,
92
98
  now: this.#options.now,
93
99
  logger: this.#logger,
100
+ setTimeoutImpl: this.#options.setTimeoutImpl,
101
+ clearTimeoutImpl: this.#options.clearTimeoutImpl,
94
102
  });
95
103
  if (this.#options.insula && client.insula) {
96
104
  this.#insula = acquireInsulaQuotaSource(client.insula, this.#options.tokenPath);
@@ -122,6 +130,27 @@ export class ClaustrumConsumer {
122
130
  ? latest.reach
123
131
  : undefined;
124
132
  }
133
+ /** How this consumer currently learns about changes, without I/O. */
134
+ watchState() {
135
+ return Object.freeze({
136
+ vault: this.#vaultWatch?.mode ?? 'polling',
137
+ ...(this.#options.insula && {
138
+ insula: this.#insula?.source.watchMode() ?? 'polling',
139
+ }),
140
+ });
141
+ }
142
+ /** Close the watches with the connection they belong to. */
143
+ #stopWatch() {
144
+ this.#vaultWatch?.close();
145
+ this.#vaultWatch = undefined;
146
+ this.#releaseInsulaWatch?.();
147
+ this.#releaseInsulaWatch = undefined;
148
+ }
149
+ /** Whether any account is using an in-memory outage receipt, without I/O. */
150
+ keptReceiptState() {
151
+ return (this.#custody?.keptReceiptState() ??
152
+ Object.freeze({ active: false, since: null }));
153
+ }
125
154
  /** Rows for `/routing`, from the last committed roster. */
126
155
  routingRows() {
127
156
  return vaultRoutingRows(this.#roster);
@@ -134,6 +163,7 @@ export class ClaustrumConsumer {
134
163
  if (!(await this.#active())) {
135
164
  this.#roster = undefined;
136
165
  this.#grantReach = undefined;
166
+ this.#stopWatch();
137
167
  this.#custody?.close();
138
168
  this.#custody = undefined;
139
169
  this.#insula?.release();
@@ -164,6 +194,7 @@ export class ClaustrumConsumer {
164
194
  this.#assertOpen();
165
195
  const changed = roster?.view !== this.#roster?.view;
166
196
  this.#roster = roster;
197
+ custody.retain(roster?.rows ?? []);
167
198
  // Each discovery replaces the previous value, including with unknown. A
168
199
  // refresh that served a peer's roster discovered nothing and leaves it;
169
200
  // grantReach() hides it once the roster's view no longer matches.
@@ -175,12 +206,23 @@ export class ClaustrumConsumer {
175
206
  // notification, and the poll runs every few seconds.
176
207
  if (roster && changed)
177
208
  this.#options.onRoster?.(roster);
209
+ this.#discoveredAt = (this.#options.now ?? Date.now)();
178
210
  return roster;
179
211
  })().finally(() => {
180
212
  this.#refreshing = undefined;
181
213
  });
182
214
  return this.#refreshing;
183
215
  }
216
+ /**
217
+ * Poll in the background and open the vault and insula watches. Until the
218
+ * vault watch is accepted, and whenever it is unavailable, every tick lists
219
+ * the vault exactly as before watches existed. While it is open a tick
220
+ * lists only when five minutes have passed since the last discovery, since
221
+ * the watch keeps no history; it still checks `isCustodyActive`, so a switch
222
+ * to local mode closes the connection as quickly as before. Insula is
223
+ * polled on the tick only while its own watch is not open. A host not in
224
+ * vault mode makes no request.
225
+ */
184
226
  start() {
185
227
  if (this.#started)
186
228
  return;
@@ -188,8 +230,10 @@ export class ClaustrumConsumer {
188
230
  this.#started = true;
189
231
  const tick = async () => {
190
232
  try {
191
- await this.refresh();
233
+ if (await this.#listDue())
234
+ await this.refresh();
192
235
  await this.refreshInsulaQuota();
236
+ this.#openWatch();
193
237
  }
194
238
  catch (error) {
195
239
  if (!this.#shutdown.signal.aborted)
@@ -208,6 +252,53 @@ export class ClaustrumConsumer {
208
252
  };
209
253
  void tick();
210
254
  }
255
+ /** Whether a background tick must list the vault now. */
256
+ async #listDue() {
257
+ if (this.#vaultWatch?.mode !== 'watching')
258
+ return true;
259
+ const now = (this.#options.now ?? Date.now)();
260
+ if (this.#discoveredAt === undefined ||
261
+ now - this.#discoveredAt >= SAFETY_POLL_MS)
262
+ return true;
263
+ // refresh() is what closes the connection when the host left vault mode.
264
+ return !(await this.#active());
265
+ }
266
+ /**
267
+ * Open the vault and insula watches once a vault connection exists. Only
268
+ * reached in vault mode: a host in local mode never connects.
269
+ */
270
+ #openWatch() {
271
+ const custody = this.#custody;
272
+ if (this.#shutdown.signal.aborted || !custody)
273
+ return;
274
+ // The same entry point a host without a consumer uses, so both apply
275
+ // notices, refusals and outages alike.
276
+ this.#vaultWatch ??= custody.watchRoster({
277
+ view: () => this.#roster?.view,
278
+ // The existing discovery, so a watched change commits the roster and
279
+ // re-applies it to kept receipts exactly as a poll does. A refresh
280
+ // already in flight may have listed before the change was announced,
281
+ // so wait for it and list again.
282
+ onView: async () => {
283
+ while (this.#refreshing)
284
+ await this.#refreshing.catch(() => { });
285
+ await this.refresh();
286
+ },
287
+ // Custody has dropped kept receipts, as a poll meeting the same
288
+ // refusal from list_scoped would.
289
+ onRefused: (error) => this.#options.onError?.(error),
290
+ onError: (error) => {
291
+ if (!this.#shutdown.signal.aborted)
292
+ this.#options.onError?.(error);
293
+ },
294
+ });
295
+ if (this.#options.insula && this.#insula && !this.#releaseInsulaWatch)
296
+ this.#releaseInsulaWatch = this.#insula.source.watch({
297
+ provider: this.#options.insula.provider,
298
+ setTimeoutImpl: this.#options.setTimeoutImpl,
299
+ clearTimeoutImpl: this.#options.clearTimeoutImpl,
300
+ });
301
+ }
211
302
  /**
212
303
  * Fetch on a background tick, at most once per minute across projects with
213
304
  * the same daemon and enrollment token path. Admission must use the cache
@@ -266,6 +357,44 @@ export class ClaustrumConsumer {
266
357
  return undefined;
267
358
  }
268
359
  }
360
+ /** Cache-only background lookup, preserving the reason a route has no reading. */
361
+ async lookupInsulaQuotaForRoute(routeId) {
362
+ const options = this.#options.insula;
363
+ if (!options || this.#shutdown.signal.aborted || !(await this.#active()))
364
+ return { miss: 'unreachable' };
365
+ try {
366
+ await readClaustrumEnrollmentToken(this.#options.tokenPath);
367
+ const roster = await readVaultRoster(this.#options.rosterPath);
368
+ const account = roster?.rows.find((row) => row.routeId === routeId)?.accountIdentity;
369
+ if (!account || !this.#insula)
370
+ return { miss: 'unreachable' };
371
+ const result = this.#insula.source.lookup(options.provider, account, (this.#options.now ?? Date.now)());
372
+ const reading = result.reading &&
373
+ options.normalize(result.reading.entry, result.reading.context);
374
+ if (result.miss)
375
+ return { miss: result.miss, ...(reading !== undefined && { reading }) };
376
+ // Refusing an echo or unsupported window leaves the plugin's poll in charge.
377
+ return reading === undefined ? { miss: 'unreachable' } : { reading };
378
+ }
379
+ catch {
380
+ return { miss: 'unreachable' };
381
+ }
382
+ }
383
+ /** Skip the fallback poll while insula is cold or its upstream fetch failed. */
384
+ async pollInsulaQuotaForRoute(routeId, fallback) {
385
+ const result = await this.lookupInsulaQuotaForRoute(routeId);
386
+ if (result.miss === 'cold' || result.miss === 'upstream-failed') {
387
+ if (this.#insulaSkips.get(routeId) !== result.miss) {
388
+ this.#insulaSkips.set(routeId, result.miss);
389
+ this.#logger.warn('skipped insula provider poll', {
390
+ reason: result.miss,
391
+ });
392
+ }
393
+ return undefined;
394
+ }
395
+ this.#insulaSkips.delete(routeId);
396
+ return result.miss ? fallback() : result.reading;
397
+ }
269
398
  #normalizeInsulaQuota(accountIdentity) {
270
399
  if (!this.#options.insula || !accountIdentity)
271
400
  return undefined;
@@ -282,20 +411,33 @@ export class ClaustrumConsumer {
282
411
  async authorize(routeId, signal) {
283
412
  this.#assertOpen(signal);
284
413
  const roster = this.#roster ?? (await this.#wait(this.refresh(), signal));
285
- if (!roster || !(await this.#active()))
414
+ if (!roster || !(await this.#active())) {
415
+ this.#custody?.close();
416
+ this.#custody = undefined;
417
+ this.#stopWatch();
418
+ this.#insula?.release();
419
+ this.#insula = undefined;
286
420
  throw new ClaustrumConsumerError('not-active', 'Claustrum scoped custody is not active');
421
+ }
287
422
  const current = await readVaultRoster(this.#options.rosterPath);
288
423
  this.#assertOpen(signal);
289
424
  const observed = roster.rows.find((row) => row.routeId === routeId);
290
425
  const configured = current?.rows.find((row) => row.routeId === routeId);
291
- if (configured && !configured.enabled)
426
+ if (configured && !configured.enabled) {
427
+ if (observed)
428
+ this.#custody?.forget(observed.credentialId);
292
429
  throw new ClaustrumConsumerError('route-declined', 'Claustrum route is disabled');
430
+ }
293
431
  if (!observed ||
294
432
  !configured ||
295
433
  configured.state !== 'active' ||
296
434
  configured.credentialId !== observed.credentialId ||
297
- configured.accountIdentity !== observed.accountIdentity)
435
+ configured.accountIdentity !== observed.accountIdentity ||
436
+ configured.credentialType !== observed.credentialType) {
437
+ if (observed)
438
+ this.#custody?.forget(observed.credentialId);
298
439
  throw new ClaustrumConsumerError('route-unavailable', 'Claustrum route is disabled, removed or changed');
440
+ }
299
441
  const custody = await this.#wait(this.#getCustody(), signal);
300
442
  const combined = combineAbortSignals([
301
443
  this.#shutdown.signal,
@@ -308,7 +450,7 @@ export class ClaustrumConsumer {
308
450
  ...(configured.accountIdentity !== undefined && {
309
451
  accountIdentity: configured.accountIdentity,
310
452
  }),
311
- }, combined.signal);
453
+ }, combined.signal, routeId);
312
454
  }
313
455
  finally {
314
456
  combined.dispose();
@@ -332,6 +474,10 @@ export class ClaustrumConsumer {
332
474
  const signal = options.signal;
333
475
  let served = await this.authorize(routeId, signal);
334
476
  let response = await dispatch(served, signal);
477
+ if (response.status === 401 && served.fromKeptReceipt) {
478
+ await this.reportFailure(served, 401, options.reporterSource ?? 'direct');
479
+ return response;
480
+ }
335
481
  if (response.status === 401 && !signal?.aborted) {
336
482
  let current;
337
483
  try {
@@ -366,6 +512,7 @@ export class ClaustrumConsumer {
366
512
  this.#assertOpen();
367
513
  await declineVaultRoute(this.#options.rosterPath, routeId);
368
514
  this.#roster = await readVaultRoster(this.#options.rosterPath);
515
+ this.#custody?.retain(this.#roster?.rows ?? []);
369
516
  }
370
517
  async accept(routeId) {
371
518
  this.#assertOpen();
@@ -421,6 +568,7 @@ export class ClaustrumConsumer {
421
568
  if (this.#timer)
422
569
  (this.#options.clearTimeoutImpl ?? clearTimeout)(this.#timer);
423
570
  this.#timer = undefined;
571
+ this.#stopWatch();
424
572
  this.#custody?.close();
425
573
  this.#insula?.release();
426
574
  this.#insula = undefined;