@cortexkit/common-auth 0.4.6 → 0.6.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.
@@ -1,6 +1,7 @@
1
1
  export type { AtomicWriteOptions } from './atomic-write.js';
2
2
  export { writeJsonAtomic } from './atomic-write.js';
3
3
  export { WRITER_LOCK_CONSTANTS } from './lock-constants.js';
4
+ export type { LockLoss, RefreshFileLock } from './refresh-file-lock.js';
4
5
  export { acquireRefreshFileLock, isLostMarkerRaceError, } from './refresh-file-lock.js';
5
- export type { LockOptions } from './with-lock.js';
6
+ export type { LockOptions, LockOwnershipDetails } from './with-lock.js';
6
7
  export { LockContentionError, LockOwnershipError, lockPathFor, withLock, } from './with-lock.js';
@@ -1,4 +1,18 @@
1
1
  export declare function isLostMarkerRaceError(error: unknown): boolean;
2
+ export interface LockLoss {
3
+ readonly reason: 'taken-over' | 'expired' | 'unreadable' | 'renewal-failed' | 'marker-lost';
4
+ readonly expectedOwnerId: string;
5
+ readonly observedOwnerId?: string;
6
+ readonly observedExpiresAt?: number;
7
+ }
8
+ export interface RefreshFileLock {
9
+ readonly ownerId: string;
10
+ assertOwned(): Promise<void>;
11
+ release(): Promise<void>;
12
+ /** Resolves once on detected loss; remains pending after an owner's release. */
13
+ whenLost(): Promise<LockLoss>;
14
+ hasLost(): boolean;
15
+ }
2
16
  export declare function acquireRefreshFileLock(options: {
3
17
  name: string;
4
18
  ttlMs: number;
@@ -12,7 +26,4 @@ export declare function acquireRefreshFileLock(options: {
12
26
  renew?: boolean;
13
27
  renewIntervalMs?: number;
14
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>;
15
- }): Promise<{
16
- release: () => Promise<void>;
17
- assertOwned: () => Promise<void>;
18
- } | null>;
29
+ }): Promise<RefreshFileLock | null>;
@@ -19,6 +19,30 @@ export async function acquireRefreshFileLock(options) {
19
19
  const now = options.now ?? Date.now;
20
20
  let renewTimer = null;
21
21
  let released = false;
22
+ let loss;
23
+ let resolveLoss;
24
+ const lostPromise = new Promise((resolve) => {
25
+ resolveLoss = resolve;
26
+ });
27
+ function recordLoss(reason, owner) {
28
+ if (loss || released)
29
+ return;
30
+ loss = Object.freeze({
31
+ reason,
32
+ expectedOwnerId: ownerId,
33
+ ...(typeof owner?.ownerId === 'string'
34
+ ? { observedOwnerId: owner.ownerId }
35
+ : {}),
36
+ ...(typeof owner?.expiresAt === 'number'
37
+ ? { observedExpiresAt: owner.expiresAt }
38
+ : {}),
39
+ });
40
+ if (renewTimer) {
41
+ clearRefreshLockRenewalTimeout(renewTimer);
42
+ renewTimer = null;
43
+ }
44
+ resolveLoss(loss);
45
+ }
22
46
  let renewalInFlight = null;
23
47
  // Only the owner of this exclusively-created marker may remove or renew a
24
48
  // lock. A contender recovering a stale marker can accidentally rename a newer
@@ -203,7 +227,7 @@ export async function acquireRefreshFileLock(options) {
203
227
  }
204
228
  }
205
229
  function scheduleRenewal() {
206
- if (!options.renew || released)
230
+ if (!options.renew || released || loss)
207
231
  return;
208
232
  const intervalMs = options.renewIntervalMs ?? Math.max(1_000, Math.floor(options.ttlMs / 3));
209
233
  renewTimer = setRefreshLockRenewalTimeout(() => {
@@ -213,19 +237,25 @@ export async function acquireRefreshFileLock(options) {
213
237
  const markerAcquired = await withEvictionMarker(async () => {
214
238
  const owner = await readOwner();
215
239
  const currentNow = now();
216
- if (released || owner?.ownerId !== ownerId) {
240
+ if (released || loss) {
241
+ shouldReschedule = false;
242
+ return;
243
+ }
244
+ if (owner?.ownerId !== ownerId) {
245
+ recordLoss('taken-over', owner);
217
246
  shouldReschedule = false;
218
247
  return;
219
248
  }
220
249
  // An expired lease is no longer ours to extend; a contender may
221
250
  // already be eligible to acquire it.
222
- if (Number(owner?.expiresAt) <= currentNow) {
251
+ if (!(Number(owner?.expiresAt) > currentNow)) {
252
+ recordLoss('expired', owner);
223
253
  shouldReschedule = false;
224
254
  return;
225
255
  }
226
256
  if (options.onStep)
227
257
  await options.onStep('renewal-owner-confirmed');
228
- if (released) {
258
+ if (released || loss) {
229
259
  shouldReschedule = false;
230
260
  return;
231
261
  }
@@ -233,7 +263,7 @@ export async function acquireRefreshFileLock(options) {
233
263
  return;
234
264
  if (options.onStep)
235
265
  await options.onStep('renewal-write-fenced');
236
- if (released) {
266
+ if (released || loss) {
237
267
  shouldReschedule = false;
238
268
  return;
239
269
  }
@@ -241,10 +271,15 @@ export async function acquireRefreshFileLock(options) {
241
271
  return;
242
272
  if (options.onStep)
243
273
  await options.onStep('renewal-write-ready');
274
+ if (released || loss) {
275
+ shouldReschedule = false;
276
+ return;
277
+ }
244
278
  await writeOwner();
245
279
  if (!(await ownsEvictionMarker())) {
246
280
  // If marker ownership cannot be read, stop claiming the lease
247
281
  // and remove only a record that still carries our owner id.
282
+ recordLoss('marker-lost');
248
283
  shouldReschedule = false;
249
284
  await relinquishLockAfterMarkerLoss();
250
285
  return;
@@ -255,7 +290,17 @@ export async function acquireRefreshFileLock(options) {
255
290
  }
256
291
  }
257
292
  catch {
258
- // Transient marker and filesystem failures retry on the next interval.
293
+ // Retry transient failures only while the lease can still be verified.
294
+ try {
295
+ const owner = await readOwner();
296
+ if (owner?.ownerId !== ownerId)
297
+ recordLoss('taken-over', owner);
298
+ else if (!(Number(owner?.expiresAt) > now()))
299
+ recordLoss('expired', owner);
300
+ }
301
+ catch {
302
+ recordLoss('renewal-failed');
303
+ }
259
304
  }
260
305
  finally {
261
306
  if (options.onStep) {
@@ -266,7 +311,7 @@ export async function acquireRefreshFileLock(options) {
266
311
  // Errors from the onStep observer must not reject the renewal.
267
312
  }
268
313
  }
269
- if (shouldReschedule && !released)
314
+ if (shouldReschedule && !released && !loss)
270
315
  scheduleRenewal();
271
316
  }
272
317
  })();
@@ -341,20 +386,35 @@ export async function acquireRefreshFileLock(options) {
341
386
  return null;
342
387
  scheduleRenewal();
343
388
  return {
389
+ ownerId,
390
+ whenLost: () => lostPromise,
391
+ hasLost: () => loss !== undefined,
344
392
  assertOwned: async () => {
393
+ let observed;
345
394
  try {
346
395
  const owner = await readOwner();
396
+ observed = owner;
347
397
  if (!released &&
398
+ !loss &&
348
399
  owner?.ownerId === ownerId &&
349
400
  Number(owner?.expiresAt) > now())
350
401
  return;
402
+ recordLoss(owner?.ownerId !== ownerId ? 'taken-over' : 'expired', owner);
351
403
  }
352
404
  catch {
353
405
  // Unreadable ownership is not evidence of a valid lease.
406
+ recordLoss('unreadable');
354
407
  }
355
408
  throw new LockOwnershipError({
356
409
  target: options.path,
357
410
  name: options.name,
411
+ expectedOwnerId: ownerId,
412
+ ...(typeof observed?.ownerId === 'string'
413
+ ? { observedOwnerId: observed.ownerId }
414
+ : {}),
415
+ ...(typeof observed?.expiresAt === 'number'
416
+ ? { observedExpiresAt: observed.expiresAt }
417
+ : {}),
358
418
  });
359
419
  },
360
420
  release: async () => {
@@ -11,15 +11,16 @@ export declare class LockContentionError extends Error {
11
11
  timeoutMs: number;
12
12
  });
13
13
  }
14
+ export interface LockOwnershipDetails {
15
+ target: string;
16
+ name: string;
17
+ expectedOwnerId?: string;
18
+ observedOwnerId?: string;
19
+ observedExpiresAt?: number;
20
+ }
14
21
  export declare class LockOwnershipError extends Error {
15
- readonly details: {
16
- target: string;
17
- name: string;
18
- };
19
- constructor(details: {
20
- target: string;
21
- name: string;
22
- });
22
+ readonly details: LockOwnershipDetails;
23
+ constructor(details: LockOwnershipDetails);
23
24
  }
24
25
  export interface LockOptions {
25
26
  name: string;
@@ -1,8 +1,8 @@
1
1
  export type { OpenCode2AuthFailureKind } from './errors.js';
2
2
  export { OpenCode2AuthError } from './errors.js';
3
- export { applyHeaderEdits, DEFAULT_MAX_RECORDS, installOpenCode2Auth, } from './install.js';
3
+ export { ATTEMPT_HEADER, applyHeaderEdits, DEFAULT_MAX_RECORDS, installOpenCode2Auth, } from './install.js';
4
4
  export type { FormAnswer, PoolAuthorization, PoolLoginMethod, RegisterOpenCode2AuthMethodsOptions, } from './integration.js';
5
5
  export { isPlaceholderCredential, PLACEHOLDER_LIFETIME_MS, PLACEHOLDER_METADATA_KEY, PLACEHOLDER_PREFIX, placeholderCredential, placeholderSecret, registerOpenCode2AuthMethods, } from './integration.js';
6
6
  export type { ServerSentEvent } from './sse.js';
7
7
  export { watchServerSentEvents } from './sse.js';
8
- export type { AccountHeadersResult, AccountRequest, Attempt, AttemptEndReason, AttemptOutcome, ChooseAccountInput, EventVerdict, HeaderEdits, HostError, InstallOpenCode2AuthOptions, LimitSignal, OpenCode2AuthAdapter, OpenCode2AuthEventName, OpenCode2AuthEvents, OpenCode2AuthInstallation, OpenCode2AuthLogger, OpenCode2HookContext, RequestKind, RequestScope, RetryReason, SelectingHook, Transport, } from './types.js';
8
+ export type { AccountHeadersResult, AccountRequest, Attempt, AttemptEndReason, AttemptOutcome, ChooseAccountInput, EventVerdict, HeaderEdits, HostError, InstallOpenCode2AuthOptions, LimitSignal, OpenCode2AuthAdapter, OpenCode2AuthEventName, OpenCode2AuthEvents, OpenCode2AuthInstallation, OpenCode2AuthLogger, OpenCode2HookContext, RequestKind, RequestScope, ResponseAccount, RetryReason, SelectingHook, Transport, } from './types.js';
@@ -1,4 +1,4 @@
1
1
  export { OpenCode2AuthError } from './errors.js';
2
- export { applyHeaderEdits, DEFAULT_MAX_RECORDS, installOpenCode2Auth, } from './install.js';
2
+ export { ATTEMPT_HEADER, applyHeaderEdits, DEFAULT_MAX_RECORDS, installOpenCode2Auth, } from './install.js';
3
3
  export { isPlaceholderCredential, PLACEHOLDER_LIFETIME_MS, PLACEHOLDER_METADATA_KEY, PLACEHOLDER_PREFIX, placeholderCredential, placeholderSecret, registerOpenCode2AuthMethods, } from './integration.js';
4
4
  export { watchServerSentEvents } from './sse.js';
@@ -1,15 +1,25 @@
1
1
  import type { HeaderEdits, InstallOpenCode2AuthOptions, OpenCode2AuthAdapter, OpenCode2AuthInstallation, OpenCode2HookContext } from './types.js';
2
2
  export declare const DEFAULT_MAX_RECORDS = 512;
3
+ /**
4
+ * The request header `model.request` sets to the attempt it started. The
5
+ * host builds the HTTP request and the WebSocket handshake from the headers
6
+ * `model.request` leaves, so `http.request` and `experimental.ws.handshake`
7
+ * read it to find their own attempt among several of one session and kind,
8
+ * and remove it: it never reaches the wire.
9
+ */
10
+ export declare const ATTEMPT_HEADER = "x-common-auth-attempt";
3
11
  /** Applies edits to a plain header record, replacing every spelling of each name. */
4
12
  export declare function applyHeaderEdits(target: Record<string, string>, edits: HeaderEdits): void;
5
13
  /**
6
14
  * Installs multi-account auth on OpenCode 2's own provider drivers. Every
7
15
  * hook is scoped to `adapter.providerID`:
8
16
  *
9
- * - `model.request` picks the account for the request's `sessionID:kind`,
10
- * which starts a new attempt, and sets its headers;
11
- * - `http.request` and `experimental.ws.handshake` set them again, because
12
- * the host applies its own credential after `model.request`;
17
+ * - `model.request` picks the account for one model call of a session and
18
+ * request kind, which starts a new attempt, sets its headers and marks the
19
+ * request with the attempt (`ATTEMPT_HEADER`);
20
+ * - `http.request` and `experimental.ws.handshake` find the attempt by that
21
+ * mark, remove it and set the account headers again, because the host
22
+ * applies its own credential after `model.request`;
13
23
  * - `experimental.ws.send` (only when the adapter rewrites frames) rewrites
14
24
  * each outgoing frame;
15
25
  * - `http.response` and `experimental.ws.receive` read quota, refusals,
@@ -19,19 +29,19 @@ export declare function applyHeaderEdits(target: Record<string, string>, edits:
19
29
  * before any output, so `model.request` runs again and can pick another
20
30
  * account, and refuses to retry once output has started.
21
31
  *
22
- * Attribution. An HTTP response belongs to the attempt whose `http.request`
23
- * produced its request object, and to nothing else. The host hands
24
- * `http.response` the request object the `http.request` hooks left, so a
25
- * different object means a later hook replaced it; nothing then proves which
26
- * send the response answers (an earlier attempt's send may still be in
27
- * flight, and only the newest attempt of each session and kind is kept), and
28
- * its feedback is dropped rather than guessed by recency. No marker can ride
29
- * on the request instead: the host builds the wire request from that same
30
- * object, so a marker would be sent to the provider. A WebSocket frame
31
- * belongs to the newest attempt of its session and kind only while that
32
- * attempt went out over WebSocket and has not ended: the host runs one
33
- * exchange at a time per session socket, so frames between one handshake
34
- * and the next belong to the earlier attempt. Anything else is attributed to
35
- * no attempt and reaches no adapter callback or listener.
32
+ * Attribution. A send belongs to the attempt named by the mark
33
+ * `model.request` left on it, so several attempts of one session and kind
34
+ * can be in flight at once, each on its own account. An HTTP response
35
+ * belongs to the attempt whose `http.request` produced its request object,
36
+ * and to nothing else. The host hands `http.response` the request object the
37
+ * `http.request` hooks left, so a different object means a later hook
38
+ * replaced it; nothing then proves which send the response answers, and its
39
+ * feedback is dropped rather than guessed by recency. A WebSocket frame
40
+ * names no attempt, so frames go to the newest open attempt of their session
41
+ * and kind that went out over WebSocket, and an older one is abandoned as
42
+ * soon as a newer attempt of that session and kind begins or goes out over
43
+ * WebSocket: the host runs one exchange at a time on a session's socket.
44
+ * Anything else is attributed to no attempt and reaches no adapter callback
45
+ * or listener.
36
46
  */
37
47
  export declare function installOpenCode2Auth<Q, A = unknown>(ctx: OpenCode2HookContext, adapter: OpenCode2AuthAdapter<Q, A>, options?: InstallOpenCode2AuthOptions): Promise<OpenCode2AuthInstallation<Q, A>>;