@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.
- package/dist/logger/redact.js +42 -4
- package/dist/rpc/rpc-client.d.ts +1 -1
- package/dist/rpc/rpc-client.js +111 -24
- package/dist/store/errors.d.ts +1 -1
- package/dist/store/identity.d.ts +2 -0
- package/dist/store/identity.js +9 -1
- package/dist/store/refresh.d.ts +17 -1
- package/dist/store/refresh.js +35 -2
- package/dist/store/rows.d.ts +6 -1
- package/dist/store/rows.js +17 -7
- package/dist/store/torn.js +8 -1
- package/dist/tui-build/build-tui.js +39 -4
- package/package.json +7 -5
package/dist/logger/redact.js
CHANGED
|
@@ -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
|
-
|
|
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)
|
|
66
|
+
keys && isSecretKey(key)
|
|
67
|
+
? MASK
|
|
68
|
+
: walk(item, keys, seen, childDepth),
|
|
31
69
|
]));
|
|
32
70
|
}
|
|
33
71
|
finally {
|
package/dist/rpc/rpc-client.d.ts
CHANGED
|
@@ -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;
|
package/dist/rpc/rpc-client.js
CHANGED
|
@@ -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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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) {
|
package/dist/store/errors.d.ts
CHANGED
|
@@ -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
|
package/dist/store/identity.d.ts
CHANGED
|
@@ -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
|
/**
|
package/dist/store/identity.js
CHANGED
|
@@ -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
|
-
|
|
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
|
package/dist/store/refresh.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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;
|
package/dist/store/refresh.js
CHANGED
|
@@ -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;
|
package/dist/store/rows.d.ts
CHANGED
|
@@ -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
|
|
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
|
package/dist/store/rows.js
CHANGED
|
@@ -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]:
|
|
126
|
-
|
|
127
|
-
|
|
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
|
|
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
|
package/dist/store/torn.js
CHANGED
|
@@ -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(
|
|
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(
|
|
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.
|
|
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.
|
|
99
|
-
"@opentui/solid": ">=0.5.
|
|
100
|
+
"@opentui/core": ">=0.5.11",
|
|
101
|
+
"@opentui/solid": ">=0.5.11",
|
|
100
102
|
"solid-js": "1.9.12",
|
|
101
|
-
"@cortexkit/claustrum-client": ">=0.
|
|
103
|
+
"@cortexkit/claustrum-client": ">=0.4.0",
|
|
102
104
|
"@opencode/plugin": ">=2.0.21"
|
|
103
105
|
},
|
|
104
106
|
"peerDependenciesMeta": {
|