@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.
@@ -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: 1,
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
- * Marks a row disabled. Since 0.2.3 it takes the row lock and the caller's
424
- * extra locks before the store locks, as the other row writes do, so it waits
425
- * for a refresh of the row instead of landing during its provider call.
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
- export async function disableRow(rt, id, reason, options = {}) {
428
- assertNotInsideHook('disable');
429
- return runOperation(rt.ctx, 'disable', id, options.onFailure, async (locks, progress) => {
430
- const { row: seen } = await readRow(rt, 'disable', id);
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(rt.ctx, locks, progress, { operation: 'disable', rowId: id }, async (tx) => {
435
- const row = tx.row(id);
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('disable', id);
472
+ throw unknownRow(operation, id);
438
473
  if (rowLockKey(row) !== rowLockKey(seen))
439
- throw keyChanged('disable', id);
440
- disableIn(tx, id, reason);
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 { id };
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 async function enableRow(rt, id, options = {}) {
454
- assertNotInsideHook('enable');
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');
@@ -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. The row is shown as the write
172
- * leaves it once completed, is never a candidate, and the next store write
173
- * on it writes the config to match.
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
@@ -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 per-row entries of a config, created (empty) when absent. */
414
- export function ensureEntries(config) {
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
- const pool = config[POOL_KEY];
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
@@ -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?: {
@@ -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.6.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": {