@cortexkit/common-auth 0.3.0 → 0.4.1

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 (60) 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 +237 -45
  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 +14 -2
  47. package/dist/store/errors.d.ts +6 -3
  48. package/dist/store/identity.d.ts +13 -4
  49. package/dist/store/index.d.ts +1 -1
  50. package/dist/store/mutate.d.ts +27 -4
  51. package/dist/store/mutate.js +43 -26
  52. package/dist/store/pool.d.ts +16 -3
  53. package/dist/store/pool.js +7 -2
  54. package/dist/store/rows.d.ts +20 -6
  55. package/dist/store/rows.js +141 -46
  56. package/dist/store/schema.d.ts +74 -5
  57. package/dist/store/schema.js +112 -10
  58. package/dist/store/torn.d.ts +29 -0
  59. package/dist/store/torn.js +113 -0
  60. 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);
@@ -6,7 +6,7 @@ import { type PullHook } from './pull.js';
6
6
  import { type ProviderRefresh, type RefreshOptions, type RefreshOutcome } from './refresh.js';
7
7
  import { type LockEnvironment, type PoolLockOptions, type PoolLockSpec } from './refresh-lock.js';
8
8
  import { type AddInput, type AddResult, type RemoveOptions, type RemoveResult, type ReorderOptions, type ReorderResult, type RowOperationOptions, type RowToggleOptions } from './rows.js';
9
- import { type PoolCredential, type PoolRow, type QuotaCodec, type StoredCredential } from './schema.js';
9
+ import { type PoolCredential, type PoolRow, type QuotaCodec, type RotateCredential, type StoredCredential } from './schema.js';
10
10
  import { type SettingsMutator, type SettingsRead, type UpdateSettingsOptions, type UpdateSettingsResult } from './settings.js';
11
11
  export interface OpenPoolStoreOptions {
12
12
  /** The provider every row of this pool belongs to; keys the provider-wide lock. */
@@ -75,7 +75,13 @@ export interface PoolStore {
75
75
  credential: StoredCredential;
76
76
  credentialEpoch: number;
77
77
  }>;
78
- rotate(id: string, credential: PoolCredential, input?: {
78
+ /**
79
+ * Refreshes the secret a row holds without changing its account or
80
+ * endpoint. Since 0.4.1 an API key may leave out `baseURL` and `authHeader`
81
+ * to keep the row's, and one that gives another is refused
82
+ * (`endpoint-mismatch`) before writing: that is a `replace`.
83
+ */
84
+ rotate(id: string, credential: RotateCredential, input?: {
79
85
  identity?: string;
80
86
  }, options?: RowOperationOptions): Promise<{
81
87
  id: string;
@@ -120,7 +126,14 @@ export interface PoolStore {
120
126
  * (`invalid-input`). Takes `extraLocks`, then the store locks.
121
127
  */
122
128
  updateSettings(mutator: SettingsMutator, options?: UpdateSettingsOptions): Promise<UpdateSettingsResult>;
123
- recordIdentity(id: string, identity: string, options?: RowOperationOptions): Promise<{
129
+ /**
130
+ * Records the identity a lookup found for the row's credential. Since
131
+ * 0.3.1 it takes the credential epoch the lookup was issued for, and
132
+ * refuses a lookup that completes after the row was replaced
133
+ * (`attribution`) or a row recorded for another account
134
+ * (`identity-mismatch`).
135
+ */
136
+ recordIdentity(id: string, identity: string, attribution: Pick<Attribution, 'credentialEpoch'>, options?: RowOperationOptions): Promise<{
124
137
  id: string;
125
138
  disabled: string[];
126
139
  }>;
@@ -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 RotateCredential, 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,16 @@ 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. An API key must
95
+ * belong to the endpoint the row holds in `tx` (see `onRowEndpoint`).
92
96
  */
93
- export declare function rotateIn(rt: StoreRuntime, tx: Transaction, id: string, credential: PoolCredential, extra?: {
97
+ export declare function rotateIn(rt: StoreRuntime, tx: Transaction, id: string, given: RotateCredential, extra?: {
94
98
  stamp?: number;
95
99
  clearErrors?: boolean;
100
+ binding?: CredentialBinding;
96
101
  }): Promise<StoredCredential>;
97
102
  export declare function addRow(rt: StoreRuntime, input: AddInput, options?: RowOperationOptions): Promise<AddResult>;
98
103
  export declare function replaceRow(rt: StoreRuntime, id: string, credential: PoolCredential, input?: {
@@ -102,7 +107,7 @@ export declare function replaceRow(rt: StoreRuntime, id: string, credential: Poo
102
107
  credential: StoredCredential;
103
108
  credentialEpoch: number;
104
109
  }>;
105
- export declare function rotateRow(rt: StoreRuntime, id: string, credential: PoolCredential, input?: {
110
+ export declare function rotateRow(rt: StoreRuntime, id: string, credential: RotateCredential, input?: {
106
111
  identity?: string;
107
112
  }, options?: RowOperationOptions): Promise<{
108
113
  id: string;
@@ -146,7 +151,16 @@ export declare function removeRow(rt: StoreRuntime, id: string, options?: Remove
146
151
  * An order equal to the current one writes nothing.
147
152
  */
148
153
  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<{
154
+ /**
155
+ * Records the wire identity an identity lookup found for a row's credential.
156
+ * `attribution` is the credential epoch the lookup was issued for (a row
157
+ * without an entry is at epoch 1): a lookup that completes after the row was
158
+ * replaced is refused (`attribution`), as a quota reading would be, so the
159
+ * first credential's account is never recorded on the second credential. A
160
+ * row already recorded for another account refuses (`identity-mismatch`):
161
+ * that is a replacement, not something learnt about the same credential.
162
+ */
163
+ export declare function recordRowIdentity(rt: StoreRuntime, id: string, identity: string, attribution: Pick<Attribution, 'credentialEpoch'>, options?: RowOperationOptions): Promise<{
150
164
  id: string;
151
165
  disabled: string[];
152
166
  }>;
@@ -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, isCredentialEpoch, 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,10 +14,51 @@ 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
+ * The endpoint an API-key roster row sends its key to, as `buildRawRows` loads
18
+ * it: the trimmed `baseURL`, and a bearer header unless the row names
19
+ * `x-api-key`.
18
20
  */
19
- export async function rotateIn(rt, tx, id, credential, extra = {}) {
21
+ function rowEndpoint(raw) {
22
+ return {
23
+ baseURL: String(raw.baseURL).trim(),
24
+ authHeader: raw.authHeader === 'x-api-key' ? 'x-api-key' : 'authorization-bearer',
25
+ };
26
+ }
27
+ /**
28
+ * The credential as it will sit in the row. The state file holds only an API
29
+ * key; its endpoint lives in the roster row. So an API key written into a row
30
+ * is sent wherever that row says, and one given for another `baseURL` or
31
+ * `authHeader` would silently be paired with an endpoint it was not issued
32
+ * for. A part the caller leaves out is the row's; a part it gives must equal
33
+ * the row's, else the write is refused (`endpoint-mismatch`) before anything
34
+ * is written. Moving a row to another endpoint is a `replace`, which writes
35
+ * the new endpoint first.
36
+ */
37
+ function onRowEndpoint(tx, id, credential) {
38
+ if (credential.type !== 'api')
39
+ return credential;
40
+ const raw = tx.rosterRow(id);
41
+ if (!isRecord(raw)) {
42
+ if (credential.baseURL === undefined)
43
+ throw refusal(tx.info.operation, id, 'invalid-input', 'api credential needs a valid baseURL');
44
+ return { ...credential, baseURL: credential.baseURL };
45
+ }
46
+ const endpoint = rowEndpoint(raw);
47
+ const baseURL = credential.baseURL?.trim() ?? endpoint.baseURL;
48
+ const authHeader = credential.authHeader ?? endpoint.authHeader;
49
+ if (baseURL !== endpoint.baseURL || authHeader !== endpoint.authHeader)
50
+ throw refusal(tx.info.operation, id, 'endpoint-mismatch', `row ${id} sends its key to another endpoint or header; a key for another endpoint is a replacement`);
51
+ return { ...credential, baseURL, authHeader };
52
+ }
53
+ /**
54
+ * Writes a credential into the state file (one write), stamped with the
55
+ * credential epoch the row's entry holds in `tx` (1 without an entry) and,
56
+ * for a replace, the binding the config is about to get. A rotation is the
57
+ * same lineage: no epoch bump, no identity or quota change. An API key must
58
+ * belong to the endpoint the row holds in `tx` (see `onRowEndpoint`).
59
+ */
60
+ export async function rotateIn(rt, tx, id, given, extra = {}) {
61
+ const credential = onRowEndpoint(tx, id, given);
20
62
  const prior = tx.stateAccount(id);
21
63
  const priorStamp = typeof prior?.lastRefreshedAt === 'number'
22
64
  ? prior.lastRefreshedAt
@@ -32,18 +74,13 @@ export async function rotateIn(rt, tx, id, credential, extra = {}) {
32
74
  delete kept.lastQuotaRefreshError;
33
75
  delete kept.quota;
34
76
  }
35
- const raw = tx.rosterRow(id);
36
- const stored = credential.type === 'api' && isRecord(raw)
37
- ? storedCredential({
38
- ...credential,
39
- baseURL: String(raw.baseURL ?? credential.baseURL),
40
- ...(raw.authHeader === 'x-api-key' ||
41
- raw.authHeader === 'authorization-bearer'
42
- ? { authHeader: raw.authHeader }
43
- : {}),
44
- }, stamp)
45
- : storedCredential(credential, stamp);
46
- tx.setStateAccount(id, { ...kept, ...stateFieldsFor(credential, stamp) });
77
+ const stored = storedCredential(credential, stamp);
78
+ const epoch = tx.entry(id)?.credentialEpoch;
79
+ tx.setStateAccount(id, {
80
+ ...kept,
81
+ ...stateFieldsFor(credential, stamp),
82
+ [CREDENTIAL_STAMP_KEY]: stampFor(stored, typeof epoch === 'number' ? epoch : 1, extra.binding),
83
+ });
47
84
  await tx.commitState(stored);
48
85
  return stored;
49
86
  }
@@ -51,7 +88,9 @@ function checkInput(operation, id, credential) {
51
88
  const idIssue = operation === 'add' ? idProblem(id) : undefined;
52
89
  if (idIssue)
53
90
  throw refusal(operation, id, 'invalid-input', idIssue);
54
- const credentialIssue = credentialProblem(credential);
91
+ const credentialIssue = credentialProblem(credential, {
92
+ baseURLOptional: operation === 'rotate',
93
+ });
55
94
  if (credentialIssue)
56
95
  throw refusal(operation, id, 'invalid-input', credentialIssue);
57
96
  }
@@ -64,6 +103,10 @@ function requireUsableRow(operation, id, row, credential) {
64
103
  throw refusal(operation, id, 'type-mismatch', `row ${id} holds a ${row.type} credential`);
65
104
  return row;
66
105
  }
106
+ /** The row is recorded for another account than the one given. */
107
+ function identityMismatch(operation, id) {
108
+ return refusal(operation, id, 'identity-mismatch', `row ${id} is recorded for another account; a credential of a different account is a replacement`);
109
+ }
67
110
  /** Keying changed between the unlocked read and the locked one: retry. */
68
111
  function keyChanged(operation, id) {
69
112
  return refusal(operation, id, 'row-key-changed', `row ${id}'s wire identity changed while its lock was being taken`, true);
@@ -86,6 +129,14 @@ export async function addRow(rt, input, options = {}) {
86
129
  const fingerprint = fingerprintOf(credential);
87
130
  const same = rows.find((row) => row.invalid === undefined && row.fingerprint === fingerprint);
88
131
  if (same) {
132
+ // The same secret is the same credential, so re-adding it rotates
133
+ // that row. An identity or endpoint given with it must match the
134
+ // row's; a different one is refused rather than silently replaced
135
+ // by what the row already holds.
136
+ if (identity !== undefined &&
137
+ same.identity !== undefined &&
138
+ identity !== same.identity)
139
+ throw identityMismatch('add', same.id);
89
140
  const stored = await rotateIn(rt, tx, same.id, credential);
90
141
  return { id: same.id, outcome: 'rotated', credential: stored };
91
142
  }
@@ -97,18 +148,23 @@ export async function addRow(rt, input, options = {}) {
97
148
  throw refusal('add', id, 'id-exists', `row ${id} already holds a credential`);
98
149
  if (existing.type !== credential.type)
99
150
  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) {
151
+ if (identity !== undefined &&
152
+ existing.identity !== undefined &&
153
+ identity !== existing.identity)
154
+ throw identityMismatch('add', id);
155
+ // A roster row without a credential (left by another writer, or
156
+ // by an add of an earlier version that stopped between its
157
+ // writes): writing the credential now completes it at epoch 1.
158
+ if (!existing.hasEntry)
103
159
  tx.setEntry(id, {
104
160
  credentialEpoch: 1,
105
161
  needsFirstReading: credential.type === 'oauth',
106
162
  });
107
- await tx.commitConfig();
108
- }
109
163
  const stored = await rotateIn(rt, tx, id, credential, {
110
164
  clearErrors: true,
111
165
  });
166
+ if (!existing.hasEntry)
167
+ await tx.commitConfig();
112
168
  return { id, outcome: 'completed', credential: stored };
113
169
  }
114
170
  tx.roster().push(rosterRowFor({
@@ -134,8 +190,14 @@ export async function addRow(rt, input, options = {}) {
134
190
  outcome = 'added-disabled';
135
191
  }
136
192
  }
137
- await tx.commitConfig();
193
+ // The credential is written first. A crash before the config write
194
+ // then leaves a state entry no roster row names, which no reader
195
+ // loads and `remove` drops; written the other way round, the new
196
+ // roster row could load beside a credential left under its id by an
197
+ // interrupted removal. Nothing of such a leftover entry is kept.
198
+ tx.dropStateAccount(id);
138
199
  const stored = await rotateIn(rt, tx, id, credential);
200
+ await tx.commitConfig();
139
201
  return { id, outcome, credential: stored };
140
202
  });
141
203
  });
@@ -159,33 +221,38 @@ export async function replaceRow(rt, id, credential, input = {}, options = {}) {
159
221
  const row = requireUsableRow('replace', id, tx.row(id), credential);
160
222
  if (rowLockKey(row) !== rowLockKey(seen))
161
223
  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,
224
+ const priorEpoch = tx.entry(id)?.credentialEpoch;
225
+ const credentialEpoch = (typeof priorEpoch === 'number' ? priorEpoch : 1) + 1;
226
+ // An epoch past the safe integer range could equal the one before
227
+ // it, so readers could not tell the new credential from the old.
228
+ // Refused before anything is written; the row keeps its credential.
229
+ if (!isCredentialEpoch(credentialEpoch))
230
+ throw refusal('replace', id, 'invalid-row', `row ${id}'s credential epoch cannot advance past ${Number.MAX_SAFE_INTEGER}; remove the row and add the new credential as a new row`);
231
+ const binding = {
232
+ ...(input.identity !== undefined
233
+ ? { identity: input.identity }
234
+ : {}),
235
+ ...(credential.type === 'api'
236
+ ? {
237
+ baseURL: credential.baseURL.trim(),
238
+ authHeader: credential.authHeader ?? 'authorization-bearer',
239
+ }
240
+ : {}),
171
241
  };
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
- }
242
+ bindReplacement(tx, id, credentialEpoch, binding);
183
243
  if (input.identity !== undefined)
184
244
  disableIdentityDuplicates(tx, input.identity);
185
- await tx.commitConfig();
245
+ // The new credential goes first, stamped with the new epoch and the
246
+ // binding. A crash before the config write leaves the stamp ahead
247
+ // of the config: every reader shows the row torn (completed, never
248
+ // a candidate) and the next store write completes the config from
249
+ // the stamp, so no reader pairs either credential with the other
250
+ // account's identity or endpoint.
186
251
  const stored = await rotateIn(rt, tx, id, credential, {
187
252
  clearErrors: true,
253
+ binding,
188
254
  });
255
+ await tx.commitConfig();
189
256
  return { id, credential: stored, credentialEpoch };
190
257
  });
191
258
  });
@@ -208,6 +275,13 @@ export async function rotateRow(rt, id, credential, input = {}, options = {}) {
208
275
  const row = requireUsableRow('rotate', id, tx.row(id), credential);
209
276
  if (rowLockKey(row) !== rowLockKey(seen))
210
277
  throw keyChanged('rotate', id);
278
+ // A rotation stays with one account: it may record the first
279
+ // identity the row learns, but a credential of another known
280
+ // account is a replacement (new epoch, quota and errors dropped).
281
+ if (input.identity !== undefined &&
282
+ row.identity !== undefined &&
283
+ input.identity !== row.identity)
284
+ throw identityMismatch('rotate', id);
211
285
  const stored = await rotateIn(rt, tx, id, credential);
212
286
  let configChanged = false;
213
287
  if (!row.hasEntry) {
@@ -445,14 +519,31 @@ export async function reorderRows(rt, ids, options = {}) {
445
519
  tx.config.accounts = next;
446
520
  await tx.commitConfig();
447
521
  return { ids: order, outcome: 'reordered' };
448
- });
522
+ },
523
+ // A reorder keeps every roster row and entry byte for byte, so it
524
+ // leaves a torn row for a write on that row to complete.
525
+ { completeTorn: false });
449
526
  });
450
527
  }
451
- export async function recordRowIdentity(rt, id, identity, options = {}) {
528
+ /**
529
+ * Records the wire identity an identity lookup found for a row's credential.
530
+ * `attribution` is the credential epoch the lookup was issued for (a row
531
+ * without an entry is at epoch 1): a lookup that completes after the row was
532
+ * replaced is refused (`attribution`), as a quota reading would be, so the
533
+ * first credential's account is never recorded on the second credential. A
534
+ * row already recorded for another account refuses (`identity-mismatch`):
535
+ * that is a replacement, not something learnt about the same credential.
536
+ */
537
+ export async function recordRowIdentity(rt, id, identity, attribution, options = {}) {
452
538
  assertNotInsideHook('recordIdentity');
453
539
  return runOperation(rt.ctx, 'recordIdentity', id, options.onFailure, async (locks, progress) => {
454
540
  if (typeof identity !== 'string' || identity.length === 0)
455
541
  throw refusal('recordIdentity', id, 'invalid-input', 'identity must be non-empty');
542
+ const captured = isRecord(attribution)
543
+ ? attribution.credentialEpoch
544
+ : undefined;
545
+ if (!isCredentialEpoch(captured))
546
+ throw refusal('recordIdentity', id, 'invalid-input', 'the credential epoch the identity lookup was issued for is required');
456
547
  const { row: seen } = await readRow(rt, 'recordIdentity', id);
457
548
  await locks.acquire(rowLockSpec(rt, seen));
458
549
  await locks.acquire(options.providerLock ?? rt.providerLock);
@@ -462,6 +553,10 @@ export async function recordRowIdentity(rt, id, identity, options = {}) {
462
553
  const row = requireUsableRow('recordIdentity', id, tx.row(id));
463
554
  if (rowLockKey(row) !== rowLockKey(seen))
464
555
  throw keyChanged('recordIdentity', id);
556
+ if ((row.credentialEpoch ?? 1) !== captured)
557
+ throw refusal('recordIdentity', id, 'attribution', `the identity for ${id} was looked up for a credential the row no longer holds`, true);
558
+ if (row.identity !== undefined && row.identity !== identity)
559
+ throw identityMismatch('recordIdentity', id);
465
560
  const disabled = recordIdentityIn(tx, id, identity);
466
561
  await tx.commitConfig();
467
562
  return { id, disabled };