@cortexkit/common-auth 0.4.6 → 0.6.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.
- package/dist/fs/index.d.ts +2 -1
- package/dist/fs/refresh-file-lock.d.ts +15 -4
- package/dist/fs/refresh-file-lock.js +67 -7
- package/dist/fs/with-lock.d.ts +9 -8
- package/dist/opencode2/index.d.ts +2 -2
- package/dist/opencode2/index.js +1 -1
- package/dist/opencode2/install.d.ts +28 -18
- package/dist/opencode2/install.js +236 -84
- package/dist/opencode2/types.d.ts +62 -14
- package/dist/store/errors.d.ts +2 -2
- package/dist/store/index.d.ts +4 -3
- package/dist/store/index.js +1 -1
- package/dist/store/mutate.d.ts +3 -1
- package/dist/store/mutate.js +4 -0
- package/dist/store/pool.d.ts +24 -8
- package/dist/store/pool.js +3 -0
- package/dist/store/provider-state.d.ts +86 -0
- package/dist/store/provider-state.js +186 -0
- package/dist/store/refresh.d.ts +8 -0
- package/dist/store/refresh.js +9 -0
- package/dist/store/rows.d.ts +22 -6
- package/dist/store/rows.js +60 -7
- package/dist/store/schema.d.ts +109 -1
- package/dist/store/schema.js +85 -8
- package/dist/store/torn.d.ts +2 -1
- package/dist/store/torn.js +4 -1
- package/package.json +2 -2
package/dist/store/rows.js
CHANGED
|
@@ -2,8 +2,9 @@ import { PoolOperationError } from './errors.js';
|
|
|
2
2
|
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
|
+
import { acceptProviderState, mergedProviderState, providerStateCoverage, replacementProviderState, } from './provider-state.js';
|
|
5
6
|
import { readRow, refusal, requireBound, rowLockSpec, unknownRow, } from './runtime.js';
|
|
6
|
-
import { CREDENTIAL_STAMP_KEY, credentialProblem, fingerprintOf, idProblem, isCredentialEpoch, isRecord, rosterRowFor, rotationStamp, rowLockKey, stampFor, stateFieldsFor, storedCredential, } from './schema.js';
|
|
7
|
+
import { boundProviderStateDigest, CREDENTIAL_STAMP_KEY, credentialProblem, fingerprintOf, idProblem, isCredentialEpoch, isRecord, PROVIDER_STATE_KEY, rosterRowFor, rotationStamp, rowLockKey, stampFor, stateFieldsFor, storedCredential, } from './schema.js';
|
|
7
8
|
import { bindReplacement } from './torn.js';
|
|
8
9
|
/** Fields of a state entry that belong to the credential it replaces. */
|
|
9
10
|
const CREDENTIAL_STATE_FIELDS = [
|
|
@@ -90,6 +91,14 @@ function bindingInTx(tx, id, stored, learnt) {
|
|
|
90
91
|
export async function rotateIn(rt, tx, id, given, extra = {}) {
|
|
91
92
|
const credential = onRowEndpoint(tx, id, given);
|
|
92
93
|
const prior = tx.stateAccount(id);
|
|
94
|
+
const stateWrite = extra.providerState ?? { kind: 'keep' };
|
|
95
|
+
const epoch = tx.entry(id)?.credentialEpoch;
|
|
96
|
+
const credentialEpoch = typeof epoch === 'number' ? epoch : 1;
|
|
97
|
+
// The provider state goes in the same state write as the credential and
|
|
98
|
+
// its stamp, so no reader ever sees one without the other. A kept value is
|
|
99
|
+
// bound by the new stamp only if the old one bound it to this row at the
|
|
100
|
+
// epoch being written; a replace moves the epoch, so it never keeps one.
|
|
101
|
+
const providerStateBinding = providerStateCoverage(rt.ctx.providerState, stateWrite, prior, prior && Object.hasOwn(prior, PROVIDER_STATE_KEY) ? tx.row(id) : undefined, credentialEpoch);
|
|
93
102
|
const priorStamp = typeof prior?.lastRefreshedAt === 'number'
|
|
94
103
|
? prior.lastRefreshedAt
|
|
95
104
|
: undefined;
|
|
@@ -104,12 +113,20 @@ export async function rotateIn(rt, tx, id, given, extra = {}) {
|
|
|
104
113
|
delete kept.lastQuotaRefreshError;
|
|
105
114
|
delete kept.quota;
|
|
106
115
|
}
|
|
116
|
+
if (stateWrite.kind === 'clear')
|
|
117
|
+
delete kept[PROVIDER_STATE_KEY];
|
|
118
|
+
else if (stateWrite.kind === 'set')
|
|
119
|
+
kept[PROVIDER_STATE_KEY] = stateWrite.value;
|
|
107
120
|
const stored = storedCredential(credential, stamp);
|
|
108
|
-
const epoch = tx.entry(id)?.credentialEpoch;
|
|
109
121
|
tx.setStateAccount(id, {
|
|
110
122
|
...kept,
|
|
111
123
|
...stateFieldsFor(credential, stamp),
|
|
112
|
-
[CREDENTIAL_STAMP_KEY]: stampFor(stored,
|
|
124
|
+
[CREDENTIAL_STAMP_KEY]: stampFor(stored, credentialEpoch, extra.binding ?? bindingInTx(tx, id, stored, extra.identity), {
|
|
125
|
+
replace: extra.binding !== undefined,
|
|
126
|
+
...(providerStateBinding !== undefined
|
|
127
|
+
? { providerState: providerStateBinding }
|
|
128
|
+
: {}),
|
|
129
|
+
}),
|
|
113
130
|
});
|
|
114
131
|
await tx.commitState(stored);
|
|
115
132
|
return stored;
|
|
@@ -126,9 +143,14 @@ export async function rotateIn(rt, tx, id, given, extra = {}) {
|
|
|
126
143
|
async function stampIdentityIn(tx, row, identity) {
|
|
127
144
|
if (!row.credential || row.stamp !== 'bound')
|
|
128
145
|
return;
|
|
146
|
+
const account = tx.stateAccount(row.id);
|
|
147
|
+
// The provider state stays bound across the identity being learnt: the old
|
|
148
|
+
// stamp bound it to the row with no identity, the new one to the identity
|
|
149
|
+
// the config is about to record.
|
|
150
|
+
const providerState = boundProviderStateDigest(account, row.credential, row.credentialEpoch ?? 1, row.identity);
|
|
129
151
|
tx.setStateAccount(row.id, {
|
|
130
|
-
...(
|
|
131
|
-
[CREDENTIAL_STAMP_KEY]: stampFor(row.credential, row.credentialEpoch ?? 1, bindingInTx(tx, row.id, row.credential, identity)),
|
|
152
|
+
...(account ?? {}),
|
|
153
|
+
[CREDENTIAL_STAMP_KEY]: stampFor(row.credential, row.credentialEpoch ?? 1, bindingInTx(tx, row.id, row.credential, identity), providerState !== undefined ? { providerState } : {}),
|
|
132
154
|
});
|
|
133
155
|
await tx.commitState();
|
|
134
156
|
}
|
|
@@ -165,6 +187,9 @@ export async function addRow(rt, input, options = {}) {
|
|
|
165
187
|
const { id, credential, identity, label } = input;
|
|
166
188
|
const result = await runOperation(ctx, 'add', id, options.onFailure, async (locks, progress) => {
|
|
167
189
|
checkInput('add', id, credential);
|
|
190
|
+
const incoming = input.providerState === undefined
|
|
191
|
+
? undefined
|
|
192
|
+
: acceptProviderState(ctx.providerState, 'add', id, input.providerState);
|
|
168
193
|
if (ctx.removedIds.has(id))
|
|
169
194
|
throw refusal('add', id, 'id-removed', `id ${id} was removed from the roster in this process and is not reused`);
|
|
170
195
|
await locks.acquire(rowLockSpec(rt, { id, identity }));
|
|
@@ -190,7 +215,13 @@ export async function addRow(rt, input, options = {}) {
|
|
|
190
215
|
same.identity !== undefined &&
|
|
191
216
|
identity !== same.identity)
|
|
192
217
|
throw identityMismatch('add', same.id);
|
|
193
|
-
const stored = await rotateIn(rt, tx, same.id, credential
|
|
218
|
+
const stored = await rotateIn(rt, tx, same.id, credential, {
|
|
219
|
+
...(incoming !== undefined
|
|
220
|
+
? {
|
|
221
|
+
providerState: mergedProviderState(ctx.providerState, 'add', same.id, same.providerState, incoming),
|
|
222
|
+
}
|
|
223
|
+
: {}),
|
|
224
|
+
});
|
|
194
225
|
return { id: same.id, outcome: 'rotated', credential: stored };
|
|
195
226
|
}
|
|
196
227
|
const existing = rows.find((row) => row.id === id);
|
|
@@ -219,6 +250,9 @@ export async function addRow(rt, input, options = {}) {
|
|
|
219
250
|
});
|
|
220
251
|
const stored = await rotateIn(rt, tx, id, credential, {
|
|
221
252
|
clearErrors: true,
|
|
253
|
+
...(incoming !== undefined
|
|
254
|
+
? { providerState: { kind: 'set', value: incoming } }
|
|
255
|
+
: {}),
|
|
222
256
|
});
|
|
223
257
|
if (!existing.hasEntry)
|
|
224
258
|
await tx.commitConfig();
|
|
@@ -253,7 +287,11 @@ export async function addRow(rt, input, options = {}) {
|
|
|
253
287
|
// roster row could load beside a credential left under its id by an
|
|
254
288
|
// interrupted removal. Nothing of such a leftover entry is kept.
|
|
255
289
|
tx.dropStateAccount(id);
|
|
256
|
-
const stored = await rotateIn(rt, tx, id, credential
|
|
290
|
+
const stored = await rotateIn(rt, tx, id, credential, {
|
|
291
|
+
...(incoming !== undefined
|
|
292
|
+
? { providerState: { kind: 'set', value: incoming } }
|
|
293
|
+
: {}),
|
|
294
|
+
});
|
|
257
295
|
await tx.commitConfig();
|
|
258
296
|
return { id, outcome, credential: stored };
|
|
259
297
|
});
|
|
@@ -268,6 +306,9 @@ export async function replaceRow(rt, id, credential, input = {}, options = {}) {
|
|
|
268
306
|
const { ctx } = rt;
|
|
269
307
|
const result = await runOperation(ctx, 'replace', id, options.onFailure, async (locks, progress) => {
|
|
270
308
|
checkInput('replace', id, credential);
|
|
309
|
+
const incoming = input.providerState === undefined
|
|
310
|
+
? undefined
|
|
311
|
+
: acceptProviderState(ctx.providerState, 'replace', id, input.providerState);
|
|
271
312
|
const { row: seen } = await readRow(rt, 'replace', id);
|
|
272
313
|
await locks.acquire(rowLockSpec(rt, seen));
|
|
273
314
|
if (credential.type === 'oauth')
|
|
@@ -285,6 +326,9 @@ export async function replaceRow(rt, id, credential, input = {}, options = {}) {
|
|
|
285
326
|
// Refused before anything is written; the row keeps its credential.
|
|
286
327
|
if (!isCredentialEpoch(credentialEpoch))
|
|
287
328
|
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`);
|
|
329
|
+
// Decided before anything is written: a hook that throws or
|
|
330
|
+
// returns a value the codec rejects leaves the row as it was.
|
|
331
|
+
const providerState = replacementProviderState(ctx.providerState, row, credentialEpoch, input.identity, incoming);
|
|
288
332
|
const binding = {
|
|
289
333
|
...(input.identity !== undefined
|
|
290
334
|
? { identity: input.identity }
|
|
@@ -308,6 +352,7 @@ export async function replaceRow(rt, id, credential, input = {}, options = {}) {
|
|
|
308
352
|
const stored = await rotateIn(rt, tx, id, credential, {
|
|
309
353
|
clearErrors: true,
|
|
310
354
|
binding,
|
|
355
|
+
providerState,
|
|
311
356
|
});
|
|
312
357
|
await tx.commitConfig();
|
|
313
358
|
return { id, credential: stored, credentialEpoch };
|
|
@@ -322,6 +367,9 @@ export async function rotateRow(rt, id, credential, input = {}, options = {}) {
|
|
|
322
367
|
const { ctx } = rt;
|
|
323
368
|
return runOperation(ctx, 'rotate', id, options.onFailure, async (locks, progress) => {
|
|
324
369
|
checkInput('rotate', id, credential);
|
|
370
|
+
const incoming = input.providerState === undefined
|
|
371
|
+
? undefined
|
|
372
|
+
: acceptProviderState(ctx.providerState, 'rotate', id, input.providerState);
|
|
325
373
|
const { row: seen } = await readRow(rt, 'rotate', id);
|
|
326
374
|
await locks.acquire(rowLockSpec(rt, seen));
|
|
327
375
|
if (input.identity !== undefined && credential.type === 'oauth')
|
|
@@ -347,6 +395,11 @@ export async function rotateRow(rt, id, credential, input = {}, options = {}) {
|
|
|
347
395
|
: undefined;
|
|
348
396
|
const stored = await rotateIn(rt, tx, id, credential, {
|
|
349
397
|
identity: learnt,
|
|
398
|
+
...(incoming !== undefined
|
|
399
|
+
? {
|
|
400
|
+
providerState: mergedProviderState(ctx.providerState, 'rotate', id, row.providerState, incoming),
|
|
401
|
+
}
|
|
402
|
+
: {}),
|
|
350
403
|
});
|
|
351
404
|
let configChanged = false;
|
|
352
405
|
if (!row.hasEntry) {
|
package/dist/store/schema.d.ts
CHANGED
|
@@ -42,6 +42,69 @@ export interface QuotaCodec {
|
|
|
42
42
|
validate(value: unknown): boolean;
|
|
43
43
|
merge(stored: unknown | undefined, observation: unknown): unknown;
|
|
44
44
|
}
|
|
45
|
+
/**
|
|
46
|
+
* What `ProviderStateCodec.onReplace` is told about a replacement: the row,
|
|
47
|
+
* the credential epoch the new credential starts, the identity the replace
|
|
48
|
+
* records (absent: none), and the provider state the caller handed to
|
|
49
|
+
* `replace`, if any.
|
|
50
|
+
*/
|
|
51
|
+
export interface ProviderStateReplacement {
|
|
52
|
+
id: string;
|
|
53
|
+
credentialEpoch: number;
|
|
54
|
+
identity?: string;
|
|
55
|
+
incoming?: unknown;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Codec for the provider state a plugin keeps beside each row's credential
|
|
59
|
+
* (a project id, a device fingerprint, eligibility times: whatever belongs to
|
|
60
|
+
* that credential). The store never interprets the value: it stores it as
|
|
61
|
+
* JSON, asks `validate` before every write and on every load, and calls the
|
|
62
|
+
* hooks below where two values meet. Every hook is synchronous and runs
|
|
63
|
+
* under the store locks.
|
|
64
|
+
*/
|
|
65
|
+
export interface ProviderStateCodec {
|
|
66
|
+
validate(value: unknown): boolean;
|
|
67
|
+
/**
|
|
68
|
+
* The part of a (valid) value that belongs to the credential: what is true
|
|
69
|
+
* of the account the credential signs in to, such as a project id or a
|
|
70
|
+
* device fingerprint, as opposed to what the plugin merely tracks about
|
|
71
|
+
* its use, such as a cooldown or a cursor. The credential stamp covers the
|
|
72
|
+
* digest of exactly this projection, so a foreign edit of it hides the
|
|
73
|
+
* value, while the rest may change through `updateProviderState` without
|
|
74
|
+
* the stamp being rewritten. It must be a pure function of the value that
|
|
75
|
+
* returns JSON with a deterministic key order. Without it the whole value
|
|
76
|
+
* is credential-bound.
|
|
77
|
+
*/
|
|
78
|
+
credentialBound?(value: unknown): unknown;
|
|
79
|
+
/**
|
|
80
|
+
* Combines the value on disk with one a write brings (`add` of a secret the
|
|
81
|
+
* pool already holds, `rotate`, or a refresh whose provider returned a
|
|
82
|
+
* state). Called only when the row has a value on disk; without it the
|
|
83
|
+
* incoming value replaces the stored one. Its result is validated.
|
|
84
|
+
*/
|
|
85
|
+
merge?(onDisk: unknown, incoming: unknown): unknown;
|
|
86
|
+
/**
|
|
87
|
+
* The provider state of a row once `replace` gives it a new credential
|
|
88
|
+
* epoch. `previous` is the old credential's value (undefined when the row
|
|
89
|
+
* shows none). Returning undefined clears it. Without this hook a replace
|
|
90
|
+
* keeps the value handed to `replace` and otherwise clears the state.
|
|
91
|
+
*/
|
|
92
|
+
onReplace?(previous: unknown | undefined, replacement: ProviderStateReplacement): unknown | undefined;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Why a row shows no provider state although the state file holds one for it.
|
|
96
|
+
*
|
|
97
|
+
* `uncovered`: the value's credential-bound part (see
|
|
98
|
+
* `ProviderStateCodec.credentialBound`) is not the one this store last
|
|
99
|
+
* wrote beside the row's credential at its credential epoch and recorded
|
|
100
|
+
* identity. Another writer edited it, or
|
|
101
|
+
* wrote the credential beside it without knowing about it (an older version
|
|
102
|
+
* of this library, which drops the coverage, or a writer that does not know
|
|
103
|
+
* the pool at all), or it belongs to an earlier credential of the row.
|
|
104
|
+
* `invalid`: the value is covered but the codec rejects it, or the store was
|
|
105
|
+
* opened without a provider-state codec.
|
|
106
|
+
*/
|
|
107
|
+
export type ProviderStateDrop = 'uncovered' | 'invalid';
|
|
45
108
|
/**
|
|
46
109
|
* What the stamp beside a row's credential proves (see `CredentialStamp`).
|
|
47
110
|
*
|
|
@@ -82,6 +145,18 @@ export interface PoolRow {
|
|
|
82
145
|
disabledReason?: string;
|
|
83
146
|
/** The opaque quota map, as validated by the codec. */
|
|
84
147
|
quota?: unknown;
|
|
148
|
+
/**
|
|
149
|
+
* The opaque provider state kept beside the credential, as validated by
|
|
150
|
+
* the provider-state codec. Absent when the row has none, or when the one
|
|
151
|
+
* on disk is not shown (see `providerStateDropped`).
|
|
152
|
+
*/
|
|
153
|
+
providerState?: unknown;
|
|
154
|
+
/**
|
|
155
|
+
* Set when the state file holds a provider state for the row that is not
|
|
156
|
+
* shown, and why. The value stays on disk untouched until a write on the
|
|
157
|
+
* row sets or clears it; the row itself stays usable.
|
|
158
|
+
*/
|
|
159
|
+
providerStateDropped?: ProviderStateDrop;
|
|
85
160
|
hasEntry: boolean;
|
|
86
161
|
/** A row that may be refreshed, pulled for, or admitted. */
|
|
87
162
|
candidate: boolean;
|
|
@@ -147,6 +222,12 @@ export interface CredentialBinding {
|
|
|
147
222
|
* only on replace. `replace` marks a stamp written by a replace, which is
|
|
148
223
|
* what lets a reader complete a replace that stopped after writing the
|
|
149
224
|
* credential: a stamp from any other write is never completed as torn.
|
|
225
|
+
*
|
|
226
|
+
* `providerState` is the digest of the credential-bound part of the provider
|
|
227
|
+
* state beside the credential (see `providerStateDigest`); it is absent when
|
|
228
|
+
* the row has none, so the stamp of a row without provider state is exactly
|
|
229
|
+
* what 0.5.0 wrote. It is apart from `digest` and `dispatch`, which keep
|
|
230
|
+
* their meaning: the credential's stamp status never depends on it.
|
|
150
231
|
*/
|
|
151
232
|
export interface CredentialStamp {
|
|
152
233
|
credentialEpoch: number;
|
|
@@ -154,7 +235,13 @@ export interface CredentialStamp {
|
|
|
154
235
|
dispatch?: string;
|
|
155
236
|
binding?: CredentialBinding;
|
|
156
237
|
replace?: true;
|
|
238
|
+
providerState?: string;
|
|
157
239
|
}
|
|
240
|
+
/**
|
|
241
|
+
* Key, inside a state-file account entry, of the provider state kept beside
|
|
242
|
+
* the credential. Older readers ignore it.
|
|
243
|
+
*/
|
|
244
|
+
export declare const PROVIDER_STATE_KEY = "commonAuthProviderState";
|
|
158
245
|
export type ConfigClassification = {
|
|
159
246
|
status: 'ready';
|
|
160
247
|
exists: boolean;
|
|
@@ -208,7 +295,28 @@ export declare function dispatchDigest(credential: PoolCredential | StoredCreden
|
|
|
208
295
|
*/
|
|
209
296
|
export declare function stampFor(credential: PoolCredential | StoredCredential, credentialEpoch: number, binding: CredentialBinding, options?: {
|
|
210
297
|
replace?: boolean;
|
|
298
|
+
providerState?: string;
|
|
211
299
|
}): CredentialStamp;
|
|
300
|
+
/**
|
|
301
|
+
* The digest a stamp carries for a provider state: of its credential-bound
|
|
302
|
+
* part (`ProviderStateCodec.credentialBound`, the whole value without it), as
|
|
303
|
+
* it serializes. The store only ever stores values that survive a JSON round
|
|
304
|
+
* trip unchanged, so the digest of a value read back from disk equals the one
|
|
305
|
+
* computed when it was written. Its input prefix differs from every other
|
|
306
|
+
* digest the store writes.
|
|
307
|
+
*/
|
|
308
|
+
export declare function providerStateDigest(codec: Pick<ProviderStateCodec, 'credentialBound'> | undefined, value: unknown): string;
|
|
309
|
+
/**
|
|
310
|
+
* The provider-state digest of the stamp in a state-file account entry, when
|
|
311
|
+
* that stamp binds it to this row: written with this credential (its lineage
|
|
312
|
+
* digest matches), at this credential epoch, naming this recorded identity.
|
|
313
|
+
* Whether the value beside it still has that digest is checked apart, by
|
|
314
|
+
* `providerStateFields`; this alone is what a write that keeps the value
|
|
315
|
+
* carries into the stamp it writes, so a value no stamp of this store bound
|
|
316
|
+
* to the row stays unbound rather than being vouched for, and one edited
|
|
317
|
+
* after it was bound stays detectably edited.
|
|
318
|
+
*/
|
|
319
|
+
export declare function boundProviderStateDigest(account: unknown, credential: PoolCredential | StoredCredential | undefined, credentialEpoch: number | undefined, identity: string | undefined): string | undefined;
|
|
212
320
|
/**
|
|
213
321
|
* A credential epoch is a positive safe integer. Above `MAX_SAFE_INTEGER`,
|
|
214
322
|
* adding one may give back the same number, so a replace would not move the
|
|
@@ -245,7 +353,7 @@ export declare function setEntryIn(config: Record<string, unknown>, id: string,
|
|
|
245
353
|
* malformed per-row entry makes that one row invalid (never a candidate) and
|
|
246
354
|
* blocks nothing else.
|
|
247
355
|
*/
|
|
248
|
-
export declare function buildRawRows(config: Record<string, unknown>, state: Record<string, unknown>, codec: QuotaCodec): PoolRow[];
|
|
356
|
+
export declare function buildRawRows(config: Record<string, unknown>, state: Record<string, unknown>, codec: QuotaCodec, providerCodec?: ProviderStateCodec): PoolRow[];
|
|
249
357
|
/** The row-lock key: recorded wire identity when known, else the local id. */
|
|
250
358
|
export declare function rowLockKey(row: Pick<PoolRow, 'id' | 'identity'>): string;
|
|
251
359
|
/**
|
package/dist/store/schema.js
CHANGED
|
@@ -18,6 +18,11 @@ export const REFRESH_STAMP_TOLERANCE_MS = 5 * 60_000;
|
|
|
18
18
|
* credential beside it belongs to. Older readers ignore it.
|
|
19
19
|
*/
|
|
20
20
|
export const CREDENTIAL_STAMP_KEY = POOL_KEY;
|
|
21
|
+
/**
|
|
22
|
+
* Key, inside a state-file account entry, of the provider state kept beside
|
|
23
|
+
* the credential. Older readers ignore it.
|
|
24
|
+
*/
|
|
25
|
+
export const PROVIDER_STATE_KEY = 'commonAuthProviderState';
|
|
21
26
|
export function isRecord(value) {
|
|
22
27
|
return value != null && typeof value === 'object' && !Array.isArray(value);
|
|
23
28
|
}
|
|
@@ -112,8 +117,49 @@ export function stampFor(credential, credentialEpoch, binding, options = {}) {
|
|
|
112
117
|
dispatch: dispatchDigest(credential),
|
|
113
118
|
binding: { ...binding },
|
|
114
119
|
...(options.replace ? { replace: true } : {}),
|
|
120
|
+
...(options.providerState !== undefined
|
|
121
|
+
? { providerState: options.providerState }
|
|
122
|
+
: {}),
|
|
115
123
|
};
|
|
116
124
|
}
|
|
125
|
+
/**
|
|
126
|
+
* The digest a stamp carries for a provider state: of its credential-bound
|
|
127
|
+
* part (`ProviderStateCodec.credentialBound`, the whole value without it), as
|
|
128
|
+
* it serializes. The store only ever stores values that survive a JSON round
|
|
129
|
+
* trip unchanged, so the digest of a value read back from disk equals the one
|
|
130
|
+
* computed when it was written. Its input prefix differs from every other
|
|
131
|
+
* digest the store writes.
|
|
132
|
+
*/
|
|
133
|
+
export function providerStateDigest(codec, value) {
|
|
134
|
+
const bound = codec?.credentialBound ? codec.credentialBound(value) : value;
|
|
135
|
+
return createHash('sha256')
|
|
136
|
+
.update(`provider-state\0${JSON.stringify(bound ?? null)}`)
|
|
137
|
+
.digest('hex');
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* The provider-state digest of the stamp in a state-file account entry, when
|
|
141
|
+
* that stamp binds it to this row: written with this credential (its lineage
|
|
142
|
+
* digest matches), at this credential epoch, naming this recorded identity.
|
|
143
|
+
* Whether the value beside it still has that digest is checked apart, by
|
|
144
|
+
* `providerStateFields`; this alone is what a write that keeps the value
|
|
145
|
+
* carries into the stamp it writes, so a value no stamp of this store bound
|
|
146
|
+
* to the row stays unbound rather than being vouched for, and one edited
|
|
147
|
+
* after it was bound stays detectably edited.
|
|
148
|
+
*/
|
|
149
|
+
export function boundProviderStateDigest(account, credential, credentialEpoch, identity) {
|
|
150
|
+
if (!credential || credentialEpoch === undefined)
|
|
151
|
+
return undefined;
|
|
152
|
+
if (!isRecord(account) || !Object.hasOwn(account, PROVIDER_STATE_KEY))
|
|
153
|
+
return undefined;
|
|
154
|
+
const stamp = parseStamp(account[CREDENTIAL_STAMP_KEY]);
|
|
155
|
+
if (!stamp ||
|
|
156
|
+
stamp.providerState === undefined ||
|
|
157
|
+
stamp.digest !== credentialDigest(credential) ||
|
|
158
|
+
stamp.credentialEpoch !== credentialEpoch ||
|
|
159
|
+
stamp.binding?.identity !== identity)
|
|
160
|
+
return undefined;
|
|
161
|
+
return stamp.providerState;
|
|
162
|
+
}
|
|
117
163
|
/**
|
|
118
164
|
* A credential epoch is a positive safe integer. Above `MAX_SAFE_INTEGER`,
|
|
119
165
|
* adding one may give back the same number, so a replace would not move the
|
|
@@ -135,15 +181,20 @@ export function parseStamp(raw) {
|
|
|
135
181
|
return undefined;
|
|
136
182
|
if ('replace' in raw && raw.replace !== true)
|
|
137
183
|
return undefined;
|
|
184
|
+
if ('providerState' in raw && typeof raw.providerState !== 'string')
|
|
185
|
+
return undefined;
|
|
138
186
|
const marks = {
|
|
139
187
|
...(typeof raw.dispatch === 'string' ? { dispatch: raw.dispatch } : {}),
|
|
140
188
|
...(raw.replace === true ? { replace: true } : {}),
|
|
189
|
+
...(typeof raw.providerState === 'string'
|
|
190
|
+
? { providerState: raw.providerState }
|
|
191
|
+
: {}),
|
|
141
192
|
};
|
|
142
|
-
// Every stamp that carries a dispatch digest
|
|
143
|
-
// without is not a stamp this store writes,
|
|
144
|
-
// identity unchecked.
|
|
193
|
+
// Every stamp that carries a dispatch digest or a provider-state digest is
|
|
194
|
+
// written with a binding; one without is not a stamp this store writes,
|
|
195
|
+
// and it would leave the row's identity unchecked.
|
|
145
196
|
if (!('binding' in raw))
|
|
146
|
-
return 'dispatch' in raw || 'replace' in raw
|
|
197
|
+
return 'dispatch' in raw || 'replace' in raw || 'providerState' in raw
|
|
147
198
|
? undefined
|
|
148
199
|
: { credentialEpoch: epoch, digest: raw.digest };
|
|
149
200
|
const binding = raw.binding;
|
|
@@ -389,7 +440,7 @@ export function setEntryIn(config, id, entry) {
|
|
|
389
440
|
* malformed per-row entry makes that one row invalid (never a candidate) and
|
|
390
441
|
* blocks nothing else.
|
|
391
442
|
*/
|
|
392
|
-
export function buildRawRows(config, state, codec) {
|
|
443
|
+
export function buildRawRows(config, state, codec, providerCodec) {
|
|
393
444
|
const entries = entriesOf(config);
|
|
394
445
|
const stateAccounts = stateAccountsOf(state);
|
|
395
446
|
const seen = new Set();
|
|
@@ -419,6 +470,9 @@ export function buildRawRows(config, state, codec) {
|
|
|
419
470
|
const entry = hasEntry ? parseEntry(entries[id], codec) : undefined;
|
|
420
471
|
const credential = credentialFor(raw, stateAccounts[id]);
|
|
421
472
|
const enabled = raw.enabled !== false;
|
|
473
|
+
// A row without a per-row entry is at credential epoch 1 (the epoch the
|
|
474
|
+
// store stamps and later gives it), so its stamp is checked against 1.
|
|
475
|
+
const stampEpoch = entry ? entry.credentialEpoch : hasEntry ? undefined : 1;
|
|
422
476
|
const identity = typeof raw.accountId === 'string' && raw.accountId
|
|
423
477
|
? raw.accountId
|
|
424
478
|
: undefined;
|
|
@@ -440,9 +494,8 @@ export function buildRawRows(config, state, codec) {
|
|
|
440
494
|
? { disabledReason: entry.disabledReason }
|
|
441
495
|
: {}),
|
|
442
496
|
...(entry && 'quota' in entry ? { quota: entry.quota } : {}),
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
stamp: stampStatusOf(credential, entry ? entry.credentialEpoch : hasEntry ? undefined : 1, identity, stateAccounts[id]),
|
|
497
|
+
...providerStateFields(stateAccounts[id], credential, stampEpoch, identity, providerCodec),
|
|
498
|
+
stamp: stampStatusOf(credential, stampEpoch, identity, stateAccounts[id]),
|
|
446
499
|
};
|
|
447
500
|
if (hasEntry && !entry) {
|
|
448
501
|
row.invalid = 'entry';
|
|
@@ -454,6 +507,30 @@ export function buildRawRows(config, state, codec) {
|
|
|
454
507
|
}
|
|
455
508
|
return rows;
|
|
456
509
|
}
|
|
510
|
+
/**
|
|
511
|
+
* The provider-state fields of a loaded row. A value on disk is shown only
|
|
512
|
+
* when the stamp beside the credential binds a provider state to this row's
|
|
513
|
+
* credential, epoch and identity (see `boundProviderStateDigest`), the codec
|
|
514
|
+
* accepts the value, and the value's credential-bound part has the digest
|
|
515
|
+
* that stamp names. Otherwise the row says why it shows none and stays
|
|
516
|
+
* usable: the value is the plugin's own derived data, which it can rebuild,
|
|
517
|
+
* and the credential beside it may well be sound.
|
|
518
|
+
*/
|
|
519
|
+
function providerStateFields(account, credential, credentialEpoch, identity, codec) {
|
|
520
|
+
if (!isRecord(account) || !Object.hasOwn(account, PROVIDER_STATE_KEY))
|
|
521
|
+
return {};
|
|
522
|
+
const digest = boundProviderStateDigest(account, credential, credentialEpoch, identity);
|
|
523
|
+
if (digest === undefined)
|
|
524
|
+
return { providerStateDropped: 'uncovered' };
|
|
525
|
+
const value = account[PROVIDER_STATE_KEY];
|
|
526
|
+
// The value is validated before it is projected, so the projection only
|
|
527
|
+
// ever sees a value of the shape the codec accepts.
|
|
528
|
+
if (!codec?.validate(value))
|
|
529
|
+
return { providerStateDropped: 'invalid' };
|
|
530
|
+
if (providerStateDigest(codec, value) !== digest)
|
|
531
|
+
return { providerStateDropped: 'uncovered' };
|
|
532
|
+
return { providerState: value };
|
|
533
|
+
}
|
|
457
534
|
/** The row-lock key: recorded wire identity when known, else the local id. */
|
|
458
535
|
export function rowLockKey(row) {
|
|
459
536
|
return row.identity ?? row.id;
|
package/dist/store/torn.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type RowEditor } from './identity.js';
|
|
2
|
-
import { type CredentialBinding, type CredentialStamp, type PoolRow, type QuotaCodec } from './schema.js';
|
|
2
|
+
import { type CredentialBinding, type CredentialStamp, type PoolRow, type ProviderStateCodec, type QuotaCodec } from './schema.js';
|
|
3
3
|
/** How a row left between the two writes of an operation is completed. */
|
|
4
4
|
export type TornCompletion = {
|
|
5
5
|
kind: 'replace';
|
|
@@ -45,4 +45,5 @@ export declare function completeTornRows(config: Record<string, unknown>, state:
|
|
|
45
45
|
*/
|
|
46
46
|
export declare function loadRows(config: Record<string, unknown>, state: Record<string, unknown>, codec: QuotaCodec, options?: {
|
|
47
47
|
requireCredentialStamps?: boolean;
|
|
48
|
+
providerState?: ProviderStateCodec;
|
|
48
49
|
}): PoolRow[];
|
package/dist/store/torn.js
CHANGED
|
@@ -192,7 +192,10 @@ export function completeTornRows(config, state, codec, options = {}) {
|
|
|
192
192
|
*/
|
|
193
193
|
export function loadRows(config, state, codec, options = {}) {
|
|
194
194
|
const { config: whole, torn } = completeTornRows(config, state, codec, options);
|
|
195
|
-
|
|
195
|
+
// A torn replace wrote its provider state beside the new credential, under
|
|
196
|
+
// the stamp naming the new epoch, so the completed row shows the new
|
|
197
|
+
// credential with the state written for it, never with the old one.
|
|
198
|
+
const rows = buildRawRows(whole, state, codec, options.providerState);
|
|
196
199
|
for (const row of rows) {
|
|
197
200
|
if (torn.includes(row.id)) {
|
|
198
201
|
row.torn = true;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cortexkit/common-auth",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Shared code for the CortexKit auth plugins: account pool, quota and routing, commands and auth menu, OpenCode 2 hooks, Claustrum custody, and plumbing (loopback RPC, file locks, logger, sidebar state, TUI preferences and build).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -87,7 +87,7 @@
|
|
|
87
87
|
"types": "bun run typecheck",
|
|
88
88
|
"format": "biome check --write --unsafe .",
|
|
89
89
|
"format:check": "biome format .",
|
|
90
|
-
"lint": "biome
|
|
90
|
+
"lint": "biome check .",
|
|
91
91
|
"prepublishOnly": "bun run build",
|
|
92
92
|
"check:ranges": "bun scripts/check-installed-ranges.mjs"
|
|
93
93
|
},
|