@cortexkit/common-auth 0.11.2 → 0.11.3
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/store/pool.d.ts +4 -0
- package/dist/store/rows.d.ts +3 -2
- package/dist/store/rows.js +88 -6
- package/dist/store/torn.js +6 -0
- package/package.json +1 -1
package/dist/store/pool.d.ts
CHANGED
|
@@ -33,6 +33,10 @@ export interface OpenPoolStoreOptions {
|
|
|
33
33
|
* provider call or write, checked again under the locks and at commit.
|
|
34
34
|
* Nothing makes such a row bound except `replace`, which starts a new
|
|
35
35
|
* credential epoch and drops what was observed about the old one.
|
|
36
|
+
* An interrupted add's state-only orphan is recovered only when its stamp
|
|
37
|
+
* binds the stored and incoming material, including a supplied identity and
|
|
38
|
+
* an API key's endpoint/header. Otherwise add refuses `unbound-credential`
|
|
39
|
+
* without writing; `remove(id)` explicitly discards the orphan.
|
|
36
40
|
*/
|
|
37
41
|
requireCredentialStamps?: boolean;
|
|
38
42
|
/** Injected clock for leases, refresh stamps and `addedAt`. */
|
package/dist/store/rows.d.ts
CHANGED
|
@@ -46,7 +46,7 @@ export type RowToggleOptions = Pick<RowOperationOptions, 'onFailure' | 'extraLoc
|
|
|
46
46
|
export interface RemoveView {
|
|
47
47
|
/**
|
|
48
48
|
* The row as loaded; undefined when the roster no longer holds the id and
|
|
49
|
-
* only its state-file entry is left (
|
|
49
|
+
* only its state-file entry is left (an add or removal interrupted between writes).
|
|
50
50
|
*/
|
|
51
51
|
row: PoolRow | undefined;
|
|
52
52
|
/** The config file as read under the store locks. */
|
|
@@ -70,7 +70,8 @@ export type RemoveResult = {
|
|
|
70
70
|
/**
|
|
71
71
|
* `removed`: the roster row was dropped (and its state entry, if any).
|
|
72
72
|
* `completed`: only a state-file entry was left, by a removal interrupted
|
|
73
|
-
* between its config and state writes
|
|
73
|
+
* between its config and state writes or an add interrupted before its
|
|
74
|
+
* config write, and it is now dropped.
|
|
74
75
|
*/
|
|
75
76
|
outcome: 'removed' | 'completed';
|
|
76
77
|
};
|
package/dist/store/rows.js
CHANGED
|
@@ -5,7 +5,7 @@ import { DUPLICATE_IDENTITY_REASON, disableIdentityDuplicates, disableIn, enable
|
|
|
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';
|
|
8
|
-
import { boundProviderStateDigest, CREDENTIAL_STAMP_KEY, credentialProblem, fingerprintOf, idProblem, isCredentialEpoch, isRecord, nextAddEpochIn, PROVIDER_STATE_KEY, rosterRowFor, rotationStamp, rowLockKey, stampFor, stateFieldsFor, storedCredential, } from './schema.js';
|
|
8
|
+
import { boundProviderStateDigest, buildRawRows, CREDENTIAL_STAMP_KEY, credentialDigest, credentialProblem, dispatchDigest, fingerprintOf, idProblem, isCredentialEpoch, isRecord, nextAddEpochIn, POOL_KEY, PROVIDER_STATE_KEY, parseStamp, providerStateDigest, rosterRowFor, rotationStamp, rowLockKey, stampFor, stateFieldsFor, storedCredential, } from './schema.js';
|
|
9
9
|
import { applyTransition, bindReplacement, TRANSITION_STAMP_KEY, } from './torn.js';
|
|
10
10
|
/** Fields of a state entry that belong to the credential it replaces. */
|
|
11
11
|
const CREDENTIAL_STATE_FIELDS = [
|
|
@@ -203,6 +203,74 @@ function identityMismatch(operation, id) {
|
|
|
203
203
|
function keyChanged(operation, id) {
|
|
204
204
|
return refusal(operation, id, 'row-key-changed', `row ${id}'s wire identity changed while its lock was being taken`, true);
|
|
205
205
|
}
|
|
206
|
+
/**
|
|
207
|
+
* A strict replay may finish a state-first add, but must not replace what the
|
|
208
|
+
* interrupted write stamped. Load the orphan against its stamped descriptor
|
|
209
|
+
* using the ordinary row/stamp machinery, then compare the caller's material.
|
|
210
|
+
* The roster has no endpoint or identity yet, so neither can be inferred from
|
|
211
|
+
* the incoming add. Refusal happens before even repairing other torn rows.
|
|
212
|
+
*/
|
|
213
|
+
function matchingInterruptedAdd(rt, tx, input, incoming) {
|
|
214
|
+
const { id, credential, identity } = input;
|
|
215
|
+
if (!rt.ctx.requireCredentialStamps ||
|
|
216
|
+
tx.rosterRow(id) ||
|
|
217
|
+
!hasStateAccount(tx.state, id))
|
|
218
|
+
return undefined;
|
|
219
|
+
const refuse = () => {
|
|
220
|
+
throw refusal('add', id, 'unbound-credential', `orphan ${id} cannot be bound to this add's material; remove it explicitly before adding different material`);
|
|
221
|
+
};
|
|
222
|
+
const account = tx.stateAccount(id);
|
|
223
|
+
const stamp = parseStamp(account?.[CREDENTIAL_STAMP_KEY]);
|
|
224
|
+
if (!stamp?.binding || stamp.dispatch === undefined || stamp.replace)
|
|
225
|
+
return refuse();
|
|
226
|
+
if (credential.type === 'api' &&
|
|
227
|
+
(stamp.binding.baseURL === undefined ||
|
|
228
|
+
stamp.binding.authHeader === undefined))
|
|
229
|
+
return refuse();
|
|
230
|
+
const descriptor = credential.type === 'api'
|
|
231
|
+
? {
|
|
232
|
+
...credential,
|
|
233
|
+
baseURL: stamp.binding.baseURL,
|
|
234
|
+
authHeader: stamp.binding.authHeader,
|
|
235
|
+
}
|
|
236
|
+
: credential;
|
|
237
|
+
const [orphan] = buildRawRows({
|
|
238
|
+
accounts: [
|
|
239
|
+
rosterRowFor({
|
|
240
|
+
id,
|
|
241
|
+
credential: descriptor,
|
|
242
|
+
identity: stamp.binding.identity,
|
|
243
|
+
addedAt: 0,
|
|
244
|
+
}),
|
|
245
|
+
],
|
|
246
|
+
[POOL_KEY]: {
|
|
247
|
+
rows: { [id]: { credentialEpoch: nextAddEpochIn(tx.config, id) } },
|
|
248
|
+
},
|
|
249
|
+
}, tx.state, rt.ctx.codec, rt.ctx.providerState);
|
|
250
|
+
if (!orphan?.credential || orphan.invalid || orphan.stamp !== 'bound')
|
|
251
|
+
return refuse();
|
|
252
|
+
if (identity !== undefined && identity !== orphan.identity)
|
|
253
|
+
return refuse();
|
|
254
|
+
if (credentialDigest(credential) !== stamp.digest)
|
|
255
|
+
return refuse();
|
|
256
|
+
if (credential.type === 'api') {
|
|
257
|
+
if (credential.baseURL.trim() !== stamp.binding.baseURL)
|
|
258
|
+
return refuse();
|
|
259
|
+
if ((credential.authHeader ?? 'authorization-bearer') !==
|
|
260
|
+
stamp.binding.authHeader)
|
|
261
|
+
return refuse();
|
|
262
|
+
}
|
|
263
|
+
else if (dispatchDigest(credential) !== stamp.dispatch)
|
|
264
|
+
return refuse();
|
|
265
|
+
// Provider-state coverage is separate from credential stamp status. Keep
|
|
266
|
+
// the interrupted write's value, but never complete a changed bound value.
|
|
267
|
+
if (stamp.providerState !== undefined && orphan.providerState === undefined)
|
|
268
|
+
return refuse();
|
|
269
|
+
if (incoming !== undefined &&
|
|
270
|
+
providerStateDigest(rt.ctx.providerState, incoming) !== stamp.providerState)
|
|
271
|
+
return refuse();
|
|
272
|
+
return orphan;
|
|
273
|
+
}
|
|
206
274
|
export async function addRow(rt, input, options = {}) {
|
|
207
275
|
assertNotInsideHook('add');
|
|
208
276
|
const { ctx } = rt;
|
|
@@ -220,9 +288,13 @@ export async function addRow(rt, input, options = {}) {
|
|
|
220
288
|
for (const extra of options.extraLocks ?? [])
|
|
221
289
|
await locks.acquire(extra);
|
|
222
290
|
return withTransaction(ctx, locks, progress, { operation: 'add', rowId: id }, async (tx) => {
|
|
291
|
+
const orphan = matchingInterruptedAdd(rt, tx, input, incoming);
|
|
292
|
+
if (!orphan)
|
|
293
|
+
await tx.completeTorn();
|
|
223
294
|
const rows = tx.rows();
|
|
224
295
|
const fingerprint = fingerprintOf(credential);
|
|
225
|
-
const same =
|
|
296
|
+
const same = !orphan &&
|
|
297
|
+
rows.find((row) => row.invalid === undefined && row.fingerprint === fingerprint);
|
|
226
298
|
if (same) {
|
|
227
299
|
// Re-adding a secret whose stamp is not proved would rotate it in
|
|
228
300
|
// and keep the identity and quota recorded beside it, making that
|
|
@@ -286,10 +358,13 @@ export async function addRow(rt, input, options = {}) {
|
|
|
286
358
|
const credentialEpoch = nextAddEpochIn(tx.config, id);
|
|
287
359
|
if (!isCredentialEpoch(credentialEpoch))
|
|
288
360
|
throw refusal('add', id, 'id-removed', `id ${id} has held every credential epoch and is not reused; add the credential under another id`);
|
|
361
|
+
const addedIdentity = orphan?.identity ?? identity;
|
|
289
362
|
tx.roster().push(rosterRowFor({
|
|
290
363
|
id,
|
|
291
364
|
credential,
|
|
292
|
-
...(
|
|
365
|
+
...(addedIdentity !== undefined
|
|
366
|
+
? { identity: addedIdentity }
|
|
367
|
+
: {}),
|
|
293
368
|
...(label !== undefined ? { label } : {}),
|
|
294
369
|
addedAt: ctx.now(),
|
|
295
370
|
}));
|
|
@@ -298,17 +373,24 @@ export async function addRow(rt, input, options = {}) {
|
|
|
298
373
|
needsFirstReading: credential.type === 'oauth',
|
|
299
374
|
});
|
|
300
375
|
let outcome = 'added';
|
|
301
|
-
if (
|
|
376
|
+
if (addedIdentity !== undefined && credential.type === 'oauth') {
|
|
302
377
|
// Same account, different credential: kept on disk, disabled.
|
|
303
378
|
const holder = rows.find((row) => row.invalid === undefined &&
|
|
304
379
|
row.type === 'oauth' &&
|
|
305
380
|
row.enabled &&
|
|
306
|
-
row.identity ===
|
|
381
|
+
row.identity === addedIdentity);
|
|
307
382
|
if (holder) {
|
|
308
383
|
disableIn(tx, id, DUPLICATE_IDENTITY_REASON);
|
|
309
384
|
outcome = 'added-disabled';
|
|
310
385
|
}
|
|
311
386
|
}
|
|
387
|
+
if (orphan?.credential) {
|
|
388
|
+
// The state half is already durable and proved. A config-only
|
|
389
|
+
// completion preserves the credential, stamp and provider state
|
|
390
|
+
// byte for byte; a crash leaves either this orphan or a whole row.
|
|
391
|
+
await tx.commitConfig();
|
|
392
|
+
return { id, outcome, credential: orphan.credential };
|
|
393
|
+
}
|
|
312
394
|
// The credential is written first. A crash before the config write
|
|
313
395
|
// then leaves a state entry no roster row names, which no reader
|
|
314
396
|
// loads and `remove` drops; written the other way round, the new
|
|
@@ -322,7 +404,7 @@ export async function addRow(rt, input, options = {}) {
|
|
|
322
404
|
});
|
|
323
405
|
await tx.commitConfig();
|
|
324
406
|
return { id, outcome, credential: stored };
|
|
325
|
-
});
|
|
407
|
+
}, { completeTorn: false });
|
|
326
408
|
});
|
|
327
409
|
if (credential.type === 'oauth' &&
|
|
328
410
|
(result.outcome === 'added' || result.outcome === 'completed'))
|
package/dist/store/torn.js
CHANGED
|
@@ -186,6 +186,12 @@ export function tornStamps(config, state, codec, options = {}) {
|
|
|
186
186
|
}
|
|
187
187
|
else if (stamp.credentialEpoch === epoch &&
|
|
188
188
|
stamp.binding.identity === row.identity) {
|
|
189
|
+
// The lineage digest alone misses an access-token or expiry edit. A
|
|
190
|
+
// strict store must not repair the config from a transition whose stamp
|
|
191
|
+
// no longer describes the credential; the row stays unbound instead.
|
|
192
|
+
if (options.requireCredentialStamps &&
|
|
193
|
+
stamp.dispatch !== dispatchDigest(row.credential))
|
|
194
|
+
continue;
|
|
189
195
|
const transition = stampedTransition(account[CREDENTIAL_STAMP_KEY]);
|
|
190
196
|
if (transition &&
|
|
191
197
|
entryIn(config, row.id)?.[TRANSITION_MARK_KEY] !== transition.mark)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cortexkit/common-auth",
|
|
3
|
-
"version": "0.11.
|
|
3
|
+
"version": "0.11.3",
|
|
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": {
|