@cortexkit/antigravity-auth-core 3.0.7 → 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/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 +31 -1
- package/dist/common-auth-embedded/claustrum/consumer.js +114 -5
- package/dist/common-auth-embedded/claustrum/custody.d.ts +106 -14
- package/dist/common-auth-embedded/claustrum/custody.js +294 -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/quota/format.d.ts +3 -0
- package/dist/common-auth-embedded/quota/format.js +69 -1
- package/dist/common-auth-embedded/quota/index.d.ts +1 -1
- package/dist/common-auth-embedded/quota/projection.d.ts +16 -0
- package/dist/common-auth-embedded/source-output.json +81 -49
- 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 +6 -1
- package/dist/vault-account-source.d.ts.map +1 -1
- package/dist/vault-account-source.js +7 -1
- package/dist/vault-account-source.js.map +1 -1
- package/dist/vault-folder-refusal.js +3 -3
- package/package.json +3 -3
|
@@ -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
6
|
import { type InsulaQuotaOptions } 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
|
|
@@ -5,6 +5,7 @@ import { readClaustrumEnrollmentToken } from './enrollment.js';
|
|
|
5
5
|
import { ClaustrumConsumerError } from './errors.js';
|
|
6
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,10 @@ export class ClaustrumConsumer {
|
|
|
29
30
|
#timer;
|
|
30
31
|
#started = false;
|
|
31
32
|
#insula;
|
|
33
|
+
#vaultWatch;
|
|
34
|
+
#releaseInsulaWatch;
|
|
35
|
+
/** When this instance last completed a discovery, by `now`. */
|
|
36
|
+
#discoveredAt;
|
|
32
37
|
constructor(options) {
|
|
33
38
|
this.#options = options;
|
|
34
39
|
this.#logger = options.logger ?? createLogger('claustrum');
|
|
@@ -91,6 +96,8 @@ export class ClaustrumConsumer {
|
|
|
91
96
|
requireAssertion: this.#options.requireAssertion,
|
|
92
97
|
now: this.#options.now,
|
|
93
98
|
logger: this.#logger,
|
|
99
|
+
setTimeoutImpl: this.#options.setTimeoutImpl,
|
|
100
|
+
clearTimeoutImpl: this.#options.clearTimeoutImpl,
|
|
94
101
|
});
|
|
95
102
|
if (this.#options.insula && client.insula) {
|
|
96
103
|
this.#insula = acquireInsulaQuotaSource(client.insula, this.#options.tokenPath);
|
|
@@ -122,6 +129,27 @@ export class ClaustrumConsumer {
|
|
|
122
129
|
? latest.reach
|
|
123
130
|
: undefined;
|
|
124
131
|
}
|
|
132
|
+
/** How this consumer currently learns about changes, without I/O. */
|
|
133
|
+
watchState() {
|
|
134
|
+
return Object.freeze({
|
|
135
|
+
vault: this.#vaultWatch?.mode ?? 'polling',
|
|
136
|
+
...(this.#options.insula && {
|
|
137
|
+
insula: this.#insula?.source.watchMode() ?? 'polling',
|
|
138
|
+
}),
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
/** Close the watches with the connection they belong to. */
|
|
142
|
+
#stopWatch() {
|
|
143
|
+
this.#vaultWatch?.close();
|
|
144
|
+
this.#vaultWatch = undefined;
|
|
145
|
+
this.#releaseInsulaWatch?.();
|
|
146
|
+
this.#releaseInsulaWatch = undefined;
|
|
147
|
+
}
|
|
148
|
+
/** Whether any account is using an in-memory outage receipt, without I/O. */
|
|
149
|
+
keptReceiptState() {
|
|
150
|
+
return (this.#custody?.keptReceiptState() ??
|
|
151
|
+
Object.freeze({ active: false, since: null }));
|
|
152
|
+
}
|
|
125
153
|
/** Rows for `/routing`, from the last committed roster. */
|
|
126
154
|
routingRows() {
|
|
127
155
|
return vaultRoutingRows(this.#roster);
|
|
@@ -134,6 +162,7 @@ export class ClaustrumConsumer {
|
|
|
134
162
|
if (!(await this.#active())) {
|
|
135
163
|
this.#roster = undefined;
|
|
136
164
|
this.#grantReach = undefined;
|
|
165
|
+
this.#stopWatch();
|
|
137
166
|
this.#custody?.close();
|
|
138
167
|
this.#custody = undefined;
|
|
139
168
|
this.#insula?.release();
|
|
@@ -164,6 +193,7 @@ export class ClaustrumConsumer {
|
|
|
164
193
|
this.#assertOpen();
|
|
165
194
|
const changed = roster?.view !== this.#roster?.view;
|
|
166
195
|
this.#roster = roster;
|
|
196
|
+
custody.retain(roster?.rows ?? []);
|
|
167
197
|
// Each discovery replaces the previous value, including with unknown. A
|
|
168
198
|
// refresh that served a peer's roster discovered nothing and leaves it;
|
|
169
199
|
// grantReach() hides it once the roster's view no longer matches.
|
|
@@ -175,12 +205,23 @@ export class ClaustrumConsumer {
|
|
|
175
205
|
// notification, and the poll runs every few seconds.
|
|
176
206
|
if (roster && changed)
|
|
177
207
|
this.#options.onRoster?.(roster);
|
|
208
|
+
this.#discoveredAt = (this.#options.now ?? Date.now)();
|
|
178
209
|
return roster;
|
|
179
210
|
})().finally(() => {
|
|
180
211
|
this.#refreshing = undefined;
|
|
181
212
|
});
|
|
182
213
|
return this.#refreshing;
|
|
183
214
|
}
|
|
215
|
+
/**
|
|
216
|
+
* Poll in the background and open the vault and insula watches. Until the
|
|
217
|
+
* vault watch is accepted, and whenever it is unavailable, every tick lists
|
|
218
|
+
* the vault exactly as before watches existed. While it is open a tick
|
|
219
|
+
* lists only when five minutes have passed since the last discovery, since
|
|
220
|
+
* the watch keeps no history; it still checks `isCustodyActive`, so a switch
|
|
221
|
+
* to local mode closes the connection as quickly as before. Insula is
|
|
222
|
+
* polled on the tick only while its own watch is not open. A host not in
|
|
223
|
+
* vault mode makes no request.
|
|
224
|
+
*/
|
|
184
225
|
start() {
|
|
185
226
|
if (this.#started)
|
|
186
227
|
return;
|
|
@@ -188,8 +229,10 @@ export class ClaustrumConsumer {
|
|
|
188
229
|
this.#started = true;
|
|
189
230
|
const tick = async () => {
|
|
190
231
|
try {
|
|
191
|
-
await this
|
|
232
|
+
if (await this.#listDue())
|
|
233
|
+
await this.refresh();
|
|
192
234
|
await this.refreshInsulaQuota();
|
|
235
|
+
this.#openWatch();
|
|
193
236
|
}
|
|
194
237
|
catch (error) {
|
|
195
238
|
if (!this.#shutdown.signal.aborted)
|
|
@@ -208,6 +251,53 @@ export class ClaustrumConsumer {
|
|
|
208
251
|
};
|
|
209
252
|
void tick();
|
|
210
253
|
}
|
|
254
|
+
/** Whether a background tick must list the vault now. */
|
|
255
|
+
async #listDue() {
|
|
256
|
+
if (this.#vaultWatch?.mode !== 'watching')
|
|
257
|
+
return true;
|
|
258
|
+
const now = (this.#options.now ?? Date.now)();
|
|
259
|
+
if (this.#discoveredAt === undefined ||
|
|
260
|
+
now - this.#discoveredAt >= SAFETY_POLL_MS)
|
|
261
|
+
return true;
|
|
262
|
+
// refresh() is what closes the connection when the host left vault mode.
|
|
263
|
+
return !(await this.#active());
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Open the vault and insula watches once a vault connection exists. Only
|
|
267
|
+
* reached in vault mode: a host in local mode never connects.
|
|
268
|
+
*/
|
|
269
|
+
#openWatch() {
|
|
270
|
+
const custody = this.#custody;
|
|
271
|
+
if (this.#shutdown.signal.aborted || !custody)
|
|
272
|
+
return;
|
|
273
|
+
// The same entry point a host without a consumer uses, so both apply
|
|
274
|
+
// notices, refusals and outages alike.
|
|
275
|
+
this.#vaultWatch ??= custody.watchRoster({
|
|
276
|
+
view: () => this.#roster?.view,
|
|
277
|
+
// The existing discovery, so a watched change commits the roster and
|
|
278
|
+
// re-applies it to kept receipts exactly as a poll does. A refresh
|
|
279
|
+
// already in flight may have listed before the change was announced,
|
|
280
|
+
// so wait for it and list again.
|
|
281
|
+
onView: async () => {
|
|
282
|
+
while (this.#refreshing)
|
|
283
|
+
await this.#refreshing.catch(() => { });
|
|
284
|
+
await this.refresh();
|
|
285
|
+
},
|
|
286
|
+
// Custody has dropped kept receipts, as a poll meeting the same
|
|
287
|
+
// refusal from list_scoped would.
|
|
288
|
+
onRefused: (error) => this.#options.onError?.(error),
|
|
289
|
+
onError: (error) => {
|
|
290
|
+
if (!this.#shutdown.signal.aborted)
|
|
291
|
+
this.#options.onError?.(error);
|
|
292
|
+
},
|
|
293
|
+
});
|
|
294
|
+
if (this.#options.insula && this.#insula && !this.#releaseInsulaWatch)
|
|
295
|
+
this.#releaseInsulaWatch = this.#insula.source.watch({
|
|
296
|
+
provider: this.#options.insula.provider,
|
|
297
|
+
setTimeoutImpl: this.#options.setTimeoutImpl,
|
|
298
|
+
clearTimeoutImpl: this.#options.clearTimeoutImpl,
|
|
299
|
+
});
|
|
300
|
+
}
|
|
211
301
|
/**
|
|
212
302
|
* Fetch on a background tick, at most once per minute across projects with
|
|
213
303
|
* the same daemon and enrollment token path. Admission must use the cache
|
|
@@ -282,20 +372,33 @@ export class ClaustrumConsumer {
|
|
|
282
372
|
async authorize(routeId, signal) {
|
|
283
373
|
this.#assertOpen(signal);
|
|
284
374
|
const roster = this.#roster ?? (await this.#wait(this.refresh(), signal));
|
|
285
|
-
if (!roster || !(await this.#active()))
|
|
375
|
+
if (!roster || !(await this.#active())) {
|
|
376
|
+
this.#custody?.close();
|
|
377
|
+
this.#custody = undefined;
|
|
378
|
+
this.#stopWatch();
|
|
379
|
+
this.#insula?.release();
|
|
380
|
+
this.#insula = undefined;
|
|
286
381
|
throw new ClaustrumConsumerError('not-active', 'Claustrum scoped custody is not active');
|
|
382
|
+
}
|
|
287
383
|
const current = await readVaultRoster(this.#options.rosterPath);
|
|
288
384
|
this.#assertOpen(signal);
|
|
289
385
|
const observed = roster.rows.find((row) => row.routeId === routeId);
|
|
290
386
|
const configured = current?.rows.find((row) => row.routeId === routeId);
|
|
291
|
-
if (configured && !configured.enabled)
|
|
387
|
+
if (configured && !configured.enabled) {
|
|
388
|
+
if (observed)
|
|
389
|
+
this.#custody?.forget(observed.credentialId);
|
|
292
390
|
throw new ClaustrumConsumerError('route-declined', 'Claustrum route is disabled');
|
|
391
|
+
}
|
|
293
392
|
if (!observed ||
|
|
294
393
|
!configured ||
|
|
295
394
|
configured.state !== 'active' ||
|
|
296
395
|
configured.credentialId !== observed.credentialId ||
|
|
297
|
-
configured.accountIdentity !== observed.accountIdentity
|
|
396
|
+
configured.accountIdentity !== observed.accountIdentity ||
|
|
397
|
+
configured.credentialType !== observed.credentialType) {
|
|
398
|
+
if (observed)
|
|
399
|
+
this.#custody?.forget(observed.credentialId);
|
|
298
400
|
throw new ClaustrumConsumerError('route-unavailable', 'Claustrum route is disabled, removed or changed');
|
|
401
|
+
}
|
|
299
402
|
const custody = await this.#wait(this.#getCustody(), signal);
|
|
300
403
|
const combined = combineAbortSignals([
|
|
301
404
|
this.#shutdown.signal,
|
|
@@ -308,7 +411,7 @@ export class ClaustrumConsumer {
|
|
|
308
411
|
...(configured.accountIdentity !== undefined && {
|
|
309
412
|
accountIdentity: configured.accountIdentity,
|
|
310
413
|
}),
|
|
311
|
-
}, combined.signal);
|
|
414
|
+
}, combined.signal, routeId);
|
|
312
415
|
}
|
|
313
416
|
finally {
|
|
314
417
|
combined.dispose();
|
|
@@ -332,6 +435,10 @@ export class ClaustrumConsumer {
|
|
|
332
435
|
const signal = options.signal;
|
|
333
436
|
let served = await this.authorize(routeId, signal);
|
|
334
437
|
let response = await dispatch(served, signal);
|
|
438
|
+
if (response.status === 401 && served.fromKeptReceipt) {
|
|
439
|
+
await this.reportFailure(served, 401, options.reporterSource ?? 'direct');
|
|
440
|
+
return response;
|
|
441
|
+
}
|
|
335
442
|
if (response.status === 401 && !signal?.aborted) {
|
|
336
443
|
let current;
|
|
337
444
|
try {
|
|
@@ -366,6 +473,7 @@ export class ClaustrumConsumer {
|
|
|
366
473
|
this.#assertOpen();
|
|
367
474
|
await declineVaultRoute(this.#options.rosterPath, routeId);
|
|
368
475
|
this.#roster = await readVaultRoster(this.#options.rosterPath);
|
|
476
|
+
this.#custody?.retain(this.#roster?.rows ?? []);
|
|
369
477
|
}
|
|
370
478
|
async accept(routeId) {
|
|
371
479
|
this.#assertOpen();
|
|
@@ -421,6 +529,7 @@ export class ClaustrumConsumer {
|
|
|
421
529
|
if (this.#timer)
|
|
422
530
|
(this.#options.clearTimeoutImpl ?? clearTimeout)(this.#timer);
|
|
423
531
|
this.#timer = undefined;
|
|
532
|
+
this.#stopWatch();
|
|
424
533
|
this.#custody?.close();
|
|
425
534
|
this.#insula?.release();
|
|
426
535
|
this.#insula = undefined;
|
|
@@ -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
|
/**
|
|
@@ -132,6 +137,57 @@ export interface ClaustrumScopedAttempt {
|
|
|
132
137
|
readonly accessToken: string;
|
|
133
138
|
readonly recordVersion: number;
|
|
134
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;
|
|
135
191
|
}
|
|
136
192
|
/** Reads the provider identity a served token executes under, when the plugin can tell. */
|
|
137
193
|
export type IdentityParser = (accessToken: string) => string | undefined;
|
|
@@ -151,13 +207,15 @@ export declare function decideScopedRetryAfter401(site: string, served: Claustru
|
|
|
151
207
|
* so it cannot expire while a long request is still using it.
|
|
152
208
|
*/
|
|
153
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;
|
|
154
212
|
/**
|
|
155
213
|
* Reads this consumer's vault credentials (list, fetch, 401 report), with the
|
|
156
|
-
* enrollment token as authorization. Used by every host of a plugin.
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
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.
|
|
161
219
|
*/
|
|
162
220
|
export declare class ClaustrumScopedCustody {
|
|
163
221
|
#private;
|
|
@@ -177,7 +235,13 @@ export declare class ClaustrumScopedCustody {
|
|
|
177
235
|
requireAssertion?: boolean;
|
|
178
236
|
now?: () => number;
|
|
179
237
|
logger?: ClaustrumLogger;
|
|
238
|
+
setTimeoutImpl?: typeof setTimeout;
|
|
239
|
+
clearTimeoutImpl?: typeof clearTimeout;
|
|
180
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;
|
|
181
245
|
/**
|
|
182
246
|
* List this consumer's credentials. A record that cannot be used is skipped
|
|
183
247
|
* and warned about rather than failing the whole list, so one bad record
|
|
@@ -186,15 +250,23 @@ export declare class ClaustrumScopedCustody {
|
|
|
186
250
|
*/
|
|
187
251
|
discover(signal?: AbortSignal): Promise<VaultInventory>;
|
|
188
252
|
/**
|
|
189
|
-
*
|
|
190
|
-
*
|
|
191
|
-
*
|
|
192
|
-
*
|
|
193
|
-
* refuse; the receipt records what the vault asserted separately from what
|
|
194
|
-
* the roster expected. With it, a reply that does not itself name the
|
|
195
|
-
* 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.
|
|
196
257
|
*/
|
|
197
|
-
|
|
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>;
|
|
198
270
|
/**
|
|
199
271
|
* Report that a send this consumer actually made was rejected. Only a 401
|
|
200
272
|
* is reported (the vault refuses 429, 402 and 5xx reports), addressed by
|
|
@@ -203,5 +275,25 @@ export declare class ClaustrumScopedCustody {
|
|
|
203
275
|
* custody did not issue is refused rather than reported.
|
|
204
276
|
*/
|
|
205
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;
|
|
206
298
|
close(): void;
|
|
207
299
|
}
|