@cortexkit/common-auth 0.9.4 → 0.10.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.
@@ -91,6 +91,16 @@ export declare function hostEnrollmentPaths(input: {
91
91
  override?: string;
92
92
  cwd?: string;
93
93
  }): ClaustrumEnrollmentPaths;
94
+ /** Account-file paths are injectable for isolated setup tests; no environment overrides. */
95
+ export interface EnrollmentAncestorOptions {
96
+ passwdPath?: string;
97
+ groupPath?: string;
98
+ }
99
+ /**
100
+ * Protect setup writes from directory replacement. Group write access is safe
101
+ * only when local account records prove that the effective user's group is private.
102
+ */
103
+ export declare function refuseWritableAncestor(parent: string, options?: EnrollmentAncestorOptions): Promise<void>;
94
104
  export declare function readClaustrumEnrollmentStatus(paths: ClaustrumEnrollmentPaths, proposedName: string): Promise<ClaustrumEnrollmentStatus>;
95
105
  /**
96
106
  * Read fresh bearer material for one scoped operation; never publish it.
@@ -111,6 +121,7 @@ export declare class ClaustrumEnrollmentManager {
111
121
  now?: () => number;
112
122
  mintSecret?: () => string;
113
123
  writeTokenFile?: typeof writeEnrollmentTokenFile;
124
+ ancestorOptions?: EnrollmentAncestorOptions;
114
125
  });
115
126
  status(): Promise<ClaustrumEnrollmentStatus>;
116
127
  resetTerminal(): Promise<ClaustrumEnrollmentResetResult>;
@@ -1,6 +1,6 @@
1
1
  import { createHash, randomBytes, randomUUID } from 'node:crypto';
2
2
  import { constants as fsConstants } from 'node:fs';
3
- import { chmod, mkdir, open, realpath, rename, stat, unlink, } from 'node:fs/promises';
3
+ import { chmod, mkdir, open, readFile, realpath, rename, stat, unlink, } from 'node:fs/promises';
4
4
  import { basename, dirname, extname, isAbsolute, join, resolve, } from 'node:path';
5
5
  import { ClaustrumCredentialError, ClaustrumClient as ClaustrumWireClient, writeEnrollmentTokenFile, } from '@cortexkit/claustrum-client';
6
6
  import { acquireRefreshFileLock } from '../fs/index.js';
@@ -183,7 +183,7 @@ function validateReadableSecretFile(metadata) {
183
183
  if ((metadata.mode & 0o077) !== 0) {
184
184
  throw new ClaustrumConsumerError('unsafe-file', 'Claustrum enrollment file must be owner-only');
185
185
  }
186
- const expectedUid = process.getuid?.();
186
+ const expectedUid = process.geteuid?.();
187
187
  if (expectedUid !== undefined && metadata.uid !== expectedUid) {
188
188
  throw new ClaustrumConsumerError('unsafe-file', 'Claustrum enrollment file must be owned by the current user');
189
189
  }
@@ -221,11 +221,61 @@ async function readBoundedJson(path) {
221
221
  await descriptor.close();
222
222
  }
223
223
  }
224
+ function accountRecords(text, fields) {
225
+ const records = [];
226
+ for (const line of text.split('\n')) {
227
+ if (line.trim() === '' || line.startsWith('#'))
228
+ continue;
229
+ const parts = line.split(':');
230
+ if (parts.length !== fields ||
231
+ !/^[^\s:,]+$/.test(parts[0] ?? '') ||
232
+ !/^\d+$/.test(parts[2] ?? '') ||
233
+ !Number.isSafeInteger(Number(parts[2])) ||
234
+ (fields === 7 &&
235
+ (!/^\d+$/.test(parts[3] ?? '') ||
236
+ !Number.isSafeInteger(Number(parts[3])))) ||
237
+ (fields === 4 &&
238
+ parts[3] !== '' &&
239
+ !/^[^\s:,]+(?:,[^\s:,]+)*$/.test(parts[3] ?? '')))
240
+ return undefined;
241
+ records.push(parts);
242
+ }
243
+ return records;
244
+ }
245
+ async function isPrivateGroup(uid, gid, options) {
246
+ try {
247
+ const [passwdText, groupText] = await Promise.all([
248
+ readFile(options.passwdPath ?? '/etc/passwd', 'utf8'),
249
+ readFile(options.groupPath ?? '/etc/group', 'utf8'),
250
+ ]);
251
+ const users = accountRecords(passwdText, 7);
252
+ const groups = accountRecords(groupText, 4);
253
+ if (!users || !groups)
254
+ return false;
255
+ const owners = users.filter((user) => Number(user[2]) === uid);
256
+ const matchingGroups = groups.filter((group) => Number(group[2]) === gid);
257
+ if (owners.length !== 1 || matchingGroups.length !== 1)
258
+ return false;
259
+ const name = owners[0]?.[0];
260
+ const members = matchingGroups[0]?.[3] ?? '';
261
+ return ((members === '' ||
262
+ members.split(',').every((member) => member === name)) &&
263
+ !users.some((user) => Number(user[2]) !== uid && Number(user[3]) === gid));
264
+ }
265
+ catch {
266
+ // Missing local records (including NSS-only accounts) cannot prove exclusivity.
267
+ return false;
268
+ }
269
+ }
224
270
  /**
225
- * Refuse a path below any group- or world-writable directory without the
226
- * sticky bit: another user could swap the file out from under us there.
271
+ * Protect setup writes from directory replacement. Group write access is safe
272
+ * only when local account records prove that the effective user's group is private.
227
273
  */
228
- async function refuseWritableAncestor(parent) {
274
+ export async function refuseWritableAncestor(parent, options = {}) {
275
+ const uid = process.geteuid?.();
276
+ if (uid === undefined)
277
+ return;
278
+ const gid = process.getegid?.();
229
279
  let component;
230
280
  try {
231
281
  component = await realpath(parent);
@@ -233,12 +283,23 @@ async function refuseWritableAncestor(parent) {
233
283
  catch {
234
284
  return;
235
285
  }
286
+ // Load lazily, once per traversal: account changes take effect on the next write.
287
+ let privateGroup;
236
288
  for (;;) {
237
289
  const metadata = await stat(component).catch(() => undefined);
238
290
  if (metadata &&
239
291
  (metadata.mode & 0o022) !== 0 &&
240
292
  (metadata.mode & 0o1000) === 0) {
241
- throw new ClaustrumConsumerError('unsafe-file', 'Claustrum enrollment path has an unsafe writable ancestor');
293
+ if (privateGroup === undefined) {
294
+ privateGroup =
295
+ gid !== undefined && (await isPrivateGroup(uid, gid, options));
296
+ }
297
+ if ((metadata.mode & 0o002) !== 0 ||
298
+ metadata.uid !== uid ||
299
+ metadata.gid !== gid ||
300
+ !privateGroup) {
301
+ throw new ClaustrumConsumerError('unsafe-file', `Claustrum enrollment path has a writable ancestor: ${component}. Another user could replace it. Run: chmod g-w,o-w ${component}`);
302
+ }
242
303
  }
243
304
  const next = dirname(component);
244
305
  if (next === component)
@@ -254,17 +315,17 @@ async function refuseWritableAncestor(parent) {
254
315
  async function ensurePrivateDirectory(directory) {
255
316
  await mkdir(directory, { recursive: true, mode: 0o700 });
256
317
  const metadata = await stat(directory);
257
- const uid = process.getuid?.();
318
+ const uid = process.geteuid?.();
258
319
  if (uid !== undefined && metadata.uid !== uid) {
259
320
  throw new ClaustrumConsumerError('unsafe-file', 'Claustrum enrollment directory must be owned by the current user');
260
321
  }
261
322
  if ((metadata.mode & 0o077) !== 0)
262
323
  await chmod(directory, 0o700);
263
324
  }
264
- async function writeStateAtomic(path, state) {
325
+ async function writeStateAtomic(path, state, ancestorOptions = {}) {
265
326
  const parent = dirname(path);
266
327
  await ensurePrivateDirectory(parent);
267
- await refuseWritableAncestor(parent);
328
+ await refuseWritableAncestor(parent, ancestorOptions);
268
329
  const bytes = `${JSON.stringify(state)}\n`;
269
330
  if (Buffer.byteLength(bytes) > ENROLLMENT_FILE_MAX_BYTES) {
270
331
  throw new ClaustrumConsumerError('invalid-state', 'Claustrum enrollment state is too large');
@@ -321,7 +382,10 @@ export async function readClaustrumEnrollmentStatus(paths, proposedName) {
321
382
  * Re-reading per operation is what lets an operator reissue a token on disk.
322
383
  */
323
384
  export async function readClaustrumEnrollmentToken(tokenPath) {
324
- await refuseWritableAncestor(tokenPath);
385
+ // Reads rely on O_NOFOLLOW and regular, owner-only, effective-uid-owned files:
386
+ // another user cannot plant a file owned by us. Ancestor checks protect writes
387
+ // from placing our secret in a swapped directory, not reads; keep their
388
+ // realpath/stat and account-file I/O off the authorization path.
325
389
  const value = await readBoundedJson(tokenPath);
326
390
  if (value === undefined)
327
391
  throw new ClaustrumConsumerError('not-enrolled', 'Claustrum enrollment is not configured');
@@ -368,7 +432,9 @@ export class ClaustrumEnrollmentManager {
368
432
  #now;
369
433
  #mintSecret;
370
434
  #writeTokenFile;
435
+ #ancestorOptions;
371
436
  constructor(options) {
437
+ this.#ancestorOptions = options.ancestorOptions ?? {};
372
438
  this.#client = options.client;
373
439
  this.#paths = options.paths;
374
440
  this.#proposedName = options.proposedName;
@@ -392,7 +458,7 @@ export class ClaustrumEnrollmentManager {
392
458
  errorCode: code,
393
459
  updatedAt: this.#now(),
394
460
  };
395
- await writeStateAtomic(this.#paths.statePath, blocked);
461
+ await writeStateAtomic(this.#paths.statePath, blocked, this.#ancestorOptions);
396
462
  return statusFromState(blocked);
397
463
  }
398
464
  async reconcile() {
@@ -421,7 +487,7 @@ export class ClaustrumEnrollmentManager {
421
487
  tokenGeneration: token.token_generation,
422
488
  updatedAt: this.#now(),
423
489
  };
424
- await writeStateAtomic(this.#paths.statePath, approved);
490
+ await writeStateAtomic(this.#paths.statePath, approved, this.#ancestorOptions);
425
491
  state = approved;
426
492
  }
427
493
  return {
@@ -442,7 +508,7 @@ export class ClaustrumEnrollmentManager {
442
508
  errorCode: 'missing_token',
443
509
  updatedAt: this.#now(),
444
510
  };
445
- await writeStateAtomic(this.#paths.statePath, blocked);
511
+ await writeStateAtomic(this.#paths.statePath, blocked, this.#ancestorOptions);
446
512
  return statusFromState(blocked);
447
513
  }
448
514
  if (state && state.phase !== 'pending')
@@ -462,7 +528,7 @@ export class ClaustrumEnrollmentManager {
462
528
  // The secret is on disk before the proposal leaves the process: the
463
529
  // vault answers a repeated proposal with the same secret with the same
464
530
  // request id, so a crash between propose and saving the id costs nothing.
465
- await writeStateAtomic(this.#paths.statePath, state);
531
+ await writeStateAtomic(this.#paths.statePath, state, this.#ancestorOptions);
466
532
  }
467
533
  if (!state.requestId) {
468
534
  try {
@@ -478,7 +544,7 @@ export class ClaustrumEnrollmentManager {
478
544
  requestId: proposed.requestId,
479
545
  updatedAt: this.#now(),
480
546
  };
481
- await writeStateAtomic(this.#paths.statePath, state);
547
+ await writeStateAtomic(this.#paths.statePath, state, this.#ancestorOptions);
482
548
  }
483
549
  catch (error) {
484
550
  const refusal = classifyEnrollmentError(error);
@@ -511,11 +577,13 @@ export class ClaustrumEnrollmentManager {
511
577
  proposedName: state.proposedName,
512
578
  updatedAt: this.#now(),
513
579
  };
514
- await writeStateAtomic(this.#paths.statePath, denied);
580
+ await writeStateAtomic(this.#paths.statePath, denied, this.#ancestorOptions);
515
581
  return statusFromState(denied);
516
582
  }
517
583
  // The vault returns the token exactly once: it reaches disk before the
518
584
  // pending metadata (and its secret) is replaced.
585
+ await ensurePrivateDirectory(dirname(this.#paths.tokenPath));
586
+ await refuseWritableAncestor(dirname(this.#paths.tokenPath), this.#ancestorOptions);
519
587
  await this.#writeTokenFile(this.#paths.tokenPath, {
520
588
  token: outcome.token,
521
589
  token_generation: outcome.tokenGeneration,
@@ -528,7 +596,7 @@ export class ClaustrumEnrollmentManager {
528
596
  tokenGeneration: outcome.tokenGeneration,
529
597
  updatedAt: this.#now(),
530
598
  };
531
- await writeStateAtomic(this.#paths.statePath, approved);
599
+ await writeStateAtomic(this.#paths.statePath, approved, this.#ancestorOptions);
532
600
  return statusFromState(approved);
533
601
  }
534
602
  catch (error) {
@@ -7,7 +7,7 @@ export { type ClaustrumEnrollmentClient, type ClaustrumEnrollmentConnection, Cla
7
7
  export { ClaustrumConsumerError, type ClaustrumConsumerFailureKind, type ClaustrumLogger, } from './errors.js';
8
8
  export { assertHostSlotMatchesMode, assertNotCustodyPlaceholder, CUSTODY_PLACEHOLDER_PREFIX, classifyHostSlot, custodyPlaceholder, custodyPlaceholderKey, type HostSlotContent, isCustodyPlaceholder, isCustodyPlaceholderValue, } from './host-slot.js';
9
9
  export { acceptAccount, type DeclinedAccount, declineAccount, isDeclined, } from './interlock.js';
10
- export { type AccountMapper, acceptVaultRoute, DEFAULT_ROUTE_PREFIX, declineVaultRoute, mutateVaultRoster, type ProjectionOptions, projectVaultRoster, type QuotaReceipt, readVaultRoster, recordVaultQuota, refreshVaultRoster, resolveVaultPrimary, type VaultPrimary, type VaultPrimaryBinding, type VaultPrimaryUnavailableReason, type VaultRosterFile, type VaultRosterRow, vaultRoutingRows, } from './roster.js';
10
+ export { type AccountMapper, acceptVaultRoute, DEFAULT_ROUTE_PREFIX, declineVaultRoute, mutateVaultRoster, type ProjectionOptions, projectVaultRoster, type QuotaReceipt, readVaultRoster, recordVaultQuota, refreshVaultRoster, resolveVaultPrimary, type VaultPrimary, type VaultPrimaryBinding, type VaultPrimaryUnavailableReason, type VaultRosterFile, type VaultRosterLockContext, type VaultRosterRow, vaultRoutingRows, } from './roster.js';
11
11
  /**
12
12
  * Connect the client that lists and fetches this consumer's vault credentials
13
13
  * on the request path. `connectionFile` is required: this library
@@ -86,11 +86,16 @@ export declare function projectVaultRoster(previous: VaultRosterFile | undefined
86
86
  */
87
87
  export declare function vaultRoutingRows(roster: VaultRosterFile | undefined): RoutingRow[];
88
88
  export declare function readVaultRoster(path: string): Promise<VaultRosterFile | undefined>;
89
+ /** The ownership assertion available to a roster mutation callback. */
90
+ export interface VaultRosterLockContext {
91
+ assertOwned: () => Promise<void>;
92
+ }
89
93
  /**
90
94
  * Read, change and write the roster under its write lock. `change` returns
91
- * undefined to leave the file as it is.
95
+ * undefined to leave the file as it is. Call `assertOwned()` immediately
96
+ * before any rename performed inside `change`; if it throws, do not rename.
92
97
  */
93
- export declare function mutateVaultRoster<T>(path: string, change: (current: VaultRosterFile | undefined) => Promise<{
98
+ export declare function mutateVaultRoster<T>(path: string, change: (current: VaultRosterFile | undefined, context: VaultRosterLockContext) => Promise<{
94
99
  next?: VaultRosterFile;
95
100
  result: T;
96
101
  }> | {
@@ -313,12 +313,15 @@ const WRITE_LOCK = {
313
313
  const DISCOVERY_LEASE = { name: 'claustrum-roster', ttlMs: 30_000 };
314
314
  /**
315
315
  * Read, change and write the roster under its write lock. `change` returns
316
- * undefined to leave the file as it is.
316
+ * undefined to leave the file as it is. Call `assertOwned()` immediately
317
+ * before any rename performed inside `change`; if it throws, do not rename.
317
318
  */
318
319
  export async function mutateVaultRoster(path, change) {
319
320
  return withLock(path, WRITE_LOCK, async (lock) => {
320
321
  const current = await readVaultRoster(path);
321
- const { next, result } = await change(current);
322
+ const { next, result } = await change(current, {
323
+ assertOwned: () => lock.assertOwned(),
324
+ });
322
325
  if (next) {
323
326
  await lock.assertOwned();
324
327
  await writeJsonAtomic(path, next);
@@ -25,5 +25,9 @@ export declare function acquireRefreshFileLock(options: {
25
25
  now?: () => number;
26
26
  renew?: boolean;
27
27
  renewIntervalMs?: number;
28
- onStep?: (step: 'stale-marker-stat' | 'stale-marker-claimed' | 'stale-lock-confirmed' | 'eviction-marker-acquired' | 'renewal-owner-confirmed' | 'renewal-marker-unavailable' | 'renewal-write-fenced' | 'renewal-write-ready' | 'relinquish-read' | 'renewal-finished' | 'release-owner-confirmed') => void | Promise<void>;
28
+ /** Notifies a live-owner refusal without awaiting; observer failures are ignored. */
29
+ onContended?: () => void;
30
+ /** Synchronous, never awaited; throws and returned promise/thenable rejections are isolated. */
31
+ onRenewalTimer?: (event: 'scheduled' | 'cancelled') => void;
32
+ onStep?: (step: 'stale-marker-stat' | 'stale-marker-claimed' | 'stale-lock-observed' | 'stale-lock-confirmed' | 'stale-lock-removed' | 'stale-lock-recreated' | 'eviction-marker-acquired' | 'renewal-owner-confirmed' | 'renewal-marker-unavailable' | 'renewal-write-fenced' | 'renewal-write-ready' | 'relinquish-read' | 'renewal-finished' | 'release-owner-confirmed') => void | Promise<void>;
29
33
  }): Promise<RefreshFileLock | null>;
@@ -24,6 +24,27 @@ export async function acquireRefreshFileLock(options) {
24
24
  const lostPromise = new Promise((resolve) => {
25
25
  resolveLoss = resolve;
26
26
  });
27
+ function notifyTimerStep(event) {
28
+ try {
29
+ const result = options.onRenewalTimer?.(event);
30
+ if (result &&
31
+ (typeof result === 'object' || typeof result === 'function') &&
32
+ 'then' in result &&
33
+ typeof result.then === 'function') {
34
+ void Promise.resolve(result).catch(() => { });
35
+ }
36
+ }
37
+ catch {
38
+ // Timer observer failures must not replace the lock operation result.
39
+ }
40
+ }
41
+ function cancelRenewalTimer() {
42
+ if (!renewTimer)
43
+ return;
44
+ clearRefreshLockRenewalTimeout(renewTimer);
45
+ renewTimer = null;
46
+ notifyTimerStep('cancelled');
47
+ }
27
48
  function recordLoss(reason, owner) {
28
49
  if (loss || released)
29
50
  return;
@@ -37,10 +58,7 @@ export async function acquireRefreshFileLock(options) {
37
58
  ? { observedExpiresAt: owner.expiresAt }
38
59
  : {}),
39
60
  });
40
- if (renewTimer) {
41
- clearRefreshLockRenewalTimeout(renewTimer);
42
- renewTimer = null;
43
- }
61
+ cancelRenewalTimer();
44
62
  resolveLoss(loss);
45
63
  }
46
64
  let renewalInFlight = null;
@@ -211,9 +229,10 @@ export async function acquireRefreshFileLock(options) {
211
229
  }
212
230
  return true;
213
231
  }
214
- // Marker loss after a write may mean our record replaced a successor's.
215
- // Delete only a record still owned by us; a concurrent successor write can
216
- // then yield zero winners, never two.
232
+ // Marker loss after a write may mean another contender has taken over.
233
+ // Delete only a record still owned by us, so an already-installed
234
+ // successor is preserved. The owner read and the removal are not atomic:
235
+ // a successor record written between them can still be deleted.
217
236
  async function relinquishLockAfterMarkerLoss() {
218
237
  for (let attempt = 0; attempt < MAX_STEAL_ATTEMPTS; attempt++) {
219
238
  if (options.onStep)
@@ -241,6 +260,7 @@ export async function acquireRefreshFileLock(options) {
241
260
  return;
242
261
  const intervalMs = options.renewIntervalMs ?? Math.max(1_000, Math.floor(options.ttlMs / 3));
243
262
  renewTimer = setRefreshLockRenewalTimeout(() => {
263
+ renewTimer = null;
244
264
  const renewal = (async () => {
245
265
  let shouldReschedule = !released;
246
266
  try {
@@ -333,6 +353,22 @@ export async function acquireRefreshFileLock(options) {
333
353
  }, intervalMs);
334
354
  if ('unref' in renewTimer)
335
355
  renewTimer.unref();
356
+ notifyTimerStep('scheduled');
357
+ }
358
+ function contended() {
359
+ try {
360
+ const result = options.onContended?.();
361
+ if (result &&
362
+ (typeof result === 'object' || typeof result === 'function') &&
363
+ 'then' in result &&
364
+ typeof result.then === 'function') {
365
+ void Promise.resolve(result).catch(() => { });
366
+ }
367
+ }
368
+ catch {
369
+ // Observers cannot change whether the lock is acquired.
370
+ }
371
+ return null;
336
372
  }
337
373
  let acquired = await tryAcquire();
338
374
  if (!acquired) {
@@ -341,7 +377,9 @@ export async function acquireRefreshFileLock(options) {
341
377
  if (acquired)
342
378
  break;
343
379
  if (await lockIsLive())
344
- return null;
380
+ return contended();
381
+ if (options.onStep)
382
+ await options.onStep('stale-lock-observed');
345
383
  try {
346
384
  if (!(await tryAcquireEvictionMarker())) {
347
385
  await backoff();
@@ -360,7 +398,7 @@ export async function acquireRefreshFileLock(options) {
360
398
  }
361
399
  try {
362
400
  if (await lockIsLive())
363
- return null;
401
+ return contended();
364
402
  // Verify marker ownership before removing a lock found not live.
365
403
  if (!(await ownsEvictionMarker()))
366
404
  return null;
@@ -374,14 +412,18 @@ export async function acquireRefreshFileLock(options) {
374
412
  // Fence check 3: re-verify ownership after removing the stale lock.
375
413
  if (!(await ownsEvictionMarker()))
376
414
  return null;
415
+ if (options.onStep)
416
+ await options.onStep('stale-lock-removed');
377
417
  acquired = await tryAcquire();
378
418
  if (!acquired)
379
419
  return null;
420
+ if (options.onStep)
421
+ await options.onStep('stale-lock-recreated');
380
422
  // Fence check 4: re-verify ownership after acquiring the lock. If the
381
423
  // marker was stolen between tryAcquire and this check, release the
382
424
  // just-acquired lock and return null (fail-closed).
383
425
  if (!(await ownsEvictionMarker())) {
384
- await rm(lockPath, { recursive: true, force: true }).catch(() => { });
426
+ await relinquishLockAfterMarkerLoss();
385
427
  acquired = false;
386
428
  return null;
387
429
  }
@@ -429,10 +471,7 @@ export async function acquireRefreshFileLock(options) {
429
471
  },
430
472
  release: async () => {
431
473
  released = true;
432
- if (renewTimer) {
433
- clearRefreshLockRenewalTimeout(renewTimer);
434
- renewTimer = null;
435
- }
474
+ cancelRenewalTimer();
436
475
  await renewalInFlight;
437
476
  for (let attempt = 0; attempt < MAX_STEAL_ATTEMPTS; attempt++) {
438
477
  try {
@@ -24,20 +24,29 @@ export class RpcRequestError extends Error {
24
24
  const MAX_BODY_BYTES = 1_000_000;
25
25
  /** The request body exceeded the cap; answered 413. */
26
26
  class BodyTooLargeError extends Error {
27
+ declaredOversize;
28
+ constructor(declaredOversize) {
29
+ super('body too large');
30
+ this.declaredOversize = declaredOversize;
31
+ }
27
32
  }
28
33
  function readBody(req) {
29
34
  return new Promise((resolve, reject) => {
30
- const tooLarge = () => {
31
- // Keep reading and discarding the rest, so the client finishes sending
32
- // and can read the 413 instead of seeing a reset connection.
35
+ const tooLarge = (declaredOversize = false) => {
36
+ // Streamed overflow drains without closing at response finish: doing so
37
+ // caused EPIPE on a Bun 1.4.2 uploader. Declared oversize also drains, but
38
+ // half-closes after the 413: Bun 1.3.14 reused refused fetch connections
39
+ // despite Connection: close. Half-close preserved complete 413 JSON for
40
+ // tested fetch and slow declared-length uploaders on both runtimes (see
41
+ // research/load-probe/RPC-413-CLOSE.md for the delivery/lifecycle samples).
33
42
  req.removeAllListeners('data');
34
43
  req.on('data', () => { });
35
44
  req.resume();
36
- reject(new BodyTooLargeError('body too large'));
45
+ reject(new BodyTooLargeError(declaredOversize));
37
46
  };
38
47
  const declared = Number(req.headers['content-length']);
39
48
  if (Number.isFinite(declared) && declared > MAX_BODY_BYTES) {
40
- tooLarge();
49
+ tooLarge(true);
41
50
  return;
42
51
  }
43
52
  const chunks = [];
@@ -133,8 +142,21 @@ export async function startRpcServer(options) {
133
142
  catch (error) {
134
143
  if (!(error instanceof BodyTooLargeError))
135
144
  throw error;
136
- // Close the connection after answering: the rest of the oversized
137
- // body is not worth keeping the socket for.
145
+ if (error.declaredOversize) {
146
+ const socket = req.socket;
147
+ res.once('finish', () => {
148
+ socket.end();
149
+ // Bound an abandoned upload to two seconds, matching the server's
150
+ // default request-delivery timeout. end() alone left Bun 1.3.14
151
+ // compatibility sockets open.
152
+ const timer = setTimeout(() => socket.destroy(), 2_000);
153
+ timer.unref();
154
+ req.once('end', () => socket.destroy());
155
+ socket.once('close', () => clearTimeout(timer));
156
+ if (req.complete)
157
+ socket.destroy();
158
+ });
159
+ }
138
160
  return json(413, { error: 'body too large' }, { connection: 'close' });
139
161
  }
140
162
  let params;
@@ -19,7 +19,7 @@ export interface PoolLockSpec extends Partial<PoolLockOptions> {
19
19
  */
20
20
  export declare const POOL_LOCK_DEFAULTS: Readonly<PoolLockOptions>;
21
21
  export type LockEvent = {
22
- type: 'acquired' | 'released';
22
+ type: 'acquired' | 'released' | 'contended';
23
23
  name: string;
24
24
  path: string;
25
25
  };
@@ -17,6 +17,24 @@ export const POOL_LOCK_DEFAULTS = Object.freeze({
17
17
  * expired during the wait.
18
18
  */
19
19
  export async function acquirePoolLock(spec, defaults, env) {
20
+ const emit = (type) => {
21
+ try {
22
+ const result = env.onLockEvent?.({
23
+ type,
24
+ name: spec.name,
25
+ path: spec.path,
26
+ });
27
+ if (result &&
28
+ (typeof result === 'object' || typeof result === 'function') &&
29
+ 'then' in result &&
30
+ typeof result.then === 'function') {
31
+ void Promise.resolve(result).catch(() => { });
32
+ }
33
+ }
34
+ catch {
35
+ // Lock observers must not affect acquisition or release.
36
+ }
37
+ };
20
38
  const options = { ...defaults, ...definedOnly(spec) };
21
39
  const started = performance.now();
22
40
  for (;;) {
@@ -26,6 +44,7 @@ export async function acquirePoolLock(spec, defaults, env) {
26
44
  ttlMs: options.ttlMs,
27
45
  now: env.now,
28
46
  renew: options.renew,
47
+ onContended: () => emit('contended'),
29
48
  ...(options.renewIntervalMs !== undefined
30
49
  ? { renewIntervalMs: options.renewIntervalMs }
31
50
  : {}),
@@ -36,22 +55,14 @@ export async function acquirePoolLock(spec, defaults, env) {
36
55
  : {}),
37
56
  });
38
57
  if (lock) {
39
- env.onLockEvent?.({
40
- type: 'acquired',
41
- name: spec.name,
42
- path: spec.path,
43
- });
58
+ emit('acquired');
44
59
  const held = {
45
60
  name: spec.name,
46
61
  path: spec.path,
47
62
  assertOwned: () => lock.assertOwned(),
48
63
  release: async () => {
49
64
  await lock.release();
50
- env.onLockEvent?.({
51
- type: 'released',
52
- name: spec.name,
53
- path: spec.path,
54
- });
65
+ emit('released');
55
66
  },
56
67
  };
57
68
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cortexkit/common-auth",
3
- "version": "0.9.4",
3
+ "version": "0.10.0",
4
4
  "description": "Shared code for the CortexKit auth plugins: account pool, quota and routing, commands and auth menu, OpenCode 2 hooks, Claustrum custody, and plumbing (loopback RPC, file locks, logger, sidebar state, TUI preferences and build).",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -85,7 +85,7 @@
85
85
  "dist/"
86
86
  ],
87
87
  "scripts": {
88
- "build": "bun scripts/check-installed-ranges.mjs && tsc -p tsconfig.build.json",
88
+ "build": "bun scripts/check-local-deps.mjs && bun scripts/check-installed-ranges.mjs && tsc -p tsconfig.build.json",
89
89
  "test": "bun test",
90
90
  "typecheck": "tsc --noEmit",
91
91
  "types": "bun run typecheck",
@@ -94,7 +94,7 @@
94
94
  "lint": "biome check .",
95
95
  "prepublishOnly": "bun run build",
96
96
  "check:ranges": "bun scripts/check-installed-ranges.mjs",
97
- "check:claustrum-040": "bun scripts/check-claustrum-040.mjs",
97
+ "check:claustrum-floor": "bun scripts/check-claustrum-floor.mjs",
98
98
  "check:opentui-0511": "bun scripts/check-opentui-0511.mjs"
99
99
  },
100
100
  "dependencies": {
@@ -104,7 +104,7 @@
104
104
  "@opentui/core": ">=0.5.11",
105
105
  "@opentui/solid": ">=0.5.11",
106
106
  "solid-js": "1.9.12",
107
- "@cortexkit/claustrum-client": ">=0.4.0",
107
+ "@cortexkit/claustrum-client": ">=0.6.2",
108
108
  "@opencode/plugin": ">=2.0.21"
109
109
  },
110
110
  "peerDependenciesMeta": {
@@ -126,7 +126,7 @@
126
126
  },
127
127
  "devDependencies": {
128
128
  "@biomejs/biome": "2.5.14",
129
- "@cortexkit/claustrum-client": "0.5.0",
129
+ "@cortexkit/claustrum-client": "0.6.2",
130
130
  "@opencode/plugin": "2.0.21",
131
131
  "@opentui/core": ">=0.5.12",
132
132
  "@opentui/solid": ">=0.5.12",