@cortexkit/common-auth 0.3.0 → 0.4.0

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