@cortexkit/claustrum-client 0.13.0 → 0.15.0

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/README.md CHANGED
@@ -13,8 +13,8 @@ credential caching, refresh, account scheduling, and retry policy.
13
13
  expiry-skew refresh can take seconds. Request paths should peek cached state,
14
14
  not call `get` speculatively.
15
15
  • `permanent` with `not_found` means the handle is unknown OR revoked — uniform refusal
16
- by design (the vault cannot enumerate which). Treat it as gone either way: re-run
17
- `ck auth migrate-opencode` to mint a fresh handle; the prior record, if any, is kept.
16
+ by design (the vault cannot enumerate which). Treat it as gone either way; the
17
+ prior record, if any, is kept.
18
18
  • `auth_required` means the record is latched and needs reauthentication.
19
19
  • `statusCredential` on an unknown or revoked handle RESOLVES, it does not throw: it
20
20
  returns `ready: false, lastErrorCode: 'not_found'` with `recordVersion`, `credentialId`
@@ -23,6 +23,9 @@ credential caching, refresh, account scheduling, and retry policy.
23
23
  as a version older than every real one.
24
24
  • Unknown server error classes are bounded to `transient`; callers must retry them
25
25
  rather than treating a forward-compatible class as permanent.
26
+ • Account pools use the refresh adapter from `list_scoped` plus opaque `accountId`.
27
+ Use `recordVersion` to invalidate cached material, never as part of a pool key:
28
+ a token refresh must not split one account into a new pool.
26
29
  • **Changed in 0.13.0:** a transport `Error` frame whose answer no retry or reconnect
27
30
  can change is reported as `permanent` with action `gone`, and the client no longer
28
31
  reconnects for it: `unknown_method`, `invalid_params`, `invalid_request`,
@@ -52,9 +55,20 @@ credential caching, refresh, account scheduling, and retry policy.
52
55
  before categories existed). Both are `undefined` against a vault too old to send
53
56
  them; treat that as unknown, not as zero. A present but malformed value refuses the
54
57
  reply like any other malformed field.
58
+ • Since 0.14.0, `reportUsageScoped({ credentialId, reading, enrollmentToken? })`
59
+ sends fire-and-forget quota readings. `accepted` means queued, never that the
60
+ usage-processing module insula applied the reading. Coalesce per credential and
61
+ never await it on a request path.
62
+ The client leaves reading validation to the vault, which refuses top-level
63
+ `credentialId` and `vouched` keys and compact JSON readings over 15,360 bytes with
64
+ `invalid_params`.
55
65
 
56
66
  The client sends `consumerIdentity: null` for every managed request so inherited
57
- `SUBC_MODULE_ID` and `SUBC_LAUNCH_NONCE` cannot impersonate a supervising host.
67
+ launch identity cannot impersonate a supervising host. Nonce possession, not process
68
+ ancestry, is the authority boundary: environment variables and an inheritable nonce
69
+ descriptor can reach helpers unless scrubbed or closed. Helpers using other clients
70
+ must explicitly omit route identity (for subc-client, `consumerIdentity: null`) when
71
+ they should be anonymous; process ancestry alone does not enforce isolation.
58
72
 
59
73
  **Changed in 0.11.0:** the client depends on `@cortexkit/subc-client` ^0.22.0 (was
60
74
  ^0.16.0). A host that also uses subc-client must move to the 0.22 line, so both share one
@@ -62,6 +76,21 @@ copy. One behaviour reaches callers: while the vault is restarting or refusing r
62
76
  opens as retryable, a call now keeps retrying for up to 90 s (paced, with jitter) instead
63
77
  of 30 s. Give every vault call on a latency-sensitive path its own timeout.
64
78
 
79
+ ## Reading credential material
80
+
81
+ The wire payload is opaque bytes. `getCredential(handle, minTtlMs?)` and
82
+ `getScoped({ credentialId, enrollmentToken?, minTtlMs? })` keep their text results
83
+ (`ServedCredential.material: string`) and require valid UTF-8. They refuse a
84
+ non-UTF-8 payload with `invalid_response`; this does not mean the stored credential
85
+ is corrupt.
86
+
87
+ Use `getCredentialBytes(handle, minTtlMs?)` or
88
+ `getScopedBytes({ credentialId, enrollmentToken?, minTtlMs? })` for binary cookies
89
+ or any material that may not be UTF-8. Both return `ServedCredentialBytes`, with
90
+ `material: Uint8Array` preserving every payload byte and the same metadata, grant
91
+ checks, and TTL semantics as their text counterparts. Neither byte method performs
92
+ text decoding.
93
+
65
94
  ## Watching for changes
66
95
 
67
96
  Since 0.12.0, `client.watchScoped(onNotice, { enrollmentToken? })` holds an
@@ -132,20 +161,10 @@ for (;;) {
132
161
  }
133
162
  ```
134
163
 
135
- ## OpenCode handle-file exports
136
-
137
- `defaultHandleFilePath`, `readHandleFile`, `handleFileRevision`, and `parseHandleFile`
138
- are exported for tenant plugins consuming the OpenCode handle manifest. The shared
139
- `HANDLE_FILE_CONTRACT` is pinned to `maxBytes: 262144`, `mode: 0o600`,
140
- `labelRe: /^[a-z0-9][a-z0-9._-]{0,63}$/`, and `handleRe: /^ckh_[A-Za-z0-9_-]{43}$/`.
141
-
142
- ### Private files and Windows support
164
+ ## Private files and Windows support
143
165
 
144
- Enrollment tokens support Windows as well as Unix. Reading a handle manifest works on
145
- Windows; writing one does not: the manifest lock's stale-lock eviction renames a lock
146
- another process may hold open, which Windows refuses, so `withManifestLock` and
147
- `writeHandleFileLocked` reject on Windows with code `unsupported_platform` rather than
148
- risk two writers overlapping. The client uses `@cortexkit/store` 0.2.1 or newer: exclusive private creation
166
+ Enrollment tokens support Windows as well as Unix. The client uses
167
+ `@cortexkit/store` 0.2.1 or newer: exclusive private creation
149
168
  protects files before any secret bytes are written, and atomic replacement keeps
150
169
  that protection. Unix files use mode 0600 and new directories use 0700. On Windows,
151
170
  the store's `@cortexkit/private-files` native module creates owner-only ACLs and the
@@ -167,7 +186,7 @@ preserved: an owned, non-sticky group-writable directory (including a 0775
167
186
 
168
187
  ## Testing a consumer of this client
169
188
 
170
- Three things on a developer box silently satisfy what a consumer's test is trying to
189
+ Two things on a developer box silently satisfy what a consumer's test is trying to
171
190
  prove. Each was found by a consumer shipping green and failing elsewhere.
172
191
 
173
192
  • **A connection file at the default path is a host fact, not a fixture.** If the vault
@@ -175,9 +194,6 @@ prove. Each was found by a consumer shipping green and failing elsewhere.
175
194
  up, so a suite can pass on the daemon's real socket and fail anywhere without one.
176
195
  Force it absent (`CLAUSTRUM_SUBC_CONNECTION=/nonexistent/x.json`, and clear
177
196
  `XDG_RUNTIME_DIR`) and prove the suite still passes.
178
- • **Tests default into the operator's live config.** A suite that resolves
179
- `~/.config/opencode` without an override writes lock files and manifests beside real
180
- credentials — passing locally, and mutating state no CI runner has.
181
197
  • **A bare `bun` run is not an oracle for module resolution.** The Bun CLI auto-installs
182
198
  a public dependency it cannot resolve; a compiled binary does not. All readings below
183
199
  are bun 1.3.14, same module, no `node_modules` — a toolchain behaviour with no version
@@ -208,5 +224,5 @@ prove. Each was found by a consumer shipping green and failing elsewhere.
208
224
  result: a freshly linked unsigned binary is SIGKILLed, and `rc=137` with empty stderr
209
225
  reads as a resolution failure while being a signature one.
210
226
 
211
- The shape is the same in all three: the producing machine supplies the thing under test.
227
+ The shape is the same in both: the producing machine supplies the thing under test.
212
228
  A passing check on it is evidence only when the ambient supply is removed first.
@@ -1,12 +1,7 @@
1
+ import { checkPrivatePath } from './private-path.js';
1
2
  export type EnrollmentTokenFile = {
2
3
  token: string;
3
4
  token_generation: number;
4
5
  };
5
- /**
6
- * Atomically replace a consumer's enrollment credential.
7
- *
8
- * Creation uses owner-only Unix permissions or native Windows ACLs before writing
9
- * any bytes. @cortexkit/store checks that ancestors cannot be replaced by other
10
- * users, and on Windows also verifies the completed parent's owner-only ACL.
11
- */
12
- export declare function writeEnrollmentTokenFile(path: string, value: EnrollmentTokenFile): Promise<void>;
6
+ export declare function createEnrollmentWriter(checkPath?: typeof checkPrivatePath): (path: string, value: EnrollmentTokenFile) => Promise<void>;
7
+ export declare const writeEnrollmentTokenFile: (path: string, value: EnrollmentTokenFile) => Promise<void>;
@@ -11,53 +11,57 @@ function validateTokenFile(value) {
11
11
  throw new Error('enrollment token generation must be a positive safe integer');
12
12
  }
13
13
  }
14
- async function refuseWritableAncestor(parent) {
15
- const result = await checkPrivatePath(parent);
16
- if (!result.ok)
17
- throw new Error(notPrivate('enrollment token folder', result));
18
- }
19
- /**
20
- * Atomically replace a consumer's enrollment credential.
21
- *
22
- * Creation uses owner-only Unix permissions or native Windows ACLs before writing
23
- * any bytes. @cortexkit/store checks that ancestors cannot be replaced by other
24
- * users, and on Windows also verifies the completed parent's owner-only ACL.
25
- */
26
- export async function writeEnrollmentTokenFile(path, value) {
27
- validateTokenFile(value);
28
- path = resolve(path);
29
- const parent = dirname(path);
30
- // Native Windows creation rejects ancestors that other users can replace. A missing
31
- // private parent can be created under a safe but non-private folder; afterwards the
32
- // async check also requires the completed parent's protected owner-only ACL.
33
- if (process.platform !== 'win32')
14
+ export function createEnrollmentWriter(checkPath = checkPrivatePath) {
15
+ async function refuseWritableAncestor(parent) {
16
+ const result = await checkPath(parent);
17
+ if (!result.ok)
18
+ throw new Error(notPrivate('enrollment token folder', result));
19
+ }
20
+ /**
21
+ * Atomically replace a consumer's enrollment credential.
22
+ *
23
+ * Creation uses owner-only Unix permissions or native Windows ACLs before writing
24
+ * any bytes. @cortexkit/store checks that ancestors cannot be replaced by other
25
+ * users, and on Windows also verifies the completed parent's owner-only ACL.
26
+ */
27
+ async function writeEnrollmentTokenFile(path, value) {
28
+ validateTokenFile(value);
29
+ path = resolve(path);
30
+ const parent = dirname(path);
31
+ // Native Windows creation rejects ancestors that other users can replace. A missing
32
+ // private parent can be created under a safe but non-private folder; afterwards the
33
+ // async check also requires the completed parent's protected owner-only ACL.
34
+ if (process.platform !== 'win32')
35
+ await refuseWritableAncestor(parent);
36
+ await createPrivateParents(parent);
34
37
  await refuseWritableAncestor(parent);
35
- await createPrivateParents(parent);
36
- await refuseWritableAncestor(parent);
37
- const temporary = join(parent, `.${basename(path)}.${process.pid}.${randomBytes(12).toString('hex')}.tmp`);
38
- let created = false;
39
- try {
40
- created = true;
41
- try {
42
- await createPrivateFile(temporary, `${JSON.stringify(value)}\n`);
43
- }
44
- catch (error) {
45
- if (privateCreationCode(error) === 'EEXIST')
46
- created = false;
47
- throw error;
48
- }
49
- const descriptor = await open(temporary, 'r+');
38
+ const temporary = join(parent, `.${basename(path)}.${process.pid}.${randomBytes(12).toString('hex')}.tmp`);
39
+ let created = false;
50
40
  try {
51
- await descriptor.sync();
41
+ created = true;
42
+ try {
43
+ await createPrivateFile(temporary, `${JSON.stringify(value)}\n`);
44
+ }
45
+ catch (error) {
46
+ if (privateCreationCode(error) === 'EEXIST')
47
+ created = false;
48
+ throw error;
49
+ }
50
+ const descriptor = await open(temporary, 'r+');
51
+ try {
52
+ await descriptor.sync();
53
+ }
54
+ finally {
55
+ await descriptor.close();
56
+ }
57
+ await rename(temporary, path);
58
+ created = false;
52
59
  }
53
60
  finally {
54
- await descriptor.close();
61
+ if (created)
62
+ await unlink(temporary).catch(() => undefined);
55
63
  }
56
- await rename(temporary, path);
57
- created = false;
58
- }
59
- finally {
60
- if (created)
61
- await unlink(temporary).catch(() => undefined);
62
64
  }
65
+ return writeEnrollmentTokenFile;
63
66
  }
67
+ export const writeEnrollmentTokenFile = createEnrollmentWriter();
package/dist/index.d.ts CHANGED
@@ -1,8 +1,6 @@
1
1
  export { detectClaustrumConnection, getDefaultClaustrumConnectionPath, resolveClaustrumConnectionPath, type ClaustrumDetection, type ClaustrumEndpoint, } from './detect.js';
2
2
  export { storeIdentity, storageFingerprint } from './identity.js';
3
3
  export { writeEnrollmentTokenFile, type EnrollmentTokenFile, } from './enrollment.js';
4
- export { MANIFEST_LOCK, manifestLockQuarantinePrefix, withManifestLock, writeHandleFileLocked, type ManifestHandleAccount, type ManifestHandleFile, type ManifestHandleProvider, type ManifestLockError, type ManifestLockErrorCode, } from './manifest-lock.js';
5
4
  export { ClaustrumCredentialError, credentialErrorAction, ERROR_CLASS_WIRE_SET, type ClaustrumCredentialErrorAction, type ClaustrumCredentialErrorClass, } from './errors.js';
6
- export { ClaustrumClient, type ClaustrumClientOptions, type ClaustrumConnector, type ClaustrumReporterSource, type CredentialStatus, type EnrollmentPollOutcome, type ScopedGrantTuple, type ScopedInventory, type ScopedInventoryRow, type ServedCredential, } from './wire.js';
5
+ export { ClaustrumClient, type ClaustrumClientOptions, type ClaustrumConnector, type ClaustrumReporterSource, type CredentialStatus, type EnrollmentPollOutcome, type ScopedGrantTuple, type ScopedInventory, type ScopedInventoryRow, type ServedCredential, type ServedCredentialBytes, } from './wire.js';
7
6
  export { ScopedWatchError, type ScopedWatch, type ScopedWatchNotice, type ScopedWatchOptions, type ScopedWatchOutcome, } from './watch.js';
8
- export { HANDLE_FILE_CONTRACT, HandleFileValidationError, credentialIdMatchesProvider, defaultHandleFilePath, handleFileRevision, identifierIsValid, parseHandleFile, readHandleFile, type HandleAccount, type HandleFileIo, type HandleProvider, type OpenCodeHandleFileV1, } from './handles.js';
package/dist/index.js CHANGED
@@ -1,8 +1,6 @@
1
1
  export { detectClaustrumConnection, getDefaultClaustrumConnectionPath, resolveClaustrumConnectionPath, } from './detect.js';
2
2
  export { storeIdentity, storageFingerprint } from './identity.js';
3
3
  export { writeEnrollmentTokenFile, } from './enrollment.js';
4
- export { MANIFEST_LOCK, manifestLockQuarantinePrefix, withManifestLock, writeHandleFileLocked, } from './manifest-lock.js';
5
4
  export { ClaustrumCredentialError, credentialErrorAction, ERROR_CLASS_WIRE_SET, } from './errors.js';
6
5
  export { ClaustrumClient, } from './wire.js';
7
6
  export { ScopedWatchError, } from './watch.js';
8
- export { HANDLE_FILE_CONTRACT, HandleFileValidationError, credentialIdMatchesProvider, defaultHandleFilePath, handleFileRevision, identifierIsValid, parseHandleFile, readHandleFile, } from './handles.js';
@@ -1,6 +1,4 @@
1
- import { AsyncLocalStorage } from 'node:async_hooks';
2
1
  import { type OwnerOnlyPathAsyncOptions, type OwnerOnlyPathResult } from '@cortexkit/store';
3
- export declare const privatePathContext: AsyncLocalStorage<OwnerOnlyPathAsyncOptions>;
4
2
  export declare const checkPrivatePath: (path: string, options?: OwnerOnlyPathAsyncOptions) => Promise<OwnerOnlyPathResult>;
5
3
  /**
6
4
  * The refusal text for a path the store judged not private. It names the folder and the
@@ -1,12 +1,7 @@
1
- import { AsyncLocalStorage } from 'node:async_hooks';
2
1
  import { stat } from 'node:fs/promises';
3
2
  import { dirname, resolve } from 'node:path';
4
3
  import { checkOwnerOnlyPathAsync, createPrivateDirectory } from '@cortexkit/store';
5
- // Tests run the ownership check against fixture uids and account files through this
6
- // context, isolated per concurrent test. It is internal: the package `exports` map
7
- // exposes only the index, so consumers cannot reach it.
8
- export const privatePathContext = new AsyncLocalStorage();
9
- export const checkPrivatePath = (path, options) => checkOwnerOnlyPathAsync(resolve(path), privatePathContext.getStore() ?? options);
4
+ export const checkPrivatePath = (path, options) => checkOwnerOnlyPathAsync(resolve(path), options);
10
5
  /**
11
6
  * The refusal text for a path the store judged not private. It names the folder and the
12
7
  * store's own reason (foreign owner, world-writable, a sticky folder without an
package/dist/wire.d.ts CHANGED
@@ -26,19 +26,22 @@ export type ClaustrumClientOptions = {
26
26
  * an outage caused that way is visible only to the consumer that caused it.
27
27
  */
28
28
  export type ServedCredential = {
29
+ /** UTF-8 text; use `ServedCredentialBytes` for opaque, potentially non-UTF-8 material. */
29
30
  material: string;
30
31
  recordVersion: number;
31
32
  expiresAtMs: number | null;
32
33
  /**
33
34
  * Operator-chosen record label for verifying a caller-held binding. It is not a routing key:
34
- * account-scoped routing joins `accountId` with `recordVersion`.
35
+ * Account-scoped routing joins `accountId` with `refreshAdapter` from the inventory,
36
+ * never `recordVersion`, which only invalidates cached material.
35
37
  */
36
38
  credentialId?: string;
37
39
  /** Non-secret Code Assist project identity, present only for antigravity credentials. */
38
40
  projectId?: string;
39
41
  /**
40
- * Provider account identity the served token executes under. Account-scoped routing joins this
41
- * value with `recordVersion`; it is neither the operator's credential label nor the bearer handle.
42
+ * Provider account identity the served token executes under. Account pools join this value with
43
+ * the inventory's `refreshAdapter`, not the material's `recordVersion`, so refreshes do not split
44
+ * one account into new pools. It is neither the operator's credential label nor the bearer handle.
42
45
  *
43
46
  * BEST-EFFORT, NEVER GUARANTEED, including for JWT providers. The vault parses the claim live
44
47
  * from the served access token and falls back to stored login-time identity; both legs can be
@@ -52,6 +55,10 @@ export type ServedCredential = {
52
55
  /** Non-secret organization or workspace display metadata captured at login. */
53
56
  orgName?: string;
54
57
  };
58
+ /** The same credential metadata with opaque payload bytes, without text conversion. */
59
+ export type ServedCredentialBytes = Omit<ServedCredential, 'material'> & {
60
+ material: Uint8Array;
61
+ };
55
62
  /**
56
63
  * Non-secret health for one handle, read without minting anything.
57
64
  *
@@ -93,7 +100,10 @@ export declare class ClaustrumClient {
93
100
  #private;
94
101
  private constructor();
95
102
  static connect(options?: ClaustrumClientOptions): Promise<ClaustrumClient>;
103
+ /** Fetch UTF-8 text; use `getCredentialBytes` if the payload may contain arbitrary bytes. */
96
104
  getCredential(handle: string, minTtlMs?: number): Promise<ServedCredential>;
105
+ /** Fetch opaque payload bytes by bearer handle without UTF-8 decoding. */
106
+ getCredentialBytes(handle: string, minTtlMs?: number): Promise<ServedCredentialBytes>;
97
107
  statusCredential(handle: string): Promise<CredentialStatus>;
98
108
  reportAuthFailure(input: {
99
109
  handle: string;
@@ -111,7 +121,8 @@ export declare class ClaustrumClient {
111
121
  /** One watch on a dedicated route. Reopen/reconnect policy belongs to the caller. */
112
122
  watchScoped(onNotice: (notice: ScopedWatchNotice) => void, options?: ScopedWatchOptions): ScopedWatch;
113
123
  /**
114
- * Fetch by credential id, authorized by a grant rather than a bearer handle.
124
+ * Fetch UTF-8 text by credential id, authorized by a grant rather than a bearer handle.
125
+ * Use `getScopedBytes` if the payload may contain arbitrary bytes.
115
126
  *
116
127
  * `minTtlMs` is a DEMAND, not a hint: if a real upstream exchange still cannot satisfy
117
128
  * it, the vault refuses with `ttl_unsatisfiable` (class `context_overflow`) rather than
@@ -123,6 +134,12 @@ export declare class ClaustrumClient {
123
134
  enrollmentToken?: string;
124
135
  minTtlMs?: number;
125
136
  }): Promise<ServedCredential>;
137
+ /** Fetch opaque bytes by credential id, with the same grant and TTL rules as `getScoped`. */
138
+ getScopedBytes(input: {
139
+ credentialId: string;
140
+ enrollmentToken?: string;
141
+ minTtlMs?: number;
142
+ }): Promise<ServedCredentialBytes>;
126
143
  /**
127
144
  * Report a served credential dead, addressed by id rather than by handle.
128
145
  *
@@ -138,6 +155,19 @@ export declare class ClaustrumClient {
138
155
  recordVersion: number;
139
156
  reporterSource: ClaustrumReporterSource;
140
157
  }): Promise<void>;
158
+ /**
159
+ * Fire-and-forget quota reporting: `accepted` means queued, never that the
160
+ * usage-processing module insula applied the reading.
161
+ * Callers should coalesce per credential and never await this on a request path.
162
+ *
163
+ * Reading contents are not validated here. The vault rejects top-level `credentialId`
164
+ * and `vouched` keys and compact JSON readings over 15,360 bytes with `invalid_params`.
165
+ */
166
+ reportUsageScoped(input: {
167
+ credentialId: string;
168
+ reading: Record<string, unknown>;
169
+ enrollmentToken?: string;
170
+ }): Promise<void>;
141
171
  /**
142
172
  * Propose an enrollment. The consumer mints `requestSecret` itself and sends only its
143
173
  * hash; the raw secret NEVER leaves the process, and it is what authorizes the poll.
package/dist/wire.js CHANGED
@@ -52,6 +52,17 @@ function isOptionalString(value) {
52
52
  * widen the published surface.
53
53
  */
54
54
  export function decodeCredential(response, logUnknownClass) {
55
+ const credential = decodeCredentialBytes(response, logUnknownClass);
56
+ let material;
57
+ try {
58
+ material = new TextDecoder('utf-8', { fatal: true }).decode(credential.material);
59
+ }
60
+ catch {
61
+ throw asCredentialError(response, 'invalid_response', logUnknownClass);
62
+ }
63
+ return { ...credential, material };
64
+ }
65
+ function decodeCredentialBytes(response, logUnknownClass) {
55
66
  if (hasCredentialError(response))
56
67
  throw asCredentialError(response, 'invalid_response', logUnknownClass);
57
68
  const result = isRecord(response) && isRecord(response.result) ? response.result : undefined;
@@ -86,15 +97,8 @@ export function decodeCredential(response, logUnknownClass) {
86
97
  !isOptionalString(orgName)) {
87
98
  throw asCredentialError(response, 'invalid_response', logUnknownClass);
88
99
  }
89
- let material;
90
- try {
91
- material = new TextDecoder('utf-8', { fatal: true }).decode(Uint8Array.from(payload));
92
- }
93
- catch {
94
- throw asCredentialError(response, 'invalid_response', logUnknownClass);
95
- }
96
100
  return {
97
- material,
101
+ material: Uint8Array.from(payload),
98
102
  recordVersion,
99
103
  expiresAtMs,
100
104
  credentialId,
@@ -160,6 +164,7 @@ export class ClaustrumClient {
160
164
  });
161
165
  return new ClaustrumClient(client, connector, connectionFile, options.handshakeTimeoutMs, identity, options.logger ?? ((errorClass) => console.warn(errorClass)));
162
166
  }
167
+ /** Fetch UTF-8 text; use `getCredentialBytes` if the payload may contain arbitrary bytes. */
163
168
  async getCredential(handle, minTtlMs) {
164
169
  const response = await this.#call('credential.get', {
165
170
  handle,
@@ -168,6 +173,15 @@ export class ClaustrumClient {
168
173
  });
169
174
  return decodeCredential(response, this.#logger);
170
175
  }
176
+ /** Fetch opaque payload bytes by bearer handle without UTF-8 decoding. */
177
+ async getCredentialBytes(handle, minTtlMs) {
178
+ const response = await this.#call('credential.get', {
179
+ handle,
180
+ min_ttl_ms: minTtlMs,
181
+ force_refresh: false,
182
+ });
183
+ return decodeCredentialBytes(response, this.#logger);
184
+ }
171
185
  async statusCredential(handle) {
172
186
  const response = await this.#call('credential.status', { handle });
173
187
  return decodeStatus(response, this.#logger);
@@ -204,7 +218,8 @@ export class ClaustrumClient {
204
218
  });
205
219
  }
206
220
  /**
207
- * Fetch by credential id, authorized by a grant rather than a bearer handle.
221
+ * Fetch UTF-8 text by credential id, authorized by a grant rather than a bearer handle.
222
+ * Use `getScopedBytes` if the payload may contain arbitrary bytes.
208
223
  *
209
224
  * `minTtlMs` is a DEMAND, not a hint: if a real upstream exchange still cannot satisfy
210
225
  * it, the vault refuses with `ttl_unsatisfiable` (class `context_overflow`) rather than
@@ -219,6 +234,15 @@ export class ClaustrumClient {
219
234
  });
220
235
  return decodeCredential(response, this.#logger);
221
236
  }
237
+ /** Fetch opaque bytes by credential id, with the same grant and TTL rules as `getScoped`. */
238
+ async getScopedBytes(input) {
239
+ const response = await this.#call('credential.get_scoped', {
240
+ credential_id: input.credentialId,
241
+ enrollment_token: input.enrollmentToken,
242
+ min_ttl_ms: input.minTtlMs,
243
+ });
244
+ return decodeCredentialBytes(response, this.#logger);
245
+ }
222
246
  /**
223
247
  * Report a served credential dead, addressed by id rather than by handle.
224
248
  *
@@ -241,6 +265,26 @@ export class ClaustrumClient {
241
265
  if (result?.accepted !== true)
242
266
  throw asCredentialError(response, 'invalid_response', this.#logger);
243
267
  }
268
+ /**
269
+ * Fire-and-forget quota reporting: `accepted` means queued, never that the
270
+ * usage-processing module insula applied the reading.
271
+ * Callers should coalesce per credential and never await this on a request path.
272
+ *
273
+ * Reading contents are not validated here. The vault rejects top-level `credentialId`
274
+ * and `vouched` keys and compact JSON readings over 15,360 bytes with `invalid_params`.
275
+ */
276
+ async reportUsageScoped(input) {
277
+ const response = await this.#call('usage.report_scoped', {
278
+ credential_id: input.credentialId,
279
+ reading: input.reading,
280
+ enrollment_token: input.enrollmentToken,
281
+ });
282
+ if (hasCredentialError(response))
283
+ throw asCredentialError(response, 'invalid_response', this.#logger);
284
+ const result = isRecord(response) && isRecord(response.result) ? response.result : undefined;
285
+ if (result?.accepted !== true)
286
+ throw asCredentialError(response, 'invalid_response', this.#logger);
287
+ }
244
288
  /**
245
289
  * Propose an enrollment. The consumer mints `requestSecret` itself and sends only its
246
290
  * hash; the raw secret NEVER leaves the process, and it is what authorizes the poll.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cortexkit/claustrum-client",
3
- "version": "0.13.0",
3
+ "version": "0.15.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
package/dist/handles.d.ts DELETED
@@ -1,62 +0,0 @@
1
- export declare const HANDLE_FILE_CONTRACT: {
2
- readonly maxBytes: number;
3
- readonly mode: 384;
4
- readonly labelRe: RegExp;
5
- readonly handleRe: RegExp;
6
- };
7
- export declare class HandleFileValidationError extends Error {
8
- name: string;
9
- }
10
- export type HandleAccount = {
11
- label: string;
12
- handle: string;
13
- credential_id: string;
14
- superseded?: string[];
15
- minTtlMs?: number;
16
- };
17
- export type HandleProvider = {
18
- provider: string;
19
- shape: 'api' | 'oauth';
20
- serve: string;
21
- accounts: HandleAccount[];
22
- };
23
- export type OpenCodeHandleFileV1 = {
24
- version: 1;
25
- providers: HandleProvider[];
26
- };
27
- export declare function identifierIsValid(value: unknown): value is string;
28
- export declare function credentialIdMatchesProvider(value: unknown, provider: string): value is string;
29
- export declare function parseHandleFile(value: unknown): OpenCodeHandleFileV1;
30
- type HandleFileStat = {
31
- isFile(): boolean;
32
- isDirectory?(): boolean;
33
- isSymbolicLink?(): boolean;
34
- mode: number;
35
- size?: number;
36
- gid?: number;
37
- uid?: number;
38
- mtimeMs?: number;
39
- };
40
- type HandleFileDescriptor = {
41
- read?(buffer: Uint8Array, offset: number, length: number, position: number): Promise<{
42
- bytesRead: number;
43
- }> | {
44
- bytesRead: number;
45
- };
46
- stat(): Promise<HandleFileStat>;
47
- readFile(options: {
48
- encoding: 'utf8';
49
- }): Promise<string>;
50
- close(): Promise<void>;
51
- };
52
- export type HandleFileIo = {
53
- stat?: (path: string) => Promise<HandleFileStat>;
54
- lstat?: (path: string) => Promise<HandleFileStat>;
55
- readFile?: (path: string, encoding: 'utf8') => Promise<string>;
56
- open?: (path: string) => Promise<HandleFileDescriptor>;
57
- currentUid?: () => number | undefined;
58
- };
59
- export declare function defaultHandleFilePath(env?: NodeJS.ProcessEnv): string;
60
- export declare function readHandleFile(path?: string, io?: HandleFileIo): Promise<OpenCodeHandleFileV1>;
61
- export declare function handleFileRevision(path?: string, io?: HandleFileIo): Promise<string>;
62
- export {};
package/dist/handles.js DELETED
@@ -1,255 +0,0 @@
1
- import { createHash } from 'node:crypto';
2
- import { constants } from 'node:fs';
3
- import { lstat as nodeLstat, open as nodeOpen, readFile, stat as nodeStat } from 'node:fs/promises';
4
- import { userInfo } from 'node:os';
5
- import { dirname, join, resolve } from 'node:path';
6
- import { checkPrivatePath, notPrivate } from './private-path.js';
7
- export const HANDLE_FILE_CONTRACT = {
8
- maxBytes: 256 * 1024,
9
- mode: 0o600,
10
- // Shape only: identifier validity also rejects FORBIDDEN_IDENTIFIERS; use
11
- // identifierIsValid for validation so callers do not accept parser-rejected labels.
12
- labelRe: /^[a-z0-9][a-z0-9._-]{0,63}$/,
13
- handleRe: /^ckh_[A-Za-z0-9_-]{43}$/,
14
- };
15
- const FORBIDDEN_IDENTIFIERS = new Set(['__proto__', 'constructor', 'prototype']);
16
- export class HandleFileValidationError extends Error {
17
- name = 'HandleFileValidationError';
18
- }
19
- function isAccount(value) {
20
- if (!value || typeof value !== 'object')
21
- return false;
22
- const account = value;
23
- return typeof account.label === 'string' && typeof account.handle === 'string' &&
24
- typeof account.credential_id === 'string' &&
25
- (account.superseded === undefined ||
26
- (Array.isArray(account.superseded) && account.superseded.every((handle) => typeof handle === 'string')));
27
- }
28
- function handleIsValid(handle) {
29
- return typeof handle === 'string' && HANDLE_FILE_CONTRACT.handleRe.test(handle);
30
- }
31
- export function identifierIsValid(value) {
32
- return typeof value === 'string' && HANDLE_FILE_CONTRACT.labelRe.test(value) && !FORBIDDEN_IDENTIFIERS.has(value);
33
- }
34
- export function credentialIdMatchesProvider(value, provider) {
35
- if (typeof value !== 'string')
36
- return false;
37
- const segments = value.split(':');
38
- return value.length > 0 && segments[1] === provider && segments.every((segment) => segment.length > 0);
39
- }
40
- function invalid(message) {
41
- throw new HandleFileValidationError(message);
42
- }
43
- export function parseHandleFile(value) {
44
- if (!value || typeof value !== 'object')
45
- invalid('handle file must be an object');
46
- const file = value;
47
- if (file.version !== 1 || !Array.isArray(file.providers)) {
48
- invalid('handle file must have version 1 and providers');
49
- }
50
- const providerIds = new Set();
51
- const providers = file.providers.map((provider, index) => {
52
- if (!provider || typeof provider !== 'object')
53
- invalid(`provider ${index} must be an object`);
54
- const item = provider;
55
- if (!identifierIsValid(item.provider))
56
- invalid(`provider ${index} has invalid provider`);
57
- if (providerIds.has(item.provider))
58
- invalid(`provider ${index} duplicates provider ${item.provider}`);
59
- providerIds.add(item.provider);
60
- if (item.shape !== 'api' && item.shape !== 'oauth')
61
- invalid(`provider ${index} has invalid shape`);
62
- if (typeof item.serve !== 'string' || !item.serve)
63
- invalid(`provider ${index} requires serve`);
64
- if (!Array.isArray(item.accounts) || item.accounts.length === 0 || !item.accounts.every(isAccount)) {
65
- invalid(`provider ${index} has invalid accounts`);
66
- }
67
- const labels = new Set();
68
- for (const account of item.accounts) {
69
- if (!identifierIsValid(account.label))
70
- invalid(`provider ${index} has an invalid account label`);
71
- if (labels.has(account.label))
72
- invalid(`provider ${index} duplicates account label ${account.label}`);
73
- labels.add(account.label);
74
- if (!handleIsValid(account.handle))
75
- invalid(`provider ${index} account ${account.label} has invalid handle`);
76
- if (account.minTtlMs !== undefined && !(Number.isSafeInteger(account.minTtlMs) && account.minTtlMs >= 0)) {
77
- invalid(`provider ${index} account ${account.label} has invalid minTtlMs`);
78
- }
79
- // Segment 2 of the credential id must BE the provider block it sits in. Without
80
- // this the check was non-empty-string only, so an `oauth:openai` binding parsed
81
- // cleanly inside an `anthropic` block -- a cross-provider smuggle that every
82
- // tenant reading this manifest would have honoured. Two peer tenants found the
83
- // same hole in their own parsers independently.
84
- //
85
- // SCOPED TO SEGMENT 2 ONLY, deliberately. Segment 1 (the kind) is an OPEN SET --
86
- // `oauth:`, `chatgpt:`, `antigravity:`, `apikey:` are all live in this vault today
87
- // -- so a kind allowlist would refuse real ids. Segment 3+ (the label) is
88
- // operator-chosen and may be absent: main is the 2-segment `oauth:anthropic`,
89
- // fallbacks are 3-segment. Constraining either would reject the deployment this
90
- // contract describes.
91
- //
92
- // NO SEGMENT MAY BE EMPTY. Segment 2 alone is what fences the provider, but a
93
- // position-1 check ignores the rest of the string, and that left two ids passing
94
- // that name credentials which cannot exist: `:anthropic:x` (empty kind) and
95
- // `oauth:anthropic:` (empty label) both satisfy "segment 2 is the provider"
96
- // literally. Neither is a smuggle; both defer a GUARANTEED resolve-time failure
97
- // past the door, and under custody a resolve-time failure on a tombstoned account
98
- // is a dark route rather than a refused row.
99
- //
100
- // It also removes an asymmetry nobody designed and everyone would read as a bug:
101
- // `oauth::anthropic` rejected while `:anthropic:x` passed, purely because the
102
- // check indexed position 1 and ignored positions 0 and 2. Agreed with the peer
103
- // tenant and mirrored on their side, so this is chosen rather than defaulted --
104
- // the previous behaviour was two independent defaults that happened to differ.
105
- //
106
- // The emptiness guard is ALSO explicit rather than a consequence: `''.split(':')`
107
- // yields `['']`, whose `[1]` is `undefined` and cannot equal a provider string,
108
- // so an empty id would reject anyway -- but that is a coincidence doing
109
- // load-bearing work, and the check this replaced (`!account.credential_id`) was
110
- // the emptiness guard. NO TEST DISTINGUISHES THAT ONE (empty rejects with or
111
- // without it, verified by removal), so it is kept for a future reader who loosens
112
- // the comparison, not for an arm it could never redden. The non-empty-SEGMENT
113
- // rule in credentialIdMatchesProvider is different: it reddens, and is pinned.
114
- if (!credentialIdMatchesProvider(account.credential_id, item.provider)) {
115
- invalid(`provider ${index} account ${account.label} has invalid credential id`);
116
- }
117
- if (account.superseded?.some((handle) => !handleIsValid(handle))) {
118
- invalid(`provider ${index} account ${account.label} has invalid superseded handle`);
119
- }
120
- }
121
- return {
122
- provider: item.provider,
123
- shape: item.shape,
124
- serve: item.serve,
125
- accounts: item.accounts.map((account) => ({
126
- ...account,
127
- ...(account.superseded === undefined ? {} : { superseded: account.superseded }),
128
- })),
129
- };
130
- });
131
- return { version: 1, providers };
132
- }
133
- export function defaultHandleFilePath(env = process.env) {
134
- if (env.CLAUSTRUM_OPENCODE_HANDLES)
135
- return env.CLAUSTRUM_OPENCODE_HANDLES;
136
- const configHome = env.XDG_CONFIG_HOME || (env.HOME ? join(env.HOME, '.config') : '.config');
137
- return join(configHome, 'cortexkit', 'opencode-handles.json');
138
- }
139
- function currentUid() {
140
- return process.platform === 'win32' ? undefined : process.getuid?.() ?? userInfo().uid;
141
- }
142
- async function readBounded(descriptor, cap) {
143
- if (!descriptor.read)
144
- throw new Error('readBounded requires a descriptor exposing read()');
145
- const buffer = Buffer.alloc(cap + 1);
146
- let total = 0;
147
- while (total < cap + 1) {
148
- const chunk = await descriptor.read(buffer, total, buffer.length - total, total);
149
- if (chunk.bytesRead === 0)
150
- break;
151
- total += chunk.bytesRead;
152
- }
153
- return { buffer, bytes: total > cap ? -1 : total };
154
- }
155
- async function readHandleSnapshot(path = defaultHandleFilePath(), io = {}) {
156
- path = resolve(path);
157
- const stat = io.stat ?? nodeStat;
158
- const lstat = io.lstat ?? nodeLstat;
159
- const read = io.readFile ?? readFile;
160
- const openFd = io.open ?? ((candidate) => nodeOpen(candidate, constants.O_RDONLY | constants.O_NOFOLLOW));
161
- let descriptor;
162
- try {
163
- let metadata;
164
- try {
165
- if (io.lstat || io.readFile) {
166
- metadata = await lstat(path);
167
- }
168
- else {
169
- descriptor = await openFd(path);
170
- metadata = await descriptor.stat();
171
- }
172
- }
173
- catch (error) {
174
- if (error.code === 'ENOENT')
175
- return { file: { version: 1, providers: [] } };
176
- if (error.code === 'ELOOP')
177
- invalid('handle file must not be a symlink');
178
- invalid(`cannot stat handle file: ${error instanceof Error ? error.message : String(error)}`);
179
- }
180
- if (metadata.isSymbolicLink?.())
181
- invalid('handle file must not be a symlink');
182
- if (!metadata.isFile())
183
- invalid('handle file must be a regular file');
184
- if ((metadata.size ?? 0) > HANDLE_FILE_CONTRACT.maxBytes)
185
- invalid('handle file exceeds 256 KiB');
186
- if (process.platform !== 'win32' && (metadata.mode & 0o777) !== HANDLE_FILE_CONTRACT.mode)
187
- invalid('handle file mode must be exactly 0600');
188
- const uid = io.currentUid ?? currentUid;
189
- const expectedUid = uid();
190
- if (expectedUid !== undefined && metadata.uid !== undefined && metadata.uid !== expectedUid) {
191
- invalid('handle file is not owned by the current uid');
192
- }
193
- let parent;
194
- try {
195
- parent = await stat(dirname(path));
196
- }
197
- catch (error) {
198
- invalid(`cannot stat handle file parent: ${error instanceof Error ? error.message : String(error)}`);
199
- }
200
- if (!parent.isDirectory?.())
201
- invalid('handle file parent must be a directory');
202
- if (expectedUid !== undefined && parent.uid !== undefined && parent.uid !== expectedUid) {
203
- invalid('handle file parent is not owned by the current uid');
204
- }
205
- // HandleFileIo's injected stat supplies ancestor metadata as well as the immediate
206
- // parent. Pass that metadata to @cortexkit/store's Unix path check.
207
- const fixtureStat = io.stat && (async (candidate) => {
208
- const value = await io.stat(candidate);
209
- return { ...value, isDirectory: () => value.isDirectory?.() ?? false };
210
- });
211
- const protection = await checkPrivatePath(process.platform === 'win32' ? path : dirname(path), fixtureStat ? {
212
- euid: expectedUid,
213
- fs: { stat: fixtureStat, lstat: fixtureStat },
214
- } : undefined);
215
- if (!protection.ok)
216
- invalid(notPrivate('handle file folder', protection));
217
- let source;
218
- try {
219
- if (descriptor) {
220
- const { buffer, bytes } = await readBounded(descriptor, HANDLE_FILE_CONTRACT.maxBytes);
221
- if (bytes === -1)
222
- invalid('handle file exceeds 256 KiB');
223
- source = buffer.subarray(0, bytes).toString('utf8');
224
- }
225
- else {
226
- source = await read(path, 'utf8');
227
- }
228
- }
229
- catch (error) {
230
- if (error instanceof HandleFileValidationError)
231
- throw error;
232
- invalid(`cannot read handle file: ${error instanceof Error ? error.message : String(error)}`);
233
- }
234
- let value;
235
- try {
236
- value = JSON.parse(source);
237
- }
238
- catch {
239
- invalid('handle file contains invalid JSON');
240
- }
241
- return { file: parseHandleFile(value), source, mtimeMs: metadata.mtimeMs };
242
- }
243
- finally {
244
- await descriptor?.close();
245
- }
246
- }
247
- export async function readHandleFile(path = defaultHandleFilePath(), io = {}) {
248
- return (await readHandleSnapshot(path, io)).file;
249
- }
250
- export async function handleFileRevision(path = defaultHandleFilePath(), io = {}) {
251
- const snapshot = await readHandleSnapshot(path, io);
252
- if (snapshot.source === undefined)
253
- invalid('cannot revise absent handle file');
254
- return `${snapshot.mtimeMs ?? 0}:${createHash('sha256').update(snapshot.source).digest('hex')}`;
255
- }
@@ -1,32 +0,0 @@
1
- import { type OpenCodeHandleFileV1 } from './handles.js';
2
- export declare const MANIFEST_LOCK: {
3
- ttlMs: number;
4
- renewEveryMs: number;
5
- ownerKeys: readonly ['tenant', 'pid', 'claimed_at_ms', 'nonce'];
6
- staleTargetRe: RegExp;
7
- errorCodes: readonly ['lock_busy', 'owner_invalid', 'renewal_failed', 'unsupported_platform'];
8
- };
9
- /** Thrown by the lock. Branch on `code`; the message is diagnostic and may be reworded. */
10
- export type ManifestLockErrorCode = (typeof MANIFEST_LOCK.errorCodes)[number];
11
- export type ManifestLockError = Error & {
12
- code: ManifestLockErrorCode;
13
- };
14
- export type ManifestHandleAccount = OpenCodeHandleFileV1['providers'][number]['accounts'][number];
15
- export type ManifestHandleProvider = OpenCodeHandleFileV1['providers'][number];
16
- export type ManifestHandleFile = OpenCodeHandleFileV1;
17
- type TestOptions = {
18
- ttlMs?: number;
19
- renewEveryMs?: number;
20
- retryMinMs?: number;
21
- retryMaxMs?: number;
22
- afterClaim?: () => Promise<void> | void;
23
- beforeEvict?: () => Promise<void>;
24
- afterEvictRenameAttempt?: () => Promise<void>;
25
- afterEvict?: () => void;
26
- beforeManifestRename?: (path: string) => Promise<void>;
27
- };
28
- export declare function __setManifestLockTestOptions(options?: TestOptions): void;
29
- export declare function manifestLockQuarantinePrefix(path: string, basename?: (p: string) => string): string | undefined;
30
- export declare function withManifestLock<T>(path: string, tenant: string, fn: () => Promise<T> | T): Promise<T>;
31
- export declare function writeHandleFileLocked(path: string, tenant: string, mutate: (file: ManifestHandleFile) => void | ManifestHandleFile | Promise<void | ManifestHandleFile>): Promise<void>;
32
- export {};
@@ -1,333 +0,0 @@
1
- import { createPrivateDirectory, createPrivateFile } from '@cortexkit/store';
2
- import { checkPrivatePath, createPrivateParents, notPrivate, privateCreationCode } from './private-path.js';
3
- import { lstat, open, readFile, readdir, rename, rm, stat, unlink } from 'node:fs/promises';
4
- import { randomBytes, randomInt } from 'node:crypto';
5
- import { basename as pathBasename, dirname, join, resolve } from 'node:path';
6
- import { HANDLE_FILE_CONTRACT, parseHandleFile } from './handles.js';
7
- export const MANIFEST_LOCK = { ttlMs: 30_000, renewEveryMs: 10_000, ownerKeys: ['tenant', 'pid', 'claimed_at_ms', 'nonce'], staleTargetRe: /^\.lock\.stale-\d+-(?!\.{1,2}$)(?!.*[. ]$)[^/\\\x00-\x1f:*?"<>|]{1,128}$/, errorCodes: ['lock_busy', 'owner_invalid', 'renewal_failed', 'unsupported_platform'] };
8
- // The claim deadline bounds when the last stale-lock rename is issued, not when it lands:
9
- // a rename issued at deadline-1ms can complete afterwards, by tens to hundreds of ms on a
10
- // loaded host. The retry deadline and staleness window both read `ttl` today; if they split,
11
- // this bound must keep their max so reclamation still covers the longer role.
12
- const MANIFEST_LOCK_QUARANTINE_RECLAIM_MARGIN_MS = 5_000;
13
- let testOptions;
14
- export function __setManifestLockTestOptions(options) { testOptions = options; }
15
- // 16 CSPRNG bytes: a collision needs both the same millisecond and the same nonce. Do not
16
- // simplify this to a counter, pid+timestamp, or a short token.
17
- const token = () => randomBytes(16).toString('base64url');
18
- const code = privateCreationCode;
19
- const sleep = async (ms) => new Promise((resolve) => setTimeout(resolve, ms));
20
- // A tenant classifying a failure must not string-match our prose: a copy-edit would
21
- // silently reclassify a retryable busy-lock as an unknown error, with nothing failing
22
- // loudly. The code is the contract; the message is free to change.
23
- const lockError = (code, message) => Object.assign(new Error(message), { code });
24
- const unsupportedOnWindows = () => lockError('unsupported_platform', 'handle-file manifest locking is not supported on Windows');
25
- function parseOwner(source) {
26
- let value;
27
- try {
28
- value = JSON.parse(source);
29
- }
30
- catch {
31
- throw lockError('owner_invalid', 'manifest lock owner invalid');
32
- }
33
- if (!value || typeof value !== 'object')
34
- throw lockError('owner_invalid', 'manifest lock owner invalid');
35
- const owner = value;
36
- // Widen the nonce alphabet only after every tenant has this path-safe reader; an older
37
- // allowlist reader can otherwise wedge forever on the first owner using the new alphabet.
38
- const staleTarget = `.lock.stale-${owner.claimed_at_ms}-${owner.nonce}`;
39
- if (MANIFEST_LOCK.ownerKeys.some((key) => !Object.hasOwn(owner, key)) || typeof owner.claimed_at_ms !== 'number' || !Number.isInteger(owner.claimed_at_ms) || owner.claimed_at_ms < 0 || typeof owner.nonce !== 'string' || !MANIFEST_LOCK.staleTargetRe.test(staleTarget))
40
- throw lockError('owner_invalid', 'manifest lock owner invalid');
41
- return owner;
42
- }
43
- const readOwner = async (path) => parseOwner(await readFile(path, 'utf8'));
44
- async function writeOwner(lock, owner) {
45
- const target = join(lock, 'owner');
46
- const temporary = join(lock, `owner.${process.pid}.${token()}.tmp`);
47
- let file;
48
- try {
49
- await createPrivateFile(temporary, `${JSON.stringify(owner)}\n`);
50
- file = await open(temporary, 'r+');
51
- await file.sync();
52
- await file.close();
53
- file = undefined;
54
- await rename(temporary, target);
55
- }
56
- finally {
57
- await file?.close().catch(() => { });
58
- await unlink(temporary).catch(() => { });
59
- }
60
- }
61
- // Split out from the sweep so the platform claim is testable from a POSIX runner: this takes
62
- // the basename FUNCTION, so a test can drive it with path.win32.basename without pretending
63
- // a Linux host is Windows. A filesystem test cannot do that -- on Linux a '/' split and
64
- // basename agree, so the defect below survives every filesystem test written against it.
65
- //
66
- // pathBasename, not a split on '/': on Windows the manifest path is backslash-separated, so a
67
- // '/' split yields the whole path, nothing matches the prefix, and the sweep becomes a silent
68
- // no-op on the one platform the quarantine bound is hardest to observe. Fails closed -- it
69
- // cannot reap the wrong directory -- but the feature is inert, which is worse than loud.
70
- export function manifestLockQuarantinePrefix(path, basename = pathBasename) {
71
- const name = basename(path);
72
- return name ? name : undefined;
73
- }
74
- async function reclaimStaleManifestLockQuarantines(path, ttlMs, claimDeadlineMs) {
75
- const reclaimAgeMs = Math.max(ttlMs, claimDeadlineMs) + MANIFEST_LOCK_QUARANTINE_RECLAIM_MARGIN_MS;
76
- let names;
77
- try {
78
- names = await readdir(dirname(path));
79
- }
80
- catch {
81
- return;
82
- }
83
- const basename = manifestLockQuarantinePrefix(path);
84
- if (!basename)
85
- return;
86
- await Promise.all(names.map(async (name) => {
87
- const staleTarget = name.startsWith(basename) ? name.slice(basename.length) : undefined;
88
- if (!staleTarget || !MANIFEST_LOCK.staleTargetRe.test(staleTarget))
89
- return;
90
- const target = join(dirname(path), name);
91
- let metadata;
92
- try {
93
- metadata = await lstat(target);
94
- }
95
- catch {
96
- return;
97
- }
98
- if (!metadata.isDirectory() || Date.now() - metadata.mtimeMs < reclaimAgeMs)
99
- return;
100
- await rm(target, { recursive: true, force: true }).catch(() => { });
101
- }));
102
- }
103
- export async function withManifestLock(path, tenant, fn) {
104
- return withLockCommit(path, tenant, async () => fn());
105
- }
106
- async function withLockCommit(path, tenant, fn) {
107
- // Stale-lock eviction renames the lock directory while another process may hold it
108
- // open. That is safe under POSIX rename; on Windows CI two evictors both won and a
109
- // rename of an open lock failed with EPERM. Until the protocol is proven there,
110
- // refuse rather than risk two writers overlapping on the shared handle file.
111
- if (process.platform === 'win32')
112
- throw unsupportedOnWindows();
113
- path = resolve(path);
114
- const lock = `${path}.lock`, ownerPath = join(lock, 'owner'), ttl = testOptions?.ttlMs ?? MANIFEST_LOCK.ttlMs, renewEvery = testOptions?.renewEveryMs ?? MANIFEST_LOCK.renewEveryMs, retryMin = testOptions?.retryMinMs ?? 25, retryMax = testOptions?.retryMaxMs ?? 75;
115
- const nonce = token(), started = Date.now(), deadline = started + ttl;
116
- while (true) {
117
- try {
118
- await createPrivateDirectory(lock);
119
- await writeOwner(lock, { tenant, pid: process.pid, claimed_at_ms: Date.now(), nonce });
120
- await testOptions?.afterClaim?.();
121
- break;
122
- }
123
- catch (error) {
124
- if (code(error) !== 'EEXIST') {
125
- if (code(error) !== 'ENOENT')
126
- await rm(lock, { recursive: true, force: true }).catch(() => { });
127
- throw error;
128
- }
129
- }
130
- let observed, ownerReadError;
131
- try {
132
- observed = await readOwner(ownerPath);
133
- }
134
- catch (error) {
135
- ownerReadError = error;
136
- if (code(error) !== 'ENOENT' && Date.now() >= deadline)
137
- throw code(error) === 'owner_invalid' ? error : lockError('lock_busy', 'manifest lock busy');
138
- }
139
- if (!observed && code(ownerReadError) === 'ENOENT') {
140
- const metadata = await lstat(lock).catch(() => undefined);
141
- if (metadata?.isDirectory() && Date.now() - metadata.mtimeMs >= ttl) {
142
- await testOptions?.beforeEvict?.();
143
- const stale = `${lock}.stale-${Math.floor(metadata.mtimeMs)}-${token()}`;
144
- try {
145
- await rename(lock, stale);
146
- // A claimant may have published its owner while the eviction was pending.
147
- const moved = await lstat(stale);
148
- const ownerExists = await lstat(join(stale, 'owner')).then(() => true, (error) => code(error) !== 'ENOENT');
149
- if (!ownerExists && moved.mtimeMs === metadata.mtimeMs) {
150
- testOptions?.afterEvict?.();
151
- continue;
152
- }
153
- await rename(stale, lock).catch(() => { });
154
- }
155
- catch (error) {
156
- if (!['ENOENT', 'EEXIST', 'ENOTEMPTY'].includes(code(error) ?? ''))
157
- throw error;
158
- }
159
- }
160
- }
161
- if (observed && Date.now() - observed.claimed_at_ms >= ttl) {
162
- await testOptions?.beforeEvict?.();
163
- // The owner record remains because the quarantine is the ABA guard, not an audit log;
164
- // bounded reclamation below is what makes that retention finite.
165
- const stale = `${lock}.stale-${observed.claimed_at_ms}-${observed.nonce}`;
166
- let renameError;
167
- try {
168
- await rename(lock, stale);
169
- }
170
- catch (error) {
171
- renameError = error;
172
- }
173
- await testOptions?.afterEvictRenameAttempt?.();
174
- if (renameError === undefined) {
175
- const moved = await readOwner(join(stale, 'owner')).catch(() => undefined);
176
- if (moved?.nonce === observed.nonce && moved.claimed_at_ms === observed.claimed_at_ms) {
177
- testOptions?.afterEvict?.();
178
- continue;
179
- }
180
- await rename(stale, lock).catch(() => { });
181
- }
182
- else if (!['ENOENT', 'EEXIST', 'ENOTEMPTY'].includes(code(renameError) ?? ''))
183
- throw renameError;
184
- }
185
- if (Date.now() >= deadline)
186
- throw code(ownerReadError) === 'owner_invalid' ? ownerReadError : lockError('lock_busy', 'manifest lock busy');
187
- await sleep(Math.min(randomInt(retryMin, retryMax + 1), Math.max(1, deadline - Date.now())));
188
- }
189
- await reclaimStaleManifestLockQuarantines(path, ttl, deadline - started);
190
- let renewal = Promise.resolve(), failed = false, stopped = false;
191
- const timer = setInterval(() => { renewal = renewal.then(async () => { if (failed)
192
- return; try {
193
- const current = await readOwner(ownerPath);
194
- if (current.nonce !== nonce || Date.now() - current.claimed_at_ms >= ttl)
195
- throw new Error('lease lost');
196
- await writeOwner(lock, { ...current, claimed_at_ms: Date.now() });
197
- }
198
- catch {
199
- failed = true;
200
- } }); }, renewEvery);
201
- timer.unref?.();
202
- const commit = async () => { if (!stopped) {
203
- stopped = true;
204
- clearInterval(timer);
205
- await renewal;
206
- } ; const current = await readOwner(ownerPath).catch(() => undefined); if (failed || !current || current.nonce !== nonce || Date.now() - current.claimed_at_ms >= ttl)
207
- throw lockError('renewal_failed', 'manifest lock renewal failed; write aborted'); };
208
- try {
209
- const result = await fn(commit);
210
- if (failed)
211
- throw lockError('renewal_failed', 'manifest lock renewal failed; write aborted');
212
- return result;
213
- }
214
- finally {
215
- if (!stopped)
216
- clearInterval(timer);
217
- await renewal;
218
- const current = await readOwner(ownerPath).catch(() => undefined);
219
- if (!current || current.nonce !== nonce || Date.now() - current.claimed_at_ms >= ttl)
220
- console.warn('manifest lock lease lost, not releasing', { path, tenant });
221
- else {
222
- const release = `${lock}.release-${nonce}`;
223
- try {
224
- await rename(lock, release);
225
- const moved = await readOwner(join(release, 'owner')).catch(() => undefined);
226
- if (!moved || moved.nonce !== nonce || Date.now() - moved.claimed_at_ms >= ttl) {
227
- await rename(release, lock).catch(() => { });
228
- console.warn('manifest lock lease lost, not releasing', { path, tenant });
229
- }
230
- else
231
- await rm(release, { recursive: true, force: true });
232
- }
233
- catch {
234
- console.warn('manifest lock lease lost, not releasing', { path, tenant });
235
- }
236
- }
237
- }
238
- }
239
- async function readManifest(path) {
240
- let metadata;
241
- try {
242
- metadata = await lstat(path);
243
- }
244
- catch (error) {
245
- if (code(error) === 'ENOENT')
246
- return { version: 1, providers: [] };
247
- throw error;
248
- }
249
- if (metadata.isSymbolicLink() || !metadata.isFile())
250
- throw new Error('handle file must be a regular file');
251
- if (process.platform !== 'win32' && (metadata.mode & 0o777) !== HANDLE_FILE_CONTRACT.mode)
252
- throw new Error('handle file mode must be exactly 0600');
253
- const protection = await checkPrivatePath(path);
254
- if (!protection.ok)
255
- throw new Error(notPrivate('handle file folder', protection));
256
- const source = await readFile(path);
257
- if (source.byteLength > HANDLE_FILE_CONTRACT.maxBytes)
258
- throw new Error('handle file exceeds 256 KiB');
259
- return parseHandleFile(JSON.parse(source.toString('utf8')));
260
- }
261
- const foreign = (file, tenant) => file.providers.filter((provider) => provider.serve !== tenant).map((provider) => JSON.stringify(provider));
262
- async function refuseWritableAncestors(parent) {
263
- const protection = await checkPrivatePath(parent);
264
- if (!protection.ok)
265
- throw new Error(notPrivate('handle file folder', protection));
266
- }
267
- async function prepareParent(path) {
268
- const parent = dirname(path);
269
- // Check existing ancestors before creating directories under a replaceable tree.
270
- if (process.platform !== 'win32')
271
- await refuseWritableAncestors(parent);
272
- const existing = await stat(parent).catch((error) => { if (code(error) !== 'ENOENT')
273
- throw error; return undefined; });
274
- if (existing && typeof process.getuid === 'function' && existing.uid !== process.getuid())
275
- throw new Error('handle file parent is not owned by the current uid');
276
- await createPrivateParents(parent);
277
- const metadata = await stat(parent);
278
- if (!metadata.isDirectory())
279
- throw new Error('handle file parent must be a directory');
280
- if (typeof process.getuid === 'function' && metadata.uid !== process.getuid())
281
- throw new Error('handle file parent is not owned by the current uid');
282
- await refuseWritableAncestors(parent);
283
- }
284
- async function writeAtomic(path, file, commit) {
285
- const bytes = Buffer.from(JSON.stringify(file));
286
- if (bytes.byteLength > HANDLE_FILE_CONTRACT.maxBytes)
287
- throw new Error('handle file exceeds 256 KiB');
288
- // pathBasename for the same reason the quarantine sweep uses it, but this one fails LOUD
289
- // rather than inert: a '/' split over a backslash path yields the whole path, so the temp
290
- // name embeds separators and a drive-letter colon, join() nests it under its own directory,
291
- // and O_CREAT|O_EXCL throws on a name Windows will not accept -- taking the manifest WRITE
292
- // down, not just the reclaim. Covered by the dual-basename test, which a POSIX filesystem
293
- // test cannot reach.
294
- const temporary = join(dirname(path), `.${pathBasename(path)}.${process.pid}.${token()}.tmp`);
295
- let handle;
296
- try {
297
- await createPrivateFile(temporary, bytes);
298
- handle = await open(temporary, 'r+');
299
- await handle.sync();
300
- await handle.close();
301
- handle = undefined;
302
- await testOptions?.beforeManifestRename?.(`${path}.lock`);
303
- await commit();
304
- await rename(temporary, path);
305
- }
306
- finally {
307
- await handle?.close().catch(() => { });
308
- await unlink(temporary).catch(() => { });
309
- }
310
- }
311
- export async function writeHandleFileLocked(path, tenant, mutate) {
312
- // Refuse before any path check, so a Windows caller gets the platform reason rather
313
- // than a private-path refusal from the parent check that runs next.
314
- if (process.platform === 'win32')
315
- throw unsupportedOnWindows();
316
- path = resolve(path);
317
- await prepareParent(path);
318
- await withLockCommit(path, tenant, async (commit) => {
319
- const before = await readManifest(path), working = structuredClone(before), result = await mutate(working), next = parseHandleFile(result ?? working), beforeForeign = foreign(before, tenant);
320
- if (JSON.stringify(foreign(next, tenant)) !== JSON.stringify(beforeForeign))
321
- throw new Error('manifest mutation changed another tenant block');
322
- await writeAtomic(path, next, commit);
323
- if (process.platform !== 'win32' && ((await lstat(path)).mode & 0o777) !== HANDLE_FILE_CONTRACT.mode)
324
- throw new Error('manifest readback mode is not 0600');
325
- if (!(await checkPrivatePath(path)).ok)
326
- throw new Error('manifest readback mode is not 0600');
327
- const readback = await readManifest(path);
328
- if (JSON.stringify(readback) !== JSON.stringify(next))
329
- throw new Error('manifest readback differs');
330
- if (JSON.stringify(foreign(readback, tenant)) !== JSON.stringify(beforeForeign))
331
- throw new Error('manifest readback changed another tenant block');
332
- });
333
- }