@cortexkit/common-auth 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,15 +4,47 @@ import { createServer, } from 'node:http';
4
4
  import { join } from 'node:path';
5
5
  import { isSessionId, } from './notifications.js';
6
6
  import { sweepRpcState, writePortFile } from './port-file.js';
7
+ /**
8
+ * Thrown by an `apply` or `drain` handler to refuse a request with a 4xx
9
+ * status. Its message is sent on the wire as `{error: message}`, so it must
10
+ * be written for the client and never quote a credential. Any other error a
11
+ * handler throws answers 500 with a fixed code.
12
+ */
13
+ export class RpcRequestError extends Error {
14
+ status;
15
+ constructor(status, message) {
16
+ if (!Number.isInteger(status) || status < 400 || status > 499)
17
+ throw new RangeError(`RpcRequestError status must be 4xx, got ${status}`);
18
+ super(message);
19
+ this.name = 'RpcRequestError';
20
+ this.status = status;
21
+ }
22
+ }
23
+ const MAX_BODY_BYTES = 1_000_000;
24
+ /** The request body exceeded the cap; answered 413. */
25
+ class BodyTooLargeError extends Error {
26
+ }
7
27
  function readBody(req) {
8
28
  return new Promise((resolve, reject) => {
29
+ const tooLarge = () => {
30
+ // Keep reading and discarding the rest, so the client finishes sending
31
+ // and can read the 413 instead of seeing a reset connection.
32
+ req.removeAllListeners('data');
33
+ req.on('data', () => { });
34
+ req.resume();
35
+ reject(new BodyTooLargeError('body too large'));
36
+ };
37
+ const declared = Number(req.headers['content-length']);
38
+ if (Number.isFinite(declared) && declared > MAX_BODY_BYTES) {
39
+ tooLarge();
40
+ return;
41
+ }
9
42
  const chunks = [];
10
43
  let size = 0;
11
44
  req.on('data', (chunk) => {
12
45
  size += chunk.length;
13
- if (size > 1_000_000) {
14
- req.destroy();
15
- reject(new Error('body too large'));
46
+ if (size > MAX_BODY_BYTES) {
47
+ tooLarge();
16
48
  return;
17
49
  }
18
50
  chunks.push(chunk);
@@ -21,6 +53,23 @@ function readBody(req) {
21
53
  req.on('error', reject);
22
54
  });
23
55
  }
56
+ class ApplyDeadlineError extends Error {
57
+ }
58
+ /** Settle with `work`, or reject at `ms` while `work` keeps running. */
59
+ async function withDeadline(work, ms) {
60
+ let timer;
61
+ const deadline = new Promise((_, reject) => {
62
+ timer = setTimeout(() => reject(new ApplyDeadlineError('apply deadline exceeded')), ms);
63
+ });
64
+ // A handler that fails after its deadline has nobody left to answer.
65
+ work.catch(() => { });
66
+ try {
67
+ return await Promise.race([work, deadline]);
68
+ }
69
+ finally {
70
+ clearTimeout(timer);
71
+ }
72
+ }
24
73
  function tokenOk(header, token) {
25
74
  if (!header?.startsWith('Bearer '))
26
75
  return false;
@@ -45,25 +94,43 @@ export async function startRpcServer(options) {
45
94
  server.requestTimeout = receiptTimeoutMs;
46
95
  server.headersTimeout = receiptTimeoutMs;
47
96
  async function dispatch(req, res) {
48
- const json = (status, value) => {
49
- // Guard against writing to a socket that was destroyed (e.g. when
50
- // readBody rejected after req.destroy() on an oversized body).
97
+ const json = (status, value, headers = {}) => {
98
+ // Guard against writing to a socket that is already gone (the
99
+ // inactivity timeout destroys it).
51
100
  if (res.headersSent || res.writableEnded || res.destroyed)
52
101
  return;
53
- res.writeHead(status, { 'content-type': 'application/json' });
102
+ res.writeHead(status, { 'content-type': 'application/json', ...headers });
54
103
  res.end(JSON.stringify(value));
55
104
  };
56
105
  try {
57
- const url = req.url ?? '';
58
- if (req.method === 'GET' && url === '/health')
106
+ // Route on the pathname alone, so a query string does not turn a known
107
+ // method into a 404.
108
+ const path = new URL(req.url ?? '/', 'http://127.0.0.1').pathname;
109
+ if (req.method === 'GET' && path === '/health')
59
110
  return json(200, { ok: true });
60
- if (req.method !== 'POST' || !url.startsWith('/rpc/'))
111
+ if (req.method !== 'POST' || !path.startsWith('/rpc/'))
61
112
  return json(404, { error: 'not found' });
62
113
  if (!tokenOk(req.headers.authorization, token))
63
114
  return json(401, { error: 'unauthorized' });
64
- const method = url.slice('/rpc/'.length);
65
- const body = await readBody(req);
66
- const params = JSON.parse(body || '{}');
115
+ const method = path.slice('/rpc/'.length);
116
+ let body;
117
+ try {
118
+ body = await readBody(req);
119
+ }
120
+ catch (error) {
121
+ if (!(error instanceof BodyTooLargeError))
122
+ throw error;
123
+ // Close the connection after answering: the rest of the oversized
124
+ // body is not worth keeping the socket for.
125
+ return json(413, { error: 'body too large' }, { connection: 'close' });
126
+ }
127
+ let params;
128
+ try {
129
+ params = JSON.parse(body || '{}');
130
+ }
131
+ catch {
132
+ return json(400, { error: 'invalid json' });
133
+ }
67
134
  if (method === 'pending-notifications') {
68
135
  if (options.requireSession === true && !isSessionId(params.sessionId))
69
136
  return json(400, { error: 'session required' });
@@ -78,12 +145,29 @@ export async function startRpcServer(options) {
78
145
  return json(200, { messages });
79
146
  }
80
147
  if (method === 'apply') {
81
- const result = await options.apply(params);
148
+ const work = Promise.resolve(options.apply(params));
149
+ const result = options.applyDeadlineMs === undefined
150
+ ? await work
151
+ : await withDeadline(work, options.applyDeadlineMs);
82
152
  return json(200, result);
83
153
  }
84
154
  return json(404, { error: 'unknown method' });
85
155
  }
86
156
  catch (error) {
157
+ if (error instanceof RpcRequestError) {
158
+ log.debug('rpc request refused', {
159
+ pid: process.pid,
160
+ status: error.status,
161
+ });
162
+ return json(error.status, { error: error.message });
163
+ }
164
+ if (error instanceof ApplyDeadlineError) {
165
+ log.warn('rpc apply deadline exceeded', {
166
+ pid: process.pid,
167
+ deadlineMs: options.applyDeadlineMs,
168
+ });
169
+ return json(504, { error: 'handler deadline exceeded' });
170
+ }
87
171
  // A handler's exception can quote a request or a credential, so its
88
172
  // text goes to the plugin's log channel only; the wire gets a fixed code.
89
173
  log.warn('rpc request failed', {
@@ -68,6 +68,16 @@ export interface SidebarFileOptions<T> {
68
68
  * that the lock was lost. Without it such a write is only reported.
69
69
  */
70
70
  repair?: SidebarRepair<T>;
71
+ /**
72
+ * Name of the lock writers coordinate on, as `<path>.<lockName>.lock`.
73
+ * Defaults to `'sidebar-write'`. It names both the lock a write takes and
74
+ * the lock whose ownership is checked before the rename, so a takeover of
75
+ * this name refuses the write. A plugin keeping an existing writer's lock
76
+ * identity passes that writer's name; mutual exclusion with that writer is
77
+ * promised only while it holds a live lease of the same name in the same
78
+ * lock-file format, not for its stale-lock reclamation or renewal.
79
+ */
80
+ lockName?: string;
71
81
  }
72
82
  export interface SidebarFile<T> {
73
83
  read(): Promise<T>;
@@ -43,6 +43,7 @@ export function createSidebarFile(options) {
43
43
  };
44
44
  const lockOptions = {
45
45
  ...WRITER_LOCK_CONSTANTS.sidebar,
46
+ name: options.lockName ?? WRITER_LOCK_CONSTANTS.sidebar.name,
46
47
  timeoutMs: options.timeoutMs ?? WRITER_LOCK_CONSTANTS.sidebar.timeoutMs,
47
48
  };
48
49
  /**
@@ -2,8 +2,19 @@ import { type StoreRuntime } from './runtime.js';
2
2
  /**
3
3
  * What a pull or refresh captured about its row (named by id alongside) when
4
4
  * it was issued. A result applies only while the row with that id still has
5
- * this credential epoch and this recorded identity; a replaced credential bumps the epoch, so work issued
6
- * for the old one is discarded.
5
+ * this credential epoch and this recorded identity; a replaced credential
6
+ * bumps the epoch, so work issued for the old one is discarded.
7
+ *
8
+ * The epoch names one credential lineage of the row: a credential given by
9
+ * `add` or `replace`, through every refresh and `rotate` of it, which keep
10
+ * the epoch. Since 0.8.0 that holds across removal too: a row added under an
11
+ * id the pool held before starts past every epoch that id held (the store
12
+ * records them when it drops a row, in the config file, so every process
13
+ * sees it), so an attribution taken for a removed row is refused once the id
14
+ * is added again, even with the same identity. Writers older than 0.8.0
15
+ * start a re-added id at epoch 1 again, and a writer that does not know the
16
+ * pool can remove and re-add a row without the store seeing it; work
17
+ * attributed across either may still apply to the new credential.
7
18
  */
8
19
  export interface Attribution {
9
20
  credentialEpoch: number;
@@ -19,7 +19,7 @@ export type PoolFailurePhase = 'before-first-write' | 'after-first-write' | 'pul
19
19
  * lock outcomes (a wait that ran out, and a lease found lost); the rest are
20
20
  * refusals and failures of the operation itself.
21
21
  */
22
- export type PoolFailureKind = 'lock-contention' | 'lock-ownership' | 'pending-migration' | 'load-error' | 'unknown-row' | 'invalid-row' | 'invalid-input' | 'id-exists' | 'id-removed' | 'type-mismatch' | 'no-credential' | 'row-disabled' | 'row-protected' | 'duplicate-identity' | 'identity-mismatch' | 'endpoint-mismatch' | 'row-key-changed' | 'invalid-order' | 'refresh-stamp-ahead' | 'unbound-credential' | 'attribution' | 'provider' | 'pull' | 'invalid-quota' | 'invalid-provider-state' | 'after-persist-hook' | 'unexpected';
22
+ export type PoolFailureKind = 'lock-contention' | 'lock-ownership' | 'pending-migration' | 'load-error' | 'unknown-row' | 'invalid-row' | 'invalid-input' | 'id-exists' | 'id-removed' | 'type-mismatch' | 'no-credential' | 'row-disabled' | 'row-protected' | 'duplicate-identity' | 'identity-mismatch' | 'identity-contradicted' | 'endpoint-mismatch' | 'row-key-changed' | 'invalid-order' | 'refresh-stamp-ahead' | 'unbound-credential' | 'attribution' | 'provider' | 'pull' | 'invalid-quota' | 'invalid-provider-state' | 'after-persist-hook' | 'unexpected';
23
23
  /**
24
24
  * The single failure value of every store operation. `committed` is present
25
25
  * only when the operation had already written a credential to the state file
@@ -9,6 +9,8 @@ export interface RowEditor {
9
9
  entry(id: string): Record<string, unknown> | undefined;
10
10
  setEntry(id: string, entry: Record<string, unknown>): void;
11
11
  }
12
+ /** Prefix of a refresh quarantine reason, followed by JSON of the expected and returned identities. */
13
+ export declare const IDENTITY_CONTRADICTED_REASON_PREFIX = "identity-contradicted: ";
12
14
  /** The reason recorded on a row disabled because an earlier row is the same account. */
13
15
  export declare const DUPLICATE_IDENTITY_REASON = "duplicate-identity";
14
16
  /**
@@ -1,3 +1,5 @@
1
+ /** Prefix of a refresh quarantine reason, followed by JSON of the expected and returned identities. */
2
+ export const IDENTITY_CONTRADICTED_REASON_PREFIX = 'identity-contradicted: ';
1
3
  /** The reason recorded on a row disabled because an earlier row is the same account. */
2
4
  export const DUPLICATE_IDENTITY_REASON = 'duplicate-identity';
3
5
  /**
@@ -22,7 +24,13 @@ export function disableIn(tx, id, reason) {
22
24
  return;
23
25
  raw.enabled = false;
24
26
  const entry = tx.entry(id) ?? { credentialEpoch: 1, needsFirstReading: true };
25
- tx.setEntry(id, { ...entry, disabledReason: reason });
27
+ // A routine disable must not erase the evidence needed to refuse enable.
28
+ const quarantined = typeof entry.disabledReason === 'string' &&
29
+ entry.disabledReason.startsWith(IDENTITY_CONTRADICTED_REASON_PREFIX);
30
+ tx.setEntry(id, {
31
+ ...entry,
32
+ disabledReason: quarantined ? entry.disabledReason : reason,
33
+ });
26
34
  }
27
35
  /**
28
36
  * Marks a row enabled: `enabled: true` in the roster row and no
@@ -118,7 +118,11 @@ export declare class Transaction {
118
118
  * Writes the config: legacy `version: 1` and the legacy roster beside
119
119
  * `commonAuthPool`, every other top-level key and every unrecognised pool
120
120
  * key untouched. Entries for ids no longer in the roster are dropped here,
121
- * and remembered so the id is not reused in this process.
121
+ * and remembered so the id is not reused in this process. Every id the
122
+ * write drops (a roster row the files held when the transaction read them,
123
+ * or an entry left without one) has its epoch recorded in the config (see
124
+ * `retireEpochsIn`), which is what keeps a later `add` of the id, from any
125
+ * process, past every epoch an attribution could name.
122
126
  */
123
127
  commitConfig(options?: {
124
128
  counted?: boolean;
@@ -4,7 +4,7 @@ import { LockContentionError, LockOwnershipError } from '../fs/with-lock.js';
4
4
  import { PoolOperationError, } from './errors.js';
5
5
  import { callFailureHook } from './hooks.js';
6
6
  import { LockStack, } from './refresh-lock.js';
7
- import { classifyConfig, classifyState, ensureEntries, entryIn, isRecord, LEGACY_STORE_VERSION, POOL_KEY, POOL_ROWS_KEY, POOL_SCHEMA_VERSION, rosterRowIn, setEntryIn, } from './schema.js';
7
+ import { classifyConfig, classifyState, ensureEntries, entryIn, isRecord, LEGACY_STORE_VERSION, POOL_KEY, POOL_ROWS_KEY, POOL_SCHEMA_VERSION, retireEpochsIn, rosterOf, rosterRowIn, setEntryIn, } from './schema.js';
8
8
  import { completeTornRows, loadRows } from './torn.js';
9
9
  async function readJson(path) {
10
10
  let text;
@@ -169,7 +169,11 @@ export class Transaction {
169
169
  * Writes the config: legacy `version: 1` and the legacy roster beside
170
170
  * `commonAuthPool`, every other top-level key and every unrecognised pool
171
171
  * key untouched. Entries for ids no longer in the roster are dropped here,
172
- * and remembered so the id is not reused in this process.
172
+ * and remembered so the id is not reused in this process. Every id the
173
+ * write drops (a roster row the files held when the transaction read them,
174
+ * or an entry left without one) has its epoch recorded in the config (see
175
+ * `retireEpochsIn`), which is what keeps a later `add` of the id, from any
176
+ * process, past every epoch an attribution could name.
173
177
  */
174
178
  async commitConfig(options = {}) {
175
179
  const roster = this.roster();
@@ -177,6 +181,14 @@ export class Transaction {
177
181
  for (const raw of roster)
178
182
  if (isRecord(raw) && typeof raw.id === 'string')
179
183
  rosterIds.add(raw.id);
184
+ const dropped = new Set();
185
+ for (const raw of rosterOf(this.snapshot.config))
186
+ if (isRecord(raw) && typeof raw.id === 'string' && !rosterIds.has(raw.id))
187
+ dropped.add(raw.id);
188
+ for (const id of Object.keys(this.entries()))
189
+ if (!rosterIds.has(id))
190
+ dropped.add(id);
191
+ retireEpochsIn(this.config, dropped);
180
192
  const entries = this.entries();
181
193
  const kept = {};
182
194
  for (const [id, entry] of Object.entries(entries)) {
@@ -88,6 +88,13 @@ export interface PoolStore {
88
88
  }): Promise<{
89
89
  status: InitializeOutcome;
90
90
  }>;
91
+ /**
92
+ * Adds a row, or completes or rotates the row already holding the id or
93
+ * the secret. A new row starts at credential epoch 1; since 0.8.0 one
94
+ * whose id the pool held before starts one past the highest epoch that id
95
+ * held (see `Attribution`), and an id that held `Number.MAX_SAFE_INTEGER`
96
+ * refuses (`id-removed`) before writing.
97
+ */
91
98
  add(input: AddInput, options?: RowOperationOptions): Promise<AddResult>;
92
99
  /**
93
100
  * Gives a row a new credential and a new credential epoch. Since 0.6.0 the
@@ -27,7 +27,11 @@ export interface RefreshOptions {
27
27
  providerLock?: PoolLockSpec;
28
28
  /** Taken after the provider-wide lock in this order, released in reverse. */
29
29
  extraLocks?: readonly PoolLockSpec[];
30
- /** Awaited once the rotation is persisted and the store locks released. */
30
+ /**
31
+ * Awaited once an ordinary rotation is persisted and the store locks released.
32
+ * Never called for `identity-contradicted`: its credential must not propagate
33
+ * as the row's recorded account.
34
+ */
31
35
  onPersisted?: (rowId: string, credential: StoredCredential) => void | Promise<void>;
32
36
  onFailure?: FailureHook;
33
37
  /**
@@ -42,6 +46,18 @@ export type RefreshOutcome = {
42
46
  rowId: string;
43
47
  credential: StoredCredential;
44
48
  identity?: string;
49
+ }
50
+ /**
51
+ * The provider's successor credential is stored bound to expectedIdentity,
52
+ * not returnedIdentity. The row stays disabled until the adapter supplies
53
+ * an identity-validated replacement credential through replace.
54
+ */
55
+ | {
56
+ status: 'identity-contradicted';
57
+ rowId: string;
58
+ expectedIdentity: string;
59
+ returnedIdentity: string;
60
+ credential: StoredCredential;
45
61
  } | {
46
62
  status: 'refused';
47
63
  rowId: string;
@@ -1,11 +1,13 @@
1
+ import { randomUUID } from 'node:crypto';
1
2
  import { PoolOperationError } from './errors.js';
2
3
  import { assertNotInsideHook, runInsideHook } from './hooks.js';
3
- import { recordIdentityIn } from './identity.js';
4
+ import { IDENTITY_CONTRADICTED_REASON_PREFIX, recordIdentityIn, } from './identity.js';
4
5
  import { runOperation, withTransaction } from './mutate.js';
5
6
  import { acceptProviderState, mergedProviderState } from './provider-state.js';
6
7
  import { rotateIn } from './rows.js';
7
8
  import { readRow, refusal, requireBound, rowLockSpec, } from './runtime.js';
8
9
  import { rotationStamp, rotationStampUntrusted, rowLockKey, } from './schema.js';
10
+ import { applyTransition } from './torn.js';
9
11
  function requireRefreshable(id, row) {
10
12
  if (!row)
11
13
  throw refusal('refresh', id, 'unknown-row', `no row ${id} in the pool`);
@@ -137,6 +139,21 @@ export async function refreshRow(rt, id, provider, options = {}) {
137
139
  refresh: result.refresh,
138
140
  expires: result.expires,
139
141
  };
142
+ const contradiction = current.identity !== undefined &&
143
+ result.identity !== undefined &&
144
+ current.identity !== result.identity
145
+ ? {
146
+ expectedIdentity: current.identity,
147
+ returnedIdentity: result.identity,
148
+ }
149
+ : undefined;
150
+ const transition = contradiction
151
+ ? {
152
+ mark: randomUUID(),
153
+ enabled: false,
154
+ reason: `${IDENTITY_CONTRADICTED_REASON_PREFIX}${JSON.stringify(contradiction)}`,
155
+ }
156
+ : undefined;
140
157
  const learnt = current.identity === undefined && result.identity
141
158
  ? result.identity
142
159
  : undefined;
@@ -145,6 +162,11 @@ export async function refreshRow(rt, id, provider, options = {}) {
145
162
  // forward rather than leaving an identity no stamp proves.
146
163
  const stored = await rotateIn(rt, tx, id, credential, {
147
164
  stamp: rotationStamp(prior, now),
165
+ // Write state first with the disable marker, then config below.
166
+ // Once the successor is durable, recovery projects the disable even
167
+ // if config has not landed. A crash before the first rename still
168
+ // loses an in-memory provider reply, as in any ordinary refresh.
169
+ transition,
148
170
  identity: learnt,
149
171
  ...(incoming !== undefined
150
172
  ? {
@@ -152,16 +174,27 @@ export async function refreshRow(rt, id, provider, options = {}) {
152
174
  }
153
175
  : {}),
154
176
  });
177
+ if (transition !== undefined) {
178
+ applyTransition(tx, id, transition);
179
+ await tx.commitConfig();
180
+ }
155
181
  let identity = current.identity;
156
182
  if (learnt !== undefined) {
157
183
  recordIdentityIn(tx, id, learnt);
158
184
  identity = learnt;
159
185
  await tx.commitConfig();
160
186
  }
161
- return { stored, identity, refused: undefined };
187
+ return { stored, identity, contradiction, refused: undefined };
162
188
  });
163
189
  if (commit.refused !== undefined)
164
190
  return { status: 'refused', rowId: id, reason: commit.refused };
191
+ if (commit.contradiction !== undefined)
192
+ return {
193
+ status: 'identity-contradicted',
194
+ rowId: id,
195
+ ...commit.contradiction,
196
+ credential: commit.stored,
197
+ };
165
198
  if (options.onPersisted) {
166
199
  try {
167
200
  const persisted = options.onPersisted;
@@ -5,6 +5,7 @@ import { type ProviderStateWrite, type RowTransitionMutator, type UpdateProvider
5
5
  import type { PoolLockSpec } from './refresh-lock.js';
6
6
  import { type StoreRuntime } from './runtime.js';
7
7
  import { type CredentialBinding, type PoolCredential, type PoolRow, type RotateCredential, type StoredCredential } from './schema.js';
8
+ import { type StampedTransition } from './torn.js';
8
9
  export type FailureHook = (rowId: string, error: PoolOperationError) => void | Promise<void>;
9
10
  export interface RowOperationOptions {
10
11
  /** Called once, awaited, on every non-success path, before locks release. */
@@ -122,6 +123,8 @@ export declare function rotateIn(rt: StoreRuntime, tx: Transaction, id: string,
122
123
  binding?: CredentialBinding;
123
124
  identity?: string;
124
125
  providerState?: ProviderStateWrite;
126
+ /** Config transition persisted with the successor credential for crash recovery. */
127
+ transition?: StampedTransition;
125
128
  }): Promise<StoredCredential>;
126
129
  export declare function addRow(rt: StoreRuntime, input: AddInput, options?: RowOperationOptions): Promise<AddResult>;
127
130
  export declare function replaceRow(rt: StoreRuntime, id: string, credential: PoolCredential, input?: CredentialWriteInput, options?: RowOperationOptions): Promise<{
@@ -178,7 +181,9 @@ export interface RowTransitionResult {
178
181
  export declare function disableRow(rt: StoreRuntime, id: string, reason: string, options?: RowTransitionOptions): Promise<RowTransitionResult>;
179
182
  /**
180
183
  * Clears a row's `enabled: false` and its `disabledReason` in one config
181
- * write. An OAuth row whose recorded identity another enabled OAuth row holds
184
+ * write. An identity-contradicted row refuses with `identity-contradicted`
185
+ * until the caller validates a replacement's identity and supplies it to replace.
186
+ * An OAuth row whose recorded identity another enabled OAuth row holds
182
187
  * stays disabled and the call refuses (`duplicate-identity`): the same rule
183
188
  * that makes `add` store such a row disabled. Enabling a row that is already
184
189
  * enabled writes nothing. See `RowTransitionOptions` for the attributed
@@ -192,7 +197,9 @@ export declare function enableRow(rt: StoreRuntime, id: string, options?: RowTra
192
197
  * between the two leaves a row every reader already sees as removed, with
193
198
  * only an orphaned state entry that no reader loads; calling `remove` again
194
199
  * drops that entry (`completed`). As with every id the store drops, the id is
195
- * not reused by `add` in this process.
200
+ * not reused by `add` in this process, and the config write records the
201
+ * row's credential epoch, so an `add` of the id in any other process starts
202
+ * past it (see `nextAddEpochIn`).
196
203
  */
197
204
  export declare function removeRow(rt: StoreRuntime, id: string, options?: RemoveOptions): Promise<RemoveResult>;
198
205
  /**
@@ -1,11 +1,11 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { PoolOperationError } from './errors.js';
3
3
  import { assertNotInsideHook, runInsideHook } from './hooks.js';
4
- import { DUPLICATE_IDENTITY_REASON, disableIdentityDuplicates, disableIn, enableIn, recordIdentityIn, } from './identity.js';
4
+ import { DUPLICATE_IDENTITY_REASON, disableIdentityDuplicates, disableIn, enableIn, IDENTITY_CONTRADICTED_REASON_PREFIX, recordIdentityIn, } from './identity.js';
5
5
  import { notReadyError, readPool, runOperation, withTransaction, } from './mutate.js';
6
6
  import { acceptProviderState, mergedProviderState, planProviderStateIn, providerStateCoverage, replacementProviderState, } from './provider-state.js';
7
7
  import { readRow, refusal, requireBound, rowLockSpec, unknownRow, } from './runtime.js';
8
- import { boundProviderStateDigest, CREDENTIAL_STAMP_KEY, credentialProblem, fingerprintOf, idProblem, isCredentialEpoch, isRecord, PROVIDER_STATE_KEY, rosterRowFor, rotationStamp, rowLockKey, stampFor, stateFieldsFor, storedCredential, } from './schema.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
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 = [
@@ -122,12 +122,17 @@ export async function rotateIn(rt, tx, id, given, extra = {}) {
122
122
  tx.setStateAccount(id, {
123
123
  ...kept,
124
124
  ...stateFieldsFor(credential, stamp),
125
- [CREDENTIAL_STAMP_KEY]: stampFor(stored, credentialEpoch, extra.binding ?? bindingInTx(tx, id, stored, extra.identity), {
126
- replace: extra.binding !== undefined,
127
- ...(providerStateBinding !== undefined
128
- ? { providerState: providerStateBinding }
125
+ [CREDENTIAL_STAMP_KEY]: {
126
+ ...(extra.transition !== undefined
127
+ ? { [TRANSITION_STAMP_KEY]: extra.transition }
129
128
  : {}),
130
- }),
129
+ ...stampFor(stored, credentialEpoch, extra.binding ?? bindingInTx(tx, id, stored, extra.identity), {
130
+ replace: extra.binding !== undefined,
131
+ ...(providerStateBinding !== undefined
132
+ ? { providerState: providerStateBinding }
133
+ : {}),
134
+ }),
135
+ },
131
136
  });
132
137
  await tx.commitState(stored);
133
138
  return stored;
@@ -259,6 +264,12 @@ export async function addRow(rt, input, options = {}) {
259
264
  await tx.commitConfig();
260
265
  return { id, outcome: 'completed', credential: stored };
261
266
  }
267
+ // An id the pool held before starts past every epoch it held, so
268
+ // work attributed to the earlier row's credential, from this
269
+ // process or another, never matches the new one.
270
+ const credentialEpoch = nextAddEpochIn(tx.config, id);
271
+ if (!isCredentialEpoch(credentialEpoch))
272
+ throw refusal('add', id, 'id-removed', `id ${id} has held every credential epoch and is not reused; add the credential under another id`);
262
273
  tx.roster().push(rosterRowFor({
263
274
  id,
264
275
  credential,
@@ -267,7 +278,7 @@ export async function addRow(rt, input, options = {}) {
267
278
  addedAt: ctx.now(),
268
279
  }));
269
280
  tx.setEntry(id, {
270
- credentialEpoch: 1,
281
+ credentialEpoch,
271
282
  needsFirstReading: credential.type === 'oauth',
272
283
  });
273
284
  let outcome = 'added';
@@ -474,6 +485,9 @@ async function transitionRow(rt, operation, id, flag, options) {
474
485
  row.identity !== fence.identity)
475
486
  throw refusal(operation, id, 'attribution', `the ${operation} of ${id} was issued for a credential or account the row no longer holds`, true);
476
487
  }
488
+ if (flag.enabled &&
489
+ row.disabledReason?.startsWith(IDENTITY_CONTRADICTED_REASON_PREFIX))
490
+ throw refusal('enable', id, 'identity-contradicted', `row ${id} needs an identity-validated credential replacement before it can be enabled`);
477
491
  // An enable of a row that is already enabled has nothing to write
478
492
  // to the config; a disable always rewrites it, as it always has.
479
493
  const writesConfig = !flag.enabled || !row.enabled || row.disabledReason !== undefined;
@@ -561,7 +575,9 @@ export function disableRow(rt, id, reason, options = {}) {
561
575
  }
562
576
  /**
563
577
  * Clears a row's `enabled: false` and its `disabledReason` in one config
564
- * write. An OAuth row whose recorded identity another enabled OAuth row holds
578
+ * write. An identity-contradicted row refuses with `identity-contradicted`
579
+ * until the caller validates a replacement's identity and supplies it to replace.
580
+ * An OAuth row whose recorded identity another enabled OAuth row holds
565
581
  * stays disabled and the call refuses (`duplicate-identity`): the same rule
566
582
  * that makes `add` store such a row disabled. Enabling a row that is already
567
583
  * enabled writes nothing. See `RowTransitionOptions` for the attributed
@@ -577,7 +593,9 @@ export function enableRow(rt, id, options = {}) {
577
593
  * between the two leaves a row every reader already sees as removed, with
578
594
  * only an orphaned state entry that no reader loads; calling `remove` again
579
595
  * drops that entry (`completed`). As with every id the store drops, the id is
580
- * not reused by `add` in this process.
596
+ * not reused by `add` in this process, and the config write records the
597
+ * row's credential epoch, so an `add` of the id in any other process starts
598
+ * past it (see `nextAddEpochIn`).
581
599
  */
582
600
  export async function removeRow(rt, id, options = {}) {
583
601
  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
  /**
@@ -348,6 +357,37 @@ export declare function ensureEntries(config: Record<string, unknown>): Record<s
348
357
  export declare function entryIn(config: Record<string, unknown>, id: string): Record<string, unknown> | undefined;
349
358
  /** Sets an entry as an own property, so an id such as `toString` is safe. */
350
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;
351
391
  /**
352
392
  * Builds the rows of a ready pool from the files exactly as they are, without
353
393
  * looking at credential stamps (see `loadRows` for the rows every reader