@cortexkit/antigravity-auth-core 3.0.6 → 3.0.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/account-migration.d.ts +1 -1
- package/dist/account-migration.d.ts.map +1 -1
- package/dist/account-migration.js +228 -53
- package/dist/account-migration.js.map +1 -1
- package/dist/agy-transport.d.ts.map +1 -1
- package/dist/agy-transport.js +2 -0
- package/dist/agy-transport.js.map +1 -1
- package/dist/common-auth-embedded/claustrum/availability.d.ts +89 -0
- package/dist/common-auth-embedded/claustrum/availability.js +106 -0
- package/dist/common-auth-embedded/claustrum/consumer.d.ts +41 -1
- package/dist/common-auth-embedded/claustrum/consumer.js +152 -6
- package/dist/common-auth-embedded/claustrum/custody.d.ts +135 -14
- package/dist/common-auth-embedded/claustrum/custody.js +311 -16
- package/dist/common-auth-embedded/claustrum/errors.d.ts +1 -0
- package/dist/common-auth-embedded/claustrum/index.d.ts +5 -3
- package/dist/common-auth-embedded/claustrum/index.js +4 -1
- package/dist/common-auth-embedded/claustrum/insula.d.ts +48 -0
- package/dist/common-auth-embedded/claustrum/insula.js +286 -21
- package/dist/common-auth-embedded/claustrum/watch.d.ts +141 -0
- package/dist/common-auth-embedded/claustrum/watch.js +254 -0
- package/dist/common-auth-embedded/fs/private-files.js +10 -2
- package/dist/common-auth-embedded/fs/refresh-file-lock.d.ts +8 -2
- package/dist/common-auth-embedded/fs/refresh-file-lock.js +6 -1
- package/dist/common-auth-embedded/pi-slot/lock.js +7 -3
- package/dist/common-auth-embedded/quota/format.d.ts +17 -3
- package/dist/common-auth-embedded/quota/format.js +203 -18
- package/dist/common-auth-embedded/quota/index.d.ts +2 -2
- package/dist/common-auth-embedded/quota/index.js +1 -1
- package/dist/common-auth-embedded/quota/projection.d.ts +16 -0
- package/dist/common-auth-embedded/source-output.json +105 -73
- package/dist/common-auth-embedded/store/pool.d.ts +1 -1
- package/dist/common-auth-embedded/store/pool.js +0 -1
- package/dist/common-auth-embedded/store/refresh-lock.d.ts +0 -1
- package/dist/common-auth-embedded/store/refresh-lock.js +0 -1
- package/dist/credential-redaction.d.ts +8 -4
- package/dist/credential-redaction.d.ts.map +1 -1
- package/dist/credential-redaction.js +55 -28
- package/dist/credential-redaction.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/pre-response-deadline.d.ts +41 -0
- package/dist/pre-response-deadline.d.ts.map +1 -0
- package/dist/pre-response-deadline.js +150 -0
- package/dist/pre-response-deadline.js.map +1 -0
- package/dist/quota-presentation.d.ts.map +1 -1
- package/dist/quota-presentation.js +9 -1
- package/dist/quota-presentation.js.map +1 -1
- package/dist/vault-account-source.d.ts +27 -2
- package/dist/vault-account-source.d.ts.map +1 -1
- package/dist/vault-account-source.js +12 -1
- package/dist/vault-account-source.js.map +1 -1
- package/dist/vault-folder-refusal.js +3 -3
- package/dist/vault-grant-reach.d.ts +23 -0
- package/dist/vault-grant-reach.d.ts.map +1 -0
- package/dist/vault-grant-reach.js +15 -0
- package/dist/vault-grant-reach.js.map +1 -0
- package/package.json +3 -3
|
@@ -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 } 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`),
|
|
@@ -71,9 +87,33 @@ export declare class ClaustrumConsumer<Q = QuotaObservation> {
|
|
|
71
87
|
constructor(options: ClaustrumConsumerOptions<Q>);
|
|
72
88
|
/** The last committed roster this instance saw, without any I/O. */
|
|
73
89
|
snapshot(): VaultRosterFile | undefined;
|
|
90
|
+
/**
|
|
91
|
+
* What this consumer is granted, from its latest discovery, without any I/O.
|
|
92
|
+
* It explains an empty roster: `grants === 0` means no grant at all, while
|
|
93
|
+
* `grants > 0` means a grant that reaches no credential (`tuples` names its
|
|
94
|
+
* selector). Undefined means unknown, never zero: the vault or client is too
|
|
95
|
+
* old to send it, nothing was discovered yet, or the roster now in memory is
|
|
96
|
+
* a different view than the one this instance discovered (a peer process
|
|
97
|
+
* held the discovery lease and its newer roster was served instead).
|
|
98
|
+
*/
|
|
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;
|
|
74
104
|
/** Rows for `/routing`, from the last committed roster. */
|
|
75
105
|
routingRows(): RoutingRow[];
|
|
76
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
|
+
*/
|
|
77
117
|
start(): void;
|
|
78
118
|
/**
|
|
79
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
|
|
@@ -21,9 +22,18 @@ export class ClaustrumConsumer {
|
|
|
21
22
|
#connecting;
|
|
22
23
|
#refreshing;
|
|
23
24
|
#roster;
|
|
25
|
+
/**
|
|
26
|
+
* The grants named by this instance's latest discovery, with the view that
|
|
27
|
+
* discovery committed. Memory only: the roster file never carries it.
|
|
28
|
+
*/
|
|
29
|
+
#grantReach;
|
|
24
30
|
#timer;
|
|
25
31
|
#started = false;
|
|
26
32
|
#insula;
|
|
33
|
+
#vaultWatch;
|
|
34
|
+
#releaseInsulaWatch;
|
|
35
|
+
/** When this instance last completed a discovery, by `now`. */
|
|
36
|
+
#discoveredAt;
|
|
27
37
|
constructor(options) {
|
|
28
38
|
this.#options = options;
|
|
29
39
|
this.#logger = options.logger ?? createLogger('claustrum');
|
|
@@ -86,6 +96,8 @@ export class ClaustrumConsumer {
|
|
|
86
96
|
requireAssertion: this.#options.requireAssertion,
|
|
87
97
|
now: this.#options.now,
|
|
88
98
|
logger: this.#logger,
|
|
99
|
+
setTimeoutImpl: this.#options.setTimeoutImpl,
|
|
100
|
+
clearTimeoutImpl: this.#options.clearTimeoutImpl,
|
|
89
101
|
});
|
|
90
102
|
if (this.#options.insula && client.insula) {
|
|
91
103
|
this.#insula = acquireInsulaQuotaSource(client.insula, this.#options.tokenPath);
|
|
@@ -102,6 +114,42 @@ export class ClaustrumConsumer {
|
|
|
102
114
|
snapshot() {
|
|
103
115
|
return this.#roster;
|
|
104
116
|
}
|
|
117
|
+
/**
|
|
118
|
+
* What this consumer is granted, from its latest discovery, without any I/O.
|
|
119
|
+
* It explains an empty roster: `grants === 0` means no grant at all, while
|
|
120
|
+
* `grants > 0` means a grant that reaches no credential (`tuples` names its
|
|
121
|
+
* selector). Undefined means unknown, never zero: the vault or client is too
|
|
122
|
+
* old to send it, nothing was discovered yet, or the roster now in memory is
|
|
123
|
+
* a different view than the one this instance discovered (a peer process
|
|
124
|
+
* held the discovery lease and its newer roster was served instead).
|
|
125
|
+
*/
|
|
126
|
+
grantReach() {
|
|
127
|
+
const latest = this.#grantReach;
|
|
128
|
+
return latest && this.#roster?.view === latest.view
|
|
129
|
+
? latest.reach
|
|
130
|
+
: undefined;
|
|
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
|
+
}
|
|
105
153
|
/** Rows for `/routing`, from the last committed roster. */
|
|
106
154
|
routingRows() {
|
|
107
155
|
return vaultRoutingRows(this.#roster);
|
|
@@ -113,6 +161,8 @@ export class ClaustrumConsumer {
|
|
|
113
161
|
this.#refreshing = (async () => {
|
|
114
162
|
if (!(await this.#active())) {
|
|
115
163
|
this.#roster = undefined;
|
|
164
|
+
this.#grantReach = undefined;
|
|
165
|
+
this.#stopWatch();
|
|
116
166
|
this.#custody?.close();
|
|
117
167
|
this.#custody = undefined;
|
|
118
168
|
this.#insula?.release();
|
|
@@ -120,9 +170,17 @@ export class ClaustrumConsumer {
|
|
|
120
170
|
return undefined;
|
|
121
171
|
}
|
|
122
172
|
const custody = await this.#getCustody();
|
|
173
|
+
// Capture the inventory so its grant fields can be kept beside the roster;
|
|
174
|
+
// the roster file itself never stores them.
|
|
175
|
+
let discovered;
|
|
123
176
|
const roster = await refreshVaultRoster({
|
|
124
177
|
path: this.#options.rosterPath,
|
|
125
|
-
custody
|
|
178
|
+
custody: {
|
|
179
|
+
discover: async (signal) => {
|
|
180
|
+
discovered = await custody.discover(signal);
|
|
181
|
+
return discovered;
|
|
182
|
+
},
|
|
183
|
+
},
|
|
126
184
|
isActive: () => this.#active(),
|
|
127
185
|
signal: this.#shutdown.signal,
|
|
128
186
|
projection: () => ({
|
|
@@ -135,16 +193,35 @@ export class ClaustrumConsumer {
|
|
|
135
193
|
this.#assertOpen();
|
|
136
194
|
const changed = roster?.view !== this.#roster?.view;
|
|
137
195
|
this.#roster = roster;
|
|
196
|
+
custody.retain(roster?.rows ?? []);
|
|
197
|
+
// Each discovery replaces the previous value, including with unknown. A
|
|
198
|
+
// refresh that served a peer's roster discovered nothing and leaves it;
|
|
199
|
+
// grantReach() hides it once the roster's view no longer matches.
|
|
200
|
+
if (discovered && roster?.view !== undefined)
|
|
201
|
+
this.#grantReach = discovered.grantReach
|
|
202
|
+
? { view: roster.view, reach: discovered.grantReach }
|
|
203
|
+
: undefined;
|
|
138
204
|
// Notify on view changes only: subscribers rewrite shared state on each
|
|
139
205
|
// notification, and the poll runs every few seconds.
|
|
140
206
|
if (roster && changed)
|
|
141
207
|
this.#options.onRoster?.(roster);
|
|
208
|
+
this.#discoveredAt = (this.#options.now ?? Date.now)();
|
|
142
209
|
return roster;
|
|
143
210
|
})().finally(() => {
|
|
144
211
|
this.#refreshing = undefined;
|
|
145
212
|
});
|
|
146
213
|
return this.#refreshing;
|
|
147
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
|
+
*/
|
|
148
225
|
start() {
|
|
149
226
|
if (this.#started)
|
|
150
227
|
return;
|
|
@@ -152,8 +229,10 @@ export class ClaustrumConsumer {
|
|
|
152
229
|
this.#started = true;
|
|
153
230
|
const tick = async () => {
|
|
154
231
|
try {
|
|
155
|
-
await this
|
|
232
|
+
if (await this.#listDue())
|
|
233
|
+
await this.refresh();
|
|
156
234
|
await this.refreshInsulaQuota();
|
|
235
|
+
this.#openWatch();
|
|
157
236
|
}
|
|
158
237
|
catch (error) {
|
|
159
238
|
if (!this.#shutdown.signal.aborted)
|
|
@@ -172,6 +251,53 @@ export class ClaustrumConsumer {
|
|
|
172
251
|
};
|
|
173
252
|
void tick();
|
|
174
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
|
+
}
|
|
175
301
|
/**
|
|
176
302
|
* Fetch on a background tick, at most once per minute across projects with
|
|
177
303
|
* the same daemon and enrollment token path. Admission must use the cache
|
|
@@ -246,20 +372,33 @@ export class ClaustrumConsumer {
|
|
|
246
372
|
async authorize(routeId, signal) {
|
|
247
373
|
this.#assertOpen(signal);
|
|
248
374
|
const roster = this.#roster ?? (await this.#wait(this.refresh(), signal));
|
|
249
|
-
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;
|
|
250
381
|
throw new ClaustrumConsumerError('not-active', 'Claustrum scoped custody is not active');
|
|
382
|
+
}
|
|
251
383
|
const current = await readVaultRoster(this.#options.rosterPath);
|
|
252
384
|
this.#assertOpen(signal);
|
|
253
385
|
const observed = roster.rows.find((row) => row.routeId === routeId);
|
|
254
386
|
const configured = current?.rows.find((row) => row.routeId === routeId);
|
|
255
|
-
if (configured && !configured.enabled)
|
|
387
|
+
if (configured && !configured.enabled) {
|
|
388
|
+
if (observed)
|
|
389
|
+
this.#custody?.forget(observed.credentialId);
|
|
256
390
|
throw new ClaustrumConsumerError('route-declined', 'Claustrum route is disabled');
|
|
391
|
+
}
|
|
257
392
|
if (!observed ||
|
|
258
393
|
!configured ||
|
|
259
394
|
configured.state !== 'active' ||
|
|
260
395
|
configured.credentialId !== observed.credentialId ||
|
|
261
|
-
configured.accountIdentity !== observed.accountIdentity
|
|
396
|
+
configured.accountIdentity !== observed.accountIdentity ||
|
|
397
|
+
configured.credentialType !== observed.credentialType) {
|
|
398
|
+
if (observed)
|
|
399
|
+
this.#custody?.forget(observed.credentialId);
|
|
262
400
|
throw new ClaustrumConsumerError('route-unavailable', 'Claustrum route is disabled, removed or changed');
|
|
401
|
+
}
|
|
263
402
|
const custody = await this.#wait(this.#getCustody(), signal);
|
|
264
403
|
const combined = combineAbortSignals([
|
|
265
404
|
this.#shutdown.signal,
|
|
@@ -272,7 +411,7 @@ export class ClaustrumConsumer {
|
|
|
272
411
|
...(configured.accountIdentity !== undefined && {
|
|
273
412
|
accountIdentity: configured.accountIdentity,
|
|
274
413
|
}),
|
|
275
|
-
}, combined.signal);
|
|
414
|
+
}, combined.signal, routeId);
|
|
276
415
|
}
|
|
277
416
|
finally {
|
|
278
417
|
combined.dispose();
|
|
@@ -296,6 +435,10 @@ export class ClaustrumConsumer {
|
|
|
296
435
|
const signal = options.signal;
|
|
297
436
|
let served = await this.authorize(routeId, signal);
|
|
298
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
|
+
}
|
|
299
442
|
if (response.status === 401 && !signal?.aborted) {
|
|
300
443
|
let current;
|
|
301
444
|
try {
|
|
@@ -330,6 +473,7 @@ export class ClaustrumConsumer {
|
|
|
330
473
|
this.#assertOpen();
|
|
331
474
|
await declineVaultRoute(this.#options.rosterPath, routeId);
|
|
332
475
|
this.#roster = await readVaultRoster(this.#options.rosterPath);
|
|
476
|
+
this.#custody?.retain(this.#roster?.rows ?? []);
|
|
333
477
|
}
|
|
334
478
|
async accept(routeId) {
|
|
335
479
|
this.#assertOpen();
|
|
@@ -385,9 +529,11 @@ export class ClaustrumConsumer {
|
|
|
385
529
|
if (this.#timer)
|
|
386
530
|
(this.#options.clearTimeoutImpl ?? clearTimeout)(this.#timer);
|
|
387
531
|
this.#timer = undefined;
|
|
532
|
+
this.#stopWatch();
|
|
388
533
|
this.#custody?.close();
|
|
389
534
|
this.#insula?.release();
|
|
390
535
|
this.#insula = undefined;
|
|
391
536
|
this.#roster = undefined;
|
|
537
|
+
this.#grantReach = undefined;
|
|
392
538
|
}
|
|
393
539
|
}
|