@cortexkit/common-auth 0.5.0 → 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/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 +1 -1
package/dist/fs/index.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export type { AtomicWriteOptions } from './atomic-write.js';
|
|
2
2
|
export { writeJsonAtomic } from './atomic-write.js';
|
|
3
3
|
export { WRITER_LOCK_CONSTANTS } from './lock-constants.js';
|
|
4
|
+
export type { LockLoss, RefreshFileLock } from './refresh-file-lock.js';
|
|
4
5
|
export { acquireRefreshFileLock, isLostMarkerRaceError, } from './refresh-file-lock.js';
|
|
5
|
-
export type { LockOptions } from './with-lock.js';
|
|
6
|
+
export type { LockOptions, LockOwnershipDetails } from './with-lock.js';
|
|
6
7
|
export { LockContentionError, LockOwnershipError, lockPathFor, withLock, } from './with-lock.js';
|
|
@@ -1,4 +1,18 @@
|
|
|
1
1
|
export declare function isLostMarkerRaceError(error: unknown): boolean;
|
|
2
|
+
export interface LockLoss {
|
|
3
|
+
readonly reason: 'taken-over' | 'expired' | 'unreadable' | 'renewal-failed' | 'marker-lost';
|
|
4
|
+
readonly expectedOwnerId: string;
|
|
5
|
+
readonly observedOwnerId?: string;
|
|
6
|
+
readonly observedExpiresAt?: number;
|
|
7
|
+
}
|
|
8
|
+
export interface RefreshFileLock {
|
|
9
|
+
readonly ownerId: string;
|
|
10
|
+
assertOwned(): Promise<void>;
|
|
11
|
+
release(): Promise<void>;
|
|
12
|
+
/** Resolves once on detected loss; remains pending after an owner's release. */
|
|
13
|
+
whenLost(): Promise<LockLoss>;
|
|
14
|
+
hasLost(): boolean;
|
|
15
|
+
}
|
|
2
16
|
export declare function acquireRefreshFileLock(options: {
|
|
3
17
|
name: string;
|
|
4
18
|
ttlMs: number;
|
|
@@ -12,7 +26,4 @@ export declare function acquireRefreshFileLock(options: {
|
|
|
12
26
|
renew?: boolean;
|
|
13
27
|
renewIntervalMs?: number;
|
|
14
28
|
onStep?: (step: 'stale-marker-stat' | 'stale-marker-claimed' | 'stale-lock-confirmed' | 'eviction-marker-acquired' | 'renewal-owner-confirmed' | 'renewal-marker-unavailable' | 'renewal-write-fenced' | 'renewal-write-ready' | 'relinquish-read' | 'renewal-finished' | 'release-owner-confirmed') => void | Promise<void>;
|
|
15
|
-
}): Promise<
|
|
16
|
-
release: () => Promise<void>;
|
|
17
|
-
assertOwned: () => Promise<void>;
|
|
18
|
-
} | null>;
|
|
29
|
+
}): Promise<RefreshFileLock | null>;
|
|
@@ -19,6 +19,30 @@ export async function acquireRefreshFileLock(options) {
|
|
|
19
19
|
const now = options.now ?? Date.now;
|
|
20
20
|
let renewTimer = null;
|
|
21
21
|
let released = false;
|
|
22
|
+
let loss;
|
|
23
|
+
let resolveLoss;
|
|
24
|
+
const lostPromise = new Promise((resolve) => {
|
|
25
|
+
resolveLoss = resolve;
|
|
26
|
+
});
|
|
27
|
+
function recordLoss(reason, owner) {
|
|
28
|
+
if (loss || released)
|
|
29
|
+
return;
|
|
30
|
+
loss = Object.freeze({
|
|
31
|
+
reason,
|
|
32
|
+
expectedOwnerId: ownerId,
|
|
33
|
+
...(typeof owner?.ownerId === 'string'
|
|
34
|
+
? { observedOwnerId: owner.ownerId }
|
|
35
|
+
: {}),
|
|
36
|
+
...(typeof owner?.expiresAt === 'number'
|
|
37
|
+
? { observedExpiresAt: owner.expiresAt }
|
|
38
|
+
: {}),
|
|
39
|
+
});
|
|
40
|
+
if (renewTimer) {
|
|
41
|
+
clearRefreshLockRenewalTimeout(renewTimer);
|
|
42
|
+
renewTimer = null;
|
|
43
|
+
}
|
|
44
|
+
resolveLoss(loss);
|
|
45
|
+
}
|
|
22
46
|
let renewalInFlight = null;
|
|
23
47
|
// Only the owner of this exclusively-created marker may remove or renew a
|
|
24
48
|
// lock. A contender recovering a stale marker can accidentally rename a newer
|
|
@@ -203,7 +227,7 @@ export async function acquireRefreshFileLock(options) {
|
|
|
203
227
|
}
|
|
204
228
|
}
|
|
205
229
|
function scheduleRenewal() {
|
|
206
|
-
if (!options.renew || released)
|
|
230
|
+
if (!options.renew || released || loss)
|
|
207
231
|
return;
|
|
208
232
|
const intervalMs = options.renewIntervalMs ?? Math.max(1_000, Math.floor(options.ttlMs / 3));
|
|
209
233
|
renewTimer = setRefreshLockRenewalTimeout(() => {
|
|
@@ -213,19 +237,25 @@ export async function acquireRefreshFileLock(options) {
|
|
|
213
237
|
const markerAcquired = await withEvictionMarker(async () => {
|
|
214
238
|
const owner = await readOwner();
|
|
215
239
|
const currentNow = now();
|
|
216
|
-
if (released ||
|
|
240
|
+
if (released || loss) {
|
|
241
|
+
shouldReschedule = false;
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
if (owner?.ownerId !== ownerId) {
|
|
245
|
+
recordLoss('taken-over', owner);
|
|
217
246
|
shouldReschedule = false;
|
|
218
247
|
return;
|
|
219
248
|
}
|
|
220
249
|
// An expired lease is no longer ours to extend; a contender may
|
|
221
250
|
// already be eligible to acquire it.
|
|
222
|
-
if (Number(owner?.expiresAt)
|
|
251
|
+
if (!(Number(owner?.expiresAt) > currentNow)) {
|
|
252
|
+
recordLoss('expired', owner);
|
|
223
253
|
shouldReschedule = false;
|
|
224
254
|
return;
|
|
225
255
|
}
|
|
226
256
|
if (options.onStep)
|
|
227
257
|
await options.onStep('renewal-owner-confirmed');
|
|
228
|
-
if (released) {
|
|
258
|
+
if (released || loss) {
|
|
229
259
|
shouldReschedule = false;
|
|
230
260
|
return;
|
|
231
261
|
}
|
|
@@ -233,7 +263,7 @@ export async function acquireRefreshFileLock(options) {
|
|
|
233
263
|
return;
|
|
234
264
|
if (options.onStep)
|
|
235
265
|
await options.onStep('renewal-write-fenced');
|
|
236
|
-
if (released) {
|
|
266
|
+
if (released || loss) {
|
|
237
267
|
shouldReschedule = false;
|
|
238
268
|
return;
|
|
239
269
|
}
|
|
@@ -241,10 +271,15 @@ export async function acquireRefreshFileLock(options) {
|
|
|
241
271
|
return;
|
|
242
272
|
if (options.onStep)
|
|
243
273
|
await options.onStep('renewal-write-ready');
|
|
274
|
+
if (released || loss) {
|
|
275
|
+
shouldReschedule = false;
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
244
278
|
await writeOwner();
|
|
245
279
|
if (!(await ownsEvictionMarker())) {
|
|
246
280
|
// If marker ownership cannot be read, stop claiming the lease
|
|
247
281
|
// and remove only a record that still carries our owner id.
|
|
282
|
+
recordLoss('marker-lost');
|
|
248
283
|
shouldReschedule = false;
|
|
249
284
|
await relinquishLockAfterMarkerLoss();
|
|
250
285
|
return;
|
|
@@ -255,7 +290,17 @@ export async function acquireRefreshFileLock(options) {
|
|
|
255
290
|
}
|
|
256
291
|
}
|
|
257
292
|
catch {
|
|
258
|
-
//
|
|
293
|
+
// Retry transient failures only while the lease can still be verified.
|
|
294
|
+
try {
|
|
295
|
+
const owner = await readOwner();
|
|
296
|
+
if (owner?.ownerId !== ownerId)
|
|
297
|
+
recordLoss('taken-over', owner);
|
|
298
|
+
else if (!(Number(owner?.expiresAt) > now()))
|
|
299
|
+
recordLoss('expired', owner);
|
|
300
|
+
}
|
|
301
|
+
catch {
|
|
302
|
+
recordLoss('renewal-failed');
|
|
303
|
+
}
|
|
259
304
|
}
|
|
260
305
|
finally {
|
|
261
306
|
if (options.onStep) {
|
|
@@ -266,7 +311,7 @@ export async function acquireRefreshFileLock(options) {
|
|
|
266
311
|
// Errors from the onStep observer must not reject the renewal.
|
|
267
312
|
}
|
|
268
313
|
}
|
|
269
|
-
if (shouldReschedule && !released)
|
|
314
|
+
if (shouldReschedule && !released && !loss)
|
|
270
315
|
scheduleRenewal();
|
|
271
316
|
}
|
|
272
317
|
})();
|
|
@@ -341,20 +386,35 @@ export async function acquireRefreshFileLock(options) {
|
|
|
341
386
|
return null;
|
|
342
387
|
scheduleRenewal();
|
|
343
388
|
return {
|
|
389
|
+
ownerId,
|
|
390
|
+
whenLost: () => lostPromise,
|
|
391
|
+
hasLost: () => loss !== undefined,
|
|
344
392
|
assertOwned: async () => {
|
|
393
|
+
let observed;
|
|
345
394
|
try {
|
|
346
395
|
const owner = await readOwner();
|
|
396
|
+
observed = owner;
|
|
347
397
|
if (!released &&
|
|
398
|
+
!loss &&
|
|
348
399
|
owner?.ownerId === ownerId &&
|
|
349
400
|
Number(owner?.expiresAt) > now())
|
|
350
401
|
return;
|
|
402
|
+
recordLoss(owner?.ownerId !== ownerId ? 'taken-over' : 'expired', owner);
|
|
351
403
|
}
|
|
352
404
|
catch {
|
|
353
405
|
// Unreadable ownership is not evidence of a valid lease.
|
|
406
|
+
recordLoss('unreadable');
|
|
354
407
|
}
|
|
355
408
|
throw new LockOwnershipError({
|
|
356
409
|
target: options.path,
|
|
357
410
|
name: options.name,
|
|
411
|
+
expectedOwnerId: ownerId,
|
|
412
|
+
...(typeof observed?.ownerId === 'string'
|
|
413
|
+
? { observedOwnerId: observed.ownerId }
|
|
414
|
+
: {}),
|
|
415
|
+
...(typeof observed?.expiresAt === 'number'
|
|
416
|
+
? { observedExpiresAt: observed.expiresAt }
|
|
417
|
+
: {}),
|
|
358
418
|
});
|
|
359
419
|
},
|
|
360
420
|
release: async () => {
|
package/dist/fs/with-lock.d.ts
CHANGED
|
@@ -11,15 +11,16 @@ export declare class LockContentionError extends Error {
|
|
|
11
11
|
timeoutMs: number;
|
|
12
12
|
});
|
|
13
13
|
}
|
|
14
|
+
export interface LockOwnershipDetails {
|
|
15
|
+
target: string;
|
|
16
|
+
name: string;
|
|
17
|
+
expectedOwnerId?: string;
|
|
18
|
+
observedOwnerId?: string;
|
|
19
|
+
observedExpiresAt?: number;
|
|
20
|
+
}
|
|
14
21
|
export declare class LockOwnershipError extends Error {
|
|
15
|
-
readonly details:
|
|
16
|
-
|
|
17
|
-
name: string;
|
|
18
|
-
};
|
|
19
|
-
constructor(details: {
|
|
20
|
-
target: string;
|
|
21
|
-
name: string;
|
|
22
|
-
});
|
|
22
|
+
readonly details: LockOwnershipDetails;
|
|
23
|
+
constructor(details: LockOwnershipDetails);
|
|
23
24
|
}
|
|
24
25
|
export interface LockOptions {
|
|
25
26
|
name: string;
|
package/dist/store/errors.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { StoredCredential } from './schema.js';
|
|
2
2
|
/** Every library operation that can fail, as named in the failure value. */
|
|
3
|
-
export type PoolOperation = 'initialize' | 'add' | 'replace' | 'rotate' | 'disable' | 'enable' | 'remove' | 'reorder' | 'updateSettings' | 'recordIdentity' | 'refresh' | 'pull';
|
|
3
|
+
export type PoolOperation = 'initialize' | 'add' | 'replace' | 'rotate' | 'disable' | 'enable' | 'remove' | 'reorder' | 'updateSettings' | 'recordIdentity' | 'updateProviderState' | 'refresh' | 'pull';
|
|
4
4
|
/**
|
|
5
5
|
* How far an operation got before it failed.
|
|
6
6
|
*
|
|
@@ -19,7 +19,7 @@ export type PoolFailurePhase = 'before-first-write' | 'after-first-write' | 'pul
|
|
|
19
19
|
* lock outcomes (a wait that ran out, and a lease found lost); the rest are
|
|
20
20
|
* refusals and failures of the operation itself.
|
|
21
21
|
*/
|
|
22
|
-
export type PoolFailureKind = 'lock-contention' | 'lock-ownership' | 'pending-migration' | 'load-error' | 'unknown-row' | 'invalid-row' | 'invalid-input' | 'id-exists' | 'id-removed' | 'type-mismatch' | 'no-credential' | 'row-disabled' | 'row-protected' | 'duplicate-identity' | 'identity-mismatch' | 'endpoint-mismatch' | 'row-key-changed' | 'invalid-order' | 'refresh-stamp-ahead' | 'unbound-credential' | 'attribution' | 'provider' | 'pull' | 'invalid-quota' | 'after-persist-hook' | 'unexpected';
|
|
22
|
+
export type PoolFailureKind = 'lock-contention' | 'lock-ownership' | 'pending-migration' | 'load-error' | 'unknown-row' | 'invalid-row' | 'invalid-input' | 'id-exists' | 'id-removed' | 'type-mismatch' | 'no-credential' | 'row-disabled' | 'row-protected' | 'duplicate-identity' | 'identity-mismatch' | 'endpoint-mismatch' | 'row-key-changed' | 'invalid-order' | 'refresh-stamp-ahead' | 'unbound-credential' | 'attribution' | 'provider' | 'pull' | 'invalid-quota' | 'invalid-provider-state' | 'after-persist-hook' | 'unexpected';
|
|
23
23
|
/**
|
|
24
24
|
* The single failure value of every store operation. `committed` is present
|
|
25
25
|
* only when the operation had already written a credential to the state file
|
package/dist/store/index.d.ts
CHANGED
|
@@ -6,13 +6,14 @@ export { countUnknownIdentityRows, DUPLICATE_IDENTITY_REASON, } from './identity
|
|
|
6
6
|
export type { HoldPoint, InitializeOutcome, WriteStep } from './mutate.js';
|
|
7
7
|
export type { OpenPoolStoreOptions, PoolLoad, PoolStore, } from './pool.js';
|
|
8
8
|
export { openPoolStore } from './pool.js';
|
|
9
|
+
export type { ProviderStateMutator, UpdateProviderStateResult, } from './provider-state.js';
|
|
9
10
|
export type { PullHook, PullRequest } from './pull.js';
|
|
10
11
|
export type { ProviderRefresh, ProviderRefreshResult, RefreshOptions, RefreshOutcome, } from './refresh.js';
|
|
11
12
|
export type { LockEvent, PoolLockOptions, PoolLockSpec, } from './refresh-lock.js';
|
|
12
13
|
export { POOL_LOCK_DEFAULTS } from './refresh-lock.js';
|
|
13
|
-
export type { AddInput, AddResult, FailureHook, RemoveOptions, RemoveResult, RemoveView, ReorderOptions, ReorderResult, RowOperationOptions, RowToggleOptions, } from './rows.js';
|
|
14
|
+
export type { AddInput, AddResult, CredentialWriteInput, FailureHook, RemoveOptions, RemoveResult, RemoveView, ReorderOptions, ReorderResult, RowOperationOptions, RowToggleOptions, } from './rows.js';
|
|
14
15
|
export type { PullReason } from './runtime.js';
|
|
15
|
-
export type { ApiKeyCredential, CredentialStampStatus, OAuthCredential, PoolCredential, PoolRow, QuotaCodec, RotateCredential, StoredCredential, } from './schema.js';
|
|
16
|
-
export { fingerprintOf, LEGACY_STORE_VERSION, POOL_KEY, POOL_ROWS_KEY, POOL_SCHEMA_VERSION, REFRESH_STAMP_TOLERANCE_MS, rowLockKey, } from './schema.js';
|
|
16
|
+
export type { ApiKeyCredential, CredentialStampStatus, OAuthCredential, PoolCredential, PoolRow, ProviderStateCodec, ProviderStateDrop, ProviderStateReplacement, QuotaCodec, RotateCredential, StoredCredential, } from './schema.js';
|
|
17
|
+
export { fingerprintOf, LEGACY_STORE_VERSION, POOL_KEY, POOL_ROWS_KEY, POOL_SCHEMA_VERSION, PROVIDER_STATE_KEY, REFRESH_STAMP_TOLERANCE_MS, rowLockKey, } from './schema.js';
|
|
17
18
|
export type { PoolSettings, SettingsMutator, SettingsRead, UpdateSettingsOptions, UpdateSettingsResult, } from './settings.js';
|
|
18
19
|
export { POOL_OWNED_KEYS } from './settings.js';
|
package/dist/store/index.js
CHANGED
|
@@ -2,5 +2,5 @@ export { PoolOperationError, PoolReentryError } from './errors.js';
|
|
|
2
2
|
export { countUnknownIdentityRows, DUPLICATE_IDENTITY_REASON, } from './identity.js';
|
|
3
3
|
export { openPoolStore } from './pool.js';
|
|
4
4
|
export { POOL_LOCK_DEFAULTS } from './refresh-lock.js';
|
|
5
|
-
export { fingerprintOf, LEGACY_STORE_VERSION, POOL_KEY, POOL_ROWS_KEY, POOL_SCHEMA_VERSION, REFRESH_STAMP_TOLERANCE_MS, rowLockKey, } from './schema.js';
|
|
5
|
+
export { fingerprintOf, LEGACY_STORE_VERSION, POOL_KEY, POOL_ROWS_KEY, POOL_SCHEMA_VERSION, PROVIDER_STATE_KEY, REFRESH_STAMP_TOLERANCE_MS, rowLockKey, } from './schema.js';
|
|
6
6
|
export { POOL_OWNED_KEYS } from './settings.js';
|
package/dist/store/mutate.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { type PoolFailurePhase, type PoolOperation, PoolOperationError } from './errors.js';
|
|
2
2
|
import { type PoolLogger } from './hooks.js';
|
|
3
3
|
import { type LockEnvironment, LockStack, type PoolLockOptions, type PoolLockSpec } from './refresh-lock.js';
|
|
4
|
-
import { type PoolRow, type QuotaCodec, type StoredCredential } from './schema.js';
|
|
4
|
+
import { type PoolRow, type ProviderStateCodec, type QuotaCodec, type StoredCredential } from './schema.js';
|
|
5
5
|
/** Named points on the write path, for crash and ownership injection. */
|
|
6
6
|
export type WriteStep = 'before-config-write' | 'after-config-write' | 'before-state-write' | 'after-state-write';
|
|
7
7
|
/** Awaitable pause points on the pull and refresh paths. */
|
|
@@ -11,6 +11,8 @@ export interface StoreContext {
|
|
|
11
11
|
configPath: string;
|
|
12
12
|
statePath: string;
|
|
13
13
|
codec: QuotaCodec;
|
|
14
|
+
/** The provider-state codec; without one no row shows a provider state. */
|
|
15
|
+
providerState?: ProviderStateCodec;
|
|
14
16
|
now: () => number;
|
|
15
17
|
storeLocks: readonly PoolLockSpec[];
|
|
16
18
|
lockDefaults: PoolLockOptions;
|
package/dist/store/mutate.js
CHANGED
|
@@ -41,6 +41,7 @@ export async function readPool(ctx) {
|
|
|
41
41
|
state: state.state,
|
|
42
42
|
rows: loadRows(config.config, state.state, ctx.codec, {
|
|
43
43
|
requireCredentialStamps: ctx.requireCredentialStamps === true,
|
|
44
|
+
...(ctx.providerState ? { providerState: ctx.providerState } : {}),
|
|
44
45
|
}),
|
|
45
46
|
};
|
|
46
47
|
}
|
|
@@ -90,6 +91,9 @@ export class Transaction {
|
|
|
90
91
|
rows() {
|
|
91
92
|
return loadRows(this.config, this.state, this.ctx.codec, {
|
|
92
93
|
requireCredentialStamps: this.ctx.requireCredentialStamps === true,
|
|
94
|
+
...(this.ctx.providerState
|
|
95
|
+
? { providerState: this.ctx.providerState }
|
|
96
|
+
: {}),
|
|
93
97
|
});
|
|
94
98
|
}
|
|
95
99
|
row(id) {
|
package/dist/store/pool.d.ts
CHANGED
|
@@ -2,11 +2,12 @@ import { type Attribution } from './attribution.js';
|
|
|
2
2
|
import type { PoolOperationError } from './errors.js';
|
|
3
3
|
import type { PoolLogger } from './hooks.js';
|
|
4
4
|
import { type HoldPoint, type InitializeOutcome, type StoreContext } from './mutate.js';
|
|
5
|
+
import { type ProviderStateMutator, type UpdateProviderStateResult } from './provider-state.js';
|
|
5
6
|
import { type PullHook } from './pull.js';
|
|
6
7
|
import { type ProviderRefresh, type RefreshOptions, type RefreshOutcome } from './refresh.js';
|
|
7
8
|
import { type LockEnvironment, type PoolLockOptions, type PoolLockSpec } from './refresh-lock.js';
|
|
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 RotateCredential, type StoredCredential } from './schema.js';
|
|
9
|
+
import { type AddInput, type AddResult, type CredentialWriteInput, type RemoveOptions, type RemoveResult, type ReorderOptions, type ReorderResult, type RowOperationOptions, type RowToggleOptions } from './rows.js';
|
|
10
|
+
import { type PoolCredential, type PoolRow, type ProviderStateCodec, type QuotaCodec, type RotateCredential, type StoredCredential } from './schema.js';
|
|
10
11
|
import { type SettingsMutator, type SettingsRead, type UpdateSettingsOptions, type UpdateSettingsResult } from './settings.js';
|
|
11
12
|
export interface OpenPoolStoreOptions {
|
|
12
13
|
/** The provider every row of this pool belongs to; keys the provider-wide lock. */
|
|
@@ -14,6 +15,13 @@ export interface OpenPoolStoreOptions {
|
|
|
14
15
|
configPath: string;
|
|
15
16
|
statePath: string;
|
|
16
17
|
quota: QuotaCodec;
|
|
18
|
+
/**
|
|
19
|
+
* The codec of the provider state kept beside each row's credential (see
|
|
20
|
+
* `ProviderStateCodec`). Without it no row shows a provider state, and
|
|
21
|
+
* every write that would set one refuses (`invalid-input`); writes that
|
|
22
|
+
* leave it alone keep the value on disk as it is, and `replace` clears it.
|
|
23
|
+
*/
|
|
24
|
+
providerState?: ProviderStateCodec;
|
|
17
25
|
/**
|
|
18
26
|
* Refuse every credential this store did not stamp (default false, which
|
|
19
27
|
* loads unstamped and mis-stamped credentials as older writers left them).
|
|
@@ -81,9 +89,12 @@ export interface PoolStore {
|
|
|
81
89
|
status: InitializeOutcome;
|
|
82
90
|
}>;
|
|
83
91
|
add(input: AddInput, options?: RowOperationOptions): Promise<AddResult>;
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
92
|
+
/**
|
|
93
|
+
* Gives a row a new credential and a new credential epoch. Since 0.6.0 the
|
|
94
|
+
* row's provider state is whatever `ProviderStateCodec.onReplace` returns;
|
|
95
|
+
* without that hook it is `input.providerState`, else cleared.
|
|
96
|
+
*/
|
|
97
|
+
replace(id: string, credential: PoolCredential, input?: CredentialWriteInput, options?: RowOperationOptions): Promise<{
|
|
87
98
|
id: string;
|
|
88
99
|
credential: StoredCredential;
|
|
89
100
|
credentialEpoch: number;
|
|
@@ -94,12 +105,17 @@ export interface PoolStore {
|
|
|
94
105
|
* to keep the row's, and one that gives another is refused
|
|
95
106
|
* (`endpoint-mismatch`) before writing: that is a `replace`.
|
|
96
107
|
*/
|
|
97
|
-
rotate(id: string, credential: RotateCredential, input?: {
|
|
98
|
-
identity?: string;
|
|
99
|
-
}, options?: RowOperationOptions): Promise<{
|
|
108
|
+
rotate(id: string, credential: RotateCredential, input?: CredentialWriteInput, options?: RowOperationOptions): Promise<{
|
|
100
109
|
id: string;
|
|
101
110
|
credential: StoredCredential;
|
|
102
111
|
}>;
|
|
112
|
+
/**
|
|
113
|
+
* Changes a row's provider state without touching its credential (since
|
|
114
|
+
* 0.6.0), under the row lock, `extraLocks` and the store locks. Refuses
|
|
115
|
+
* (`attribution`) once the row has moved off the credential epoch or
|
|
116
|
+
* identity in `fence`, and (`unknown-row`) once it is removed.
|
|
117
|
+
*/
|
|
118
|
+
updateProviderState(id: string, fence: Attribution, mutator: ProviderStateMutator, options?: RowToggleOptions): Promise<UpdateProviderStateResult>;
|
|
103
119
|
/**
|
|
104
120
|
* Sets `enabled: false` and the entry's `disabledReason`. Takes the row
|
|
105
121
|
* lock, then `extraLocks`, then the store locks (the row lock and
|
package/dist/store/pool.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { recordQuota } from './attribution.js';
|
|
2
2
|
import { initializePool, readPool, } from './mutate.js';
|
|
3
|
+
import { updateProviderStateRow, } from './provider-state.js';
|
|
3
4
|
import { PullScheduler } from './pull.js';
|
|
4
5
|
import { refreshRow, } from './refresh.js';
|
|
5
6
|
import { POOL_LOCK_DEFAULTS, } from './refresh-lock.js';
|
|
@@ -47,6 +48,7 @@ export function openPoolStore(options) {
|
|
|
47
48
|
configPath: options.configPath,
|
|
48
49
|
statePath: options.statePath,
|
|
49
50
|
codec: options.quota,
|
|
51
|
+
...(options.providerState ? { providerState: options.providerState } : {}),
|
|
50
52
|
now: options.now ?? Date.now,
|
|
51
53
|
storeLocks: options.storeLocks ?? [
|
|
52
54
|
{ name: 'save', path: options.configPath },
|
|
@@ -98,6 +100,7 @@ export function openPoolStore(options) {
|
|
|
98
100
|
add: (input, callOptions) => addRow(rt, input, callOptions),
|
|
99
101
|
replace: (id, credential, input, callOptions) => replaceRow(rt, id, credential, input, callOptions),
|
|
100
102
|
rotate: (id, credential, input, callOptions) => rotateRow(rt, id, credential, input, callOptions),
|
|
103
|
+
updateProviderState: (id, fence, mutator, callOptions) => updateProviderStateRow(rt, id, fence, mutator, callOptions),
|
|
101
104
|
disable: (id, reason, callOptions) => disableRow(rt, id, reason, callOptions),
|
|
102
105
|
enable: (id, callOptions) => enableRow(rt, id, callOptions),
|
|
103
106
|
remove: (id, callOptions) => removeRow(rt, id, callOptions),
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type { Attribution } from './attribution.js';
|
|
2
|
+
import type { PoolOperation } from './errors.js';
|
|
3
|
+
import type { RowToggleOptions } from './rows.js';
|
|
4
|
+
import { type StoreRuntime } from './runtime.js';
|
|
5
|
+
import { type PoolRow, type ProviderStateCodec } from './schema.js';
|
|
6
|
+
/**
|
|
7
|
+
* What a credential write does to the provider state beside it: `keep`
|
|
8
|
+
* leaves the value on disk as it is (bound by the new stamp only if the old
|
|
9
|
+
* stamp bound it to the same row, epoch and identity), `set` stores a value
|
|
10
|
+
* the codec accepted, and `clear` deletes it.
|
|
11
|
+
*/
|
|
12
|
+
export type ProviderStateWrite = {
|
|
13
|
+
kind: 'keep';
|
|
14
|
+
} | {
|
|
15
|
+
kind: 'set';
|
|
16
|
+
value: unknown;
|
|
17
|
+
} | {
|
|
18
|
+
kind: 'clear';
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* A provider state as the store will store it: a JSON round trip of it (so
|
|
22
|
+
* its digest is the same once read back from disk), accepted by the codec.
|
|
23
|
+
* Refused before anything is written when the store has no codec, the value
|
|
24
|
+
* is not JSON, or the codec rejects it.
|
|
25
|
+
*/
|
|
26
|
+
export declare function acceptProviderState(codec: ProviderStateCodec | undefined, operation: PoolOperation, id: string, value: unknown, what?: string): unknown;
|
|
27
|
+
/**
|
|
28
|
+
* The write for a value a credential write brings (`add` of a secret the
|
|
29
|
+
* pool holds, `rotate`, a refresh): merged with the row's value on disk by
|
|
30
|
+
* the codec's `merge` when both exist, else the incoming value as is.
|
|
31
|
+
*/
|
|
32
|
+
export declare function mergedProviderState(codec: ProviderStateCodec | undefined, operation: PoolOperation, id: string, onDisk: unknown, incoming: unknown): ProviderStateWrite;
|
|
33
|
+
/**
|
|
34
|
+
* The provider state a replace leaves on the row, decided before anything is
|
|
35
|
+
* written: whatever the codec's `onReplace` returns (undefined clears it), or
|
|
36
|
+
* without that hook the value handed to `replace`, else nothing. The old
|
|
37
|
+
* credential's value is never kept by default: it describes the account the
|
|
38
|
+
* replaced credential belonged to.
|
|
39
|
+
*/
|
|
40
|
+
export declare function replacementProviderState(codec: ProviderStateCodec | undefined, row: PoolRow, credentialEpoch: number, identity: string | undefined, incoming: unknown): ProviderStateWrite;
|
|
41
|
+
/**
|
|
42
|
+
* The provider-state digest the stamp of a credential write carries, and the
|
|
43
|
+
* state-file fields it changes. `set` binds the new value; `keep` carries the
|
|
44
|
+
* old stamp's digest forward only when that stamp bound it to this row as it
|
|
45
|
+
* stood before the write (same credential lineage, the epoch being written,
|
|
46
|
+
* the identity recorded before the write); `clear` binds nothing.
|
|
47
|
+
*/
|
|
48
|
+
export declare function providerStateCoverage(codec: ProviderStateCodec | undefined, write: ProviderStateWrite, prior: Record<string, unknown> | undefined, priorRow: PoolRow | undefined, credentialEpoch: number): string | undefined;
|
|
49
|
+
/**
|
|
50
|
+
* Receives a private copy of the row's provider state (undefined when the row
|
|
51
|
+
* shows none) and the row as loaded under the locks, and returns the next
|
|
52
|
+
* provider state; returning undefined clears it, so a mutator that means to
|
|
53
|
+
* keep the value returns it. It runs under the row lock and the store locks,
|
|
54
|
+
* so it must not call back into the store (`PoolReentryError`).
|
|
55
|
+
*/
|
|
56
|
+
export type ProviderStateMutator = (current: unknown | undefined, row: PoolRow) => unknown | Promise<unknown>;
|
|
57
|
+
export type UpdateProviderStateResult = {
|
|
58
|
+
id: string;
|
|
59
|
+
/** The provider state now on disk; absent when the row has none. */
|
|
60
|
+
providerState?: unknown;
|
|
61
|
+
/**
|
|
62
|
+
* `unchanged`: the mutator returned what the row already shows (or cleared
|
|
63
|
+
* a row that holds none); nothing was written.
|
|
64
|
+
*/
|
|
65
|
+
outcome: 'updated' | 'cleared' | 'unchanged';
|
|
66
|
+
};
|
|
67
|
+
/**
|
|
68
|
+
* Changes a row's provider state without touching its credential, in one
|
|
69
|
+
* state-file write under the row lock, the caller's extra locks and the
|
|
70
|
+
* store locks. `fence` is what the caller read the row at: the write is
|
|
71
|
+
* refused (`attribution`, retryable) when the row has since moved to another
|
|
72
|
+
* credential epoch or recorded identity, and (`unknown-row`) once it is
|
|
73
|
+
* removed, so a writer that read the row before a replace or a removal never
|
|
74
|
+
* lands its value on the new credential or brings a removed row's state back.
|
|
75
|
+
*
|
|
76
|
+
* The value is bound by the stamp already beside the credential. When its
|
|
77
|
+
* credential-bound part is unchanged the stamp is left byte for byte as it
|
|
78
|
+
* is; otherwise only the stamp's provider-state digest changes, so the
|
|
79
|
+
* credential's stamp status never moves. With `requireCredentialStamps`, an
|
|
80
|
+
* unbound row refuses (`unbound-credential`) as every other strict path
|
|
81
|
+
* does. Without it, a row whose stamp was not written by this store with this
|
|
82
|
+
* credential at the row's epoch and identity refuses the same way: no stamp
|
|
83
|
+
* could bind the value, so no reader would ever show it. A `rotate` or
|
|
84
|
+
* `replace` stamps such a row.
|
|
85
|
+
*/
|
|
86
|
+
export declare function updateProviderStateRow(rt: StoreRuntime, id: string, fence: Attribution, mutator: ProviderStateMutator, options?: RowToggleOptions): Promise<UpdateProviderStateResult>;
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { assertNotInsideHook, runInsideHook } from './hooks.js';
|
|
2
|
+
import { runOperation, withTransaction } from './mutate.js';
|
|
3
|
+
import { readRow, refusal, requireBound, rowLockSpec, unknownRow, } from './runtime.js';
|
|
4
|
+
import { boundProviderStateDigest, CREDENTIAL_STAMP_KEY, credentialDigest, isCredentialEpoch, isRecord, PROVIDER_STATE_KEY, parseStamp, providerStateDigest, rowLockKey, } from './schema.js';
|
|
5
|
+
/**
|
|
6
|
+
* A provider state as the store will store it: a JSON round trip of it (so
|
|
7
|
+
* its digest is the same once read back from disk), accepted by the codec.
|
|
8
|
+
* Refused before anything is written when the store has no codec, the value
|
|
9
|
+
* is not JSON, or the codec rejects it.
|
|
10
|
+
*/
|
|
11
|
+
export function acceptProviderState(codec, operation, id, value, what = 'the provider state') {
|
|
12
|
+
if (!codec)
|
|
13
|
+
throw refusal(operation, id, 'invalid-input', 'the store was opened without a provider-state codec');
|
|
14
|
+
let normalized;
|
|
15
|
+
try {
|
|
16
|
+
const text = JSON.stringify(value);
|
|
17
|
+
normalized = text === undefined ? undefined : JSON.parse(text);
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
normalized = undefined;
|
|
21
|
+
}
|
|
22
|
+
if (normalized === undefined)
|
|
23
|
+
throw refusal(operation, id, 'invalid-provider-state', `${what} cannot be stored as JSON`);
|
|
24
|
+
if (!codec.validate(normalized))
|
|
25
|
+
throw refusal(operation, id, 'invalid-provider-state', `the provider-state codec rejected ${what}`);
|
|
26
|
+
return normalized;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* The write for a value a credential write brings (`add` of a secret the
|
|
30
|
+
* pool holds, `rotate`, a refresh): merged with the row's value on disk by
|
|
31
|
+
* the codec's `merge` when both exist, else the incoming value as is.
|
|
32
|
+
*/
|
|
33
|
+
export function mergedProviderState(codec, operation, id, onDisk, incoming) {
|
|
34
|
+
if (onDisk === undefined || !codec?.merge)
|
|
35
|
+
return { kind: 'set', value: incoming };
|
|
36
|
+
return {
|
|
37
|
+
kind: 'set',
|
|
38
|
+
value: acceptProviderState(codec, operation, id, codec.merge(structuredClone(onDisk), structuredClone(incoming)), 'the merged provider state'),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* The provider state a replace leaves on the row, decided before anything is
|
|
43
|
+
* written: whatever the codec's `onReplace` returns (undefined clears it), or
|
|
44
|
+
* without that hook the value handed to `replace`, else nothing. The old
|
|
45
|
+
* credential's value is never kept by default: it describes the account the
|
|
46
|
+
* replaced credential belonged to.
|
|
47
|
+
*/
|
|
48
|
+
export function replacementProviderState(codec, row, credentialEpoch, identity, incoming) {
|
|
49
|
+
const hook = codec?.onReplace;
|
|
50
|
+
const next = hook
|
|
51
|
+
? hook(row.providerState === undefined
|
|
52
|
+
? undefined
|
|
53
|
+
: structuredClone(row.providerState), {
|
|
54
|
+
id: row.id,
|
|
55
|
+
credentialEpoch,
|
|
56
|
+
...(identity !== undefined ? { identity } : {}),
|
|
57
|
+
...(incoming !== undefined
|
|
58
|
+
? { incoming: structuredClone(incoming) }
|
|
59
|
+
: {}),
|
|
60
|
+
})
|
|
61
|
+
: incoming;
|
|
62
|
+
if (next === undefined)
|
|
63
|
+
return { kind: 'clear' };
|
|
64
|
+
if (!hook)
|
|
65
|
+
return { kind: 'set', value: next };
|
|
66
|
+
return {
|
|
67
|
+
kind: 'set',
|
|
68
|
+
value: acceptProviderState(codec, 'replace', row.id, next, 'the provider state onReplace returned'),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* The provider-state digest the stamp of a credential write carries, and the
|
|
73
|
+
* state-file fields it changes. `set` binds the new value; `keep` carries the
|
|
74
|
+
* old stamp's digest forward only when that stamp bound it to this row as it
|
|
75
|
+
* stood before the write (same credential lineage, the epoch being written,
|
|
76
|
+
* the identity recorded before the write); `clear` binds nothing.
|
|
77
|
+
*/
|
|
78
|
+
export function providerStateCoverage(codec, write, prior, priorRow, credentialEpoch) {
|
|
79
|
+
if (write.kind === 'set')
|
|
80
|
+
return providerStateDigest(codec, write.value);
|
|
81
|
+
if (write.kind === 'clear')
|
|
82
|
+
return undefined;
|
|
83
|
+
return boundProviderStateDigest(prior, priorRow?.credential, credentialEpoch, priorRow?.identity);
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Changes a row's provider state without touching its credential, in one
|
|
87
|
+
* state-file write under the row lock, the caller's extra locks and the
|
|
88
|
+
* store locks. `fence` is what the caller read the row at: the write is
|
|
89
|
+
* refused (`attribution`, retryable) when the row has since moved to another
|
|
90
|
+
* credential epoch or recorded identity, and (`unknown-row`) once it is
|
|
91
|
+
* removed, so a writer that read the row before a replace or a removal never
|
|
92
|
+
* lands its value on the new credential or brings a removed row's state back.
|
|
93
|
+
*
|
|
94
|
+
* The value is bound by the stamp already beside the credential. When its
|
|
95
|
+
* credential-bound part is unchanged the stamp is left byte for byte as it
|
|
96
|
+
* is; otherwise only the stamp's provider-state digest changes, so the
|
|
97
|
+
* credential's stamp status never moves. With `requireCredentialStamps`, an
|
|
98
|
+
* unbound row refuses (`unbound-credential`) as every other strict path
|
|
99
|
+
* does. Without it, a row whose stamp was not written by this store with this
|
|
100
|
+
* credential at the row's epoch and identity refuses the same way: no stamp
|
|
101
|
+
* could bind the value, so no reader would ever show it. A `rotate` or
|
|
102
|
+
* `replace` stamps such a row.
|
|
103
|
+
*/
|
|
104
|
+
export async function updateProviderStateRow(rt, id, fence, mutator, options = {}) {
|
|
105
|
+
assertNotInsideHook('updateProviderState');
|
|
106
|
+
const { ctx } = rt;
|
|
107
|
+
const operation = 'updateProviderState';
|
|
108
|
+
return runOperation(ctx, operation, id, options.onFailure, async (locks, progress) => {
|
|
109
|
+
const codec = ctx.providerState;
|
|
110
|
+
if (!codec)
|
|
111
|
+
throw refusal(operation, id, 'invalid-input', 'the store was opened without a provider-state codec');
|
|
112
|
+
if (typeof mutator !== 'function')
|
|
113
|
+
throw refusal(operation, id, 'invalid-input', 'a mutator is required');
|
|
114
|
+
const captured = isRecord(fence) ? fence.credentialEpoch : undefined;
|
|
115
|
+
if (!isCredentialEpoch(captured))
|
|
116
|
+
throw refusal(operation, id, 'invalid-input', 'the credential epoch the provider state was read at is required');
|
|
117
|
+
const { row: seen } = await readRow(rt, operation, id);
|
|
118
|
+
await locks.acquire(rowLockSpec(rt, seen));
|
|
119
|
+
for (const extra of options.extraLocks ?? [])
|
|
120
|
+
await locks.acquire(extra);
|
|
121
|
+
return withTransaction(ctx, locks, progress, { operation, rowId: id }, async (tx) => {
|
|
122
|
+
const row = tx.row(id);
|
|
123
|
+
if (!row)
|
|
124
|
+
throw unknownRow(operation, id);
|
|
125
|
+
if (rowLockKey(row) !== rowLockKey(seen))
|
|
126
|
+
throw refusal(operation, id, 'row-key-changed', `row ${id}'s wire identity changed while its lock was being taken`, true);
|
|
127
|
+
if (row.invalid)
|
|
128
|
+
throw refusal(operation, id, 'invalid-row', `row ${id} failed validation`);
|
|
129
|
+
if (!row.credential)
|
|
130
|
+
throw refusal(operation, id, 'no-credential', `row ${id} holds no credential`);
|
|
131
|
+
requireBound(operation, row);
|
|
132
|
+
const epoch = row.credentialEpoch ?? 1;
|
|
133
|
+
if (epoch !== captured || row.identity !== fence.identity)
|
|
134
|
+
throw refusal(operation, id, 'attribution', `the provider state for ${id} was read for a credential or account the row no longer holds`, true);
|
|
135
|
+
const account = tx.stateAccount(id);
|
|
136
|
+
const rawStamp = account?.[CREDENTIAL_STAMP_KEY];
|
|
137
|
+
const stamp = parseStamp(rawStamp);
|
|
138
|
+
if (!isRecord(rawStamp) ||
|
|
139
|
+
!stamp?.binding ||
|
|
140
|
+
stamp.digest !== credentialDigest(row.credential) ||
|
|
141
|
+
stamp.credentialEpoch !== epoch ||
|
|
142
|
+
stamp.binding.identity !== row.identity)
|
|
143
|
+
throw refusal(operation, id, 'unbound-credential', `row ${id}'s credential carries no stamp of this store to bind a provider state to (stamp ${row.stamp}); rotate or replace it first`);
|
|
144
|
+
const current = row.providerState === undefined
|
|
145
|
+
? undefined
|
|
146
|
+
: structuredClone(row.providerState);
|
|
147
|
+
const returned = await runInsideHook(operation, () => mutator(current, row));
|
|
148
|
+
const next = returned === undefined
|
|
149
|
+
? undefined
|
|
150
|
+
: acceptProviderState(codec, operation, id, returned, 'the provider state the mutator returned');
|
|
151
|
+
const held = account !== undefined && Object.hasOwn(account, PROVIDER_STATE_KEY);
|
|
152
|
+
if (next === undefined
|
|
153
|
+
? !held
|
|
154
|
+
: row.providerState !== undefined &&
|
|
155
|
+
JSON.stringify(row.providerState) === JSON.stringify(next))
|
|
156
|
+
return {
|
|
157
|
+
id,
|
|
158
|
+
outcome: 'unchanged',
|
|
159
|
+
...(next !== undefined ? { providerState: next } : {}),
|
|
160
|
+
};
|
|
161
|
+
const nextAccount = { ...account };
|
|
162
|
+
if (next === undefined) {
|
|
163
|
+
delete nextAccount[PROVIDER_STATE_KEY];
|
|
164
|
+
const nextStamp = { ...rawStamp };
|
|
165
|
+
delete nextStamp.providerState;
|
|
166
|
+
nextAccount[CREDENTIAL_STAMP_KEY] = nextStamp;
|
|
167
|
+
}
|
|
168
|
+
else {
|
|
169
|
+
nextAccount[PROVIDER_STATE_KEY] = next;
|
|
170
|
+
const digest = providerStateDigest(codec, next);
|
|
171
|
+
// A change confined to the part the codec does not bind to the
|
|
172
|
+
// credential leaves the stamp exactly as it was.
|
|
173
|
+
if (stamp.providerState !== digest)
|
|
174
|
+
nextAccount[CREDENTIAL_STAMP_KEY] = {
|
|
175
|
+
...rawStamp,
|
|
176
|
+
providerState: digest,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
tx.setStateAccount(id, nextAccount);
|
|
180
|
+
await tx.commitState();
|
|
181
|
+
return next === undefined
|
|
182
|
+
? { id, outcome: 'cleared' }
|
|
183
|
+
: { id, outcome: 'updated', providerState: next };
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
}
|
package/dist/store/refresh.d.ts
CHANGED
|
@@ -10,6 +10,14 @@ export interface ProviderRefreshResult {
|
|
|
10
10
|
expiresIn?: number;
|
|
11
11
|
/** The account's wire identity, when the provider reports one. */
|
|
12
12
|
identity?: string;
|
|
13
|
+
/**
|
|
14
|
+
* Provider state that changes with the new token (needs the store's
|
|
15
|
+
* provider-state codec). It is merged with the row's value on disk
|
|
16
|
+
* (`ProviderStateCodec.merge`) and written in the same state write as the
|
|
17
|
+
* rotated credential, under the same commit fence. Left out, the row keeps
|
|
18
|
+
* its value.
|
|
19
|
+
*/
|
|
20
|
+
providerState?: unknown;
|
|
13
21
|
}
|
|
14
22
|
export type ProviderRefresh = (credential: OAuthCredential & {
|
|
15
23
|
lastRefreshedAt?: number;
|
package/dist/store/refresh.js
CHANGED
|
@@ -2,6 +2,7 @@ import { PoolOperationError } from './errors.js';
|
|
|
2
2
|
import { assertNotInsideHook, runInsideHook } from './hooks.js';
|
|
3
3
|
import { recordIdentityIn } from './identity.js';
|
|
4
4
|
import { runOperation, withTransaction } from './mutate.js';
|
|
5
|
+
import { acceptProviderState, mergedProviderState } from './provider-state.js';
|
|
5
6
|
import { rotateIn } from './rows.js';
|
|
6
7
|
import { readRow, refusal, requireBound, rowLockSpec, } from './runtime.js';
|
|
7
8
|
import { rotationStamp, rotationStampUntrusted, rowLockKey, } from './schema.js';
|
|
@@ -98,6 +99,9 @@ export async function refreshRow(rt, id, provider, options = {}) {
|
|
|
98
99
|
}
|
|
99
100
|
if (typeof result?.refresh !== 'string' || !result.refresh.trim())
|
|
100
101
|
throw refusal('refresh', id, 'provider', 'the provider returned no refresh token', true);
|
|
102
|
+
const incoming = result.providerState === undefined
|
|
103
|
+
? undefined
|
|
104
|
+
: acceptProviderState(ctx.providerState, 'refresh', id, result.providerState, 'the provider state the provider returned');
|
|
101
105
|
const commit = await withTransaction(ctx, locks, progress, { operation: 'refresh', rowId: id }, async (tx) => {
|
|
102
106
|
const current = tx.row(id);
|
|
103
107
|
const entry = tx.entry(id);
|
|
@@ -142,6 +146,11 @@ export async function refreshRow(rt, id, provider, options = {}) {
|
|
|
142
146
|
const stored = await rotateIn(rt, tx, id, credential, {
|
|
143
147
|
stamp: rotationStamp(prior, now),
|
|
144
148
|
identity: learnt,
|
|
149
|
+
...(incoming !== undefined
|
|
150
|
+
? {
|
|
151
|
+
providerState: mergedProviderState(ctx.providerState, 'refresh', id, current.providerState, incoming),
|
|
152
|
+
}
|
|
153
|
+
: {}),
|
|
145
154
|
});
|
|
146
155
|
let identity = current.identity;
|
|
147
156
|
if (learnt !== undefined) {
|
package/dist/store/rows.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Attribution } from './attribution.js';
|
|
2
2
|
import { PoolOperationError } from './errors.js';
|
|
3
3
|
import { type Transaction } from './mutate.js';
|
|
4
|
+
import { type ProviderStateWrite } from './provider-state.js';
|
|
4
5
|
import type { PoolLockSpec } from './refresh-lock.js';
|
|
5
6
|
import { type StoreRuntime } from './runtime.js';
|
|
6
7
|
import { type CredentialBinding, type PoolCredential, type PoolRow, type RotateCredential, type StoredCredential } from './schema.js';
|
|
@@ -80,6 +81,24 @@ export interface AddInput {
|
|
|
80
81
|
credential: PoolCredential;
|
|
81
82
|
identity?: string;
|
|
82
83
|
label?: string;
|
|
84
|
+
/**
|
|
85
|
+
* Provider state for the credential, written in the same state write as
|
|
86
|
+
* the credential (needs the store's provider-state codec). On an `add`
|
|
87
|
+
* that rotates a row already holding this secret it is merged with the
|
|
88
|
+
* row's value (`ProviderStateCodec.merge`); left out, that row keeps its
|
|
89
|
+
* value.
|
|
90
|
+
*/
|
|
91
|
+
providerState?: unknown;
|
|
92
|
+
}
|
|
93
|
+
/** What `replace` and `rotate` take beside the credential. */
|
|
94
|
+
export interface CredentialWriteInput {
|
|
95
|
+
identity?: string;
|
|
96
|
+
/**
|
|
97
|
+
* Provider state written in the same state write as the credential. For
|
|
98
|
+
* `rotate` it is merged with the row's value; left out, the row keeps its
|
|
99
|
+
* value. For `replace` see `ProviderStateCodec.onReplace`.
|
|
100
|
+
*/
|
|
101
|
+
providerState?: unknown;
|
|
83
102
|
}
|
|
84
103
|
export type AddResult = {
|
|
85
104
|
/** The row holding the credential; an existing row's id on a re-add. */
|
|
@@ -102,18 +121,15 @@ export declare function rotateIn(rt: StoreRuntime, tx: Transaction, id: string,
|
|
|
102
121
|
clearErrors?: boolean;
|
|
103
122
|
binding?: CredentialBinding;
|
|
104
123
|
identity?: string;
|
|
124
|
+
providerState?: ProviderStateWrite;
|
|
105
125
|
}): Promise<StoredCredential>;
|
|
106
126
|
export declare function addRow(rt: StoreRuntime, input: AddInput, options?: RowOperationOptions): Promise<AddResult>;
|
|
107
|
-
export declare function replaceRow(rt: StoreRuntime, id: string, credential: PoolCredential, input?: {
|
|
108
|
-
identity?: string;
|
|
109
|
-
}, options?: RowOperationOptions): Promise<{
|
|
127
|
+
export declare function replaceRow(rt: StoreRuntime, id: string, credential: PoolCredential, input?: CredentialWriteInput, options?: RowOperationOptions): Promise<{
|
|
110
128
|
id: string;
|
|
111
129
|
credential: StoredCredential;
|
|
112
130
|
credentialEpoch: number;
|
|
113
131
|
}>;
|
|
114
|
-
export declare function rotateRow(rt: StoreRuntime, id: string, credential: RotateCredential, input?: {
|
|
115
|
-
identity?: string;
|
|
116
|
-
}, options?: RowOperationOptions): Promise<{
|
|
132
|
+
export declare function rotateRow(rt: StoreRuntime, id: string, credential: RotateCredential, input?: CredentialWriteInput, options?: RowOperationOptions): Promise<{
|
|
117
133
|
id: string;
|
|
118
134
|
credential: StoredCredential;
|
|
119
135
|
}>;
|
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": {
|