@cortexkit/common-auth 0.9.5 → 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.
@@ -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.5",
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",