@cortexkit/common-auth 0.4.3 → 0.4.5

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.
@@ -100,8 +100,9 @@ export declare class Transaction {
100
100
  entry(id: string): Record<string, unknown> | undefined;
101
101
  setEntry(id: string, entry: Record<string, unknown>): void;
102
102
  /**
103
- * Writes the config of every row torn between the writes of a replace as
104
- * that replace would have left it (see `completeTornRows`), in one config
103
+ * Writes the config of every row torn between the writes of a replace, or
104
+ * of a write giving it its first identity, as that write would have left it
105
+ * (see `completeTornRows`), in one config
105
106
  * write ahead of the operation's own. The write is counted apart from the
106
107
  * operation's: it is setup, like a pull giving a row its entry, so a later
107
108
  * refusal still reports `before-first-write`.
@@ -127,14 +127,15 @@ export class Transaction {
127
127
  setEntryIn(this.config, id, entry);
128
128
  }
129
129
  /**
130
- * Writes the config of every row torn between the writes of a replace as
131
- * that replace would have left it (see `completeTornRows`), in one config
130
+ * Writes the config of every row torn between the writes of a replace, or
131
+ * of a write giving it its first identity, as that write would have left it
132
+ * (see `completeTornRows`), in one config
132
133
  * write ahead of the operation's own. The write is counted apart from the
133
134
  * operation's: it is setup, like a pull giving a row its entry, so a later
134
135
  * refusal still reports `before-first-write`.
135
136
  */
136
137
  async completeTorn() {
137
- const { config, torn } = completeTornRows(this.config, this.state, this.ctx.codec);
138
+ const { config, torn } = completeTornRows(this.config, this.state, this.ctx.codec, { requireCredentialStamps: this.ctx.requireCredentialStamps === true });
138
139
  if (torn.length === 0)
139
140
  return;
140
141
  this.config = config;
@@ -133,13 +133,20 @@ export async function refreshRow(rt, id, provider, options = {}) {
133
133
  refresh: result.refresh,
134
134
  expires: result.expires,
135
135
  };
136
+ const learnt = current.identity === undefined && result.identity
137
+ ? result.identity
138
+ : undefined;
139
+ // The rotated credential's stamp names a learnt identity before the
140
+ // config records it, so a crash between the two writes is completed
141
+ // forward rather than leaving an identity no stamp proves.
136
142
  const stored = await rotateIn(rt, tx, id, credential, {
137
143
  stamp: rotationStamp(prior, now),
144
+ identity: learnt,
138
145
  });
139
146
  let identity = current.identity;
140
- if (current.identity === undefined && result.identity) {
141
- recordIdentityIn(tx, id, result.identity);
142
- identity = result.identity;
147
+ if (learnt !== undefined) {
148
+ recordIdentityIn(tx, id, learnt);
149
+ identity = learnt;
143
150
  await tx.commitConfig();
144
151
  }
145
152
  return { stored, identity, refused: undefined };
@@ -89,15 +89,19 @@ export type AddResult = {
89
89
  };
90
90
  /**
91
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
+ * credential epoch the row's entry holds in `tx` (1 without an entry) and a
93
+ * binding: for a replace, the one the config is about to get (and the stamp
94
+ * is marked as a replace's); for every other write, the row's config as it
95
+ * stands in `tx` with the identity the operation is about to record
96
+ * (`identity`, see `bindingInTx`). A rotation is the same lineage: no epoch
97
+ * bump, no identity or quota change. An API key must belong to the endpoint
98
+ * the row holds in `tx` (see `onRowEndpoint`).
96
99
  */
97
100
  export declare function rotateIn(rt: StoreRuntime, tx: Transaction, id: string, given: RotateCredential, extra?: {
98
101
  stamp?: number;
99
102
  clearErrors?: boolean;
100
103
  binding?: CredentialBinding;
104
+ identity?: string;
101
105
  }): Promise<StoredCredential>;
102
106
  export declare function addRow(rt: StoreRuntime, input: AddInput, options?: RowOperationOptions): Promise<AddResult>;
103
107
  export declare function replaceRow(rt: StoreRuntime, id: string, credential: PoolCredential, input?: {
@@ -50,12 +50,42 @@ function onRowEndpoint(tx, id, credential) {
50
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
51
  return { ...credential, baseURL, authHeader };
52
52
  }
53
+ /**
54
+ * The binding of a credential written into a row without a replace: the
55
+ * config as it stands in `tx`, plus the identity the same operation is about
56
+ * to record (`learnt`), if any. The identity is the roster row's recorded
57
+ * one, read the way `buildRawRows` reads it. A learnt identity is stamped
58
+ * before the config records it, so a crash between the two writes leaves a
59
+ * stamp naming an identity the config lacks, which every reader completes
60
+ * forward (see `torn.ts`); the reverse order would leave a config identity
61
+ * no stamp proves. An API key's endpoint is the one it was checked against
62
+ * (see `onRowEndpoint`).
63
+ */
64
+ function bindingInTx(tx, id, stored, learnt) {
65
+ const raw = tx.rosterRow(id);
66
+ const identity = learnt ??
67
+ (isRecord(raw) && typeof raw.accountId === 'string' && raw.accountId
68
+ ? raw.accountId
69
+ : undefined);
70
+ return {
71
+ ...(identity !== undefined ? { identity } : {}),
72
+ ...(stored.type === 'api'
73
+ ? {
74
+ baseURL: stored.baseURL,
75
+ authHeader: stored.authHeader ?? 'authorization-bearer',
76
+ }
77
+ : {}),
78
+ };
79
+ }
53
80
  /**
54
81
  * 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`).
82
+ * credential epoch the row's entry holds in `tx` (1 without an entry) and a
83
+ * binding: for a replace, the one the config is about to get (and the stamp
84
+ * is marked as a replace's); for every other write, the row's config as it
85
+ * stands in `tx` with the identity the operation is about to record
86
+ * (`identity`, see `bindingInTx`). A rotation is the same lineage: no epoch
87
+ * bump, no identity or quota change. An API key must belong to the endpoint
88
+ * the row holds in `tx` (see `onRowEndpoint`).
59
89
  */
60
90
  export async function rotateIn(rt, tx, id, given, extra = {}) {
61
91
  const credential = onRowEndpoint(tx, id, given);
@@ -79,11 +109,29 @@ export async function rotateIn(rt, tx, id, given, extra = {}) {
79
109
  tx.setStateAccount(id, {
80
110
  ...kept,
81
111
  ...stateFieldsFor(credential, stamp),
82
- [CREDENTIAL_STAMP_KEY]: stampFor(stored, typeof epoch === 'number' ? epoch : 1, extra.binding),
112
+ [CREDENTIAL_STAMP_KEY]: stampFor(stored, typeof epoch === 'number' ? epoch : 1, extra.binding ?? bindingInTx(tx, id, stored, extra.identity), { replace: extra.binding !== undefined }),
83
113
  });
84
114
  await tx.commitState(stored);
85
115
  return stored;
86
116
  }
117
+ /**
118
+ * Restamps a bound row's credential, unchanged, so its stamp names the
119
+ * identity the caller is about to record in the config (same epoch, same
120
+ * credential, one state write). Written before the config for the reason
121
+ * `bindingInTx` gives. A row whose stamp is not bound (possible only without
122
+ * `requireCredentialStamps`) gets no new stamp, because a fresh stamp would
123
+ * vouch for a credential this store never proved: its identity is recorded in
124
+ * the config only, and the row keeps the stamp status it had.
125
+ */
126
+ async function stampIdentityIn(tx, row, identity) {
127
+ if (!row.credential || row.stamp !== 'bound')
128
+ return;
129
+ tx.setStateAccount(row.id, {
130
+ ...(tx.stateAccount(row.id) ?? {}),
131
+ [CREDENTIAL_STAMP_KEY]: stampFor(row.credential, row.credentialEpoch ?? 1, bindingInTx(tx, row.id, row.credential, identity)),
132
+ });
133
+ await tx.commitState();
134
+ }
87
135
  function checkInput(operation, id, credential) {
88
136
  const idIssue = operation === 'add' ? idProblem(id) : undefined;
89
137
  if (idIssue)
@@ -294,7 +342,12 @@ export async function rotateRow(rt, id, credential, input = {}, options = {}) {
294
342
  row.identity !== undefined &&
295
343
  input.identity !== row.identity)
296
344
  throw identityMismatch('rotate', id);
297
- const stored = await rotateIn(rt, tx, id, credential);
345
+ const learnt = input.identity !== undefined && row.identity === undefined
346
+ ? input.identity
347
+ : undefined;
348
+ const stored = await rotateIn(rt, tx, id, credential, {
349
+ identity: learnt,
350
+ });
298
351
  let configChanged = false;
299
352
  if (!row.hasEntry) {
300
353
  tx.setEntry(id, {
@@ -570,6 +623,10 @@ export async function recordRowIdentity(rt, id, identity, attribution, options =
570
623
  throw refusal('recordIdentity', id, 'attribution', `the identity for ${id} was looked up for a credential the row no longer holds`, true);
571
624
  if (row.identity !== undefined && row.identity !== identity)
572
625
  throw identityMismatch('recordIdentity', id);
626
+ // The stamp names the identity first; a crash before the config
627
+ // write leaves a row every reader completes forward.
628
+ if (row.identity === undefined)
629
+ await stampIdentityIn(tx, row, identity);
573
630
  const disabled = recordIdentityIn(tx, id, identity);
574
631
  await tx.commitConfig();
575
632
  return { id, disabled };
@@ -48,15 +48,21 @@ export interface QuotaCodec {
48
48
  * `none`: the row holds no credential, so there is nothing to stamp.
49
49
  * `bound`: the stamp was written with this credential (its digest matches),
50
50
  * names the row's credential epoch (1 for a row without a per-row entry),
51
- * and any identity or endpoint it records is the row's.
51
+ * the identity it records (or its recording none) and any endpoint it
52
+ * records are the row's, and its dispatch digest matches everything a
53
+ * request would send (the OAuth access and refresh tokens and expiry; the API
54
+ * key with its `baseURL` and `authHeader`).
52
55
  * `missing`: the credential carries no stamp (written by a writer that does
53
56
  * not know about stamps, or one that dropped it).
54
57
  * `malformed`: the stamp is not one this store writes (wrong shape, or an
55
58
  * epoch outside the positive safe integers).
56
59
  * `mismatched`: the stamp was written for another credential, epoch,
57
- * identity or endpoint than the row now holds.
60
+ * identity, endpoint, or token to send than the row now holds.
61
+ * `legacy`: a well-formed stamp written with this credential's secret but
62
+ * with no dispatch digest (written by 0.4.3 or earlier), so it proves
63
+ * neither the token sent nor the account or endpoint it goes to.
58
64
  */
59
- export type CredentialStampStatus = 'none' | 'bound' | 'missing' | 'malformed' | 'mismatched';
65
+ export type CredentialStampStatus = 'none' | 'bound' | 'missing' | 'malformed' | 'mismatched' | 'legacy';
60
66
  /** One row of the pool as loaded. */
61
67
  export interface PoolRow {
62
68
  id: string;
@@ -84,9 +90,12 @@ export interface PoolRow {
84
90
  /**
85
91
  * Set when a replace stopped between its two writes: the state file holds
86
92
  * the new credential, stamped with the epoch and the identity or endpoint
87
- * it belongs to, and the config still holds the replaced row. The row is
88
- * shown as the replace leaves it once completed, is never a candidate, and
89
- * the next store write on it writes the config to match.
93
+ * it belongs to, and the config still holds the replaced row. Also set when
94
+ * a write that gives a row its first identity (`recordIdentity`, or a
95
+ * `rotate` or refresh that learns one) stopped after stamping the identity
96
+ * and before recording it in the config. The row is shown as the write
97
+ * leaves it once completed, is never a candidate, and the next store write
98
+ * on it writes the config to match.
90
99
  */
91
100
  torn?: true;
92
101
  /**
@@ -111,8 +120,9 @@ export interface PoolRow {
111
120
  */
112
121
  export declare const CREDENTIAL_STAMP_KEY = "commonAuthPool";
113
122
  /**
114
- * The config-side half of a replacement: the identity the new credential
115
- * belongs to (absent: none is known) and, for an API key, its endpoint.
123
+ * What the config holds for a credential when its stamp is written: the
124
+ * identity it belongs to (absent: none is known yet) and, for an API key, its
125
+ * endpoint. For a replace it is the config the replace is about to write.
116
126
  */
117
127
  export interface CredentialBinding {
118
128
  identity?: string;
@@ -123,14 +133,27 @@ export interface CredentialBinding {
123
133
  * Written beside every credential the store puts in the state file. It names
124
134
  * the credential epoch the credential belongs to and a digest of its secret,
125
135
  * so a stamp left beside a credential another writer put there afterwards is
126
- * recognisable and ignored. A replace also records the binding it gives the
127
- * row, which is what lets a reader complete a replace that stopped after
128
- * writing the credential.
136
+ * recognisable and ignored.
137
+ *
138
+ * `digest` covers only the refresh token or API key: it names the credential
139
+ * lineage, and torn-replace detection matches it, including against stamps
140
+ * older versions wrote, so it keeps that exact definition. `dispatch` covers
141
+ * everything a request sends (see `dispatchDigest`), so a token or endpoint
142
+ * changed beside an unchanged lineage secret is caught; stamps written by
143
+ * 0.4.3 or earlier lack it.
144
+ *
145
+ * `binding` is the config the credential was written beside (see
146
+ * `CredentialBinding`); every write since 0.4.4 records it, earlier versions
147
+ * only on replace. `replace` marks a stamp written by a replace, which is
148
+ * what lets a reader complete a replace that stopped after writing the
149
+ * credential: a stamp from any other write is never completed as torn.
129
150
  */
130
151
  export interface CredentialStamp {
131
152
  credentialEpoch: number;
132
153
  digest: string;
154
+ dispatch?: string;
133
155
  binding?: CredentialBinding;
156
+ replace?: true;
134
157
  }
135
158
  export type ConfigClassification = {
136
159
  status: 'ready';
@@ -167,7 +190,25 @@ export declare function fingerprintOf(credential: PoolCredential | StoredCredent
167
190
  * dedupe key.
168
191
  */
169
192
  export declare function credentialDigest(credential: PoolCredential | StoredCredential): string;
170
- export declare function stampFor(credential: PoolCredential | StoredCredential, credentialEpoch: number, binding?: CredentialBinding): CredentialStamp;
193
+ /**
194
+ * The digest of everything a request made with the credential sends or is
195
+ * served by: the OAuth access token, refresh token and expiry (the expiry
196
+ * decides whether the access token is used or refreshed first), or the API
197
+ * key with the `baseURL` and header it is sent to. Its input prefix differs
198
+ * from both the fingerprint's and `credentialDigest`'s, so it never equals
199
+ * either. The parts are JSON-encoded as a list, so no two credentials share
200
+ * an input. An access token or expiry the state file cannot hold as loaded
201
+ * (not a string, not a finite number) counts as absent, which is how
202
+ * `buildRawRows` loads it back.
203
+ */
204
+ export declare function dispatchDigest(credential: PoolCredential | StoredCredential): string;
205
+ /**
206
+ * The stamp for a credential written into a row at `credentialEpoch`, beside
207
+ * the config `binding` describes. `replace` is set only by a replace.
208
+ */
209
+ export declare function stampFor(credential: PoolCredential | StoredCredential, credentialEpoch: number, binding: CredentialBinding, options?: {
210
+ replace?: boolean;
211
+ }): CredentialStamp;
171
212
  /**
172
213
  * A credential epoch is a positive safe integer. Above `MAX_SAFE_INTEGER`,
173
214
  * adding one may give back the same number, so a replace would not move the
@@ -69,11 +69,49 @@ export function credentialDigest(credential) {
69
69
  .update(`credential-stamp\0${secretOf(credential)}`)
70
70
  .digest('hex');
71
71
  }
72
- export function stampFor(credential, credentialEpoch, binding) {
72
+ /**
73
+ * The digest of everything a request made with the credential sends or is
74
+ * served by: the OAuth access token, refresh token and expiry (the expiry
75
+ * decides whether the access token is used or refreshed first), or the API
76
+ * key with the `baseURL` and header it is sent to. Its input prefix differs
77
+ * from both the fingerprint's and `credentialDigest`'s, so it never equals
78
+ * either. The parts are JSON-encoded as a list, so no two credentials share
79
+ * an input. An access token or expiry the state file cannot hold as loaded
80
+ * (not a string, not a finite number) counts as absent, which is how
81
+ * `buildRawRows` loads it back.
82
+ */
83
+ export function dispatchDigest(credential) {
84
+ const parts = credential.type === 'oauth'
85
+ ? [
86
+ 'oauth',
87
+ typeof credential.access === 'string' ? credential.access : null,
88
+ credential.refresh,
89
+ typeof credential.expires === 'number' &&
90
+ Number.isFinite(credential.expires)
91
+ ? credential.expires
92
+ : null,
93
+ ]
94
+ : [
95
+ 'api',
96
+ credential.apiKey,
97
+ credential.baseURL.trim(),
98
+ credential.authHeader ?? 'authorization-bearer',
99
+ ];
100
+ return createHash('sha256')
101
+ .update(`credential-dispatch\0${JSON.stringify(parts)}`)
102
+ .digest('hex');
103
+ }
104
+ /**
105
+ * The stamp for a credential written into a row at `credentialEpoch`, beside
106
+ * the config `binding` describes. `replace` is set only by a replace.
107
+ */
108
+ export function stampFor(credential, credentialEpoch, binding, options = {}) {
73
109
  return {
74
110
  credentialEpoch,
75
111
  digest: credentialDigest(credential),
76
- ...(binding ? { binding: { ...binding } } : {}),
112
+ dispatch: dispatchDigest(credential),
113
+ binding: { ...binding },
114
+ ...(options.replace ? { replace: true } : {}),
77
115
  };
78
116
  }
79
117
  /**
@@ -93,8 +131,21 @@ export function parseStamp(raw) {
93
131
  return undefined;
94
132
  if (typeof raw.digest !== 'string')
95
133
  return undefined;
134
+ if ('dispatch' in raw && typeof raw.dispatch !== 'string')
135
+ return undefined;
136
+ if ('replace' in raw && raw.replace !== true)
137
+ return undefined;
138
+ const marks = {
139
+ ...(typeof raw.dispatch === 'string' ? { dispatch: raw.dispatch } : {}),
140
+ ...(raw.replace === true ? { replace: true } : {}),
141
+ };
142
+ // Every stamp that carries a dispatch digest is written with a binding; one
143
+ // without is not a stamp this store writes, and it would leave the row's
144
+ // identity unchecked.
96
145
  if (!('binding' in raw))
97
- return { credentialEpoch: epoch, digest: raw.digest };
146
+ return 'dispatch' in raw || 'replace' in raw
147
+ ? undefined
148
+ : { credentialEpoch: epoch, digest: raw.digest };
98
149
  const binding = raw.binding;
99
150
  if (!isRecord(binding))
100
151
  return undefined;
@@ -110,6 +161,7 @@ export function parseStamp(raw) {
110
161
  return {
111
162
  credentialEpoch: epoch,
112
163
  digest: raw.digest,
164
+ ...marks,
113
165
  binding: {
114
166
  ...(typeof binding.identity === 'string'
115
167
  ? { identity: binding.identity }
@@ -125,14 +177,22 @@ export function parseStamp(raw) {
125
177
  };
126
178
  }
127
179
  /**
128
- * Whether a binding a stamp records is the row's: an identity it names must
129
- * be the row's recorded identity, and an endpoint it names must be the one
130
- * the row sends its API key to. A binding without an identity says none was
131
- * known when it was written, so an identity learnt since does not contradict
132
- * it.
180
+ * Whether the binding of a stamp is the row's: the identity it names (or its
181
+ * naming none) must be exactly the row's recorded identity, and an endpoint
182
+ * it names must be the one the row sends its API key to.
183
+ *
184
+ * Every write that gives a row an identity (`add`, `replace`, `rotate` or a
185
+ * refresh that learns one, `recordIdentity`) writes the stamp naming it
186
+ * before the config, so this store never leaves a config identity beside a
187
+ * stamp that names none or another: that is another writer's doing and is
188
+ * `mismatched`. The reverse, a stamp naming an identity beside a config that
189
+ * has none, is such a write stopped between its two writes: the row as
190
+ * `buildRawRows` reads it from the files is `mismatched`, but `loadRows`,
191
+ * which every reader goes through, shows it with the identity recorded (see
192
+ * `torn.ts`), where it is `bound`.
133
193
  */
134
194
  function bindingAgrees(binding, credential, identity) {
135
- if (binding.identity !== undefined && binding.identity !== identity)
195
+ if (binding.identity !== identity)
136
196
  return false;
137
197
  if (binding.baseURL !== undefined &&
138
198
  (credential.type !== 'api' || credential.baseURL !== binding.baseURL))
@@ -157,9 +217,17 @@ function stampStatusOf(credential, credentialEpoch, identity, account) {
157
217
  return 'malformed';
158
218
  if (stamp.digest !== credentialDigest(credential))
159
219
  return 'mismatched';
220
+ // A stamp from 0.4.3 or earlier proves neither the token sent nor (its
221
+ // binding being optional and identity-lenient) the account, so it is
222
+ // reported as such whatever else it says, and never as bound.
223
+ if (stamp.dispatch === undefined)
224
+ return 'legacy';
160
225
  if (stamp.credentialEpoch !== credentialEpoch)
161
226
  return 'mismatched';
162
- if (stamp.binding && !bindingAgrees(stamp.binding, credential, identity))
227
+ // `parseStamp` refuses a stamp with a dispatch digest and no binding.
228
+ if (!stamp.binding || !bindingAgrees(stamp.binding, credential, identity))
229
+ return 'mismatched';
230
+ if (stamp.dispatch !== dispatchDigest(credential))
163
231
  return 'mismatched';
164
232
  return 'bound';
165
233
  }
@@ -1,9 +1,19 @@
1
1
  import { type RowEditor } from './identity.js';
2
2
  import { type CredentialBinding, type CredentialStamp, type PoolRow, type QuotaCodec } from './schema.js';
3
- /** Stamps of rows torn between the two writes of a replace, by row id. */
4
- export declare function tornStamps(config: Record<string, unknown>, state: Record<string, unknown>, codec: QuotaCodec): Map<string, CredentialStamp & {
5
- binding: CredentialBinding;
6
- }>;
3
+ /** How a row left between the two writes of an operation is completed. */
4
+ export type TornCompletion = {
5
+ kind: 'replace';
6
+ stamp: CredentialStamp & {
7
+ binding: CredentialBinding;
8
+ };
9
+ } | {
10
+ kind: 'identity';
11
+ identity: string;
12
+ };
13
+ /** Rows left between the two writes of an operation, by row id. */
14
+ export declare function tornStamps(config: Record<string, unknown>, state: Record<string, unknown>, codec: QuotaCodec, options?: {
15
+ requireCredentialStamps?: boolean;
16
+ }): Map<string, TornCompletion>;
7
17
  /**
8
18
  * The config half of a replacement: the row's entry moves to the new epoch,
9
19
  * loses its quota and needs a first reading; its roster row gets the new
@@ -13,11 +23,13 @@ export declare function tornStamps(config: Record<string, unknown>, state: Recor
13
23
  */
14
24
  export declare function bindReplacement(editor: RowEditor, id: string, credentialEpoch: number, binding: CredentialBinding): void;
15
25
  /**
16
- * The config with every torn row completed as its replace would have left
17
- * it, and the ids completed. The config passed in is not modified; when
18
- * nothing is torn it is returned as is.
26
+ * The config with every torn row completed as its interrupted write would
27
+ * have left it, and the ids completed. The config passed in is not modified;
28
+ * when nothing is torn it is returned as is.
19
29
  */
20
- export declare function completeTornRows(config: Record<string, unknown>, state: Record<string, unknown>, codec: QuotaCodec): {
30
+ export declare function completeTornRows(config: Record<string, unknown>, state: Record<string, unknown>, codec: QuotaCodec, options?: {
31
+ requireCredentialStamps?: boolean;
32
+ }): {
21
33
  config: Record<string, unknown>;
22
34
  torn: string[];
23
35
  };
@@ -27,8 +39,9 @@ export declare function completeTornRows(config: Record<string, unknown>, state:
27
39
  * marked `torn` and is never a candidate; every other row is as on disk.
28
40
  * With `requireCredentialStamps`, a row whose stamp is not `bound` is marked
29
41
  * `unbound` and is never a candidate either. A torn row is shown with the
30
- * replacement's stamp, which binds the completed row, so it is not unbound:
31
- * it stays out of routing only until its completion is written.
42
+ * stamp of the interrupted write, which binds the completed row, so it is not
43
+ * unbound; once a store write puts its completion on disk it is no longer
44
+ * torn and is a candidate again like any other row.
32
45
  */
33
46
  export declare function loadRows(config: Record<string, unknown>, state: Record<string, unknown>, codec: QuotaCodec, options?: {
34
47
  requireCredentialStamps?: boolean;
@@ -1,5 +1,5 @@
1
- import { disableIdentityDuplicates } from './identity.js';
2
- import { buildRawRows, CREDENTIAL_STAMP_KEY, credentialDigest, entryIn, isRecord, parseStamp, rosterRowIn, setEntryIn, } from './schema.js';
1
+ import { disableIdentityDuplicates, recordIdentityIn, } from './identity.js';
2
+ import { buildRawRows, CREDENTIAL_STAMP_KEY, credentialDigest, dispatchDigest, entryIn, isRecord, parseStamp, rosterRowIn, setEntryIn, } from './schema.js';
3
3
  /*
4
4
  * A replace changes both files: the state file gets the new credential and
5
5
  * the config gets the new epoch, identity or endpoint. It writes the state
@@ -15,11 +15,66 @@ import { buildRawRows, CREDENTIAL_STAMP_KEY, credentialDigest, entryIn, isRecord
15
15
  * A stamp counts only beside the credential it was written with (its digest
16
16
  * matches): a writer that does not know about stamps may put another
17
17
  * credential beside an old one, and that stamp then says nothing. A stamp at
18
- * or behind the config's epoch is never torn: this store's own writes leave
19
- * the state file ahead of the config, never behind it.
18
+ * or behind the config's epoch is never torn as a replace: this store's own
19
+ * writes leave the state file ahead of the config, never behind it.
20
+ *
21
+ * Only a replace's stamp is ever completed as a replace. Since 0.4.4 every
22
+ * stamp carries a binding, so a replace's says so itself (`replace: true`); a
23
+ * stamp written by 0.4.3 or earlier has no dispatch digest, and those
24
+ * versions wrote a binding only on replace, so such a stamp with a binding is
25
+ * a replace's. A stamp from any other write that sits ahead of the config was
26
+ * not left by a crash (those writes keep the row's epoch), and completing it
27
+ * would rewrite the row's identity and drop its quota on a foreign writer's
28
+ * say-so. With `requireCredentialStamps` a 0.4.3-shaped replace stamp is not
29
+ * completed either: it proves nothing about the token sent, so the strict
30
+ * store leaves the row as it is on disk (`legacy`, unbound) rather than act
31
+ * on it; a store without the option completes it as before.
32
+ *
33
+ * A strict store also completes a replace only when its stamp describes what
34
+ * the completed row would send: the stamp's dispatch digest must be that of
35
+ * the credential beside it, with an API key moved to the endpoint the binding
36
+ * names. The lineage digest alone covers the refresh token or API key, so
37
+ * without this check another writer that changed only the access token, the
38
+ * expiry or the endpoint would still have the strict store rewrite the row's
39
+ * epoch, identity and quota before refusing the row as unbound. A store
40
+ * without the option never compared dispatch digests here and still does not.
41
+ *
42
+ * A write that gives a row its first identity (`recordIdentity`, or a
43
+ * `rotate` or refresh that learns one) follows the same order: the stamp
44
+ * naming the identity first, then the config recording it. A crash between
45
+ * leaves a stamp at the row's epoch, matching the credential beside it
46
+ * exactly (digest and dispatch digest), that names an identity the config
47
+ * does not record; that is completed forward the same way, by recording the
48
+ * identity.
20
49
  */
21
- /** Stamps of rows torn between the two writes of a replace, by row id. */
22
- export function tornStamps(config, state, codec) {
50
+ /**
51
+ * The credential a torn replace leaves once its config write lands: the
52
+ * credential loaded beside the stamp, with the endpoint the stamp's binding
53
+ * names. Only an API key's endpoint lives in the config (an OAuth credential
54
+ * is entirely in the state file, which the replace has already written). A
55
+ * replace that moves a key to a new endpoint leaves the old `baseURL` and
56
+ * `authHeader` in the config until its config write, so the binding's
57
+ * `baseURL` and `authHeader` are the ones the replacement will be sent with.
58
+ */
59
+ function projectedReplacement(credential, binding) {
60
+ if (credential.type !== 'api')
61
+ return credential;
62
+ return {
63
+ ...credential,
64
+ ...(binding.baseURL !== undefined ? { baseURL: binding.baseURL } : {}),
65
+ ...(binding.authHeader !== undefined
66
+ ? { authHeader: binding.authHeader }
67
+ : {}),
68
+ };
69
+ }
70
+ /** Whether a well-formed stamp was written by a replace. */
71
+ function isReplaceStamp(stamp) {
72
+ if (!stamp.binding)
73
+ return false;
74
+ return stamp.replace === true || stamp.dispatch === undefined;
75
+ }
76
+ /** Rows left between the two writes of an operation, by row id. */
77
+ export function tornStamps(config, state, codec, options = {}) {
23
78
  const accounts = isRecord(state.accounts) ? state.accounts : {};
24
79
  const torn = new Map();
25
80
  for (const row of buildRawRows(config, state, codec)) {
@@ -36,9 +91,37 @@ export function tornStamps(config, state, codec) {
36
91
  if (stamp.digest !== credentialDigest(row.credential))
37
92
  continue;
38
93
  // A row without an entry is at epoch 1, as everywhere else.
39
- if (stamp.credentialEpoch <= (row.credentialEpoch ?? 1))
40
- continue;
41
- torn.set(row.id, { ...stamp, binding: stamp.binding });
94
+ const epoch = row.credentialEpoch ?? 1;
95
+ if (stamp.credentialEpoch > epoch) {
96
+ if (!isReplaceStamp(stamp))
97
+ continue;
98
+ if (options.requireCredentialStamps) {
99
+ if (stamp.dispatch === undefined)
100
+ continue;
101
+ // `digest` covers only the refresh token or API key, so another
102
+ // writer may still have changed the access token, the expiry or the
103
+ // endpoint the stamp names. `dispatch` covers all of what a request
104
+ // sends; completing the replace when it disagrees would move the
105
+ // row's epoch, identity and quota on a stamp that does not describe
106
+ // the credential. So the replacement as it would be sent (the loaded
107
+ // credential with the binding's endpoint) must have the stamp's
108
+ // dispatch digest; otherwise the row stays as on disk (unbound).
109
+ if (stamp.dispatch !==
110
+ dispatchDigest(projectedReplacement(row.credential, stamp.binding)))
111
+ continue;
112
+ }
113
+ torn.set(row.id, {
114
+ kind: 'replace',
115
+ stamp: { ...stamp, binding: stamp.binding },
116
+ });
117
+ }
118
+ else if (stamp.credentialEpoch === epoch &&
119
+ stamp.dispatch !== undefined &&
120
+ stamp.dispatch === dispatchDigest(row.credential) &&
121
+ stamp.binding.identity !== undefined &&
122
+ row.identity === undefined) {
123
+ torn.set(row.id, { kind: 'identity', identity: stamp.binding.identity });
124
+ }
42
125
  }
43
126
  return torn;
44
127
  }
@@ -71,13 +154,13 @@ export function bindReplacement(editor, id, credentialEpoch, binding) {
71
154
  raw.authHeader = binding.authHeader;
72
155
  }
73
156
  /**
74
- * The config with every torn row completed as its replace would have left
75
- * it, and the ids completed. The config passed in is not modified; when
76
- * nothing is torn it is returned as is.
157
+ * The config with every torn row completed as its interrupted write would
158
+ * have left it, and the ids completed. The config passed in is not modified;
159
+ * when nothing is torn it is returned as is.
77
160
  */
78
- export function completeTornRows(config, state, codec) {
79
- const stamps = tornStamps(config, state, codec);
80
- if (stamps.size === 0)
161
+ export function completeTornRows(config, state, codec, options = {}) {
162
+ const completions = tornStamps(config, state, codec, options);
163
+ if (completions.size === 0)
81
164
  return { config, torn: [] };
82
165
  const next = structuredClone(config);
83
166
  const editor = {
@@ -86,12 +169,16 @@ export function completeTornRows(config, state, codec) {
86
169
  entry: (id) => entryIn(next, id),
87
170
  setEntry: (id, entry) => setEntryIn(next, id, entry),
88
171
  };
89
- for (const [id, stamp] of stamps)
90
- bindReplacement(editor, id, stamp.credentialEpoch, stamp.binding);
91
- for (const stamp of stamps.values())
92
- if (stamp.binding.identity !== undefined)
93
- disableIdentityDuplicates(editor, stamp.binding.identity);
94
- return { config: next, torn: [...stamps.keys()] };
172
+ for (const [id, completion] of completions)
173
+ if (completion.kind === 'replace')
174
+ bindReplacement(editor, id, completion.stamp.credentialEpoch, completion.stamp.binding);
175
+ for (const [id, completion] of completions) {
176
+ if (completion.kind === 'identity')
177
+ recordIdentityIn(editor, id, completion.identity);
178
+ else if (completion.stamp.binding.identity !== undefined)
179
+ disableIdentityDuplicates(editor, completion.stamp.binding.identity);
180
+ }
181
+ return { config: next, torn: [...completions.keys()] };
95
182
  }
96
183
  /**
97
184
  * The rows every reader gets. A torn row is shown completed (the identity,
@@ -99,11 +186,12 @@ export function completeTornRows(config, state, codec) {
99
186
  * marked `torn` and is never a candidate; every other row is as on disk.
100
187
  * With `requireCredentialStamps`, a row whose stamp is not `bound` is marked
101
188
  * `unbound` and is never a candidate either. A torn row is shown with the
102
- * replacement's stamp, which binds the completed row, so it is not unbound:
103
- * it stays out of routing only until its completion is written.
189
+ * stamp of the interrupted write, which binds the completed row, so it is not
190
+ * unbound; once a store write puts its completion on disk it is no longer
191
+ * torn and is a candidate again like any other row.
104
192
  */
105
193
  export function loadRows(config, state, codec, options = {}) {
106
- const { config: whole, torn } = completeTornRows(config, state, codec);
194
+ const { config: whole, torn } = completeTornRows(config, state, codec, options);
107
195
  const rows = buildRawRows(whole, state, codec);
108
196
  for (const row of rows) {
109
197
  if (torn.includes(row.id)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cortexkit/common-auth",
3
- "version": "0.4.3",
3
+ "version": "0.4.5",
4
4
  "description": "Shared code for the CortexKit auth plugins: account pool, quota and routing, commands and auth menu, OpenCode 2 hooks, Claustrum custody, and plumbing (loopback RPC, file locks, logger, sidebar state, TUI preferences and build).",
5
5
  "license": "MIT",
6
6
  "repository": {