@cortexkit/common-auth 0.8.0 → 0.9.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,18 @@
1
1
  const SECRET_KEY_EXACT = /^(authorization|x-api-key|cookie|set-cookie|refresh|access|token)$/i;
2
2
  const TOKEN_VALUE = /\b(Bearer\s+[\w.-]+|sk-[\w-]+|eyJ[\w.-]+)\b|ckh_[A-Za-z0-9_-]{20,}/g;
3
3
  const MASK = '***REDACTED***';
4
+ // How many errors deep a cause chain is followed. A chain this long is
5
+ // already unreadable in a log line, and an unbounded one (each cause a fresh
6
+ // object, so the cycle check never trips) must not grow the line forever.
7
+ const MAX_ERROR_DEPTH = 8;
8
+ // Error's own fields are non-enumerable, so a plain entry walk renders an
9
+ // Error as `{}` and the name, message and stack a plugin logged are lost.
10
+ // The tag check also recognises an Error from another realm (a worker or a
11
+ // vm context), whose prototype is not this realm's Error.
12
+ function isError(value) {
13
+ return (value instanceof Error ||
14
+ Object.prototype.toString.call(value) === '[object Error]');
15
+ }
4
16
  export function createRedactor(options = {}) {
5
17
  const patterns = [TOKEN_VALUE, ...(options.extraValuePatterns ?? [])].map((pattern) => new RegExp(pattern.source, pattern.flags.includes('g') ? pattern.flags : `${pattern.flags}g`));
6
18
  function isSecretKey(key) {
@@ -12,7 +24,27 @@ export function createRedactor(options = {}) {
12
24
  (normalized.endsWith('token') && !normalized.endsWith('tokens')) ||
13
25
  !!options.extraSecretKeys?.(normalized));
14
26
  }
15
- function walk(value, keys, seen) {
27
+ // The fields every log reader wants first, then whatever else the error
28
+ // carries as its own enumerable properties (which is all an Error rendered
29
+ // as before). Every entry goes through the same key and string scrubbing as
30
+ // any other object, so a token in a message or stack is still masked.
31
+ function errorEntries(error) {
32
+ const fields = error;
33
+ const entries = [
34
+ ['name', error.name],
35
+ ['message', error.message],
36
+ ['stack', error.stack],
37
+ ];
38
+ for (const key of ['code', 'status', 'cause'])
39
+ if (fields[key] !== undefined)
40
+ entries.push([key, fields[key]]);
41
+ const listed = new Set(entries.map(([key]) => key));
42
+ for (const entry of Object.entries(error))
43
+ if (!listed.has(entry[0]))
44
+ entries.push(entry);
45
+ return entries;
46
+ }
47
+ function walk(value, keys, seen, errorDepth = 0) {
16
48
  if (typeof value === 'string') {
17
49
  return patterns.reduce((text, pattern) => text.replace(pattern, MASK), value);
18
50
  }
@@ -23,11 +55,17 @@ export function createRedactor(options = {}) {
23
55
  seen.add(value);
24
56
  try {
25
57
  if (Array.isArray(value))
26
- return value.map((item) => walk(item, keys, seen));
58
+ return value.map((item) => walk(item, keys, seen, errorDepth));
59
+ const error = isError(value);
60
+ if (error && errorDepth >= MAX_ERROR_DEPTH)
61
+ return '[Truncated]';
62
+ const childDepth = error ? errorDepth + 1 : errorDepth;
27
63
  // Define own properties so a diagnostic __proto__ key cannot change the output prototype.
28
- return Object.fromEntries(Object.entries(value).map(([key, item]) => [
64
+ return Object.fromEntries((error ? errorEntries(value) : Object.entries(value)).map(([key, item]) => [
29
65
  key,
30
- keys && isSecretKey(key) ? MASK : walk(item, keys, seen),
66
+ keys && isSecretKey(key)
67
+ ? MASK
68
+ : walk(item, keys, seen, childDepth),
31
69
  ]));
32
70
  }
33
71
  finally {
@@ -1,7 +1,7 @@
1
1
  import type { ApplyRequest, ApplyResult, RpcNotification } from './notifications.js';
2
2
  import { type DiscoverPortFileOptions, type PortFileEntry } from './port-file.js';
3
3
  export interface RpcClient {
4
- pending: (lastReceivedId: number, sessionId?: string) => Promise<RpcNotification[]>;
4
+ pending: (lastReceivedId: number, sessionId?: string, timeoutMs?: number) => Promise<RpcNotification[]>;
5
5
  apply: (request: ApplyRequest, timeoutMs?: number) => Promise<ApplyResult>;
6
6
  }
7
7
  export declare const DEFAULT_RPC_TIMEOUT_MS = 2000;
@@ -1,3 +1,4 @@
1
+ import { connect } from 'node:net';
1
2
  import { discoverPortFile, } from './port-file.js';
2
3
  export const DEFAULT_RPC_TIMEOUT_MS = 2_000;
3
4
  async function call(dir, expectedPid, discoverOptions, onSelected, method, params, timeoutMs = DEFAULT_RPC_TIMEOUT_MS) {
@@ -5,28 +6,114 @@ async function call(dir, expectedPid, discoverOptions, onSelected, method, param
5
6
  onSelected?.(entry);
6
7
  if (!entry)
7
8
  return null;
8
- const controller = new AbortController();
9
- const timer = setTimeout(() => controller.abort(), timeoutMs);
10
- try {
11
- const res = await fetch(`http://127.0.0.1:${entry.port}/rpc/${method}`, {
12
- method: 'POST',
13
- headers: {
14
- 'content-type': 'application/json',
15
- authorization: `Bearer ${entry.token}`,
16
- },
17
- body: JSON.stringify(params),
18
- signal: controller.signal,
19
- });
20
- if (!res.ok)
21
- return null;
22
- return (await res.json());
23
- }
24
- catch {
25
- return null;
26
- }
27
- finally {
28
- clearTimeout(timer);
29
- }
9
+ // A raw loopback socket never consults runtime HTTP proxy settings, which
10
+ // otherwise can expose the bearer token to a configured proxy. timeoutMs is
11
+ // a total deadline for connect, request and the full response, not idle time.
12
+ return new Promise((resolve) => {
13
+ let socket;
14
+ let settled = false;
15
+ const done = (value) => {
16
+ if (settled)
17
+ return;
18
+ settled = true;
19
+ clearTimeout(timer);
20
+ socket?.destroy();
21
+ resolve(value);
22
+ };
23
+ const timer = setTimeout(() => done(null), timeoutMs);
24
+ try {
25
+ const body = JSON.stringify(params);
26
+ socket = connect({ host: '127.0.0.1', port: entry.port });
27
+ socket.on('connect', () => {
28
+ // HTTP/1.0 avoids chunked responses; explicitly request connection close
29
+ // because older Bun servers can keep delayed HTTP/1.0 replies open.
30
+ socket?.write(`POST /rpc/${method} HTTP/1.0\r\n` +
31
+ `Host: 127.0.0.1:${entry.port}\r\n` +
32
+ 'Connection: close\r\n' +
33
+ 'Content-Type: application/json\r\n' +
34
+ `Content-Length: ${Buffer.byteLength(body)}\r\n` +
35
+ `Authorization: Bearer ${entry.token}\r\n\r\n` +
36
+ body);
37
+ });
38
+ const chunks = [];
39
+ let bytes = 0;
40
+ let headerBytes;
41
+ let contentLength;
42
+ let prefix = Buffer.alloc(0);
43
+ const finishBody = () => {
44
+ if (headerBytes === undefined)
45
+ return done(null);
46
+ try {
47
+ const payload = Buffer.concat(chunks).subarray(headerBytes);
48
+ done(JSON.parse(payload.toString('utf8')));
49
+ }
50
+ catch {
51
+ done(null);
52
+ }
53
+ };
54
+ socket.on('data', (chunk) => {
55
+ if (settled)
56
+ return;
57
+ bytes += chunk.length;
58
+ chunks.push(chunk);
59
+ if (headerBytes === undefined) {
60
+ prefix = Buffer.concat([prefix, chunk]);
61
+ const separator = prefix.indexOf('\r\n\r\n');
62
+ if (separator >= 0)
63
+ headerBytes = separator + 4;
64
+ if ((headerBytes ?? prefix.length) > 16 * 1024)
65
+ return done(null);
66
+ if (headerBytes !== undefined) {
67
+ const headers = prefix
68
+ .subarray(0, separator)
69
+ .toString('latin1')
70
+ .split('\r\n');
71
+ const status = /^HTTP\/1\.[01] (\d{3})(?: |$)/.exec(headers[0] ?? '');
72
+ if (!status || Number(status[1]) < 200 || Number(status[1]) >= 300)
73
+ return done(null);
74
+ for (const header of headers.slice(1)) {
75
+ const colon = header.indexOf(':');
76
+ const name = header.slice(0, colon).toLowerCase();
77
+ const value = header.slice(colon + 1).trim();
78
+ if (name === 'transfer-encoding')
79
+ return done(null);
80
+ if (name === 'content-length') {
81
+ if (contentLength !== undefined || !/^\d+$/.test(value))
82
+ return done(null);
83
+ contentLength = Number(value);
84
+ if (!Number.isSafeInteger(contentLength) ||
85
+ contentLength > 8 * 1024 * 1024)
86
+ return done(null);
87
+ }
88
+ }
89
+ prefix = Buffer.alloc(0);
90
+ }
91
+ }
92
+ const bodyBytes = bytes - (headerBytes ?? bytes);
93
+ if (bodyBytes > 8 * 1024 * 1024)
94
+ return done(null);
95
+ if (contentLength !== undefined) {
96
+ if (bodyBytes > contentLength)
97
+ return done(null);
98
+ // Bun 1.3.14 can keep an async HTTP/1.0 reply open after sending
99
+ // its complete Content-Length body; don't wait for EOF in that case.
100
+ if (bodyBytes === contentLength)
101
+ finishBody();
102
+ }
103
+ });
104
+ socket.on('end', () => {
105
+ if (contentLength !== undefined &&
106
+ bytes - (headerBytes ?? 0) !== contentLength)
107
+ return done(null);
108
+ finishBody();
109
+ });
110
+ socket.on('error', () => done(null));
111
+ socket.on('close', () => done(null));
112
+ }
113
+ catch {
114
+ done(null);
115
+ }
116
+ });
30
117
  }
31
118
  /**
32
119
  * A client for the server in `dir`, preferring `expectedPid`'s.
@@ -52,8 +139,8 @@ export function createRpcClient(dir, expectedPid, onSelected, options = {}) {
52
139
  reportedSelection = true;
53
140
  };
54
141
  return {
55
- async pending(lastReceivedId, sessionId) {
56
- const out = await call(dir, expectedPid, discoverOptions, reportSelected, 'pending-notifications', { lastReceivedId, sessionId });
142
+ async pending(lastReceivedId, sessionId, timeoutMs) {
143
+ const out = await call(dir, expectedPid, discoverOptions, reportSelected, 'pending-notifications', { lastReceivedId, sessionId }, timeoutMs);
57
144
  return out?.messages ?? [];
58
145
  },
59
146
  async apply(request, timeoutMs) {
@@ -19,7 +19,7 @@ export type PoolFailurePhase = 'before-first-write' | 'after-first-write' | 'pul
19
19
  * lock outcomes (a wait that ran out, and a lease found lost); the rest are
20
20
  * refusals and failures of the operation itself.
21
21
  */
22
- export type PoolFailureKind = 'lock-contention' | 'lock-ownership' | 'pending-migration' | 'load-error' | 'unknown-row' | 'invalid-row' | 'invalid-input' | 'id-exists' | 'id-removed' | 'type-mismatch' | 'no-credential' | 'row-disabled' | 'row-protected' | 'duplicate-identity' | 'identity-mismatch' | 'endpoint-mismatch' | 'row-key-changed' | 'invalid-order' | 'refresh-stamp-ahead' | 'unbound-credential' | 'attribution' | 'provider' | 'pull' | 'invalid-quota' | 'invalid-provider-state' | 'after-persist-hook' | 'unexpected';
22
+ export type PoolFailureKind = 'lock-contention' | 'lock-ownership' | 'pending-migration' | 'load-error' | 'unknown-row' | 'invalid-row' | 'invalid-input' | 'id-exists' | 'id-removed' | 'type-mismatch' | 'no-credential' | 'row-disabled' | 'row-protected' | 'duplicate-identity' | 'identity-mismatch' | 'identity-contradicted' | 'endpoint-mismatch' | 'row-key-changed' | 'invalid-order' | 'refresh-stamp-ahead' | 'unbound-credential' | 'attribution' | 'provider' | 'pull' | 'invalid-quota' | 'invalid-provider-state' | 'after-persist-hook' | 'unexpected';
23
23
  /**
24
24
  * The single failure value of every store operation. `committed` is present
25
25
  * only when the operation had already written a credential to the state file
@@ -9,6 +9,8 @@ export interface RowEditor {
9
9
  entry(id: string): Record<string, unknown> | undefined;
10
10
  setEntry(id: string, entry: Record<string, unknown>): void;
11
11
  }
12
+ /** Prefix of a refresh quarantine reason, followed by JSON of the expected and returned identities. */
13
+ export declare const IDENTITY_CONTRADICTED_REASON_PREFIX = "identity-contradicted: ";
12
14
  /** The reason recorded on a row disabled because an earlier row is the same account. */
13
15
  export declare const DUPLICATE_IDENTITY_REASON = "duplicate-identity";
14
16
  /**
@@ -1,3 +1,5 @@
1
+ /** Prefix of a refresh quarantine reason, followed by JSON of the expected and returned identities. */
2
+ export const IDENTITY_CONTRADICTED_REASON_PREFIX = 'identity-contradicted: ';
1
3
  /** The reason recorded on a row disabled because an earlier row is the same account. */
2
4
  export const DUPLICATE_IDENTITY_REASON = 'duplicate-identity';
3
5
  /**
@@ -22,7 +24,13 @@ export function disableIn(tx, id, reason) {
22
24
  return;
23
25
  raw.enabled = false;
24
26
  const entry = tx.entry(id) ?? { credentialEpoch: 1, needsFirstReading: true };
25
- tx.setEntry(id, { ...entry, disabledReason: reason });
27
+ // A routine disable must not erase the evidence needed to refuse enable.
28
+ const quarantined = typeof entry.disabledReason === 'string' &&
29
+ entry.disabledReason.startsWith(IDENTITY_CONTRADICTED_REASON_PREFIX);
30
+ tx.setEntry(id, {
31
+ ...entry,
32
+ disabledReason: quarantined ? entry.disabledReason : reason,
33
+ });
26
34
  }
27
35
  /**
28
36
  * Marks a row enabled: `enabled: true` in the roster row and no
@@ -27,7 +27,11 @@ export interface RefreshOptions {
27
27
  providerLock?: PoolLockSpec;
28
28
  /** Taken after the provider-wide lock in this order, released in reverse. */
29
29
  extraLocks?: readonly PoolLockSpec[];
30
- /** Awaited once the rotation is persisted and the store locks released. */
30
+ /**
31
+ * Awaited once an ordinary rotation is persisted and the store locks released.
32
+ * Never called for `identity-contradicted`: its credential must not propagate
33
+ * as the row's recorded account.
34
+ */
31
35
  onPersisted?: (rowId: string, credential: StoredCredential) => void | Promise<void>;
32
36
  onFailure?: FailureHook;
33
37
  /**
@@ -42,6 +46,18 @@ export type RefreshOutcome = {
42
46
  rowId: string;
43
47
  credential: StoredCredential;
44
48
  identity?: string;
49
+ }
50
+ /**
51
+ * The provider's successor credential is stored bound to expectedIdentity,
52
+ * not returnedIdentity. The row stays disabled until the adapter supplies
53
+ * an identity-validated replacement credential through replace.
54
+ */
55
+ | {
56
+ status: 'identity-contradicted';
57
+ rowId: string;
58
+ expectedIdentity: string;
59
+ returnedIdentity: string;
60
+ credential: StoredCredential;
45
61
  } | {
46
62
  status: 'refused';
47
63
  rowId: string;
@@ -1,11 +1,13 @@
1
+ import { randomUUID } from 'node:crypto';
1
2
  import { PoolOperationError } from './errors.js';
2
3
  import { assertNotInsideHook, runInsideHook } from './hooks.js';
3
- import { recordIdentityIn } from './identity.js';
4
+ import { IDENTITY_CONTRADICTED_REASON_PREFIX, recordIdentityIn, } from './identity.js';
4
5
  import { runOperation, withTransaction } from './mutate.js';
5
6
  import { acceptProviderState, mergedProviderState } from './provider-state.js';
6
7
  import { rotateIn } from './rows.js';
7
8
  import { readRow, refusal, requireBound, rowLockSpec, } from './runtime.js';
8
9
  import { rotationStamp, rotationStampUntrusted, rowLockKey, } from './schema.js';
10
+ import { applyTransition } from './torn.js';
9
11
  function requireRefreshable(id, row) {
10
12
  if (!row)
11
13
  throw refusal('refresh', id, 'unknown-row', `no row ${id} in the pool`);
@@ -137,6 +139,21 @@ export async function refreshRow(rt, id, provider, options = {}) {
137
139
  refresh: result.refresh,
138
140
  expires: result.expires,
139
141
  };
142
+ const contradiction = current.identity !== undefined &&
143
+ result.identity !== undefined &&
144
+ current.identity !== result.identity
145
+ ? {
146
+ expectedIdentity: current.identity,
147
+ returnedIdentity: result.identity,
148
+ }
149
+ : undefined;
150
+ const transition = contradiction
151
+ ? {
152
+ mark: randomUUID(),
153
+ enabled: false,
154
+ reason: `${IDENTITY_CONTRADICTED_REASON_PREFIX}${JSON.stringify(contradiction)}`,
155
+ }
156
+ : undefined;
140
157
  const learnt = current.identity === undefined && result.identity
141
158
  ? result.identity
142
159
  : undefined;
@@ -145,6 +162,11 @@ export async function refreshRow(rt, id, provider, options = {}) {
145
162
  // forward rather than leaving an identity no stamp proves.
146
163
  const stored = await rotateIn(rt, tx, id, credential, {
147
164
  stamp: rotationStamp(prior, now),
165
+ // Write state first with the disable marker, then config below.
166
+ // Once the successor is durable, recovery projects the disable even
167
+ // if config has not landed. A crash before the first rename still
168
+ // loses an in-memory provider reply, as in any ordinary refresh.
169
+ transition,
148
170
  identity: learnt,
149
171
  ...(incoming !== undefined
150
172
  ? {
@@ -152,16 +174,27 @@ export async function refreshRow(rt, id, provider, options = {}) {
152
174
  }
153
175
  : {}),
154
176
  });
177
+ if (transition !== undefined) {
178
+ applyTransition(tx, id, transition);
179
+ await tx.commitConfig();
180
+ }
155
181
  let identity = current.identity;
156
182
  if (learnt !== undefined) {
157
183
  recordIdentityIn(tx, id, learnt);
158
184
  identity = learnt;
159
185
  await tx.commitConfig();
160
186
  }
161
- return { stored, identity, refused: undefined };
187
+ return { stored, identity, contradiction, refused: undefined };
162
188
  });
163
189
  if (commit.refused !== undefined)
164
190
  return { status: 'refused', rowId: id, reason: commit.refused };
191
+ if (commit.contradiction !== undefined)
192
+ return {
193
+ status: 'identity-contradicted',
194
+ rowId: id,
195
+ ...commit.contradiction,
196
+ credential: commit.stored,
197
+ };
165
198
  if (options.onPersisted) {
166
199
  try {
167
200
  const persisted = options.onPersisted;
@@ -5,6 +5,7 @@ import { type ProviderStateWrite, type RowTransitionMutator, type UpdateProvider
5
5
  import type { PoolLockSpec } from './refresh-lock.js';
6
6
  import { type StoreRuntime } from './runtime.js';
7
7
  import { type CredentialBinding, type PoolCredential, type PoolRow, type RotateCredential, type StoredCredential } from './schema.js';
8
+ import { type StampedTransition } from './torn.js';
8
9
  export type FailureHook = (rowId: string, error: PoolOperationError) => void | Promise<void>;
9
10
  export interface RowOperationOptions {
10
11
  /** Called once, awaited, on every non-success path, before locks release. */
@@ -122,6 +123,8 @@ export declare function rotateIn(rt: StoreRuntime, tx: Transaction, id: string,
122
123
  binding?: CredentialBinding;
123
124
  identity?: string;
124
125
  providerState?: ProviderStateWrite;
126
+ /** Config transition persisted with the successor credential for crash recovery. */
127
+ transition?: StampedTransition;
125
128
  }): Promise<StoredCredential>;
126
129
  export declare function addRow(rt: StoreRuntime, input: AddInput, options?: RowOperationOptions): Promise<AddResult>;
127
130
  export declare function replaceRow(rt: StoreRuntime, id: string, credential: PoolCredential, input?: CredentialWriteInput, options?: RowOperationOptions): Promise<{
@@ -178,7 +181,9 @@ export interface RowTransitionResult {
178
181
  export declare function disableRow(rt: StoreRuntime, id: string, reason: string, options?: RowTransitionOptions): Promise<RowTransitionResult>;
179
182
  /**
180
183
  * Clears a row's `enabled: false` and its `disabledReason` in one config
181
- * write. An OAuth row whose recorded identity another enabled OAuth row holds
184
+ * write. An identity-contradicted row refuses with `identity-contradicted`
185
+ * until the caller validates a replacement's identity and supplies it to replace.
186
+ * An OAuth row whose recorded identity another enabled OAuth row holds
182
187
  * stays disabled and the call refuses (`duplicate-identity`): the same rule
183
188
  * that makes `add` store such a row disabled. Enabling a row that is already
184
189
  * enabled writes nothing. See `RowTransitionOptions` for the attributed
@@ -1,7 +1,7 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { PoolOperationError } from './errors.js';
3
3
  import { assertNotInsideHook, runInsideHook } from './hooks.js';
4
- import { DUPLICATE_IDENTITY_REASON, disableIdentityDuplicates, disableIn, enableIn, recordIdentityIn, } from './identity.js';
4
+ import { DUPLICATE_IDENTITY_REASON, disableIdentityDuplicates, disableIn, enableIn, IDENTITY_CONTRADICTED_REASON_PREFIX, recordIdentityIn, } from './identity.js';
5
5
  import { notReadyError, readPool, runOperation, withTransaction, } from './mutate.js';
6
6
  import { acceptProviderState, mergedProviderState, planProviderStateIn, providerStateCoverage, replacementProviderState, } from './provider-state.js';
7
7
  import { readRow, refusal, requireBound, rowLockSpec, unknownRow, } from './runtime.js';
@@ -122,12 +122,17 @@ export async function rotateIn(rt, tx, id, given, extra = {}) {
122
122
  tx.setStateAccount(id, {
123
123
  ...kept,
124
124
  ...stateFieldsFor(credential, stamp),
125
- [CREDENTIAL_STAMP_KEY]: stampFor(stored, credentialEpoch, extra.binding ?? bindingInTx(tx, id, stored, extra.identity), {
126
- replace: extra.binding !== undefined,
127
- ...(providerStateBinding !== undefined
128
- ? { providerState: providerStateBinding }
125
+ [CREDENTIAL_STAMP_KEY]: {
126
+ ...(extra.transition !== undefined
127
+ ? { [TRANSITION_STAMP_KEY]: extra.transition }
129
128
  : {}),
130
- }),
129
+ ...stampFor(stored, credentialEpoch, extra.binding ?? bindingInTx(tx, id, stored, extra.identity), {
130
+ replace: extra.binding !== undefined,
131
+ ...(providerStateBinding !== undefined
132
+ ? { providerState: providerStateBinding }
133
+ : {}),
134
+ }),
135
+ },
131
136
  });
132
137
  await tx.commitState(stored);
133
138
  return stored;
@@ -480,6 +485,9 @@ async function transitionRow(rt, operation, id, flag, options) {
480
485
  row.identity !== fence.identity)
481
486
  throw refusal(operation, id, 'attribution', `the ${operation} of ${id} was issued for a credential or account the row no longer holds`, true);
482
487
  }
488
+ if (flag.enabled &&
489
+ row.disabledReason?.startsWith(IDENTITY_CONTRADICTED_REASON_PREFIX))
490
+ throw refusal('enable', id, 'identity-contradicted', `row ${id} needs an identity-validated credential replacement before it can be enabled`);
483
491
  // An enable of a row that is already enabled has nothing to write
484
492
  // to the config; a disable always rewrites it, as it always has.
485
493
  const writesConfig = !flag.enabled || !row.enabled || row.disabledReason !== undefined;
@@ -567,7 +575,9 @@ export function disableRow(rt, id, reason, options = {}) {
567
575
  }
568
576
  /**
569
577
  * Clears a row's `enabled: false` and its `disabledReason` in one config
570
- * write. An OAuth row whose recorded identity another enabled OAuth row holds
578
+ * write. An identity-contradicted row refuses with `identity-contradicted`
579
+ * until the caller validates a replacement's identity and supplies it to replace.
580
+ * An OAuth row whose recorded identity another enabled OAuth row holds
571
581
  * stays disabled and the call refuses (`duplicate-identity`): the same rule
572
582
  * that makes `add` store such a row disabled. Enabling a row that is already
573
583
  * enabled writes nothing. See `RowTransitionOptions` for the attributed
@@ -1,4 +1,4 @@
1
- import { disableIdentityDuplicates, disableIn, enableIn, recordIdentityIn, } from './identity.js';
1
+ import { disableIdentityDuplicates, disableIn, enableIn, IDENTITY_CONTRADICTED_REASON_PREFIX, recordIdentityIn, } from './identity.js';
2
2
  import { buildRawRows, CREDENTIAL_STAMP_KEY, credentialDigest, dispatchDigest, entryIn, isRecord, parseStamp, rosterRowIn, setEntryIn, } from './schema.js';
3
3
  /*
4
4
  * A replace changes both files: the state file gets the new credential and
@@ -209,6 +209,13 @@ export function bindReplacement(editor, id, credentialEpoch, binding) {
209
209
  needsFirstReading: true,
210
210
  };
211
211
  delete next.quota;
212
+ // An explicit identity supplied with a replacement is the adapter's validated
213
+ // account binding. Clear its quarantine on completion, including crash recovery,
214
+ // but leave the row disabled until a separate enable.
215
+ if (binding.identity !== undefined &&
216
+ typeof next.disabledReason === 'string' &&
217
+ next.disabledReason.startsWith(IDENTITY_CONTRADICTED_REASON_PREFIX))
218
+ delete next.disabledReason;
212
219
  editor.setEntry(id, next);
213
220
  const raw = editor.rosterRow(id);
214
221
  if (!raw)
@@ -2,7 +2,7 @@ import { createHash } from 'node:crypto';
2
2
  import { existsSync, realpathSync } from 'node:fs';
3
3
  import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
4
4
  import { createRequire } from 'node:module';
5
- import { basename, dirname, extname, join, relative, resolve } from 'node:path';
5
+ import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep, } from 'node:path';
6
6
  import { fileURLToPath, pathToFileURL } from 'node:url';
7
7
  import { isBareSpecifier, parseImports } from './walker.js';
8
8
  export const runtimeModules = [
@@ -109,6 +109,35 @@ export async function buildTui(entryFile, variant, destinationDir, options = {})
109
109
  selector: 'selector.js',
110
110
  externals: new Set(),
111
111
  };
112
+ // Every name below is computed from the entry's real directory: an entry
113
+ // reached through a symlink (macOS's /var -> /private/var temp directory,
114
+ // a linked checkout) must not make its own siblings look like outside files.
115
+ const entryDirectory = dirname(realpathSync(entry));
116
+ const portable = (path) => path.split(sep).join('/');
117
+ // A shared copy's name must be the same wherever the checkout lives, so it
118
+ // is derived from a key that holds no absolute path. A file inside an
119
+ // inlined package is keyed by that package's name and its path within the
120
+ // package; any other file by its path relative to the entry's directory.
121
+ // Both are one-to-one: one root per package name (the innermost root wins
122
+ // when roots nest, so a file always picks the same one), a fixed base for
123
+ // relative paths, and distinct prefixes for the two kinds. The guard in
124
+ // `visit` turns the remaining risk, two keys sharing a truncated hash, into
125
+ // an error instead of one copy silently overwriting another.
126
+ const namingRoots = [...roots]
127
+ .map(([name, root]) => ({
128
+ name,
129
+ root: existsSync(root) ? realpathSync(root) : root,
130
+ }))
131
+ .sort((a, b) => b.root.length - a.root.length);
132
+ const sharedKey = (source) => {
133
+ for (const { name, root } of namingRoots) {
134
+ const path = relative(root, source);
135
+ if (path && !path.startsWith(`..${sep}`) && !isAbsolute(path))
136
+ return `package:${name}/${portable(path)}`;
137
+ }
138
+ return `path:${portable(relative(entryDirectory, source))}`;
139
+ };
140
+ const sharedOwners = new Map();
112
141
  const copied = new Map();
113
142
  const relativeImport = (from, to) => {
114
143
  const path = relative(dirname(from), to).split('\\').join('/');
@@ -116,17 +145,23 @@ export async function buildTui(entryFile, variant, destinationDir, options = {})
116
145
  };
117
146
  const visit = async (source, shared) => {
118
147
  source = realpathSync(source);
119
- if (relative(dirname(entry), source).startsWith('../'))
148
+ if (relative(entryDirectory, source).startsWith(`..${sep}`))
120
149
  shared = true;
121
150
  const previous = copied.get(source);
122
151
  if (previous)
123
152
  return previous;
124
153
  const extension = variant === 'raw' && extname(source) === '.tsx' ? '.tsx' : '.js';
125
154
  const name = shared
126
- ? `shared/${createHash('sha256').update(source).digest('hex').slice(0, 16)}-${basename(source).replace(/\.[^.]+$/, '')}${extension}`
127
- : relative(dirname(entry), source).replace(/\.[^.]+$/, extension);
155
+ ? `shared/${createHash('sha256').update(sharedKey(source)).digest('hex').slice(0, 16)}-${basename(source).replace(/\.[^.]+$/, '')}${extension}`
156
+ : portable(relative(entryDirectory, source)).replace(/\.[^.]+$/, extension);
128
157
  if (name.startsWith('../'))
129
158
  throw new Error(`Entry dependency escapes source directory: ${source}`);
159
+ if (shared) {
160
+ const owner = sharedOwners.get(name);
161
+ if (owner !== undefined)
162
+ throw new Error(`Shared module name ${name} is claimed by both ${owner} and ${source}`);
163
+ sharedOwners.set(name, source);
164
+ }
130
165
  copied.set(source, name);
131
166
  let code = await readFile(source, 'utf8');
132
167
  if (transform && /\.[jt]sx$/.test(source))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cortexkit/common-auth",
3
- "version": "0.8.0",
3
+ "version": "0.9.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": {
@@ -89,16 +89,18 @@
89
89
  "format:check": "biome format .",
90
90
  "lint": "biome check .",
91
91
  "prepublishOnly": "bun run build",
92
- "check:ranges": "bun scripts/check-installed-ranges.mjs"
92
+ "check:ranges": "bun scripts/check-installed-ranges.mjs",
93
+ "check:claustrum-040": "bun scripts/check-claustrum-040.mjs",
94
+ "check:opentui-0511": "bun scripts/check-opentui-0511.mjs"
93
95
  },
94
96
  "dependencies": {
95
97
  "jsonc-parser": "^3.3.1"
96
98
  },
97
99
  "peerDependencies": {
98
- "@opentui/core": ">=0.5.12",
99
- "@opentui/solid": ">=0.5.12",
100
+ "@opentui/core": ">=0.5.11",
101
+ "@opentui/solid": ">=0.5.11",
100
102
  "solid-js": "1.9.12",
101
- "@cortexkit/claustrum-client": ">=0.5.0",
103
+ "@cortexkit/claustrum-client": ">=0.4.0",
102
104
  "@opencode/plugin": ">=2.0.21"
103
105
  },
104
106
  "peerDependenciesMeta": {