@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.
- package/dist/common-auth-embedded/auth-menu/login.d.ts +6 -10
- package/dist/common-auth-embedded/auth-menu/login.js +14 -11
- 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 +36 -2
- package/dist/common-auth-embedded/claustrum/consumer.js +154 -6
- 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 +71 -0
- package/dist/common-auth-embedded/claustrum/insula.js +348 -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 +87 -55
- package/dist/constants.d.ts +5 -0
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +5 -0
- package/dist/constants.js.map +1 -1
- 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 +20 -3
- package/dist/vault-account-source.d.ts.map +1 -1
- package/dist/vault-account-source.js +45 -1
- package/dist/vault-account-source.js.map +1 -1
- package/dist/vault-folder-refusal.js +3 -3
- 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
|
|
30
|
-
*
|
|
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
|
|
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
|
|
47
|
-
export declare function openBrowserForMenu(url: string, platform?: NodeJS.Platform,
|
|
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
|
|
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 {
|
|
2
|
-
/** Opens a URL with the platform's opener; false when
|
|
3
|
-
export function openBrowserForMenu(url, platform = process.platform,
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
}
|
|
37
|
-
|
|
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
|
|
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
|
|
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;
|