@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 +9 -2
- package/dist/enrollment.d.ts +3 -8
- package/dist/enrollment.js +47 -43
- package/dist/handles.d.ts +2 -0
- package/dist/handles.js +2 -2
- package/dist/manifest-lock.d.ts +10 -5
- package/dist/manifest-lock.js +211 -211
- package/dist/private-path.d.ts +0 -2
- package/dist/private-path.js +1 -6
- package/dist/wire.d.ts +13 -0
- package/dist/wire.js +20 -0
- package/package.json +1 -1
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
|
|
17
|
-
|
|
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.
|
package/dist/enrollment.d.ts
CHANGED
|
@@ -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
|
-
|
|
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>;
|
package/dist/enrollment.js
CHANGED
|
@@ -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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
package/dist/manifest-lock.d.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
28
|
+
checkPath?: typeof checkPrivatePath;
|
|
29
|
+
}>;
|
|
29
30
|
export declare function manifestLockQuarantinePrefix(path: string, basename?: (p: string) => string): string | undefined;
|
|
30
|
-
|
|
31
|
-
export declare function
|
|
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 {};
|
package/dist/manifest-lock.js
CHANGED
|
@@ -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
|
-
|
|
104
|
-
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
|
134
|
+
observed = await readOwner(ownerPath);
|
|
169
135
|
}
|
|
170
136
|
catch (error) {
|
|
171
|
-
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
|
|
183
|
-
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
-
|
|
218
|
-
const current = await readOwner(ownerPath).catch(() => undefined);
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
|
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();
|
package/dist/private-path.d.ts
CHANGED
|
@@ -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
|
package/dist/private-path.js
CHANGED
|
@@ -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
|
-
|
|
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.
|