@cortexkit/claustrum-client 0.13.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`
@@ -52,6 +52,13 @@ credential caching, refresh, account scheduling, and retry policy.
52
52
  before categories existed). Both are `undefined` against a vault too old to send
53
53
  them; treat that as unknown, not as zero. A present but malformed value refuses the
54
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`.
55
62
 
56
63
  The client sends `consumerIdentity: null` for every managed request so inherited
57
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
@@ -241,6 +241,26 @@ export class ClaustrumClient {
241
241
  if (result?.accepted !== true)
242
242
  throw asCredentialError(response, 'invalid_response', this.#logger);
243
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
+ }
244
264
  /**
245
265
  * Propose an enrollment. The consumer mints `requestSecret` itself and sends only its
246
266
  * 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.14.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",