@cortexkit/antigravity-auth-core 3.0.12 → 3.0.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/dist/account-selector.d.ts +9 -1
  2. package/dist/account-selector.d.ts.map +1 -1
  3. package/dist/account-selector.js +23 -8
  4. package/dist/account-selector.js.map +1 -1
  5. package/dist/common-auth-embedded/claustrum/consumer.d.ts +77 -2
  6. package/dist/common-auth-embedded/claustrum/consumer.js +320 -35
  7. package/dist/common-auth-embedded/claustrum/custody.d.ts +36 -1
  8. package/dist/common-auth-embedded/claustrum/custody.js +95 -11
  9. package/dist/common-auth-embedded/claustrum/enrollment.js +37 -12
  10. package/dist/common-auth-embedded/claustrum/hub.d.ts +195 -0
  11. package/dist/common-auth-embedded/claustrum/hub.js +988 -0
  12. package/dist/common-auth-embedded/claustrum/index.d.ts +13 -3
  13. package/dist/common-auth-embedded/claustrum/index.js +37 -4
  14. package/dist/common-auth-embedded/claustrum/quota-writer.d.ts +8 -1
  15. package/dist/common-auth-embedded/claustrum/quota-writer.js +34 -12
  16. package/dist/common-auth-embedded/claustrum/roster.d.ts +49 -1
  17. package/dist/common-auth-embedded/claustrum/roster.js +187 -12
  18. package/dist/common-auth-embedded/claustrum/shutdown.js +42 -0
  19. package/dist/common-auth-embedded/fs/file-read-cache.js +57 -0
  20. package/dist/common-auth-embedded/fs/refresh-file-lock.d.ts +1 -1
  21. package/dist/common-auth-embedded/fs/refresh-file-lock.js +20 -11
  22. package/dist/common-auth-embedded/quota/commit-interval.d.ts +9 -0
  23. package/dist/common-auth-embedded/quota/commit-interval.js +18 -0
  24. package/dist/common-auth-embedded/source-output.json +115 -67
  25. package/dist/common-auth-embedded/store/mutate.d.ts +2 -0
  26. package/dist/common-auth-embedded/store/mutate.js +4 -0
  27. package/dist/common-auth-embedded/store/pool.d.ts +6 -1
  28. package/dist/common-auth-embedded/store/pool.js +1 -1
  29. package/dist/common-auth-embedded/store/quota-writer.js +27 -13
  30. package/dist/common-auth-embedded/store/settings.d.ts +1 -1
  31. package/dist/common-auth-embedded/store/settings.js +16 -6
  32. package/dist/model-registry.d.ts +2 -2
  33. package/dist/model-registry.d.ts.map +1 -1
  34. package/dist/model-registry.js +6 -4
  35. package/dist/model-registry.js.map +1 -1
  36. package/dist/quota-manager.d.ts +1 -1
  37. package/dist/quota-manager.d.ts.map +1 -1
  38. package/dist/quota-manager.js +12 -4
  39. package/dist/quota-manager.js.map +1 -1
  40. package/dist/quota-presentation.d.ts.map +1 -1
  41. package/dist/quota-presentation.js +8 -0
  42. package/dist/quota-presentation.js.map +1 -1
  43. package/dist/quota-types.d.ts +1 -1
  44. package/dist/quota-types.d.ts.map +1 -1
  45. package/dist/quota-types.js +5 -0
  46. package/dist/quota-types.js.map +1 -1
  47. package/dist/vault-account-source.d.ts +6 -1
  48. package/dist/vault-account-source.d.ts.map +1 -1
  49. package/dist/vault-account-source.js +9 -0
  50. package/dist/vault-account-source.js.map +1 -1
  51. package/package.json +1 -1
@@ -2,21 +2,31 @@ import { type ClaustrumClientOptions } from '@cortexkit/claustrum-client';
2
2
  import type { ClaustrumScopedClient } from './custody.js';
3
3
  export { ClaustrumCredentialError, type ClaustrumReporterSource, type EnrollmentTokenFile, } from '@cortexkit/claustrum-client';
4
4
  export { ClaustrumUnreachableError, classifyVaultFailure, VAULT_FAILURE_CLASSES, type VaultFailureClass, } from './availability.js';
5
- export { ClaustrumConsumer, type ClaustrumConsumerOptions, type ClaustrumWatchState, type SendOptions, } from './consumer.js';
6
- export { type AccountIdentitySource, type ClaustrumFamily, type ClaustrumRosterWatch, type ClaustrumRosterWatchOptions, type ClaustrumScopedAttempt, type ClaustrumScopedClient, ClaustrumScopedCustody, type ClaustrumScopedIdentity, decideScopedRetryAfter401, type IdentityParser, isScopedCredentialRotation, KEPT_RECEIPT_MARGIN_MS, type KeptReceiptState, type ScopedRetryReason, SERVING_MARGIN_MS, type SkippedVaultReason, type SkippedVaultRecord, type VaultCredential, type VaultCredentialType, type VaultGrantReach, type VaultGrantTuple, type VaultInventory, } from './custody.js';
5
+ export { ClaustrumConsumer, type ClaustrumConsumerOptions, type ClaustrumWatchState, DEFAULT_CONNECT_TIMEOUT_MS, type SendOptions, } from './consumer.js';
6
+ export { type AccountIdentitySource, type ClassifyingClaustrumFamily, type ClaustrumFamily, type ClaustrumFamilyInput, type ClaustrumRosterWatch, type ClaustrumRosterWatchOptions, type ClaustrumScopedAttempt, type ClaustrumScopedClient, ClaustrumScopedCustody, type ClaustrumScopedIdentity, decideScopedRetryAfter401, type IdentityParser, isScopedCredentialRotation, KEPT_RECEIPT_MARGIN_MS, type KeptReceiptState, type ScopedRetryReason, SERVING_MARGIN_MS, type SkippedVaultReason, type SkippedVaultRecord, type VaultCredential, type VaultCredentialType, type VaultGrantReach, type VaultGrantTuple, type VaultInventory, type VaultRowFacts, } from './custody.js';
7
7
  export { type ClaustrumEnrollmentClient, type ClaustrumEnrollmentConnection, type ClaustrumEnrollmentDisconnectResult, ClaustrumEnrollmentManager, type ClaustrumEnrollmentPaths, type ClaustrumEnrollmentResetResult, type ClaustrumEnrollmentStatus, classifyEnrollmentError, connectClaustrumEnrollmentClient, disconnectClaustrumEnrollment, type EnrollmentDisposition, enrollmentAuthority, enrollmentName, getClaustrumEnrollmentPaths, hostEnrollmentPaths, RETRYABLE_ENROLLMENT_CODES, readClaustrumEnrollmentStatus, readClaustrumEnrollmentToken, resetClaustrumEnrollmentState, TERMINAL_ENROLLMENT_CODES, } from './enrollment.js';
8
8
  export { ClaustrumConsumerError, type ClaustrumConsumerFailureKind, type ClaustrumLogger, } from './errors.js';
9
9
  export { assertHostSlotMatchesMode, assertNotCustodyPlaceholder, CUSTODY_PLACEHOLDER_PREFIX, classifyHostSlot, custodyPlaceholder, custodyPlaceholderKey, type HostSlotContent, isCustodyPlaceholder, isCustodyPlaceholderValue, } from './host-slot.js';
10
+ export { ClaustrumDiscoveryHub, type ClaustrumDiscoveryHubOptions, type ClaustrumHubMemberOptions, type ClaustrumHubRefusals, type ClaustrumLeasedCustody, type CustodyLease, } from './hub.js';
10
11
  export { acquireInsulaQuotaSource, type InsulaQuotaContext, type InsulaQuotaLookup, type InsulaQuotaMiss, type InsulaQuotaOptions, type InsulaQuotaReading, type InsulaQuotaResult, type InsulaQuotaSource, type InsulaUsageEntry, type InsulaUsageTransport, type InsulaUsageWatch, type InsulaUsageWindow, type InsulaWatchOptions, } from './insula.js';
11
12
  export { acceptAccount, type DeclinedAccount, declineAccount, isDeclined, } from './interlock.js';
12
13
  export { drainVaultQuota } from './quota-writer.js';
13
- export { type AccountMapper, acceptVaultRoute, DEFAULT_ROUTE_PREFIX, declineVaultRoute, type MutateVaultRosterOptions, mutateVaultRoster, type ProjectionOptions, projectVaultRoster, type QuotaReceipt, type ReorderVaultRosterOptions, type ReorderVaultRosterResult, readVaultRoster, recordVaultAccountQuota, recordVaultQuota, refreshVaultRoster, reorderVaultRoster, resolveVaultPrimary, type VaultPrimary, type VaultPrimaryBinding, type VaultPrimaryUnavailableReason, type VaultRosterFile, type VaultRosterLockContext, type VaultRosterRow, vaultRoutingRows, } from './roster.js';
14
+ export { type AccountMapper, acceptVaultRoute, DEFAULT_ROUTE_PREFIX, declineVaultRoute, type MutateVaultRosterOptions, mutateVaultRoster, type ProjectionOptions, projectVaultRoster, type QuotaReceipt, type ReorderVaultRosterOptions, type ReorderVaultRosterResult, readVaultRoster, recordVaultAccountQuota, recordVaultQuota, refreshVaultRoster, reorderVaultRoster, resolveVaultPrimary, type VaultPrimary, type VaultPrimaryBinding, type VaultPrimaryUnavailableReason, type VaultRosterFile, type VaultRosterLockContext, type VaultRosterRow, type VaultRosterSeed, vaultRosterSeed, vaultRoutingRows, } from './roster.js';
14
15
  export { type ChangeNotice, ChangeNoticeReader, RESUBSCRIBE_BACKOFF_MS, SAFETY_POLL_MS, ScopedRosterWatch, type ScopedRosterWatchOptions, type ScopedWatchEnd, type ScopedWatchHandle, type ScopedWatchNoticeShape, type ScopedWatchSource, WATCH_RETRY_MS, type WatchMode, } from './watch.js';
16
+ /** How long `connectClaustrumScopedClient` waits by default. */
17
+ export declare const SCOPED_CONNECT_TIMEOUT_MS = 10000;
15
18
  /**
16
19
  * Connect the client that lists and fetches this consumer's vault credentials
17
20
  * on the request path. `connectionFile` is required: this library
18
21
  * reads no environment, so the plugin resolves the vault's connection file.
22
+ *
23
+ * The wait is bounded by `connectTimeoutMs` (default 10 s) so a connect that
24
+ * never settles cannot leave a consumer with a pending connection forever; the
25
+ * caller then gets an unreachable error and may try again. A socket handshake
26
+ * has its own limit, but local reads before it (the connection file) do not,
27
+ * and a client that arrives after this deadline is closed, not returned.
19
28
  */
20
29
  export declare function connectClaustrumScopedClient(options: ClaustrumClientOptions & {
21
30
  connectionFile: string;
31
+ connectTimeoutMs?: number;
22
32
  }): Promise<ClaustrumScopedClient>;
@@ -4,25 +4,58 @@
4
4
  // declined-account interlock and the host-slot guard. Needs the optional
5
5
  // peer `@cortexkit/claustrum-client`.
6
6
  import { ClaustrumClient, } from '@cortexkit/claustrum-client';
7
+ import { ClaustrumUnreachableError } from './availability.js';
7
8
  import { insulaUsageTransport } from './insula.js';
8
9
  export { ClaustrumCredentialError, } from '@cortexkit/claustrum-client';
9
10
  export { ClaustrumUnreachableError, classifyVaultFailure, VAULT_FAILURE_CLASSES, } from './availability.js';
10
- export { ClaustrumConsumer, } from './consumer.js';
11
+ export { ClaustrumConsumer, DEFAULT_CONNECT_TIMEOUT_MS, } from './consumer.js';
11
12
  export { ClaustrumScopedCustody, decideScopedRetryAfter401, isScopedCredentialRotation, KEPT_RECEIPT_MARGIN_MS, SERVING_MARGIN_MS, } from './custody.js';
12
13
  export { ClaustrumEnrollmentManager, classifyEnrollmentError, connectClaustrumEnrollmentClient, disconnectClaustrumEnrollment, enrollmentAuthority, enrollmentName, getClaustrumEnrollmentPaths, hostEnrollmentPaths, RETRYABLE_ENROLLMENT_CODES, readClaustrumEnrollmentStatus, readClaustrumEnrollmentToken, resetClaustrumEnrollmentState, TERMINAL_ENROLLMENT_CODES, } from './enrollment.js';
13
14
  export { ClaustrumConsumerError, } from './errors.js';
14
15
  export { assertHostSlotMatchesMode, assertNotCustodyPlaceholder, CUSTODY_PLACEHOLDER_PREFIX, classifyHostSlot, custodyPlaceholder, custodyPlaceholderKey, isCustodyPlaceholder, isCustodyPlaceholderValue, } from './host-slot.js';
16
+ export { ClaustrumDiscoveryHub, } from './hub.js';
15
17
  export { acquireInsulaQuotaSource, } from './insula.js';
16
18
  export { acceptAccount, declineAccount, isDeclined, } from './interlock.js';
17
19
  export { drainVaultQuota } from './quota-writer.js';
18
- export { acceptVaultRoute, DEFAULT_ROUTE_PREFIX, declineVaultRoute, mutateVaultRoster, projectVaultRoster, readVaultRoster, recordVaultAccountQuota, recordVaultQuota, refreshVaultRoster, reorderVaultRoster, resolveVaultPrimary, vaultRoutingRows, } from './roster.js';
20
+ export { acceptVaultRoute, DEFAULT_ROUTE_PREFIX, declineVaultRoute, mutateVaultRoster, projectVaultRoster, readVaultRoster, recordVaultAccountQuota, recordVaultQuota, refreshVaultRoster, reorderVaultRoster, resolveVaultPrimary, vaultRosterSeed, vaultRoutingRows, } from './roster.js';
19
21
  export { ChangeNoticeReader, RESUBSCRIBE_BACKOFF_MS, SAFETY_POLL_MS, ScopedRosterWatch, WATCH_RETRY_MS, } from './watch.js';
22
+ /** How long `connectClaustrumScopedClient` waits by default. */
23
+ export const SCOPED_CONNECT_TIMEOUT_MS = 10_000;
20
24
  /**
21
25
  * Connect the client that lists and fetches this consumer's vault credentials
22
26
  * on the request path. `connectionFile` is required: this library
23
27
  * reads no environment, so the plugin resolves the vault's connection file.
28
+ *
29
+ * The wait is bounded by `connectTimeoutMs` (default 10 s) so a connect that
30
+ * never settles cannot leave a consumer with a pending connection forever; the
31
+ * caller then gets an unreachable error and may try again. A socket handshake
32
+ * has its own limit, but local reads before it (the connection file) do not,
33
+ * and a client that arrives after this deadline is closed, not returned.
24
34
  */
25
35
  export async function connectClaustrumScopedClient(options) {
26
- const client = await ClaustrumClient.connect(options);
27
- return Object.assign(client, { insula: insulaUsageTransport(options) });
36
+ const { connectTimeoutMs, ...client } = options;
37
+ const limit = connectTimeoutMs ?? SCOPED_CONNECT_TIMEOUT_MS;
38
+ const connecting = ClaustrumClient.connect(client);
39
+ let expired = false;
40
+ let timer;
41
+ const deadline = new Promise((_resolve, reject) => {
42
+ timer = setTimeout(() => {
43
+ expired = true;
44
+ reject(new ClaustrumUnreachableError());
45
+ }, limit);
46
+ timer.unref?.();
47
+ });
48
+ try {
49
+ const client = await Promise.race([connecting, deadline]);
50
+ return Object.assign(client, { insula: insulaUsageTransport(options) });
51
+ }
52
+ catch (error) {
53
+ // A connection that arrives after the deadline still owns a socket.
54
+ void connecting.then((client) => client.close(), () => { });
55
+ throw error;
56
+ }
57
+ finally {
58
+ if (!expired && timer)
59
+ clearTimeout(timer);
60
+ }
28
61
  }
@@ -1,3 +1,4 @@
1
+ import { type QuotaCommitTimer } from '../quota/commit-interval.js';
1
2
  import { type QuotaObservation } from '../quota/index.js';
2
3
  import { type QuotaReceipt, type VaultRosterFile } from './roster.js';
3
4
  export type VaultQuotaPublication = {
@@ -8,11 +9,17 @@ export type VaultQuotaPublication = {
8
9
  };
9
10
  type Listener = (publication: VaultQuotaPublication) => void;
10
11
  /** Retain the shared writer; the last consumer's close drains it. */
11
- export declare function retainVaultQuotaWriter(path: string, listener: Listener): () => Promise<void>;
12
+ export declare function retainVaultQuotaWriter(path: string, listener: Listener, clock?: QuotaCommitTimer): () => Promise<void>;
12
13
  /** Enqueue without filesystem work; each quota field retains its own receipt fence. */
13
14
  export declare function enqueueVaultQuota(path: string, publication: VaultQuotaPublication): Promise<boolean>;
14
15
  /** Overlay uncommitted observations when a consumer reloads the durable roster. */
15
16
  export declare function pendingVaultQuota(path: string, roster: VaultRosterFile | undefined): VaultRosterFile | undefined;
17
+ /**
18
+ * Whether this process holds quota readings for `path` that no commit of the
19
+ * durable roster has written yet, so a reader of the file alone is not seeing
20
+ * everything this process would publish.
21
+ */
22
+ export declare function hasPendingVaultQuota(path: string): boolean;
16
23
  /** Wait at most timeoutMs for detached publication; shutdown loss is acceptable. */
17
24
  export declare function drainVaultQuota(path: string, timeoutMs?: number): Promise<void>;
18
25
  export {};
@@ -1,5 +1,6 @@
1
1
  import { resolve } from 'node:path';
2
2
  import { createLogger } from '../logger/index.js';
3
+ import { QUOTA_COMMIT_INTERVAL_MS, quotaCommitTimer, } from '../quota/commit-interval.js';
3
4
  import { isQuotaObservation } from '../quota/index.js';
4
5
  import { quotaFields } from '../quota/pending.js';
5
6
  import { applyVaultQuota, mutateVaultRoster, } from './roster.js';
@@ -7,7 +8,7 @@ import { applyVaultQuota, mutateVaultRoster, } from './roster.js';
7
8
  const writers = new Map();
8
9
  const logger = createLogger('claustrum');
9
10
  const COALESCE_MS = 100;
10
- function writerFor(path) {
11
+ function writerFor(path, clock = quotaCommitTimer) {
11
12
  const key = resolve(path);
12
13
  let writer = writers.get(key);
13
14
  if (!writer) {
@@ -17,6 +18,7 @@ function writerFor(path) {
17
18
  listeners: new Set(),
18
19
  wake: new Set(),
19
20
  abort: new AbortController(),
21
+ clock,
20
22
  failures: 0,
21
23
  draining: false,
22
24
  order: 0,
@@ -26,8 +28,8 @@ function writerFor(path) {
26
28
  return writer;
27
29
  }
28
30
  /** Retain the shared writer; the last consumer's close drains it. */
29
- export function retainVaultQuotaWriter(path, listener) {
30
- const writer = writerFor(path);
31
+ export function retainVaultQuotaWriter(path, listener, clock) {
32
+ const writer = writerFor(path, clock);
31
33
  writer.listeners.add(listener);
32
34
  let released;
33
35
  return () => (released ??= (() => {
@@ -93,37 +95,57 @@ export function pendingVaultQuota(path, roster) {
93
95
  }
94
96
  return roster;
95
97
  }
98
+ /**
99
+ * Whether this process holds quota readings for `path` that no commit of the
100
+ * durable roster has written yet, so a reader of the file alone is not seeing
101
+ * everything this process would publish.
102
+ */
103
+ export function hasPendingVaultQuota(path) {
104
+ return !!writers.get(resolve(path))?.pending.size;
105
+ }
96
106
  function schedule(writer, delay) {
97
107
  if (writer.running ||
98
108
  writer.timer ||
99
109
  !writer.pending.size ||
100
110
  writer.abort.signal.aborted)
101
111
  return;
102
- writer.timer = setTimeout(() => {
112
+ const remaining = writer.draining
113
+ ? 0
114
+ : (writer.committedAt ?? -Infinity) +
115
+ QUOTA_COMMIT_INTERVAL_MS -
116
+ writer.clock.now();
117
+ writer.timer = writer.clock.schedule(Math.max(delay, remaining), () => {
103
118
  writer.timer = undefined;
104
- flush(writer);
105
- }, delay);
119
+ return flush(writer);
120
+ });
106
121
  if (!writer.draining)
107
122
  writer.timer.unref?.();
108
123
  }
124
+ function cancelTimer(writer) {
125
+ writer.timer?.cancel();
126
+ writer.timer = undefined;
127
+ }
109
128
  function flush(writer) {
110
129
  if (writer.running || writer.abort.signal.aborted)
111
- return;
130
+ return writer.running ?? Promise.resolve();
112
131
  const batch = new Map([...writer.pending].map(([account, pending]) => [
113
132
  account,
114
133
  new Map(pending),
115
134
  ]));
116
135
  writer.running = (async () => {
117
136
  try {
118
- await mutateVaultRoster(writer.path, (current) => {
137
+ const committed = await mutateVaultRoster(writer.path, (current) => {
119
138
  let next = current;
120
139
  for (const field of orderedFields(batch)) {
121
140
  if (field.priorReading)
122
141
  next = applyVaultQuota(next, field.priorReading).roster;
123
142
  next = applyVaultQuota(next, field.publication).roster;
124
143
  }
125
- return { ...(next !== current && { next }), result: undefined };
144
+ const changed = JSON.stringify(next) !== JSON.stringify(current);
145
+ return { ...(changed && { next }), result: changed };
126
146
  }, { signal: writer.abort.signal });
147
+ if (committed)
148
+ writer.committedAt = writer.clock.now();
127
149
  for (const [account, fields] of batch) {
128
150
  const pending = writer.pending.get(account);
129
151
  for (const [key, field] of fields) {
@@ -155,6 +177,7 @@ function flush(writer) {
155
177
  schedule(writer, backoff);
156
178
  }
157
179
  })();
180
+ return writer.running;
158
181
  }
159
182
  /** Wait at most timeoutMs for detached publication; shutdown loss is acceptable. */
160
183
  export async function drainVaultQuota(path, timeoutMs = 1_000) {
@@ -163,8 +186,7 @@ export async function drainVaultQuota(path, timeoutMs = 1_000) {
163
186
  if (!writer)
164
187
  return;
165
188
  writer.draining = true;
166
- clearTimeout(writer.timer);
167
- writer.timer = undefined;
189
+ cancelTimer(writer);
168
190
  let deadline;
169
191
  let wake;
170
192
  const drained = new Promise((done) => {
@@ -194,7 +216,7 @@ export async function drainVaultQuota(path, timeoutMs = 1_000) {
194
216
  // must not discard its observations or stop its retries.
195
217
  if (!writer.listeners.size) {
196
218
  writer.abort.abort();
197
- clearTimeout(writer.timer);
219
+ cancelTimer(writer);
198
220
  writer.pending.clear();
199
221
  if (writers.get(key) === writer)
200
222
  writers.delete(key);
@@ -62,7 +62,25 @@ export interface ProjectionOptions {
62
62
  routePrefix?: string;
63
63
  mapAccount?: AccountMapper;
64
64
  now?: number;
65
+ /**
66
+ * Preferences carried over from another roster file, used only when there
67
+ * is no previous roster: its declines become this roster's declines, and
68
+ * its account order sorts the rows this inventory produces. No row of the
69
+ * seed is ever projected, so a seed cannot make an account routable.
70
+ */
71
+ seed?: VaultRosterSeed;
72
+ }
73
+ /** The preferences one roster file can pass to a first projection elsewhere. */
74
+ export interface VaultRosterSeed {
75
+ readonly declined: readonly DeclinedAccount[];
76
+ /** Accounts in the seed roster's order, matched by account, then by id. */
77
+ readonly order: readonly {
78
+ readonly credentialId: string;
79
+ readonly accountIdentity?: string;
80
+ }[];
65
81
  }
82
+ /** Declines and account order of `roster`, and nothing else of it. */
83
+ export declare function vaultRosterSeed(roster: VaultRosterFile): VaultRosterSeed;
66
84
  export declare const DEFAULT_ROUTE_PREFIX = "vault:";
67
85
  /**
68
86
  * Project the vault's list onto the previous roster. Pure: callers serialize
@@ -87,6 +105,12 @@ export declare function projectVaultRoster(previous: VaultRosterFile | undefined
87
105
  * reaches admission.
88
106
  */
89
107
  export declare function vaultRoutingRows(roster: VaultRosterFile | undefined): RoutingRow[];
108
+ /**
109
+ * `roster` as `readVaultRoster` decodes it from the file that holds it, so a
110
+ * committed roster and a later read of its file compare equal. Package
111
+ * internal: not exported from `/claustrum`.
112
+ */
113
+ export declare function decodedVaultRoster(roster: VaultRosterFile): VaultRosterFile;
90
114
  export declare function readVaultRoster(path: string): Promise<VaultRosterFile | undefined>;
91
115
  /** The ownership assertion available to a roster mutation callback. */
92
116
  export interface VaultRosterLockContext {
@@ -190,6 +214,16 @@ export declare function applyVaultQuota(current: VaultRosterFile | undefined, in
190
214
  kept: boolean;
191
215
  };
192
216
  export declare function forgetPendingVaultDiscovery(path: string): void;
217
+ /**
218
+ * Whether `error` from a roster commit at `path` means another writer had the
219
+ * roster at that moment, not that the commit itself failed: the discovery
220
+ * lease was busy before any roster existed, the write lock stayed busy, or a
221
+ * lock this commit held was lost. On Windows the same lock steps can reject
222
+ * with a PrivateFileError instead (a lock file that could not be read or
223
+ * replaced while another process held it), so a PrivateFileError naming one
224
+ * of this roster's lock files counts the same on every OS.
225
+ */
226
+ export declare function isVaultRosterLockRefusal(path: string, error: unknown): boolean;
193
227
  /**
194
228
  * Discover and commit the roster. The lease covers the list call and the
195
229
  * commit, not just the write: an opaque view cannot tell a delayed old reply
@@ -197,7 +231,8 @@ export declare function forgetPendingVaultDiscovery(path: string): void;
197
231
  * failed list, or custody switching off mid-run, never writes anything; a
198
232
  * peer holding the lease means its last committed roster is served instead.
199
233
  * A write-lock timeout keeps the discovered inventory for the next poll,
200
- * unless a peer has superseded its base view in the meantime.
234
+ * unless a peer has superseded its base view in the meantime. A list that
235
+ * would write the file the roster already holds takes no write lock at all.
201
236
  */
202
237
  export declare function refreshVaultRoster(options: {
203
238
  path: string;
@@ -206,6 +241,19 @@ export declare function refreshVaultRoster(options: {
206
241
  signal?: AbortSignal;
207
242
  projection?: ProjectionOptions | (() => ProjectionOptions);
208
243
  onInventory?: (inventory: VaultInventory) => void;
244
+ /**
245
+ * For a caller that listed before taking this path's discovery lease (the
246
+ * discovery hub lists once for several rosters): the view this roster file
247
+ * held before that list. `custody.discover` then returns that list. It is
248
+ * held to the rule a kept discovery is held to below: it is projected only
249
+ * while the file still holds this view or the list's own view. Otherwise
250
+ * another process committed since the list, and the file is returned
251
+ * unchanged. No discovery is kept for a later call, because such a caller
252
+ * lists again.
253
+ */
254
+ listedOver?: {
255
+ readonly view: string | undefined;
256
+ };
209
257
  }): Promise<VaultRosterFile | undefined>;
210
258
  /**
211
259
  * A host-owned main account as last verified from the vault: the plugin's
@@ -1,12 +1,36 @@
1
1
  import { createHash } from 'node:crypto';
2
+ import { stat } from 'node:fs/promises';
2
3
  import { resolve } from 'node:path';
3
- import { acquireRefreshFileLock, LockContentionError, withLock, writeJsonAtomic, } from '../fs/index.js';
4
+ import { acquireRefreshFileLock, LockContentionError, LockOwnershipError, lockPathFor, PrivateFileError, withLock, writeJsonAtomic, } from '../fs/index.js';
4
5
  import { readPrivateText, windowsPrivateFiles } from '../fs/private-files.js';
5
6
  import { decodeQuotaMap, isQuotaObservation, mergeQuotaObservation, } from '../quota/index.js';
6
7
  import { ClaustrumConsumerError } from './errors.js';
7
8
  import { acceptAccount, declineAccount, isDeclined, } from './interlock.js';
8
- import { enqueueVaultQuota, pendingVaultQuota, } from './quota-writer.js';
9
+ import { enqueueVaultQuota, hasPendingVaultQuota, pendingVaultQuota, } from './quota-writer.js';
9
10
  export { drainVaultQuota } from './quota-writer.js';
11
+ /** Declines and account order of `roster`, and nothing else of it. */
12
+ export function vaultRosterSeed(roster) {
13
+ return {
14
+ declined: roster.declined.map((entry) => ({ ...entry })),
15
+ order: roster.rows.flatMap((row) => memberIds(row).map((credentialId) => ({
16
+ credentialId,
17
+ ...(row.accountIdentity !== undefined && {
18
+ accountIdentity: row.accountIdentity,
19
+ }),
20
+ }))),
21
+ };
22
+ }
23
+ /** Where a projected row falls in the seed's order; unknown rows go last. */
24
+ function seedIndex(seed, row) {
25
+ const ids = memberIds(row);
26
+ const byAccount = row.accountIdentity === undefined
27
+ ? -1
28
+ : seed.order.findIndex((entry) => entry.accountIdentity === row.accountIdentity);
29
+ const index = byAccount >= 0
30
+ ? byAccount
31
+ : seed.order.findIndex((entry) => ids.includes(entry.credentialId));
32
+ return index >= 0 ? index : Infinity;
33
+ }
10
34
  export const DEFAULT_ROUTE_PREFIX = 'vault:';
11
35
  function hash(value) {
12
36
  return createHash('sha256').update(value).digest('hex');
@@ -47,8 +71,9 @@ export function projectVaultRoster(previous, inventory, options = {}) {
47
71
  const prefix = options.routePrefix ?? DEFAULT_ROUTE_PREFIX;
48
72
  const reserved = options.reservedRouteIds ?? new Set();
49
73
  const previousRows = previous?.rows ?? [];
74
+ const seed = previous ? undefined : options.seed;
50
75
  // Declines follow the account and outlive its records, so none is pruned.
51
- const declined = [...(previous?.declined ?? [])];
76
+ const declined = [...(previous?.declined ?? seed?.declined ?? [])];
52
77
  const complete = inventory.skipped.length === 0;
53
78
  const listed = new Set(inventory.credentials.map((credential) => credential.credentialId));
54
79
  const rejectedIds = new Set(inventory.skipped.flatMap((record) => record.credentialId === undefined ? [] : [record.credentialId]));
@@ -146,6 +171,11 @@ export function projectVaultRoster(previous, inventory, options = {}) {
146
171
  ...(!chosen.claimed && identity !== undefined && { unclaimed: true }),
147
172
  },
148
173
  });
174
+ // With no previous roster every row is new; a seed from another key
175
+ // sorts them by that key's order instead of by credential id.
176
+ const pushed = projected.at(-1);
177
+ if (seed && pushed)
178
+ pushed.previousIndex = seedIndex(seed, pushed.row);
149
179
  }
150
180
  // An account the reply does not account for keeps its last good projection:
151
181
  // a rejected record must not read as the account having been removed. A
@@ -293,6 +323,14 @@ function decodeRoster(value) {
293
323
  declined,
294
324
  };
295
325
  }
326
+ /**
327
+ * `roster` as `readVaultRoster` decodes it from the file that holds it, so a
328
+ * committed roster and a later read of its file compare equal. Package
329
+ * internal: not exported from `/claustrum`.
330
+ */
331
+ export function decodedVaultRoster(roster) {
332
+ return decodeRoster(JSON.parse(JSON.stringify(roster)));
333
+ }
296
334
  export async function readVaultRoster(path) {
297
335
  let text;
298
336
  try {
@@ -560,6 +598,102 @@ const pendingDiscoveries = new Map();
560
598
  export function forgetPendingVaultDiscovery(path) {
561
599
  pendingDiscoveries.delete(resolve(path));
562
600
  }
601
+ /**
602
+ * Whether `error` from a roster commit at `path` means another writer had the
603
+ * roster at that moment, not that the commit itself failed: the discovery
604
+ * lease was busy before any roster existed, the write lock stayed busy, or a
605
+ * lock this commit held was lost. On Windows the same lock steps can reject
606
+ * with a PrivateFileError instead (a lock file that could not be read or
607
+ * replaced while another process held it), so a PrivateFileError naming one
608
+ * of this roster's lock files counts the same on every OS.
609
+ */
610
+ export function isVaultRosterLockRefusal(path, error) {
611
+ const target = resolve(path);
612
+ if (error instanceof ClaustrumConsumerError)
613
+ return error.kind === 'roster-busy';
614
+ if (error instanceof LockContentionError ||
615
+ error instanceof LockOwnershipError)
616
+ return resolve(error.details.target) === target;
617
+ if (error instanceof PrivateFileError) {
618
+ const failed = resolve(error.path);
619
+ return [WRITE_LOCK.name, DISCOVERY_LEASE.name].some((name) => {
620
+ const lock = lockPathFor(target, name);
621
+ // The lock file itself, its eviction marker and the temporary files
622
+ // its owner record is staged in all start with the lock path.
623
+ return failed === lock || failed.startsWith(`${lock}.`);
624
+ });
625
+ }
626
+ return false;
627
+ }
628
+ const UNLOCKED_READ_ATTEMPTS = 4;
629
+ /**
630
+ * What identifies one version of the file at `path`: the roster is always
631
+ * replaced by a rename, so any write gives it a different inode, and an
632
+ * in-place rewrite a different size or modification time. Undefined when it
633
+ * cannot be inspected, which callers must read as 'may have changed'.
634
+ */
635
+ async function fileStamp(path) {
636
+ try {
637
+ const { dev, ino, size, mtimeMs } = await stat(path);
638
+ return { dev, ino, size, mtimeMs };
639
+ }
640
+ catch {
641
+ return undefined;
642
+ }
643
+ }
644
+ /** Whether two `fileStamp` readings are of the same file version. */
645
+ function sameFileStamp(left, right) {
646
+ return (left !== undefined &&
647
+ right !== undefined &&
648
+ left.dev === right.dev &&
649
+ left.ino === right.ino &&
650
+ left.size === right.size &&
651
+ left.mtimeMs === right.mtimeMs);
652
+ }
653
+ /**
654
+ * Whether a roster list that lands unlocked would write exactly the file at
655
+ * `path`. `overlay` names what this process holds besides the file: `target` is
656
+ * the file's own roster, which the locked commit compares against, and `local`,
657
+ * when different, is that roster plus unwritten quota readings, which must
658
+ * equal the commit's result too. Both must equal `next(target)`'s bytes, which
659
+ * is the write the commit would make; any difference, a missing or unreadable
660
+ * file, or a read that a concurrent reader keeps changing all mean 'commit'.
661
+ */
662
+ async function readUnlockedCommitTarget(path, overlay, next) {
663
+ for (let attempt = 0; attempt < UNLOCKED_READ_ATTEMPTS; attempt++) {
664
+ // Roster files are replaced by rename, so each attempt reads one whole
665
+ // file either way.
666
+ const before = await fileStamp(path);
667
+ const current = await readVaultRoster(path);
668
+ let target;
669
+ let local;
670
+ let changed;
671
+ try {
672
+ ;
673
+ ({ target, local } = overlay(current));
674
+ const written = JSON.stringify(next(target));
675
+ changed =
676
+ JSON.stringify(target) !== written ||
677
+ (local !== undefined && JSON.stringify(local) !== written);
678
+ }
679
+ catch {
680
+ return 'commit';
681
+ }
682
+ // No lock was taken, so another writer may have replaced the file while
683
+ // it was being projected. The answer must describe the file as it is now,
684
+ // otherwise a later list of the same roster would find it changed again.
685
+ if (!sameFileStamp(before, await fileStamp(path)))
686
+ continue;
687
+ if (changed)
688
+ return 'commit';
689
+ // Quota readings this process enqueued while the file was being projected
690
+ // are not part of `current`, and a list that returns unchanged while they
691
+ // wait would be the only chance to serve them as this list's roster. They
692
+ // keep their own commit either way, so this path takes the locked one.
693
+ return hasPendingVaultQuota(path) ? 'commit' : 'unchanged';
694
+ }
695
+ return 'commit';
696
+ }
563
697
  /**
564
698
  * Discover and commit the roster. The lease covers the list call and the
565
699
  * commit, not just the write: an opaque view cannot tell a delayed old reply
@@ -567,7 +701,8 @@ export function forgetPendingVaultDiscovery(path) {
567
701
  * failed list, or custody switching off mid-run, never writes anything; a
568
702
  * peer holding the lease means its last committed roster is served instead.
569
703
  * A write-lock timeout keeps the discovered inventory for the next poll,
570
- * unless a peer has superseded its base view in the meantime.
704
+ * unless a peer has superseded its base view in the meantime. A list that
705
+ * would write the file the roster already holds takes no write lock at all.
571
706
  */
572
707
  export async function refreshVaultRoster(options) {
573
708
  options.signal?.throwIfAborted();
@@ -589,10 +724,11 @@ export async function refreshVaultRoster(options) {
589
724
  return persisted;
590
725
  }
591
726
  const key = resolve(options.path);
727
+ const listedOver = options.listedOver;
592
728
  let discovered;
593
729
  try {
594
730
  const previous = await readVaultRoster(options.path);
595
- const pending = pendingDiscoveries.get(key);
731
+ const pending = listedOver ? undefined : pendingDiscoveries.get(key);
596
732
  // Opaque view cursors cannot be ordered. Reuse a discovery only while no
597
733
  // peer has committed a different view since it was read.
598
734
  discovered =
@@ -606,20 +742,58 @@ export async function refreshVaultRoster(options) {
606
742
  };
607
743
  pendingDiscoveries.delete(key);
608
744
  const { inventory } = discovered;
609
- options.onInventory?.(inventory);
745
+ // Called, not awaited: a caller noting the list here may wait for this
746
+ // roster's write lock, and this refresh decides below whether it needs it.
747
+ void options.onInventory?.(inventory);
610
748
  options.signal?.throwIfAborted();
749
+ const projection = typeof options.projection === 'function'
750
+ ? options.projection()
751
+ : options.projection;
752
+ const next = (current) => projectVaultRoster(current, inventory, projection);
753
+ const isActive = async () => {
754
+ options.signal?.throwIfAborted();
755
+ await lease.assertOwned();
756
+ return active();
757
+ };
758
+ // The write lock costs a lock file, an eviction marker and staging files in
759
+ // the roster's directory on every poll, so it is skipped when the list
760
+ // would write the file that is already there. The read deciding that takes
761
+ // no lock: a writer that commits between the read and this decision only
762
+ // makes our comparison stale, and a stale comparison never writes (the
763
+ // commit below judges the file again under the lock). Only equality with
764
+ // every byte the commit would write skips; every other answer commits.
765
+ const decision = await readUnlockedCommitTarget(options.path, (current) => {
766
+ // A hub member listed over a base view of its file: the file's own view
767
+ // is what the commit compares, so the overlay is the plain file. The
768
+ // locked commit below still applies the base-view rule.
769
+ if (listedOver)
770
+ return { target: current };
771
+ // This process's unwritten quota readings are part of the roster it
772
+ // serves, so a list that matches everything but them still commits.
773
+ const local = pendingVaultQuota(options.path, current);
774
+ return { target: current, ...(local !== current && { local }) };
775
+ }, next);
776
+ if (decision === 'unchanged') {
777
+ if (!(await isActive()))
778
+ return undefined;
779
+ // Returned as a later read of the file: that is what today's locked
780
+ // unchanged commit returns, and memory keeps only what was committed.
781
+ return await readVaultRoster(options.path);
782
+ }
611
783
  return await mutateVaultRoster(options.path, async (current) => {
612
784
  if (!(await active()))
613
785
  return { result: undefined };
614
786
  options.signal?.throwIfAborted();
615
787
  await lease.assertOwned();
616
- const projection = typeof options.projection === 'function'
617
- ? options.projection()
618
- : options.projection;
619
- const next = projectVaultRoster(current, inventory, projection);
620
- const changed = JSON.stringify(current) !== JSON.stringify(next);
621
- return { ...(changed && { next }), result: next };
788
+ if (listedOver &&
789
+ current?.view !== listedOver.view &&
790
+ current?.view !== inventory.view)
791
+ return { result: current };
792
+ const projected = next(current);
793
+ const changed = JSON.stringify(current) !== JSON.stringify(projected);
794
+ return { ...(changed && { next: projected }), result: projected };
622
795
  }, {
796
+ signal: options.signal,
623
797
  beforePublish: async () => {
624
798
  options.signal?.throwIfAborted();
625
799
  await lease.assertOwned();
@@ -629,6 +803,7 @@ export async function refreshVaultRoster(options) {
629
803
  catch (error) {
630
804
  if (error instanceof LockContentionError &&
631
805
  discovered &&
806
+ !listedOver &&
632
807
  !options.signal?.aborted &&
633
808
  (await active()))
634
809
  pendingDiscoveries.set(key, discovered);
@@ -0,0 +1,42 @@
1
+ /** Retired generations report separately until their owner closes and shares one warning. */
2
+ export function shutdownWarning(logger, shutdown) {
3
+ let warned = false;
4
+ return (pendingOperations) => {
5
+ if (warned)
6
+ return;
7
+ if (!shutdown || shutdown.aborted)
8
+ warned = true;
9
+ logger.warn('claustrum shutdown drain timed out', { pendingOperations });
10
+ };
11
+ }
12
+ /**
13
+ * Give cancelled background work two seconds to release its handles without letting
14
+ * an unresponsive connector or disk hold the host's shutdown indefinitely.
15
+ */
16
+ export async function drainShutdown(pending, onTimeout) {
17
+ if (!pending.size)
18
+ return;
19
+ let timer;
20
+ let expired = false;
21
+ const drained = (async () => {
22
+ while (!expired && pending.size)
23
+ await Promise.allSettled([...pending]);
24
+ })();
25
+ try {
26
+ await Promise.race([
27
+ drained,
28
+ new Promise((resolve) => {
29
+ timer = setTimeout(() => {
30
+ expired = true;
31
+ resolve();
32
+ }, 2_000);
33
+ }),
34
+ ]);
35
+ if (expired && pending.size)
36
+ onTimeout(pending.size);
37
+ }
38
+ finally {
39
+ expired = true;
40
+ clearTimeout(timer);
41
+ }
42
+ }