@cortexkit/common-auth 0.6.0 → 0.8.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/engine.d.ts +24 -3
- package/dist/logger/engine.js +5 -1
- package/dist/logger/index.d.ts +1 -1
- package/dist/rpc/notifications.d.ts +25 -1
- package/dist/rpc/notifications.js +28 -6
- package/dist/rpc/port-file.d.ts +10 -1
- package/dist/rpc/port-file.js +50 -16
- package/dist/rpc/rpc-client.d.ts +19 -2
- package/dist/rpc/rpc-client.js +22 -7
- package/dist/rpc/rpc-server.d.ts +18 -0
- package/dist/rpc/rpc-server.js +98 -14
- package/dist/sidebar-file/sidebar-file.d.ts +10 -0
- package/dist/sidebar-file/sidebar-file.js +1 -0
- package/dist/store/attribution.d.ts +13 -2
- package/dist/store/identity.d.ts +5 -0
- package/dist/store/identity.js +16 -0
- package/dist/store/index.d.ts +3 -2
- package/dist/store/index.js +1 -0
- package/dist/store/mutate.d.ts +5 -1
- package/dist/store/mutate.js +14 -2
- package/dist/store/pool.d.ts +15 -9
- package/dist/store/provider-state.d.ts +45 -0
- package/dist/store/provider-state.js +80 -43
- package/dist/store/rows.d.ts +47 -11
- package/dist/store/rows.js +147 -56
- package/dist/store/schema.d.ts +45 -3
- package/dist/store/schema.js +87 -3
- package/dist/store/torn.d.ts +29 -0
- package/dist/store/torn.js +72 -1
- package/package.json +1 -1
package/dist/store/rows.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
1
2
|
import { PoolOperationError } from './errors.js';
|
|
2
3
|
import { assertNotInsideHook, runInsideHook } from './hooks.js';
|
|
3
|
-
import { DUPLICATE_IDENTITY_REASON, disableIdentityDuplicates, disableIn, recordIdentityIn, } from './identity.js';
|
|
4
|
+
import { DUPLICATE_IDENTITY_REASON, disableIdentityDuplicates, disableIn, enableIn, recordIdentityIn, } from './identity.js';
|
|
4
5
|
import { notReadyError, readPool, runOperation, withTransaction, } from './mutate.js';
|
|
5
|
-
import { acceptProviderState, mergedProviderState, providerStateCoverage, replacementProviderState, } from './provider-state.js';
|
|
6
|
+
import { acceptProviderState, mergedProviderState, planProviderStateIn, providerStateCoverage, replacementProviderState, } from './provider-state.js';
|
|
6
7
|
import { readRow, refusal, requireBound, rowLockSpec, unknownRow, } from './runtime.js';
|
|
7
|
-
import { boundProviderStateDigest, CREDENTIAL_STAMP_KEY, credentialProblem, fingerprintOf, idProblem, isCredentialEpoch, isRecord, PROVIDER_STATE_KEY, rosterRowFor, rotationStamp, rowLockKey, stampFor, stateFieldsFor, storedCredential, } from './schema.js';
|
|
8
|
-
import { bindReplacement } from './torn.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';
|
|
9
|
+
import { applyTransition, bindReplacement, TRANSITION_STAMP_KEY, } from './torn.js';
|
|
9
10
|
/** Fields of a state entry that belong to the credential it replaces. */
|
|
10
11
|
const CREDENTIAL_STATE_FIELDS = [
|
|
11
12
|
'access',
|
|
@@ -258,6 +259,12 @@ export async function addRow(rt, input, options = {}) {
|
|
|
258
259
|
await tx.commitConfig();
|
|
259
260
|
return { id, outcome: 'completed', credential: stored };
|
|
260
261
|
}
|
|
262
|
+
// An id the pool held before starts past every epoch it held, so
|
|
263
|
+
// work attributed to the earlier row's credential, from this
|
|
264
|
+
// process or another, never matches the new one.
|
|
265
|
+
const credentialEpoch = nextAddEpochIn(tx.config, id);
|
|
266
|
+
if (!isCredentialEpoch(credentialEpoch))
|
|
267
|
+
throw refusal('add', id, 'id-removed', `id ${id} has held every credential epoch and is not reused; add the credential under another id`);
|
|
261
268
|
tx.roster().push(rosterRowFor({
|
|
262
269
|
id,
|
|
263
270
|
credential,
|
|
@@ -266,7 +273,7 @@ export async function addRow(rt, input, options = {}) {
|
|
|
266
273
|
addedAt: ctx.now(),
|
|
267
274
|
}));
|
|
268
275
|
tx.setEntry(id, {
|
|
269
|
-
credentialEpoch
|
|
276
|
+
credentialEpoch,
|
|
270
277
|
needsFirstReading: credential.type === 'oauth',
|
|
271
278
|
});
|
|
272
279
|
let outcome = 'added';
|
|
@@ -420,72 +427,154 @@ export async function rotateRow(rt, id, credential, input = {}, options = {}) {
|
|
|
420
427
|
});
|
|
421
428
|
}
|
|
422
429
|
/**
|
|
423
|
-
*
|
|
424
|
-
*
|
|
425
|
-
*
|
|
430
|
+
* `disable` and `enable` in one place. Takes the row lock, then the extra
|
|
431
|
+
* locks, then the store locks, as every row write does, so it waits for a
|
|
432
|
+
* refresh of the row instead of landing during its provider call.
|
|
433
|
+
*
|
|
434
|
+
* With a provider-state mutator that changes the value, the transition is
|
|
435
|
+
* written as a replace is: the state file first, carrying the value and, in
|
|
436
|
+
* the stamp, the transition itself; then the config, flipping the row and
|
|
437
|
+
* recording the transition's mark (see `torn.ts`). A stop between the two
|
|
438
|
+
* leaves a row every reader shows transitioned beside its new value. When
|
|
439
|
+
* the config already says what the transition would write (an `enable` of
|
|
440
|
+
* an enabled row), the value alone is written, in one state write.
|
|
426
441
|
*/
|
|
427
|
-
|
|
428
|
-
assertNotInsideHook(
|
|
429
|
-
|
|
430
|
-
|
|
442
|
+
async function transitionRow(rt, operation, id, flag, options) {
|
|
443
|
+
assertNotInsideHook(operation);
|
|
444
|
+
const { ctx } = rt;
|
|
445
|
+
const codec = ctx.providerState;
|
|
446
|
+
const fence = options.attribution;
|
|
447
|
+
const mutator = options.providerState;
|
|
448
|
+
return runOperation(ctx, operation, id, options.onFailure, async (locks, progress) => {
|
|
449
|
+
if (fence !== undefined &&
|
|
450
|
+
!isCredentialEpoch(isRecord(fence) ? fence.credentialEpoch : undefined))
|
|
451
|
+
throw refusal(operation, id, 'invalid-input', 'the attribution must name a credential epoch that is a positive safe integer');
|
|
452
|
+
if (mutator !== undefined) {
|
|
453
|
+
if (typeof mutator !== 'function')
|
|
454
|
+
throw refusal(operation, id, 'invalid-input', 'the provider-state mutator must be a function');
|
|
455
|
+
// A provider-state value belongs to one credential, so a change to
|
|
456
|
+
// it must say which credential it was decided for.
|
|
457
|
+
if (fence === undefined)
|
|
458
|
+
throw refusal(operation, id, 'invalid-input', 'a provider-state change needs the attribution of the credential it is for');
|
|
459
|
+
if (!codec)
|
|
460
|
+
throw refusal(operation, id, 'invalid-input', 'the store was opened without a provider-state codec');
|
|
461
|
+
}
|
|
462
|
+
const { row: seen } = await readRow(rt, operation, id);
|
|
431
463
|
await locks.acquire(rowLockSpec(rt, seen));
|
|
432
464
|
for (const extra of options.extraLocks ?? [])
|
|
433
465
|
await locks.acquire(extra);
|
|
434
|
-
return withTransaction(
|
|
435
|
-
const
|
|
466
|
+
return withTransaction(ctx, locks, progress, { operation, rowId: id }, async (tx) => {
|
|
467
|
+
const loaded = tx.row(id);
|
|
468
|
+
const row = flag.enabled
|
|
469
|
+
? requireUsableRow('enable', id, loaded)
|
|
470
|
+
: loaded;
|
|
436
471
|
if (!row || !tx.rosterRow(id))
|
|
437
|
-
throw unknownRow(
|
|
472
|
+
throw unknownRow(operation, id);
|
|
438
473
|
if (rowLockKey(row) !== rowLockKey(seen))
|
|
439
|
-
throw keyChanged(
|
|
440
|
-
|
|
474
|
+
throw keyChanged(operation, id);
|
|
475
|
+
if (fence !== undefined) {
|
|
476
|
+
// An invalid entry has no epoch to compare the fence with.
|
|
477
|
+
if (row.invalid)
|
|
478
|
+
throw refusal(operation, id, 'invalid-row', `row ${id} failed validation`);
|
|
479
|
+
if ((row.credentialEpoch ?? 1) !== fence.credentialEpoch ||
|
|
480
|
+
row.identity !== fence.identity)
|
|
481
|
+
throw refusal(operation, id, 'attribution', `the ${operation} of ${id} was issued for a credential or account the row no longer holds`, true);
|
|
482
|
+
}
|
|
483
|
+
// An enable of a row that is already enabled has nothing to write
|
|
484
|
+
// to the config; a disable always rewrites it, as it always has.
|
|
485
|
+
const writesConfig = !flag.enabled || !row.enabled || row.disabledReason !== undefined;
|
|
486
|
+
if (flag.enabled && writesConfig && row.type === 'oauth') {
|
|
487
|
+
const holder = row.identity === undefined
|
|
488
|
+
? undefined
|
|
489
|
+
: tx
|
|
490
|
+
.rows()
|
|
491
|
+
.find((other) => other.id !== id &&
|
|
492
|
+
other.invalid === undefined &&
|
|
493
|
+
other.type === 'oauth' &&
|
|
494
|
+
other.enabled &&
|
|
495
|
+
other.identity === row.identity);
|
|
496
|
+
if (holder)
|
|
497
|
+
throw refusal('enable', id, 'duplicate-identity', `row ${holder.id} is enabled with the same identity as row ${id}`);
|
|
498
|
+
}
|
|
499
|
+
if (mutator === undefined || codec === undefined) {
|
|
500
|
+
if (!writesConfig)
|
|
501
|
+
return { id };
|
|
502
|
+
if (flag.enabled)
|
|
503
|
+
enableIn(tx, id);
|
|
504
|
+
else
|
|
505
|
+
disableIn(tx, id, flag.reason);
|
|
506
|
+
await tx.commitConfig();
|
|
507
|
+
return { id };
|
|
508
|
+
}
|
|
509
|
+
if (!row.credential)
|
|
510
|
+
throw refusal(operation, id, 'no-credential', `row ${id} holds no credential`);
|
|
511
|
+
// A strict store refuses here, but not for an attribution alone:
|
|
512
|
+
// disabling or enabling a row whose credential the store cannot
|
|
513
|
+
// prove is harmless (an unbound row is never a candidate), while
|
|
514
|
+
// writing a provider state would vouch for that credential.
|
|
515
|
+
requireBound(operation, row);
|
|
516
|
+
const plan = await planProviderStateIn(tx, codec, operation, row, mutator, true);
|
|
517
|
+
if (plan.kind === 'declined')
|
|
518
|
+
return { id, declined: true };
|
|
519
|
+
const result = {
|
|
520
|
+
id,
|
|
521
|
+
providerStateOutcome: plan.kind === 'unchanged'
|
|
522
|
+
? 'unchanged'
|
|
523
|
+
: plan.value === undefined
|
|
524
|
+
? 'cleared'
|
|
525
|
+
: 'updated',
|
|
526
|
+
...(plan.value !== undefined ? { providerState: plan.value } : {}),
|
|
527
|
+
};
|
|
528
|
+
if (plan.kind === 'unchanged') {
|
|
529
|
+
if (writesConfig) {
|
|
530
|
+
if (flag.enabled)
|
|
531
|
+
enableIn(tx, id);
|
|
532
|
+
else
|
|
533
|
+
disableIn(tx, id, flag.reason);
|
|
534
|
+
await tx.commitConfig();
|
|
535
|
+
}
|
|
536
|
+
return result;
|
|
537
|
+
}
|
|
538
|
+
if (!writesConfig) {
|
|
539
|
+
tx.setStateAccount(id, plan.account);
|
|
540
|
+
await tx.commitState();
|
|
541
|
+
return result;
|
|
542
|
+
}
|
|
543
|
+
const transition = flag.enabled
|
|
544
|
+
? { mark: randomUUID(), enabled: true }
|
|
545
|
+
: { mark: randomUUID(), enabled: false, reason: flag.reason };
|
|
546
|
+
const stamp = plan.account[CREDENTIAL_STAMP_KEY];
|
|
547
|
+
tx.setStateAccount(id, {
|
|
548
|
+
...plan.account,
|
|
549
|
+
[CREDENTIAL_STAMP_KEY]: {
|
|
550
|
+
...stamp,
|
|
551
|
+
[TRANSITION_STAMP_KEY]: transition,
|
|
552
|
+
},
|
|
553
|
+
});
|
|
554
|
+
await tx.commitState();
|
|
555
|
+
applyTransition(tx, id, transition);
|
|
441
556
|
await tx.commitConfig();
|
|
442
|
-
return
|
|
557
|
+
return result;
|
|
443
558
|
});
|
|
444
559
|
});
|
|
445
560
|
}
|
|
561
|
+
/**
|
|
562
|
+
* Marks a row disabled with a reason. See `RowTransitionOptions` for the
|
|
563
|
+
* attributed form, which may change the provider state with it.
|
|
564
|
+
*/
|
|
565
|
+
export function disableRow(rt, id, reason, options = {}) {
|
|
566
|
+
return transitionRow(rt, 'disable', id, { enabled: false, reason }, options);
|
|
567
|
+
}
|
|
446
568
|
/**
|
|
447
569
|
* Clears a row's `enabled: false` and its `disabledReason` in one config
|
|
448
570
|
* write. An OAuth row whose recorded identity another enabled OAuth row holds
|
|
449
571
|
* stays disabled and the call refuses (`duplicate-identity`): the same rule
|
|
450
572
|
* that makes `add` store such a row disabled. Enabling a row that is already
|
|
451
|
-
* enabled writes nothing.
|
|
573
|
+
* enabled writes nothing. See `RowTransitionOptions` for the attributed
|
|
574
|
+
* form, which may change the provider state with it.
|
|
452
575
|
*/
|
|
453
|
-
export
|
|
454
|
-
|
|
455
|
-
return runOperation(rt.ctx, 'enable', id, options.onFailure, async (locks, progress) => {
|
|
456
|
-
const { row: seen } = await readRow(rt, 'enable', id);
|
|
457
|
-
await locks.acquire(rowLockSpec(rt, seen));
|
|
458
|
-
for (const extra of options.extraLocks ?? [])
|
|
459
|
-
await locks.acquire(extra);
|
|
460
|
-
return withTransaction(rt.ctx, locks, progress, { operation: 'enable', rowId: id }, async (tx) => {
|
|
461
|
-
const row = requireUsableRow('enable', id, tx.row(id));
|
|
462
|
-
if (rowLockKey(row) !== rowLockKey(seen))
|
|
463
|
-
throw keyChanged('enable', id);
|
|
464
|
-
if (row.enabled && row.disabledReason === undefined)
|
|
465
|
-
return { id };
|
|
466
|
-
if (row.type === 'oauth' && row.identity !== undefined) {
|
|
467
|
-
const holder = tx
|
|
468
|
-
.rows()
|
|
469
|
-
.find((other) => other.id !== id &&
|
|
470
|
-
other.invalid === undefined &&
|
|
471
|
-
other.type === 'oauth' &&
|
|
472
|
-
other.enabled &&
|
|
473
|
-
other.identity === row.identity);
|
|
474
|
-
if (holder)
|
|
475
|
-
throw refusal('enable', id, 'duplicate-identity', `row ${holder.id} is enabled with the same identity as row ${id}`);
|
|
476
|
-
}
|
|
477
|
-
const raw = tx.rosterRow(id);
|
|
478
|
-
raw.enabled = true;
|
|
479
|
-
const entry = tx.entry(id);
|
|
480
|
-
if (entry && 'disabledReason' in entry) {
|
|
481
|
-
const next = { ...entry };
|
|
482
|
-
delete next.disabledReason;
|
|
483
|
-
tx.setEntry(id, next);
|
|
484
|
-
}
|
|
485
|
-
await tx.commitConfig();
|
|
486
|
-
return { id };
|
|
487
|
-
});
|
|
488
|
-
});
|
|
576
|
+
export function enableRow(rt, id, options = {}) {
|
|
577
|
+
return transitionRow(rt, 'enable', id, { enabled: true }, options);
|
|
489
578
|
}
|
|
490
579
|
/**
|
|
491
580
|
* Deletes a row: its roster row and per-row entry (quota, epoch; the identity
|
|
@@ -494,7 +583,9 @@ export async function enableRow(rt, id, options = {}) {
|
|
|
494
583
|
* between the two leaves a row every reader already sees as removed, with
|
|
495
584
|
* only an orphaned state entry that no reader loads; calling `remove` again
|
|
496
585
|
* drops that entry (`completed`). As with every id the store drops, the id is
|
|
497
|
-
* not reused by `add` in this process
|
|
586
|
+
* not reused by `add` in this process, and the config write records the
|
|
587
|
+
* row's credential epoch, so an `add` of the id in any other process starts
|
|
588
|
+
* past it (see `nextAddEpochIn`).
|
|
498
589
|
*/
|
|
499
590
|
export async function removeRow(rt, id, options = {}) {
|
|
500
591
|
assertNotInsideHook('remove');
|
package/dist/store/schema.d.ts
CHANGED
|
@@ -4,6 +4,15 @@ export declare const POOL_KEY = "commonAuthPool";
|
|
|
4
4
|
export declare const POOL_SCHEMA_VERSION = 1;
|
|
5
5
|
/** Property of `commonAuthPool` holding the per-row entries, keyed by local id. */
|
|
6
6
|
export declare const POOL_ROWS_KEY = "rows";
|
|
7
|
+
/**
|
|
8
|
+
* Property of `commonAuthPool` (since 0.8.0) holding, per id, the highest
|
|
9
|
+
* credential epoch a row with that id held when the store last dropped it
|
|
10
|
+
* from the pool. A row added later under the same id starts past it (see
|
|
11
|
+
* `nextAddEpochIn`), so an attribution taken for the dropped row never
|
|
12
|
+
* matches the new one. Older readers ignore it, and older writers keep it
|
|
13
|
+
* as they keep every pool key they do not know.
|
|
14
|
+
*/
|
|
15
|
+
export declare const POOL_RETIRED_EPOCHS_KEY = "retiredEpochs";
|
|
7
16
|
/** The `version` older readers of the same files expect at the top level. */
|
|
8
17
|
export declare const LEGACY_STORE_VERSION = 1;
|
|
9
18
|
/**
|
|
@@ -168,9 +177,11 @@ export interface PoolRow {
|
|
|
168
177
|
* it belongs to, and the config still holds the replaced row. Also set when
|
|
169
178
|
* a write that gives a row its first identity (`recordIdentity`, or a
|
|
170
179
|
* `rotate` or refresh that learns one) stopped after stamping the identity
|
|
171
|
-
* and before recording it in the config
|
|
172
|
-
*
|
|
173
|
-
*
|
|
180
|
+
* and before recording it in the config, and when an attributed `disable`
|
|
181
|
+
* or `enable` that changed the provider state stopped after its state
|
|
182
|
+
* write and before flipping the row in the config. The row is shown as the
|
|
183
|
+
* write leaves it once completed, is never a candidate, and the next store
|
|
184
|
+
* write on it writes the config to match.
|
|
174
185
|
*/
|
|
175
186
|
torn?: true;
|
|
176
187
|
/**
|
|
@@ -346,6 +357,37 @@ export declare function ensureEntries(config: Record<string, unknown>): Record<s
|
|
|
346
357
|
export declare function entryIn(config: Record<string, unknown>, id: string): Record<string, unknown> | undefined;
|
|
347
358
|
/** Sets an entry as an own property, so an id such as `toString` is safe. */
|
|
348
359
|
export declare function setEntryIn(config: Record<string, unknown>, id: string, entry: Record<string, unknown>): void;
|
|
360
|
+
/**
|
|
361
|
+
* The credential epoch recorded for a dropped id (see
|
|
362
|
+
* `POOL_RETIRED_EPOCHS_KEY`), or undefined when none is. A value that is not
|
|
363
|
+
* a credential epoch counts as none.
|
|
364
|
+
*/
|
|
365
|
+
export declare function retiredEpochIn(config: Record<string, unknown>, id: string): number | undefined;
|
|
366
|
+
/**
|
|
367
|
+
* The credential epoch `add` gives a new row with this id: one past the
|
|
368
|
+
* highest epoch the id is known to have held, which is the epoch recorded
|
|
369
|
+
* when the store dropped it, or the epoch of an entry left behind by a writer
|
|
370
|
+
* that removed only its roster row; 1 for an id the pool never held.
|
|
371
|
+
*
|
|
372
|
+
* An attribution names a row by id and credential epoch (and identity), and
|
|
373
|
+
* an id is chosen by the plugin, so it is often the same one again (`main`).
|
|
374
|
+
* Were a re-added row to start at epoch 1 again, an attribution taken for the
|
|
375
|
+
* removed row's credential would match the new credential exactly, in this
|
|
376
|
+
* process or any other. Starting past every earlier epoch makes such an
|
|
377
|
+
* attribution fail as it does after a `replace`. The result may lie past the
|
|
378
|
+
* safe integers (an id whose last row was at `Number.MAX_SAFE_INTEGER`);
|
|
379
|
+
* `add` refuses such an id.
|
|
380
|
+
*/
|
|
381
|
+
export declare function nextAddEpochIn(config: Record<string, unknown>, id: string): number;
|
|
382
|
+
/**
|
|
383
|
+
* Records, in a config being written, the epochs of the ids it drops: for
|
|
384
|
+
* each, the epoch its entry claims (1 for a row without one, the epoch such a
|
|
385
|
+
* row is at), kept only when above what is already recorded, so the record
|
|
386
|
+
* for an id never goes down. Valid recorded values of other ids are kept; a
|
|
387
|
+
* record that is not an object, or a value in it that is not an epoch, says
|
|
388
|
+
* nothing and is replaced. An entry whose epoch cannot be read records 1.
|
|
389
|
+
*/
|
|
390
|
+
export declare function retireEpochsIn(config: Record<string, unknown>, dropped: Iterable<string>): void;
|
|
349
391
|
/**
|
|
350
392
|
* Builds the rows of a ready pool from the files exactly as they are, without
|
|
351
393
|
* looking at credential stamps (see `loadRows` for the rows every reader
|
package/dist/store/schema.js
CHANGED
|
@@ -5,6 +5,15 @@ export const POOL_KEY = 'commonAuthPool';
|
|
|
5
5
|
export const POOL_SCHEMA_VERSION = 1;
|
|
6
6
|
/** Property of `commonAuthPool` holding the per-row entries, keyed by local id. */
|
|
7
7
|
export const POOL_ROWS_KEY = 'rows';
|
|
8
|
+
/**
|
|
9
|
+
* Property of `commonAuthPool` (since 0.8.0) holding, per id, the highest
|
|
10
|
+
* credential epoch a row with that id held when the store last dropped it
|
|
11
|
+
* from the pool. A row added later under the same id starts past it (see
|
|
12
|
+
* `nextAddEpochIn`), so an attribution taken for the dropped row never
|
|
13
|
+
* matches the new one. Older readers ignore it, and older writers keep it
|
|
14
|
+
* as they keep every pool key they do not know.
|
|
15
|
+
*/
|
|
16
|
+
export const POOL_RETIRED_EPOCHS_KEY = 'retiredEpochs';
|
|
8
17
|
/** The `version` older readers of the same files expect at the top level. */
|
|
9
18
|
export const LEGACY_STORE_VERSION = 1;
|
|
10
19
|
/**
|
|
@@ -410,11 +419,15 @@ function credentialFor(raw, stateEntry) {
|
|
|
410
419
|
export function rosterRowIn(config, id) {
|
|
411
420
|
return rosterOf(config).find((raw) => isRecord(raw) && raw.id === id);
|
|
412
421
|
}
|
|
413
|
-
/** The
|
|
414
|
-
|
|
422
|
+
/** The pool object of a config, created (empty) when absent. */
|
|
423
|
+
function ensurePool(config) {
|
|
415
424
|
if (!isRecord(config[POOL_KEY]))
|
|
416
425
|
config[POOL_KEY] = {};
|
|
417
|
-
|
|
426
|
+
return config[POOL_KEY];
|
|
427
|
+
}
|
|
428
|
+
/** The per-row entries of a config, created (empty) when absent. */
|
|
429
|
+
export function ensureEntries(config) {
|
|
430
|
+
const pool = ensurePool(config);
|
|
418
431
|
if (!isRecord(pool[POOL_ROWS_KEY]))
|
|
419
432
|
pool[POOL_ROWS_KEY] = {};
|
|
420
433
|
return pool[POOL_ROWS_KEY];
|
|
@@ -433,6 +446,77 @@ export function setEntryIn(config, id, entry) {
|
|
|
433
446
|
configurable: true,
|
|
434
447
|
});
|
|
435
448
|
}
|
|
449
|
+
/**
|
|
450
|
+
* The credential epoch recorded for a dropped id (see
|
|
451
|
+
* `POOL_RETIRED_EPOCHS_KEY`), or undefined when none is. A value that is not
|
|
452
|
+
* a credential epoch counts as none.
|
|
453
|
+
*/
|
|
454
|
+
export function retiredEpochIn(config, id) {
|
|
455
|
+
const pool = config[POOL_KEY];
|
|
456
|
+
if (!isRecord(pool))
|
|
457
|
+
return undefined;
|
|
458
|
+
const retired = pool[POOL_RETIRED_EPOCHS_KEY];
|
|
459
|
+
if (!isRecord(retired) || !Object.hasOwn(retired, id))
|
|
460
|
+
return undefined;
|
|
461
|
+
const epoch = retired[id];
|
|
462
|
+
return isCredentialEpoch(epoch) ? epoch : undefined;
|
|
463
|
+
}
|
|
464
|
+
/**
|
|
465
|
+
* The epoch a per-row entry claims, read without validating the rest of the
|
|
466
|
+
* entry, or undefined when it names none a reader would accept.
|
|
467
|
+
*/
|
|
468
|
+
function entryEpochIn(config, id) {
|
|
469
|
+
const epoch = entryIn(config, id)?.credentialEpoch;
|
|
470
|
+
return isCredentialEpoch(epoch) ? epoch : undefined;
|
|
471
|
+
}
|
|
472
|
+
/**
|
|
473
|
+
* The credential epoch `add` gives a new row with this id: one past the
|
|
474
|
+
* highest epoch the id is known to have held, which is the epoch recorded
|
|
475
|
+
* when the store dropped it, or the epoch of an entry left behind by a writer
|
|
476
|
+
* that removed only its roster row; 1 for an id the pool never held.
|
|
477
|
+
*
|
|
478
|
+
* An attribution names a row by id and credential epoch (and identity), and
|
|
479
|
+
* an id is chosen by the plugin, so it is often the same one again (`main`).
|
|
480
|
+
* Were a re-added row to start at epoch 1 again, an attribution taken for the
|
|
481
|
+
* removed row's credential would match the new credential exactly, in this
|
|
482
|
+
* process or any other. Starting past every earlier epoch makes such an
|
|
483
|
+
* attribution fail as it does after a `replace`. The result may lie past the
|
|
484
|
+
* safe integers (an id whose last row was at `Number.MAX_SAFE_INTEGER`);
|
|
485
|
+
* `add` refuses such an id.
|
|
486
|
+
*/
|
|
487
|
+
export function nextAddEpochIn(config, id) {
|
|
488
|
+
return (Math.max(retiredEpochIn(config, id) ?? 0, entryEpochIn(config, id) ?? 0) + 1);
|
|
489
|
+
}
|
|
490
|
+
/**
|
|
491
|
+
* Records, in a config being written, the epochs of the ids it drops: for
|
|
492
|
+
* each, the epoch its entry claims (1 for a row without one, the epoch such a
|
|
493
|
+
* row is at), kept only when above what is already recorded, so the record
|
|
494
|
+
* for an id never goes down. Valid recorded values of other ids are kept; a
|
|
495
|
+
* record that is not an object, or a value in it that is not an epoch, says
|
|
496
|
+
* nothing and is replaced. An entry whose epoch cannot be read records 1.
|
|
497
|
+
*/
|
|
498
|
+
export function retireEpochsIn(config, dropped) {
|
|
499
|
+
const ids = [...dropped];
|
|
500
|
+
if (ids.length === 0)
|
|
501
|
+
return;
|
|
502
|
+
const pool = ensurePool(config);
|
|
503
|
+
const previous = isRecord(pool[POOL_RETIRED_EPOCHS_KEY])
|
|
504
|
+
? pool[POOL_RETIRED_EPOCHS_KEY]
|
|
505
|
+
: {};
|
|
506
|
+
const next = {};
|
|
507
|
+
const define = (id, epoch) => Object.defineProperty(next, id, {
|
|
508
|
+
value: epoch,
|
|
509
|
+
enumerable: true,
|
|
510
|
+
writable: true,
|
|
511
|
+
configurable: true,
|
|
512
|
+
});
|
|
513
|
+
for (const [id, epoch] of Object.entries(previous))
|
|
514
|
+
if (isCredentialEpoch(epoch))
|
|
515
|
+
define(id, epoch);
|
|
516
|
+
for (const id of ids)
|
|
517
|
+
define(id, Math.max(retiredEpochIn(config, id) ?? 0, entryEpochIn(config, id) ?? 1));
|
|
518
|
+
pool[POOL_RETIRED_EPOCHS_KEY] = next;
|
|
519
|
+
}
|
|
436
520
|
/**
|
|
437
521
|
* Builds the rows of a ready pool from the files exactly as they are, without
|
|
438
522
|
* looking at credential stamps (see `loadRows` for the rows every reader
|
package/dist/store/torn.d.ts
CHANGED
|
@@ -1,5 +1,31 @@
|
|
|
1
1
|
import { type RowEditor } from './identity.js';
|
|
2
2
|
import { type CredentialBinding, type CredentialStamp, type PoolRow, type ProviderStateCodec, type QuotaCodec } from './schema.js';
|
|
3
|
+
/** Key, inside a credential stamp, of an attributed enable or disable. */
|
|
4
|
+
export declare const TRANSITION_STAMP_KEY = "transition";
|
|
5
|
+
/**
|
|
6
|
+
* Key, inside a per-row config entry, of the mark of the last transition the
|
|
7
|
+
* config carries out. Older readers ignore it.
|
|
8
|
+
*/
|
|
9
|
+
export declare const TRANSITION_MARK_KEY = "transitionMark";
|
|
10
|
+
/**
|
|
11
|
+
* An attributed enable or disable as its state write records it: `mark` is
|
|
12
|
+
* unique to that write, `enabled` is the flag it sets, and `reason` the
|
|
13
|
+
* disabled reason (a disable's only).
|
|
14
|
+
*/
|
|
15
|
+
export interface StampedTransition {
|
|
16
|
+
mark: string;
|
|
17
|
+
enabled: boolean;
|
|
18
|
+
reason?: string;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Carries a transition out in a config being edited: the row's enabled flag
|
|
22
|
+
* and reason as the transition says, and its mark recorded in the entry (a
|
|
23
|
+
* row without an entry gets one at epoch 1, as `disableIn` gives it). An
|
|
24
|
+
* enable then applies the duplicate-identity rule, as every write that
|
|
25
|
+
* enables an OAuth row with a known identity does: the earlier row in roster
|
|
26
|
+
* order keeps the identity.
|
|
27
|
+
*/
|
|
28
|
+
export declare function applyTransition(editor: RowEditor, id: string, transition: StampedTransition): void;
|
|
3
29
|
/** How a row left between the two writes of an operation is completed. */
|
|
4
30
|
export type TornCompletion = {
|
|
5
31
|
kind: 'replace';
|
|
@@ -9,6 +35,9 @@ export type TornCompletion = {
|
|
|
9
35
|
} | {
|
|
10
36
|
kind: 'identity';
|
|
11
37
|
identity: string;
|
|
38
|
+
} | {
|
|
39
|
+
kind: 'transition';
|
|
40
|
+
transition: StampedTransition;
|
|
12
41
|
};
|
|
13
42
|
/** Rows left between the two writes of an operation, by row id. */
|
|
14
43
|
export declare function tornStamps(config: Record<string, unknown>, state: Record<string, unknown>, codec: QuotaCodec, options?: {
|
package/dist/store/torn.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { disableIdentityDuplicates, recordIdentityIn, } from './identity.js';
|
|
1
|
+
import { disableIdentityDuplicates, disableIn, enableIn, 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
|
|
@@ -46,7 +46,69 @@ import { buildRawRows, CREDENTIAL_STAMP_KEY, credentialDigest, dispatchDigest, e
|
|
|
46
46
|
* exactly (digest and dispatch digest), that names an identity the config
|
|
47
47
|
* does not record; that is completed forward the same way, by recording the
|
|
48
48
|
* identity.
|
|
49
|
+
*
|
|
50
|
+
* An attributed `disable` or `enable` that also changes the provider state
|
|
51
|
+
* follows the same order. Its state write carries the new value and, in the
|
|
52
|
+
* stamp, the transition (`transition: {mark, enabled, reason?}`, see
|
|
53
|
+
* `StampedTransition`); its config write then flips the row and records the
|
|
54
|
+
* transition's mark in the row's entry (`transitionMark`). A stamp bound to
|
|
55
|
+
* the row (this credential, epoch and identity, the binding that also shows
|
|
56
|
+
* the provider state beside it) whose transition mark the entry does not
|
|
57
|
+
* record is such a write stopped between its two writes, and is completed
|
|
58
|
+
* forward the same way: readers are shown the row disabled or enabled as the
|
|
59
|
+
* transition says, beside the value written with it. Once the config records
|
|
60
|
+
* the mark the transition says nothing more, so a later `enable` or
|
|
61
|
+
* `disable` of the row is never undone by it; a credential write drops it
|
|
62
|
+
* with the rest of the old stamp.
|
|
63
|
+
*/
|
|
64
|
+
/** Key, inside a credential stamp, of an attributed enable or disable. */
|
|
65
|
+
export const TRANSITION_STAMP_KEY = 'transition';
|
|
66
|
+
/**
|
|
67
|
+
* Key, inside a per-row config entry, of the mark of the last transition the
|
|
68
|
+
* config carries out. Older readers ignore it.
|
|
49
69
|
*/
|
|
70
|
+
export const TRANSITION_MARK_KEY = 'transitionMark';
|
|
71
|
+
/** The well-formed transition in a raw stamp, or undefined. */
|
|
72
|
+
function stampedTransition(rawStamp) {
|
|
73
|
+
if (!isRecord(rawStamp))
|
|
74
|
+
return undefined;
|
|
75
|
+
const raw = rawStamp[TRANSITION_STAMP_KEY];
|
|
76
|
+
if (!isRecord(raw))
|
|
77
|
+
return undefined;
|
|
78
|
+
if (typeof raw.mark !== 'string' || raw.mark.length === 0)
|
|
79
|
+
return undefined;
|
|
80
|
+
if (raw.enabled === true)
|
|
81
|
+
return { mark: raw.mark, enabled: true };
|
|
82
|
+
if (raw.enabled === false && typeof raw.reason === 'string')
|
|
83
|
+
return { mark: raw.mark, enabled: false, reason: raw.reason };
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Carries a transition out in a config being edited: the row's enabled flag
|
|
88
|
+
* and reason as the transition says, and its mark recorded in the entry (a
|
|
89
|
+
* row without an entry gets one at epoch 1, as `disableIn` gives it). An
|
|
90
|
+
* enable then applies the duplicate-identity rule, as every write that
|
|
91
|
+
* enables an OAuth row with a known identity does: the earlier row in roster
|
|
92
|
+
* order keeps the identity.
|
|
93
|
+
*/
|
|
94
|
+
export function applyTransition(editor, id, transition) {
|
|
95
|
+
if (transition.enabled)
|
|
96
|
+
enableIn(editor, id);
|
|
97
|
+
else
|
|
98
|
+
disableIn(editor, id, transition.reason ?? '');
|
|
99
|
+
if (!editor.rosterRow(id))
|
|
100
|
+
return;
|
|
101
|
+
const entry = editor.entry(id) ?? {
|
|
102
|
+
credentialEpoch: 1,
|
|
103
|
+
needsFirstReading: true,
|
|
104
|
+
};
|
|
105
|
+
editor.setEntry(id, { ...entry, [TRANSITION_MARK_KEY]: transition.mark });
|
|
106
|
+
if (!transition.enabled)
|
|
107
|
+
return;
|
|
108
|
+
const row = editor.rows().find((candidate) => candidate.id === id);
|
|
109
|
+
if (row?.type === 'oauth' && row.identity !== undefined)
|
|
110
|
+
disableIdentityDuplicates(editor, row.identity);
|
|
111
|
+
}
|
|
50
112
|
/**
|
|
51
113
|
* The credential a torn replace leaves once its config write lands: the
|
|
52
114
|
* credential loaded beside the stamp, with the endpoint the stamp's binding
|
|
@@ -122,6 +184,13 @@ export function tornStamps(config, state, codec, options = {}) {
|
|
|
122
184
|
row.identity === undefined) {
|
|
123
185
|
torn.set(row.id, { kind: 'identity', identity: stamp.binding.identity });
|
|
124
186
|
}
|
|
187
|
+
else if (stamp.credentialEpoch === epoch &&
|
|
188
|
+
stamp.binding.identity === row.identity) {
|
|
189
|
+
const transition = stampedTransition(account[CREDENTIAL_STAMP_KEY]);
|
|
190
|
+
if (transition &&
|
|
191
|
+
entryIn(config, row.id)?.[TRANSITION_MARK_KEY] !== transition.mark)
|
|
192
|
+
torn.set(row.id, { kind: 'transition', transition });
|
|
193
|
+
}
|
|
125
194
|
}
|
|
126
195
|
return torn;
|
|
127
196
|
}
|
|
@@ -175,6 +244,8 @@ export function completeTornRows(config, state, codec, options = {}) {
|
|
|
175
244
|
for (const [id, completion] of completions) {
|
|
176
245
|
if (completion.kind === 'identity')
|
|
177
246
|
recordIdentityIn(editor, id, completion.identity);
|
|
247
|
+
else if (completion.kind === 'transition')
|
|
248
|
+
applyTransition(editor, id, completion.transition);
|
|
178
249
|
else if (completion.stamp.binding.identity !== undefined)
|
|
179
250
|
disableIdentityDuplicates(editor, completion.stamp.binding.identity);
|
|
180
251
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cortexkit/common-auth",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.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": {
|