@cortexkit/claustrum-client 0.12.0 → 0.14.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,21 @@ 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
+ • **Changed in 0.13.0:** a transport `Error` frame whose answer no retry or reconnect
27
+ can change is reported as `permanent` with action `gone`, and the client no longer
28
+ reconnects for it: `unknown_method`, `invalid_params`, `invalid_request`,
29
+ `flow_scopes_unsupported`, `invalid_control_body`, `unknown_module`, and the SDK's
30
+ pre-send `missing_identity`. These are not verdicts about a credential: do not mark
31
+ anything gone or needing re-authentication on them; stop retrying and surface the
32
+ code. `unknown_method` from a vault means the vault predates that operation (for
33
+ example an enrollment op against a vault older than the enrollment ceremony):
34
+ update the vault. `watchScoped` is the exception: there `unknown_method` resolves
35
+ `closed` as `unsupported`, the signal to fall back to polling `listScoped`. Before 0.13.0 these were `transient`/`retry`. Every other
36
+ transport failure is still `transient`/`retry`.
37
+ • Since 0.13.0, when a terminal transport failure makes the client reconnect and the
38
+ reconnect or the retried call then fails, the error carries the ORIGINAL call's code
39
+ and class. Earlier versions threw the reconnect's error instead, usually
40
+ `transport_error`, hiding the original call's code.
26
41
  • `listScoped` rows always carry `providerIds` (a required field since 0.6.0, so code
27
42
  that builds rows by hand, such as test fixtures, must set it). An empty array means
28
43
  no provider ids are set. Since 0.6.2 a daemon too old to send the field decodes the
@@ -37,6 +52,13 @@ credential caching, refresh, account scheduling, and retry policy.
37
52
  before categories existed). Both are `undefined` against a vault too old to send
38
53
  them; treat that as unknown, not as zero. A present but malformed value refuses the
39
54
  reply like any other malformed field.
55
+ • Since 0.14.0, `reportUsageScoped({ credentialId, reading, enrollmentToken? })`
56
+ sends fire-and-forget quota readings. `accepted` means queued, never that the
57
+ usage-processing module insula applied the reading. Coalesce per credential and
58
+ never await it on a request path.
59
+ The client leaves reading validation to the vault, which refuses top-level
60
+ `credentialId` and `vouched` keys and compact JSON readings over 15,360 bytes with
61
+ `invalid_params`.
40
62
 
41
63
  The client sends `consumerIdentity: null` for every managed request so inherited
42
64
  `SUBC_MODULE_ID` and `SUBC_LAUNCH_NONCE` cannot impersonate a supervising host.
@@ -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/handles.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { checkPrivatePath } from './private-path.js';
1
2
  export declare const HANDLE_FILE_CONTRACT: {
2
3
  readonly maxBytes: number;
3
4
  readonly mode: 384;
@@ -50,6 +51,7 @@ type HandleFileDescriptor = {
50
51
  close(): Promise<void>;
51
52
  };
52
53
  export type HandleFileIo = {
54
+ pathOptions?: Parameters<typeof checkPrivatePath>[1];
53
55
  stat?: (path: string) => Promise<HandleFileStat>;
54
56
  lstat?: (path: string) => Promise<HandleFileStat>;
55
57
  readFile?: (path: string, encoding: 'utf8') => Promise<string>;
package/dist/handles.js CHANGED
@@ -208,10 +208,10 @@ async function readHandleSnapshot(path = defaultHandleFilePath(), io = {}) {
208
208
  const value = await io.stat(candidate);
209
209
  return { ...value, isDirectory: () => value.isDirectory?.() ?? false };
210
210
  });
211
- const protection = await checkPrivatePath(process.platform === 'win32' ? path : dirname(path), fixtureStat ? {
211
+ const protection = await checkPrivatePath(process.platform === 'win32' ? path : dirname(path), io.pathOptions ?? (fixtureStat ? {
212
212
  euid: expectedUid,
213
213
  fs: { stat: fixtureStat, lstat: fixtureStat },
214
- } : undefined);
214
+ } : undefined));
215
215
  if (!protection.ok)
216
216
  invalid(notPrivate('handle file folder', protection));
217
217
  let source;
@@ -1,3 +1,4 @@
1
+ import { checkPrivatePath } from './private-path.js';
1
2
  import { type OpenCodeHandleFileV1 } from './handles.js';
2
3
  export declare const MANIFEST_LOCK: {
3
4
  ttlMs: number;
@@ -14,7 +15,7 @@ export type ManifestLockError = Error & {
14
15
  export type ManifestHandleAccount = OpenCodeHandleFileV1['providers'][number]['accounts'][number];
15
16
  export type ManifestHandleProvider = OpenCodeHandleFileV1['providers'][number];
16
17
  export type ManifestHandleFile = OpenCodeHandleFileV1;
17
- type TestOptions = {
18
+ type ManifestLockOptions = Readonly<{
18
19
  ttlMs?: number;
19
20
  renewEveryMs?: number;
20
21
  retryMinMs?: number;
@@ -24,9 +25,13 @@ type TestOptions = {
24
25
  afterEvictRenameAttempt?: () => Promise<void>;
25
26
  afterEvict?: () => void;
26
27
  beforeManifestRename?: (path: string) => Promise<void>;
27
- };
28
- export declare function __setManifestLockTestOptions(options?: TestOptions): void;
28
+ checkPath?: typeof checkPrivatePath;
29
+ }>;
29
30
  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>;
31
+ /** Each instance captures its dependencies; callers cannot reconfigure the shared writer. */
32
+ export declare function createManifestLock(options?: ManifestLockOptions): {
33
+ withManifestLock: <T>(path: string, tenant: string, fn: () => Promise<T> | T) => Promise<T>;
34
+ writeHandleFileLocked: (path: string, tenant: string, mutate: (file: ManifestHandleFile) => void | ManifestHandleFile | Promise<void | ManifestHandleFile>) => Promise<void>;
35
+ };
36
+ export declare const withManifestLock: <T>(path: string, tenant: string, fn: () => Promise<T> | T) => Promise<T>, writeHandleFileLocked: (path: string, tenant: string, mutate: (file: ManifestHandleFile) => void | ManifestHandleFile | Promise<void | ManifestHandleFile>) => Promise<void>;
32
37
  export {};
@@ -10,8 +10,6 @@ export const MANIFEST_LOCK = { ttlMs: 30_000, renewEveryMs: 10_000, ownerKeys: [
10
10
  // loaded host. The retry deadline and staleness window both read `ttl` today; if they split,
11
11
  // this bound must keep their max so reclamation still covers the longer role.
12
12
  const MANIFEST_LOCK_QUARANTINE_RECLAIM_MARGIN_MS = 5_000;
13
- let testOptions;
14
- export function __setManifestLockTestOptions(options) { testOptions = options; }
15
13
  // 16 CSPRNG bytes: a collision needs both the same millisecond and the same nonce. Do not
16
14
  // simplify this to a counter, pid+timestamp, or a short token.
17
15
  const token = () => randomBytes(16).toString('base64url');
@@ -100,234 +98,236 @@ async function reclaimStaleManifestLockQuarantines(path, ttlMs, claimDeadlineMs)
100
98
  await rm(target, { recursive: true, force: true }).catch(() => { });
101
99
  }));
102
100
  }
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;
101
+ /** Each instance captures its dependencies; callers cannot reconfigure the shared writer. */
102
+ export function createManifestLock(options = {}) {
103
+ const settings = Object.freeze({ ...options });
104
+ const checkPath = settings.checkPath ?? checkPrivatePath;
105
+ async function withManifestLock(path, tenant, fn) {
106
+ return withLockCommit(path, tenant, async () => fn());
107
+ }
108
+ async function withLockCommit(path, tenant, fn) {
109
+ // Stale-lock eviction renames the lock directory while another process may hold it
110
+ // open. That is safe under POSIX rename; on Windows CI two evictors both won and a
111
+ // rename of an open lock failed with EPERM. Until the protocol is proven there,
112
+ // refuse rather than risk two writers overlapping on the shared handle file.
113
+ if (process.platform === 'win32')
114
+ throw unsupportedOnWindows();
115
+ path = resolve(path);
116
+ const lock = `${path}.lock`, ownerPath = join(lock, 'owner'), ttl = settings.ttlMs ?? MANIFEST_LOCK.ttlMs, renewEvery = settings.renewEveryMs ?? MANIFEST_LOCK.renewEveryMs, retryMin = settings.retryMinMs ?? 25, retryMax = settings.retryMaxMs ?? 75;
117
+ const nonce = token(), started = Date.now(), deadline = started + ttl;
118
+ while (true) {
119
+ try {
120
+ await createPrivateDirectory(lock);
121
+ await writeOwner(lock, { tenant, pid: process.pid, claimed_at_ms: Date.now(), nonce });
122
+ await settings.afterClaim?.();
123
+ break;
128
124
  }
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;
125
+ catch (error) {
126
+ if (code(error) !== 'EEXIST') {
127
+ if (code(error) !== 'ENOENT')
128
+ await rm(lock, { recursive: true, force: true }).catch(() => { });
129
+ throw error;
158
130
  }
159
131
  }
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;
132
+ let observed, ownerReadError;
167
133
  try {
168
- await rename(lock, stale);
134
+ observed = await readOwner(ownerPath);
169
135
  }
170
136
  catch (error) {
171
- renameError = error;
137
+ ownerReadError = error;
138
+ if (code(error) !== 'ENOENT' && Date.now() >= deadline)
139
+ throw code(error) === 'owner_invalid' ? error : lockError('lock_busy', 'manifest lock busy');
172
140
  }
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;
141
+ if (!observed && code(ownerReadError) === 'ENOENT') {
142
+ const metadata = await lstat(lock).catch(() => undefined);
143
+ if (metadata?.isDirectory() && Date.now() - metadata.mtimeMs >= ttl) {
144
+ await settings.beforeEvict?.();
145
+ const stale = `${lock}.stale-${Math.floor(metadata.mtimeMs)}-${token()}`;
146
+ try {
147
+ await rename(lock, stale);
148
+ // A claimant may have published its owner while the eviction was pending.
149
+ const moved = await lstat(stale);
150
+ const ownerExists = await lstat(join(stale, 'owner')).then(() => true, (error) => code(error) !== 'ENOENT');
151
+ if (!ownerExists && moved.mtimeMs === metadata.mtimeMs) {
152
+ settings.afterEvict?.();
153
+ continue;
154
+ }
155
+ await rename(stale, lock).catch(() => { });
156
+ }
157
+ catch (error) {
158
+ if (!['ENOENT', 'EEXIST', 'ENOTEMPTY'].includes(code(error) ?? ''))
159
+ throw error;
160
+ }
179
161
  }
180
- await rename(stale, lock).catch(() => { });
181
162
  }
182
- else if (!['ENOENT', 'EEXIST', 'ENOTEMPTY'].includes(code(renameError) ?? ''))
183
- throw renameError;
163
+ if (observed && Date.now() - observed.claimed_at_ms >= ttl) {
164
+ await settings.beforeEvict?.();
165
+ // The owner record remains because the quarantine is the ABA guard, not an audit log;
166
+ // bounded reclamation below is what makes that retention finite.
167
+ const stale = `${lock}.stale-${observed.claimed_at_ms}-${observed.nonce}`;
168
+ let renameError;
169
+ try {
170
+ await rename(lock, stale);
171
+ }
172
+ catch (error) {
173
+ renameError = error;
174
+ }
175
+ await settings.afterEvictRenameAttempt?.();
176
+ if (renameError === undefined) {
177
+ const moved = await readOwner(join(stale, 'owner')).catch(() => undefined);
178
+ if (moved?.nonce === observed.nonce && moved.claimed_at_ms === observed.claimed_at_ms) {
179
+ settings.afterEvict?.();
180
+ continue;
181
+ }
182
+ await rename(stale, lock).catch(() => { });
183
+ }
184
+ else if (!['ENOENT', 'EEXIST', 'ENOTEMPTY'].includes(code(renameError) ?? ''))
185
+ throw renameError;
186
+ }
187
+ if (Date.now() >= deadline)
188
+ throw code(ownerReadError) === 'owner_invalid' ? ownerReadError : lockError('lock_busy', 'manifest lock busy');
189
+ await sleep(Math.min(randomInt(retryMin, retryMax + 1), Math.max(1, deadline - Date.now())));
184
190
  }
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)
191
+ await reclaimStaleManifestLockQuarantines(path, ttl, deadline - started);
192
+ let renewal = Promise.resolve(), failed = false, stopped = false;
193
+ const timer = setInterval(() => { renewal = renewal.then(async () => { if (failed)
194
+ return; try {
195
+ const current = await readOwner(ownerPath);
196
+ if (current.nonce !== nonce || Date.now() - current.claimed_at_ms >= ttl)
197
+ throw new Error('lease lost');
198
+ await writeOwner(lock, { ...current, claimed_at_ms: Date.now() });
199
+ }
200
+ catch {
201
+ failed = true;
202
+ } }); }, renewEvery);
203
+ timer.unref?.();
204
+ const commit = async () => { if (!stopped) {
205
+ stopped = true;
216
206
  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(() => { });
207
+ await renewal;
208
+ } ; const current = await readOwner(ownerPath).catch(() => undefined); if (failed || !current || current.nonce !== nonce || Date.now() - current.claimed_at_ms >= ttl)
209
+ throw lockError('renewal_failed', 'manifest lock renewal failed; write aborted'); };
210
+ try {
211
+ const result = await fn(commit);
212
+ if (failed)
213
+ throw lockError('renewal_failed', 'manifest lock renewal failed; write aborted');
214
+ return result;
215
+ }
216
+ finally {
217
+ if (!stopped)
218
+ clearInterval(timer);
219
+ await renewal;
220
+ const current = await readOwner(ownerPath).catch(() => undefined);
221
+ if (!current || current.nonce !== nonce || Date.now() - current.claimed_at_ms >= ttl)
222
+ console.warn('manifest lock lease lost, not releasing', { path, tenant });
223
+ else {
224
+ const release = `${lock}.release-${nonce}`;
225
+ try {
226
+ await rename(lock, release);
227
+ const moved = await readOwner(join(release, 'owner')).catch(() => undefined);
228
+ if (!moved || moved.nonce !== nonce || Date.now() - moved.claimed_at_ms >= ttl) {
229
+ await rename(release, lock).catch(() => { });
230
+ console.warn('manifest lock lease lost, not releasing', { path, tenant });
231
+ }
232
+ else
233
+ await rm(release, { recursive: true, force: true });
234
+ }
235
+ catch {
228
236
  console.warn('manifest lock lease lost, not releasing', { path, tenant });
229
237
  }
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
238
  }
236
239
  }
237
240
  }
238
- }
239
- async function readManifest(path) {
240
- let metadata;
241
- try {
242
- metadata = await lstat(path);
241
+ async function readManifest(path) {
242
+ let metadata;
243
+ try {
244
+ metadata = await lstat(path);
245
+ }
246
+ catch (error) {
247
+ if (code(error) === 'ENOENT')
248
+ return { version: 1, providers: [] };
249
+ throw error;
250
+ }
251
+ if (metadata.isSymbolicLink() || !metadata.isFile())
252
+ throw new Error('handle file must be a regular file');
253
+ if (process.platform !== 'win32' && (metadata.mode & 0o777) !== HANDLE_FILE_CONTRACT.mode)
254
+ throw new Error('handle file mode must be exactly 0600');
255
+ const protection = await checkPath(path);
256
+ if (!protection.ok)
257
+ throw new Error(notPrivate('handle file folder', protection));
258
+ const source = await readFile(path);
259
+ if (source.byteLength > HANDLE_FILE_CONTRACT.maxBytes)
260
+ throw new Error('handle file exceeds 256 KiB');
261
+ return parseHandleFile(JSON.parse(source.toString('utf8')));
243
262
  }
244
- catch (error) {
245
- if (code(error) === 'ENOENT')
246
- return { version: 1, providers: [] };
247
- throw error;
263
+ const foreign = (file, tenant) => file.providers.filter((provider) => provider.serve !== tenant).map((provider) => JSON.stringify(provider));
264
+ async function refuseWritableAncestors(parent) {
265
+ const protection = await checkPath(parent);
266
+ if (!protection.ok)
267
+ throw new Error(notPrivate('handle file folder', protection));
248
268
  }
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')
269
+ async function prepareParent(path) {
270
+ const parent = dirname(path);
271
+ // Check existing ancestors before creating directories under a replaceable tree.
272
+ if (process.platform !== 'win32')
273
+ await refuseWritableAncestors(parent);
274
+ const existing = await stat(parent).catch((error) => { if (code(error) !== 'ENOENT')
275
+ throw error; return undefined; });
276
+ if (existing && typeof process.getuid === 'function' && existing.uid !== process.getuid())
277
+ throw new Error('handle file parent is not owned by the current uid');
278
+ await createPrivateParents(parent);
279
+ const metadata = await stat(parent);
280
+ if (!metadata.isDirectory())
281
+ throw new Error('handle file parent must be a directory');
282
+ if (typeof process.getuid === 'function' && metadata.uid !== process.getuid())
283
+ throw new Error('handle file parent is not owned by the current uid');
271
284
  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
285
  }
306
- finally {
307
- await handle?.close().catch(() => { });
308
- await unlink(temporary).catch(() => { });
286
+ async function writeAtomic(path, file, commit) {
287
+ const bytes = Buffer.from(JSON.stringify(file));
288
+ if (bytes.byteLength > HANDLE_FILE_CONTRACT.maxBytes)
289
+ throw new Error('handle file exceeds 256 KiB');
290
+ // Use the native basename so the temporary name cannot embed path separators.
291
+ const temporary = join(dirname(path), `.${pathBasename(path)}.${process.pid}.${token()}.tmp`);
292
+ let handle;
293
+ try {
294
+ await createPrivateFile(temporary, bytes);
295
+ handle = await open(temporary, 'r+');
296
+ await handle.sync();
297
+ await handle.close();
298
+ handle = undefined;
299
+ await settings.beforeManifestRename?.(`${path}.lock`);
300
+ await commit();
301
+ await rename(temporary, path);
302
+ }
303
+ finally {
304
+ await handle?.close().catch(() => { });
305
+ await unlink(temporary).catch(() => { });
306
+ }
309
307
  }
308
+ async function writeHandleFileLocked(path, tenant, mutate) {
309
+ // Refuse before any path check, so a Windows caller gets the platform reason rather
310
+ // than a private-path refusal from the parent check that runs next.
311
+ if (process.platform === 'win32')
312
+ throw unsupportedOnWindows();
313
+ path = resolve(path);
314
+ await prepareParent(path);
315
+ await withLockCommit(path, tenant, async (commit) => {
316
+ const before = await readManifest(path), working = structuredClone(before), result = await mutate(working), next = parseHandleFile(result ?? working), beforeForeign = foreign(before, tenant);
317
+ if (JSON.stringify(foreign(next, tenant)) !== JSON.stringify(beforeForeign))
318
+ throw new Error('manifest mutation changed another tenant block');
319
+ await writeAtomic(path, next, commit);
320
+ if (process.platform !== 'win32' && ((await lstat(path)).mode & 0o777) !== HANDLE_FILE_CONTRACT.mode)
321
+ throw new Error('manifest readback mode is not 0600');
322
+ if (!(await checkPath(path)).ok)
323
+ throw new Error('manifest readback mode is not 0600');
324
+ const readback = await readManifest(path);
325
+ if (JSON.stringify(readback) !== JSON.stringify(next))
326
+ throw new Error('manifest readback differs');
327
+ if (JSON.stringify(foreign(readback, tenant)) !== JSON.stringify(beforeForeign))
328
+ throw new Error('manifest readback changed another tenant block');
329
+ });
330
+ }
331
+ return { withManifestLock, writeHandleFileLocked };
310
332
  }
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
- }
333
+ export const { withManifestLock, writeHandleFileLocked } = createManifestLock();
@@ -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
@@ -138,6 +138,19 @@ export declare class ClaustrumClient {
138
138
  recordVersion: number;
139
139
  reporterSource: ClaustrumReporterSource;
140
140
  }): Promise<void>;
141
+ /**
142
+ * Fire-and-forget quota reporting: `accepted` means queued, never that the
143
+ * usage-processing module insula applied the reading.
144
+ * Callers should coalesce per credential and never await this on a request path.
145
+ *
146
+ * Reading contents are not validated here. The vault rejects top-level `credentialId`
147
+ * and `vouched` keys and compact JSON readings over 15,360 bytes with `invalid_params`.
148
+ */
149
+ reportUsageScoped(input: {
150
+ credentialId: string;
151
+ reading: Record<string, unknown>;
152
+ enrollmentToken?: string;
153
+ }): Promise<void>;
141
154
  /**
142
155
  * Propose an enrollment. The consumer mints `requestSecret` itself and sends only its
143
156
  * hash; the raw secret NEVER leaves the process, and it is what authorizes the poll.
package/dist/wire.js CHANGED
@@ -5,6 +5,37 @@ import { storeIdentity } from './identity.js';
5
5
  import { openScopedWatch } from './watch.js';
6
6
  const CLAUSTRUM_MODULE_ID = 'claustrum';
7
7
  const RECONNECT_BACKOFF_MS = 60_000;
8
+ /**
9
+ * Transport `Error` frame codes whose answer neither a retry nor a fresh connection can
10
+ * change. A call failing with one of these is reported as `permanent`/`gone` and is never
11
+ * reconnected; every other transport failure stays `transient`/`retry`.
12
+ *
13
+ * This is the ONLY list of such codes: the error classification and the reconnect
14
+ * decision both read it, so the two cannot disagree about a code.
15
+ *
16
+ * - `unknown_method`: the vault does not implement the operation. From a released vault
17
+ * this means the vault predates the operation, and the remedy is to update the vault.
18
+ * - `invalid_params`, `invalid_request`: the vault could not decode the request body or
19
+ * its params. The same bytes are refused the same way on any connection.
20
+ * - `flow_scopes_unsupported`: the vault refuses routes opened under a flow scope.
21
+ * - `invalid_control_body`: the vault build cannot decode the daemon's route bind.
22
+ * - `unknown_module`: the daemon has no module with this id registered or supervised at
23
+ * all. A vault that is restarting or warming up is reported as `module_warming` or
24
+ * `target_unavailable` instead, which the SDK already retries in place. A fresh
25
+ * connection to the same daemon gets the same answer, and the wasted reconnect would
26
+ * also start the reconnect backoff, leaving a genuinely wedged connection unable to
27
+ * recover for a minute.
28
+ * - `missing_identity`: the SDK refused before sending because no bind identity was set.
29
+ */
30
+ const DEFINITIVE_TRANSPORT_CODES = new Set([
31
+ 'unknown_method',
32
+ 'invalid_params',
33
+ 'invalid_request',
34
+ 'flow_scopes_unsupported',
35
+ 'invalid_control_body',
36
+ 'unknown_module',
37
+ 'missing_identity',
38
+ ]);
8
39
  function isRecord(value) {
9
40
  return typeof value === 'object' && value !== null && !Array.isArray(value);
10
41
  }
@@ -210,6 +241,26 @@ export class ClaustrumClient {
210
241
  if (result?.accepted !== true)
211
242
  throw asCredentialError(response, 'invalid_response', this.#logger);
212
243
  }
244
+ /**
245
+ * Fire-and-forget quota reporting: `accepted` means queued, never that the
246
+ * usage-processing module insula applied the reading.
247
+ * Callers should coalesce per credential and never await this on a request path.
248
+ *
249
+ * Reading contents are not validated here. The vault rejects top-level `credentialId`
250
+ * and `vouched` keys and compact JSON readings over 15,360 bytes with `invalid_params`.
251
+ */
252
+ async reportUsageScoped(input) {
253
+ const response = await this.#call('usage.report_scoped', {
254
+ credential_id: input.credentialId,
255
+ reading: input.reading,
256
+ enrollment_token: input.enrollmentToken,
257
+ });
258
+ if (hasCredentialError(response))
259
+ throw asCredentialError(response, 'invalid_response', this.#logger);
260
+ const result = isRecord(response) && isRecord(response.result) ? response.result : undefined;
261
+ if (result?.accepted !== true)
262
+ throw asCredentialError(response, 'invalid_response', this.#logger);
263
+ }
213
264
  /**
214
265
  * Propose an enrollment. The consumer mints `requestSecret` itself and sends only its
215
266
  * hash; the raw secret NEVER leaves the process, and it is what authorizes the poll.
@@ -284,46 +335,45 @@ export class ClaustrumClient {
284
335
  });
285
336
  }
286
337
  catch (error) {
287
- if (this.#shouldReconnect(error)) {
288
- try {
289
- if (this.#client === sentClient)
290
- await this.#reconnect();
291
- }
292
- catch (reconnectError) {
293
- throw this.#asTransportError(reconnectError);
294
- }
295
- try {
296
- return await this.#client.call(CLAUSTRUM_MODULE_ID, method, params, {
297
- identity: this.#identity,
298
- consumerIdentity: null,
299
- });
300
- }
301
- catch (retryError) {
302
- throw this.#asTransportError(retryError);
303
- }
338
+ if (!this.#shouldReconnect(error))
339
+ throw this.#asTransportError(error);
340
+ try {
341
+ if (this.#client === sentClient)
342
+ await this.#reconnect();
343
+ return await this.#client.call(CLAUSTRUM_MODULE_ID, method, params, {
344
+ identity: this.#identity,
345
+ consumerIdentity: null,
346
+ });
347
+ }
348
+ catch (recoveryError) {
349
+ // Report the call's own failure, not the recovery's. A failed reconnect would
350
+ // otherwise replace the original call's code with `transport_error`, so
351
+ // the one actionable code would appear once and every later diagnostic would
352
+ // name a transport failure that never happened. Only a failure that carried no
353
+ // code at all has nothing to preserve.
354
+ throw this.#transportErrorCode(error) === undefined
355
+ ? this.#asTransportError(recoveryError)
356
+ : this.#asTransportError(error);
304
357
  }
305
- throw this.#asTransportError(error);
306
358
  }
307
359
  }
360
+ #transportErrorCode(error) {
361
+ return error instanceof SubcCallError && error.code ? error.code : undefined;
362
+ }
308
363
  #asTransportError(error) {
309
364
  if (error instanceof ClaustrumCredentialError)
310
365
  return error;
311
- const code = error instanceof SubcCallError && error.code ? error.code : 'transport_error';
312
- return new ClaustrumCredentialError(code, 'transient', 'retry');
366
+ const code = this.#transportErrorCode(error);
367
+ if (code !== undefined && DEFINITIVE_TRANSPORT_CODES.has(code)) {
368
+ return new ClaustrumCredentialError(code, 'permanent', 'gone');
369
+ }
370
+ return new ClaustrumCredentialError(code ?? 'transport_error', 'transient', 'retry');
313
371
  }
314
372
  #shouldReconnect(error) {
315
373
  return (!this.#closed &&
316
374
  error instanceof SubcCallError &&
317
375
  error.kind === 'terminal' &&
318
- error.code !== 'missing_identity' &&
319
- error.code !== 'invalid_control_body' &&
320
- // The daemon answers unknown_module only when no module with this id is
321
- // registered or supervised at all; a vault that is restarting or warming up
322
- // is reported as module_warming or target_unavailable, which the SDK already
323
- // retries in place. A fresh connection to the same daemon gets the same
324
- // answer, and the wasted reconnect would also start the reconnect backoff,
325
- // leaving a genuinely wedged connection unable to recover for a minute.
326
- error.code !== 'unknown_module');
376
+ !DEFINITIVE_TRANSPORT_CODES.has(error.code ?? ''));
327
377
  }
328
378
  async #reconnect() {
329
379
  if (this.#closed)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cortexkit/claustrum-client",
3
- "version": "0.12.0",
3
+ "version": "0.14.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",