@cortexkit/common-auth 0.2.9 → 0.4.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/cachekeep/manager.d.ts +18 -6
- package/dist/cachekeep/manager.js +40 -10
- package/dist/claustrum/consumer.d.ts +13 -4
- package/dist/claustrum/consumer.js +11 -3
- package/dist/claustrum/custody.d.ts +47 -6
- package/dist/claustrum/custody.js +37 -7
- package/dist/claustrum/errors.d.ts +1 -1
- package/dist/claustrum/index.d.ts +3 -3
- package/dist/claustrum/index.js +2 -2
- package/dist/claustrum/interlock.d.ts +15 -17
- package/dist/claustrum/interlock.js +19 -26
- package/dist/claustrum/roster.d.ts +96 -6
- package/dist/claustrum/roster.js +209 -44
- package/dist/commands/builtins.d.ts +1 -1
- package/dist/commands/builtins.js +6 -1
- package/dist/commands/index.d.ts +2 -2
- package/dist/commands/index.js +1 -1
- package/dist/commands/menu.d.ts +8 -0
- package/dist/commands/menu.js +34 -13
- package/dist/commands/model.d.ts +9 -0
- package/dist/commands/seam.d.ts +40 -4
- package/dist/commands/seam.js +132 -19
- package/dist/dump/index.d.ts +94 -0
- package/dist/dump/index.js +236 -9
- package/dist/logger/engine.d.ts +52 -17
- package/dist/logger/engine.js +178 -135
- package/dist/logger/index.d.ts +2 -2
- package/dist/logger/index.js +1 -1
- package/dist/opencode2/index.d.ts +1 -1
- package/dist/opencode2/install.d.ts +23 -5
- package/dist/opencode2/install.js +335 -140
- package/dist/opencode2/types.d.ts +149 -9
- package/dist/quota/projection.d.ts +11 -4
- package/dist/quota/projection.js +11 -4
- package/dist/routing/admission.js +3 -1
- package/dist/routing/index.d.ts +2 -2
- package/dist/routing/index.js +1 -1
- package/dist/routing/sticky.d.ts +19 -6
- package/dist/routing/sticky.js +34 -23
- package/dist/rpc/notifications.d.ts +20 -0
- package/dist/rpc/notifications.js +21 -0
- package/dist/rpc/rpc-server.d.ts +9 -1
- package/dist/rpc/rpc-server.js +8 -1
- package/dist/sidebar-file/index.d.ts +1 -1
- package/dist/sidebar-file/sidebar-file.d.ts +50 -2
- package/dist/sidebar-file/sidebar-file.js +92 -21
- package/dist/store/attribution.js +11 -2
- package/dist/store/errors.d.ts +6 -3
- package/dist/store/identity.d.ts +13 -4
- package/dist/store/mutate.d.ts +23 -3
- package/dist/store/mutate.js +43 -26
- package/dist/store/pool.d.ts +8 -1
- package/dist/store/pool.js +7 -2
- package/dist/store/rows.d.ts +17 -4
- package/dist/store/rows.js +82 -33
- package/dist/store/schema.d.ts +57 -4
- package/dist/store/schema.js +100 -7
- package/dist/store/torn.d.ts +29 -0
- package/dist/store/torn.js +113 -0
- package/package.json +1 -1
package/dist/store/rows.js
CHANGED
|
@@ -3,7 +3,8 @@ import { assertNotInsideHook, runInsideHook } from './hooks.js';
|
|
|
3
3
|
import { DUPLICATE_IDENTITY_REASON, disableIdentityDuplicates, disableIn, recordIdentityIn, } from './identity.js';
|
|
4
4
|
import { notReadyError, readPool, runOperation, withTransaction, } from './mutate.js';
|
|
5
5
|
import { readRow, refusal, rowLockSpec, unknownRow, } from './runtime.js';
|
|
6
|
-
import { credentialProblem, fingerprintOf, idProblem, isRecord, rosterRowFor, rotationStamp, rowLockKey, stateFieldsFor, storedCredential, } from './schema.js';
|
|
6
|
+
import { CREDENTIAL_STAMP_KEY, credentialProblem, fingerprintOf, idProblem, isRecord, rosterRowFor, rotationStamp, rowLockKey, stampFor, stateFieldsFor, storedCredential, } from './schema.js';
|
|
7
|
+
import { bindReplacement } from './torn.js';
|
|
7
8
|
/** Fields of a state entry that belong to the credential it replaces. */
|
|
8
9
|
const CREDENTIAL_STATE_FIELDS = [
|
|
9
10
|
'access',
|
|
@@ -13,8 +14,10 @@ const CREDENTIAL_STATE_FIELDS = [
|
|
|
13
14
|
'apiKey',
|
|
14
15
|
];
|
|
15
16
|
/**
|
|
16
|
-
* Writes a
|
|
17
|
-
* the
|
|
17
|
+
* Writes a credential into the state file (one write), stamped with the
|
|
18
|
+
* credential epoch the row's entry holds in `tx` (1 without an entry) and,
|
|
19
|
+
* for a replace, the binding the config is about to get. A rotation is the
|
|
20
|
+
* same lineage: no epoch bump, no identity or quota change.
|
|
18
21
|
*/
|
|
19
22
|
export async function rotateIn(rt, tx, id, credential, extra = {}) {
|
|
20
23
|
const prior = tx.stateAccount(id);
|
|
@@ -43,7 +46,12 @@ export async function rotateIn(rt, tx, id, credential, extra = {}) {
|
|
|
43
46
|
: {}),
|
|
44
47
|
}, stamp)
|
|
45
48
|
: storedCredential(credential, stamp);
|
|
46
|
-
tx.
|
|
49
|
+
const epoch = tx.entry(id)?.credentialEpoch;
|
|
50
|
+
tx.setStateAccount(id, {
|
|
51
|
+
...kept,
|
|
52
|
+
...stateFieldsFor(credential, stamp),
|
|
53
|
+
[CREDENTIAL_STAMP_KEY]: stampFor(stored, typeof epoch === 'number' ? epoch : 1, extra.binding),
|
|
54
|
+
});
|
|
47
55
|
await tx.commitState(stored);
|
|
48
56
|
return stored;
|
|
49
57
|
}
|
|
@@ -64,6 +72,10 @@ function requireUsableRow(operation, id, row, credential) {
|
|
|
64
72
|
throw refusal(operation, id, 'type-mismatch', `row ${id} holds a ${row.type} credential`);
|
|
65
73
|
return row;
|
|
66
74
|
}
|
|
75
|
+
/** The row is recorded for another account than the one given. */
|
|
76
|
+
function identityMismatch(operation, id) {
|
|
77
|
+
return refusal(operation, id, 'identity-mismatch', `row ${id} is recorded for another account; a credential of a different account is a replacement`);
|
|
78
|
+
}
|
|
67
79
|
/** Keying changed between the unlocked read and the locked one: retry. */
|
|
68
80
|
function keyChanged(operation, id) {
|
|
69
81
|
return refusal(operation, id, 'row-key-changed', `row ${id}'s wire identity changed while its lock was being taken`, true);
|
|
@@ -97,18 +109,19 @@ export async function addRow(rt, input, options = {}) {
|
|
|
97
109
|
throw refusal('add', id, 'id-exists', `row ${id} already holds a credential`);
|
|
98
110
|
if (existing.type !== credential.type)
|
|
99
111
|
throw refusal('add', id, 'type-mismatch', `row ${id} is a ${existing.type} row`);
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
|
|
112
|
+
// A roster row without a credential (left by another writer, or
|
|
113
|
+
// by an add of an earlier version that stopped between its
|
|
114
|
+
// writes): writing the credential now completes it at epoch 1.
|
|
115
|
+
if (!existing.hasEntry)
|
|
103
116
|
tx.setEntry(id, {
|
|
104
117
|
credentialEpoch: 1,
|
|
105
118
|
needsFirstReading: credential.type === 'oauth',
|
|
106
119
|
});
|
|
107
|
-
await tx.commitConfig();
|
|
108
|
-
}
|
|
109
120
|
const stored = await rotateIn(rt, tx, id, credential, {
|
|
110
121
|
clearErrors: true,
|
|
111
122
|
});
|
|
123
|
+
if (!existing.hasEntry)
|
|
124
|
+
await tx.commitConfig();
|
|
112
125
|
return { id, outcome: 'completed', credential: stored };
|
|
113
126
|
}
|
|
114
127
|
tx.roster().push(rosterRowFor({
|
|
@@ -134,8 +147,14 @@ export async function addRow(rt, input, options = {}) {
|
|
|
134
147
|
outcome = 'added-disabled';
|
|
135
148
|
}
|
|
136
149
|
}
|
|
137
|
-
|
|
150
|
+
// The credential is written first. A crash before the config write
|
|
151
|
+
// then leaves a state entry no roster row names, which no reader
|
|
152
|
+
// loads and `remove` drops; written the other way round, the new
|
|
153
|
+
// roster row could load beside a credential left under its id by an
|
|
154
|
+
// interrupted removal. Nothing of such a leftover entry is kept.
|
|
155
|
+
tx.dropStateAccount(id);
|
|
138
156
|
const stored = await rotateIn(rt, tx, id, credential);
|
|
157
|
+
await tx.commitConfig();
|
|
139
158
|
return { id, outcome, credential: stored };
|
|
140
159
|
});
|
|
141
160
|
});
|
|
@@ -159,33 +178,33 @@ export async function replaceRow(rt, id, credential, input = {}, options = {}) {
|
|
|
159
178
|
const row = requireUsableRow('replace', id, tx.row(id), credential);
|
|
160
179
|
if (rowLockKey(row) !== rowLockKey(seen))
|
|
161
180
|
throw keyChanged('replace', id);
|
|
162
|
-
const
|
|
163
|
-
const
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
...
|
|
169
|
-
|
|
170
|
-
|
|
181
|
+
const priorEpoch = tx.entry(id)?.credentialEpoch;
|
|
182
|
+
const credentialEpoch = (typeof priorEpoch === 'number' ? priorEpoch : 1) + 1;
|
|
183
|
+
const binding = {
|
|
184
|
+
...(input.identity !== undefined
|
|
185
|
+
? { identity: input.identity }
|
|
186
|
+
: {}),
|
|
187
|
+
...(credential.type === 'api'
|
|
188
|
+
? {
|
|
189
|
+
baseURL: credential.baseURL.trim(),
|
|
190
|
+
authHeader: credential.authHeader ?? 'authorization-bearer',
|
|
191
|
+
}
|
|
192
|
+
: {}),
|
|
171
193
|
};
|
|
172
|
-
|
|
173
|
-
tx.setEntry(id, nextEntry);
|
|
174
|
-
const raw = tx.rosterRow(id);
|
|
175
|
-
if (input.identity !== undefined)
|
|
176
|
-
raw.accountId = input.identity;
|
|
177
|
-
else
|
|
178
|
-
delete raw.accountId;
|
|
179
|
-
if (credential.type === 'api') {
|
|
180
|
-
raw.baseURL = credential.baseURL.trim();
|
|
181
|
-
raw.authHeader = credential.authHeader ?? 'authorization-bearer';
|
|
182
|
-
}
|
|
194
|
+
bindReplacement(tx, id, credentialEpoch, binding);
|
|
183
195
|
if (input.identity !== undefined)
|
|
184
196
|
disableIdentityDuplicates(tx, input.identity);
|
|
185
|
-
|
|
197
|
+
// The new credential goes first, stamped with the new epoch and the
|
|
198
|
+
// binding. A crash before the config write leaves the stamp ahead
|
|
199
|
+
// of the config: every reader shows the row torn (completed, never
|
|
200
|
+
// a candidate) and the next store write completes the config from
|
|
201
|
+
// the stamp, so no reader pairs either credential with the other
|
|
202
|
+
// account's identity or endpoint.
|
|
186
203
|
const stored = await rotateIn(rt, tx, id, credential, {
|
|
187
204
|
clearErrors: true,
|
|
205
|
+
binding,
|
|
188
206
|
});
|
|
207
|
+
await tx.commitConfig();
|
|
189
208
|
return { id, credential: stored, credentialEpoch };
|
|
190
209
|
});
|
|
191
210
|
});
|
|
@@ -208,6 +227,13 @@ export async function rotateRow(rt, id, credential, input = {}, options = {}) {
|
|
|
208
227
|
const row = requireUsableRow('rotate', id, tx.row(id), credential);
|
|
209
228
|
if (rowLockKey(row) !== rowLockKey(seen))
|
|
210
229
|
throw keyChanged('rotate', id);
|
|
230
|
+
// A rotation stays with one account: it may record the first
|
|
231
|
+
// identity the row learns, but a credential of another known
|
|
232
|
+
// account is a replacement (new epoch, quota and errors dropped).
|
|
233
|
+
if (input.identity !== undefined &&
|
|
234
|
+
row.identity !== undefined &&
|
|
235
|
+
input.identity !== row.identity)
|
|
236
|
+
throw identityMismatch('rotate', id);
|
|
211
237
|
const stored = await rotateIn(rt, tx, id, credential);
|
|
212
238
|
let configChanged = false;
|
|
213
239
|
if (!row.hasEntry) {
|
|
@@ -445,14 +471,33 @@ export async function reorderRows(rt, ids, options = {}) {
|
|
|
445
471
|
tx.config.accounts = next;
|
|
446
472
|
await tx.commitConfig();
|
|
447
473
|
return { ids: order, outcome: 'reordered' };
|
|
448
|
-
}
|
|
474
|
+
},
|
|
475
|
+
// A reorder keeps every roster row and entry byte for byte, so it
|
|
476
|
+
// leaves a torn row for a write on that row to complete.
|
|
477
|
+
{ completeTorn: false });
|
|
449
478
|
});
|
|
450
479
|
}
|
|
451
|
-
|
|
480
|
+
/**
|
|
481
|
+
* Records the wire identity an identity lookup found for a row's credential.
|
|
482
|
+
* `attribution` is the credential epoch the lookup was issued for (a row
|
|
483
|
+
* without an entry is at epoch 1): a lookup that completes after the row was
|
|
484
|
+
* replaced is refused (`attribution`), as a quota reading would be, so the
|
|
485
|
+
* first credential's account is never recorded on the second credential. A
|
|
486
|
+
* row already recorded for another account refuses (`identity-mismatch`):
|
|
487
|
+
* that is a replacement, not something learnt about the same credential.
|
|
488
|
+
*/
|
|
489
|
+
export async function recordRowIdentity(rt, id, identity, attribution, options = {}) {
|
|
452
490
|
assertNotInsideHook('recordIdentity');
|
|
453
491
|
return runOperation(rt.ctx, 'recordIdentity', id, options.onFailure, async (locks, progress) => {
|
|
454
492
|
if (typeof identity !== 'string' || identity.length === 0)
|
|
455
493
|
throw refusal('recordIdentity', id, 'invalid-input', 'identity must be non-empty');
|
|
494
|
+
const captured = isRecord(attribution)
|
|
495
|
+
? attribution.credentialEpoch
|
|
496
|
+
: undefined;
|
|
497
|
+
if (typeof captured !== 'number' ||
|
|
498
|
+
!Number.isInteger(captured) ||
|
|
499
|
+
captured < 1)
|
|
500
|
+
throw refusal('recordIdentity', id, 'invalid-input', 'the credential epoch the identity lookup was issued for is required');
|
|
456
501
|
const { row: seen } = await readRow(rt, 'recordIdentity', id);
|
|
457
502
|
await locks.acquire(rowLockSpec(rt, seen));
|
|
458
503
|
await locks.acquire(options.providerLock ?? rt.providerLock);
|
|
@@ -462,6 +507,10 @@ export async function recordRowIdentity(rt, id, identity, options = {}) {
|
|
|
462
507
|
const row = requireUsableRow('recordIdentity', id, tx.row(id));
|
|
463
508
|
if (rowLockKey(row) !== rowLockKey(seen))
|
|
464
509
|
throw keyChanged('recordIdentity', id);
|
|
510
|
+
if ((row.credentialEpoch ?? 1) !== captured)
|
|
511
|
+
throw refusal('recordIdentity', id, 'attribution', `the identity for ${id} was looked up for a credential the row no longer holds`, true);
|
|
512
|
+
if (row.identity !== undefined && row.identity !== identity)
|
|
513
|
+
throw identityMismatch('recordIdentity', id);
|
|
465
514
|
const disabled = recordIdentityIn(tx, id, identity);
|
|
466
515
|
await tx.commitConfig();
|
|
467
516
|
return { id, disabled };
|
package/dist/store/schema.d.ts
CHANGED
|
@@ -58,6 +58,41 @@ export interface PoolRow {
|
|
|
58
58
|
candidate: boolean;
|
|
59
59
|
/** Set when the roster row or the per-row entry failed validation. */
|
|
60
60
|
invalid?: 'roster' | 'entry';
|
|
61
|
+
/**
|
|
62
|
+
* Set when a replace stopped between its two writes: the state file holds
|
|
63
|
+
* the new credential, stamped with the epoch and the identity or endpoint
|
|
64
|
+
* it belongs to, and the config still holds the replaced row. The row is
|
|
65
|
+
* shown as the replace leaves it once completed, is never a candidate, and
|
|
66
|
+
* the next store write on it writes the config to match.
|
|
67
|
+
*/
|
|
68
|
+
torn?: true;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Key, inside a state-file account entry, of the stamp naming what the
|
|
72
|
+
* credential beside it belongs to. Older readers ignore it.
|
|
73
|
+
*/
|
|
74
|
+
export declare const CREDENTIAL_STAMP_KEY = "commonAuthPool";
|
|
75
|
+
/**
|
|
76
|
+
* The config-side half of a replacement: the identity the new credential
|
|
77
|
+
* belongs to (absent: none is known) and, for an API key, its endpoint.
|
|
78
|
+
*/
|
|
79
|
+
export interface CredentialBinding {
|
|
80
|
+
identity?: string;
|
|
81
|
+
baseURL?: string;
|
|
82
|
+
authHeader?: 'authorization-bearer' | 'x-api-key';
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Written beside every credential the store puts in the state file. It names
|
|
86
|
+
* the credential epoch the credential belongs to and a digest of its secret,
|
|
87
|
+
* so a stamp left beside a credential another writer put there afterwards is
|
|
88
|
+
* recognisable and ignored. A replace also records the binding it gives the
|
|
89
|
+
* row, which is what lets a reader complete a replace that stopped after
|
|
90
|
+
* writing the credential.
|
|
91
|
+
*/
|
|
92
|
+
export interface CredentialStamp {
|
|
93
|
+
credentialEpoch: number;
|
|
94
|
+
digest: string;
|
|
95
|
+
binding?: CredentialBinding;
|
|
61
96
|
}
|
|
62
97
|
export type ConfigClassification = {
|
|
63
98
|
status: 'ready';
|
|
@@ -88,6 +123,15 @@ export declare function idProblem(id: unknown): string | undefined;
|
|
|
88
123
|
/** Same URL rule the older readers apply to an API-key row's `baseURL`. */
|
|
89
124
|
export declare function isValidBaseURL(value: unknown): boolean;
|
|
90
125
|
export declare function fingerprintOf(credential: PoolCredential | StoredCredential): string;
|
|
126
|
+
/**
|
|
127
|
+
* The digest a credential stamp carries. It is kept apart from the
|
|
128
|
+
* fingerprint (a different input prefix) so the persisted value is never the
|
|
129
|
+
* dedupe key.
|
|
130
|
+
*/
|
|
131
|
+
export declare function credentialDigest(credential: PoolCredential | StoredCredential): string;
|
|
132
|
+
export declare function stampFor(credential: PoolCredential | StoredCredential, credentialEpoch: number, binding?: CredentialBinding): CredentialStamp;
|
|
133
|
+
/** A well-formed stamp, or undefined for anything else (which is ignored). */
|
|
134
|
+
export declare function parseStamp(raw: unknown): CredentialStamp | undefined;
|
|
91
135
|
/** A parsed file, or the reason it could not be parsed. */
|
|
92
136
|
export type FileRead = {
|
|
93
137
|
exists: false;
|
|
@@ -102,12 +146,21 @@ export declare function classifyConfig(read: FileRead): ConfigClassification;
|
|
|
102
146
|
export declare function classifyState(read: FileRead): StateClassification;
|
|
103
147
|
export declare function rosterOf(config: Record<string, unknown>): unknown[];
|
|
104
148
|
export declare function entriesOf(config: Record<string, unknown>): Record<string, unknown>;
|
|
149
|
+
/** The first roster row with this id (the one the pool loads). */
|
|
150
|
+
export declare function rosterRowIn(config: Record<string, unknown>, id: string): Record<string, unknown> | undefined;
|
|
151
|
+
/** The per-row entries of a config, created (empty) when absent. */
|
|
152
|
+
export declare function ensureEntries(config: Record<string, unknown>): Record<string, unknown>;
|
|
153
|
+
export declare function entryIn(config: Record<string, unknown>, id: string): Record<string, unknown> | undefined;
|
|
154
|
+
/** Sets an entry as an own property, so an id such as `toString` is safe. */
|
|
155
|
+
export declare function setEntryIn(config: Record<string, unknown>, id: string, entry: Record<string, unknown>): void;
|
|
105
156
|
/**
|
|
106
|
-
* Builds the rows of a ready pool
|
|
107
|
-
*
|
|
108
|
-
*
|
|
157
|
+
* Builds the rows of a ready pool from the files exactly as they are, without
|
|
158
|
+
* looking at credential stamps (see `loadRows` for the rows every reader
|
|
159
|
+
* gets). A roster row the older readers would reject, a duplicate id, or a
|
|
160
|
+
* malformed per-row entry makes that one row invalid (never a candidate) and
|
|
161
|
+
* blocks nothing else.
|
|
109
162
|
*/
|
|
110
|
-
export declare function
|
|
163
|
+
export declare function buildRawRows(config: Record<string, unknown>, state: Record<string, unknown>, codec: QuotaCodec): PoolRow[];
|
|
111
164
|
/** The row-lock key: recorded wire identity when known, else the local id. */
|
|
112
165
|
export declare function rowLockKey(row: Pick<PoolRow, 'id' | 'identity'>): string;
|
|
113
166
|
/**
|
package/dist/store/schema.js
CHANGED
|
@@ -13,6 +13,11 @@ export const LEGACY_STORE_VERSION = 1;
|
|
|
13
13
|
* strictly above it counts as absent when they compare two copies of a token.
|
|
14
14
|
*/
|
|
15
15
|
export const REFRESH_STAMP_TOLERANCE_MS = 5 * 60_000;
|
|
16
|
+
/**
|
|
17
|
+
* Key, inside a state-file account entry, of the stamp naming what the
|
|
18
|
+
* credential beside it belongs to. Older readers ignore it.
|
|
19
|
+
*/
|
|
20
|
+
export const CREDENTIAL_STAMP_KEY = POOL_KEY;
|
|
16
21
|
export function isRecord(value) {
|
|
17
22
|
return value != null && typeof value === 'object' && !Array.isArray(value);
|
|
18
23
|
}
|
|
@@ -46,11 +51,70 @@ export function isValidBaseURL(value) {
|
|
|
46
51
|
return false;
|
|
47
52
|
}
|
|
48
53
|
}
|
|
49
|
-
|
|
50
|
-
|
|
54
|
+
function secretOf(credential) {
|
|
55
|
+
return credential.type === 'oauth'
|
|
51
56
|
? `oauth\0${credential.refresh}`
|
|
52
57
|
: `api\0${credential.apiKey}`;
|
|
53
|
-
|
|
58
|
+
}
|
|
59
|
+
export function fingerprintOf(credential) {
|
|
60
|
+
return createHash('sha256').update(secretOf(credential)).digest('hex');
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* The digest a credential stamp carries. It is kept apart from the
|
|
64
|
+
* fingerprint (a different input prefix) so the persisted value is never the
|
|
65
|
+
* dedupe key.
|
|
66
|
+
*/
|
|
67
|
+
export function credentialDigest(credential) {
|
|
68
|
+
return createHash('sha256')
|
|
69
|
+
.update(`credential-stamp\0${secretOf(credential)}`)
|
|
70
|
+
.digest('hex');
|
|
71
|
+
}
|
|
72
|
+
export function stampFor(credential, credentialEpoch, binding) {
|
|
73
|
+
return {
|
|
74
|
+
credentialEpoch,
|
|
75
|
+
digest: credentialDigest(credential),
|
|
76
|
+
...(binding ? { binding: { ...binding } } : {}),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
/** A well-formed stamp, or undefined for anything else (which is ignored). */
|
|
80
|
+
export function parseStamp(raw) {
|
|
81
|
+
if (!isRecord(raw))
|
|
82
|
+
return undefined;
|
|
83
|
+
const epoch = raw.credentialEpoch;
|
|
84
|
+
if (typeof epoch !== 'number' || !Number.isInteger(epoch) || epoch < 1)
|
|
85
|
+
return undefined;
|
|
86
|
+
if (typeof raw.digest !== 'string')
|
|
87
|
+
return undefined;
|
|
88
|
+
if (!('binding' in raw))
|
|
89
|
+
return { credentialEpoch: epoch, digest: raw.digest };
|
|
90
|
+
const binding = raw.binding;
|
|
91
|
+
if (!isRecord(binding))
|
|
92
|
+
return undefined;
|
|
93
|
+
if ('identity' in binding &&
|
|
94
|
+
(typeof binding.identity !== 'string' || !binding.identity))
|
|
95
|
+
return undefined;
|
|
96
|
+
if ('baseURL' in binding && !isValidBaseURL(binding.baseURL))
|
|
97
|
+
return undefined;
|
|
98
|
+
if ('authHeader' in binding &&
|
|
99
|
+
binding.authHeader !== 'authorization-bearer' &&
|
|
100
|
+
binding.authHeader !== 'x-api-key')
|
|
101
|
+
return undefined;
|
|
102
|
+
return {
|
|
103
|
+
credentialEpoch: epoch,
|
|
104
|
+
digest: raw.digest,
|
|
105
|
+
binding: {
|
|
106
|
+
...(typeof binding.identity === 'string'
|
|
107
|
+
? { identity: binding.identity }
|
|
108
|
+
: {}),
|
|
109
|
+
...(typeof binding.baseURL === 'string'
|
|
110
|
+
? { baseURL: binding.baseURL.trim() }
|
|
111
|
+
: {}),
|
|
112
|
+
...(binding.authHeader === 'authorization-bearer' ||
|
|
113
|
+
binding.authHeader === 'x-api-key'
|
|
114
|
+
? { authHeader: binding.authHeader }
|
|
115
|
+
: {}),
|
|
116
|
+
},
|
|
117
|
+
};
|
|
54
118
|
}
|
|
55
119
|
export function classifyConfig(read) {
|
|
56
120
|
if (!read.exists)
|
|
@@ -176,12 +240,41 @@ function credentialFor(raw, stateEntry) {
|
|
|
176
240
|
: {}),
|
|
177
241
|
};
|
|
178
242
|
}
|
|
243
|
+
/** The first roster row with this id (the one the pool loads). */
|
|
244
|
+
export function rosterRowIn(config, id) {
|
|
245
|
+
return rosterOf(config).find((raw) => isRecord(raw) && raw.id === id);
|
|
246
|
+
}
|
|
247
|
+
/** The per-row entries of a config, created (empty) when absent. */
|
|
248
|
+
export function ensureEntries(config) {
|
|
249
|
+
if (!isRecord(config[POOL_KEY]))
|
|
250
|
+
config[POOL_KEY] = {};
|
|
251
|
+
const pool = config[POOL_KEY];
|
|
252
|
+
if (!isRecord(pool[POOL_ROWS_KEY]))
|
|
253
|
+
pool[POOL_ROWS_KEY] = {};
|
|
254
|
+
return pool[POOL_ROWS_KEY];
|
|
255
|
+
}
|
|
256
|
+
export function entryIn(config, id) {
|
|
257
|
+
const entries = entriesOf(config);
|
|
258
|
+
const entry = Object.hasOwn(entries, id) ? entries[id] : undefined;
|
|
259
|
+
return isRecord(entry) ? entry : undefined;
|
|
260
|
+
}
|
|
261
|
+
/** Sets an entry as an own property, so an id such as `toString` is safe. */
|
|
262
|
+
export function setEntryIn(config, id, entry) {
|
|
263
|
+
Object.defineProperty(ensureEntries(config), id, {
|
|
264
|
+
value: entry,
|
|
265
|
+
enumerable: true,
|
|
266
|
+
writable: true,
|
|
267
|
+
configurable: true,
|
|
268
|
+
});
|
|
269
|
+
}
|
|
179
270
|
/**
|
|
180
|
-
* Builds the rows of a ready pool
|
|
181
|
-
*
|
|
182
|
-
*
|
|
271
|
+
* Builds the rows of a ready pool from the files exactly as they are, without
|
|
272
|
+
* looking at credential stamps (see `loadRows` for the rows every reader
|
|
273
|
+
* gets). A roster row the older readers would reject, a duplicate id, or a
|
|
274
|
+
* malformed per-row entry makes that one row invalid (never a candidate) and
|
|
275
|
+
* blocks nothing else.
|
|
183
276
|
*/
|
|
184
|
-
export function
|
|
277
|
+
export function buildRawRows(config, state, codec) {
|
|
185
278
|
const entries = entriesOf(config);
|
|
186
279
|
const stateAccounts = stateAccountsOf(state);
|
|
187
280
|
const seen = new Set();
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { type RowEditor } from './identity.js';
|
|
2
|
+
import { type CredentialBinding, type CredentialStamp, type PoolRow, type QuotaCodec } from './schema.js';
|
|
3
|
+
/** Stamps of rows torn between the two writes of a replace, by row id. */
|
|
4
|
+
export declare function tornStamps(config: Record<string, unknown>, state: Record<string, unknown>, codec: QuotaCodec): Map<string, CredentialStamp & {
|
|
5
|
+
binding: CredentialBinding;
|
|
6
|
+
}>;
|
|
7
|
+
/**
|
|
8
|
+
* The config half of a replacement: the row's entry moves to the new epoch,
|
|
9
|
+
* loses its quota and needs a first reading; its roster row gets the new
|
|
10
|
+
* identity (or loses the old one) and, for an API key, the new endpoint.
|
|
11
|
+
* Disabling later rows that share the new identity is left to the caller
|
|
12
|
+
* (`disableIdentityDuplicates`), so several rows can be bound first.
|
|
13
|
+
*/
|
|
14
|
+
export declare function bindReplacement(editor: RowEditor, id: string, credentialEpoch: number, binding: CredentialBinding): void;
|
|
15
|
+
/**
|
|
16
|
+
* The config with every torn row completed as its replace would have left
|
|
17
|
+
* it, and the ids completed. The config passed in is not modified; when
|
|
18
|
+
* nothing is torn it is returned as is.
|
|
19
|
+
*/
|
|
20
|
+
export declare function completeTornRows(config: Record<string, unknown>, state: Record<string, unknown>, codec: QuotaCodec): {
|
|
21
|
+
config: Record<string, unknown>;
|
|
22
|
+
torn: string[];
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* The rows every reader gets. A torn row is shown completed (the identity,
|
|
26
|
+
* endpoint and epoch its stamp names, beside the credential it stamps), is
|
|
27
|
+
* marked `torn` and is never a candidate; every other row is as on disk.
|
|
28
|
+
*/
|
|
29
|
+
export declare function loadRows(config: Record<string, unknown>, state: Record<string, unknown>, codec: QuotaCodec): PoolRow[];
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { disableIdentityDuplicates } from './identity.js';
|
|
2
|
+
import { buildRawRows, CREDENTIAL_STAMP_KEY, credentialDigest, entryIn, isRecord, parseStamp, rosterRowIn, setEntryIn, } from './schema.js';
|
|
3
|
+
/*
|
|
4
|
+
* A replace changes both files: the state file gets the new credential and
|
|
5
|
+
* the config gets the new epoch, identity or endpoint. It writes the state
|
|
6
|
+
* file first, stamping the credential with the epoch it belongs to and the
|
|
7
|
+
* binding (identity, endpoint) the config is about to get. A crash between
|
|
8
|
+
* the two writes therefore leaves a credential whose stamp is ahead of the
|
|
9
|
+
* config's epoch, and that is how every reader tells such a torn row from a
|
|
10
|
+
* whole one. The stamp holds everything the config write would have written,
|
|
11
|
+
* so a torn row is completed forward: readers are shown the completed row
|
|
12
|
+
* (never a candidate until it is on disk) and the next store write on it
|
|
13
|
+
* writes the config to match.
|
|
14
|
+
*
|
|
15
|
+
* A stamp counts only beside the credential it was written with (its digest
|
|
16
|
+
* matches): a writer that does not know about stamps may put another
|
|
17
|
+
* credential beside an old one, and that stamp then says nothing. A stamp at
|
|
18
|
+
* or behind the config's epoch is never torn: this store's own writes leave
|
|
19
|
+
* the state file ahead of the config, never behind it.
|
|
20
|
+
*/
|
|
21
|
+
/** Stamps of rows torn between the two writes of a replace, by row id. */
|
|
22
|
+
export function tornStamps(config, state, codec) {
|
|
23
|
+
const accounts = isRecord(state.accounts) ? state.accounts : {};
|
|
24
|
+
const torn = new Map();
|
|
25
|
+
for (const row of buildRawRows(config, state, codec)) {
|
|
26
|
+
if (row.invalid || !row.credential)
|
|
27
|
+
continue;
|
|
28
|
+
const account = Object.hasOwn(accounts, row.id)
|
|
29
|
+
? accounts[row.id]
|
|
30
|
+
: undefined;
|
|
31
|
+
if (!isRecord(account))
|
|
32
|
+
continue;
|
|
33
|
+
const stamp = parseStamp(account[CREDENTIAL_STAMP_KEY]);
|
|
34
|
+
if (!stamp?.binding)
|
|
35
|
+
continue;
|
|
36
|
+
if (stamp.digest !== credentialDigest(row.credential))
|
|
37
|
+
continue;
|
|
38
|
+
// A row without an entry is at epoch 1, as everywhere else.
|
|
39
|
+
if (stamp.credentialEpoch <= (row.credentialEpoch ?? 1))
|
|
40
|
+
continue;
|
|
41
|
+
torn.set(row.id, { ...stamp, binding: stamp.binding });
|
|
42
|
+
}
|
|
43
|
+
return torn;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* The config half of a replacement: the row's entry moves to the new epoch,
|
|
47
|
+
* loses its quota and needs a first reading; its roster row gets the new
|
|
48
|
+
* identity (or loses the old one) and, for an API key, the new endpoint.
|
|
49
|
+
* Disabling later rows that share the new identity is left to the caller
|
|
50
|
+
* (`disableIdentityDuplicates`), so several rows can be bound first.
|
|
51
|
+
*/
|
|
52
|
+
export function bindReplacement(editor, id, credentialEpoch, binding) {
|
|
53
|
+
const entry = editor.entry(id) ?? {};
|
|
54
|
+
const next = {
|
|
55
|
+
...entry,
|
|
56
|
+
credentialEpoch,
|
|
57
|
+
needsFirstReading: true,
|
|
58
|
+
};
|
|
59
|
+
delete next.quota;
|
|
60
|
+
editor.setEntry(id, next);
|
|
61
|
+
const raw = editor.rosterRow(id);
|
|
62
|
+
if (!raw)
|
|
63
|
+
return;
|
|
64
|
+
if (binding.identity !== undefined)
|
|
65
|
+
raw.accountId = binding.identity;
|
|
66
|
+
else
|
|
67
|
+
delete raw.accountId;
|
|
68
|
+
if (binding.baseURL !== undefined)
|
|
69
|
+
raw.baseURL = binding.baseURL;
|
|
70
|
+
if (binding.authHeader !== undefined)
|
|
71
|
+
raw.authHeader = binding.authHeader;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* The config with every torn row completed as its replace would have left
|
|
75
|
+
* it, and the ids completed. The config passed in is not modified; when
|
|
76
|
+
* nothing is torn it is returned as is.
|
|
77
|
+
*/
|
|
78
|
+
export function completeTornRows(config, state, codec) {
|
|
79
|
+
const stamps = tornStamps(config, state, codec);
|
|
80
|
+
if (stamps.size === 0)
|
|
81
|
+
return { config, torn: [] };
|
|
82
|
+
const next = structuredClone(config);
|
|
83
|
+
const editor = {
|
|
84
|
+
rows: () => buildRawRows(next, state, codec),
|
|
85
|
+
rosterRow: (id) => rosterRowIn(next, id),
|
|
86
|
+
entry: (id) => entryIn(next, id),
|
|
87
|
+
setEntry: (id, entry) => setEntryIn(next, id, entry),
|
|
88
|
+
};
|
|
89
|
+
for (const [id, stamp] of stamps)
|
|
90
|
+
bindReplacement(editor, id, stamp.credentialEpoch, stamp.binding);
|
|
91
|
+
for (const stamp of stamps.values())
|
|
92
|
+
if (stamp.binding.identity !== undefined)
|
|
93
|
+
disableIdentityDuplicates(editor, stamp.binding.identity);
|
|
94
|
+
return { config: next, torn: [...stamps.keys()] };
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* The rows every reader gets. A torn row is shown completed (the identity,
|
|
98
|
+
* endpoint and epoch its stamp names, beside the credential it stamps), is
|
|
99
|
+
* marked `torn` and is never a candidate; every other row is as on disk.
|
|
100
|
+
*/
|
|
101
|
+
export function loadRows(config, state, codec) {
|
|
102
|
+
const { config: whole, torn } = completeTornRows(config, state, codec);
|
|
103
|
+
const rows = buildRawRows(whole, state, codec);
|
|
104
|
+
if (torn.length === 0)
|
|
105
|
+
return rows;
|
|
106
|
+
for (const row of rows) {
|
|
107
|
+
if (!torn.includes(row.id))
|
|
108
|
+
continue;
|
|
109
|
+
row.torn = true;
|
|
110
|
+
row.candidate = false;
|
|
111
|
+
}
|
|
112
|
+
return rows;
|
|
113
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cortexkit/common-auth",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.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": {
|