@cortexkit/common-auth 0.4.2 → 0.4.4
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/store/attribution.js +2 -1
- package/dist/store/errors.d.ts +1 -1
- package/dist/store/index.d.ts +1 -1
- package/dist/store/mutate.d.ts +9 -2
- package/dist/store/mutate.js +10 -5
- package/dist/store/pool.d.ts +13 -0
- package/dist/store/pool.js +1 -0
- package/dist/store/pull.d.ts +1 -1
- package/dist/store/pull.js +9 -0
- package/dist/store/refresh.js +16 -4
- package/dist/store/rows.d.ts +8 -4
- package/dist/store/rows.js +77 -7
- package/dist/store/runtime.d.ts +8 -0
- package/dist/store/runtime.js +11 -0
- package/dist/store/schema.d.ts +80 -9
- package/dist/store/schema.js +118 -6
- package/dist/store/torn.d.ts +28 -9
- package/dist/store/torn.js +81 -29
- package/package.json +1 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { PoolOperationError } from './errors.js';
|
|
2
2
|
import { toFailure, withTransaction } from './mutate.js';
|
|
3
3
|
import { LockStack } from './refresh-lock.js';
|
|
4
|
-
import { refusal, unknownRow } from './runtime.js';
|
|
4
|
+
import { refusal, requireBound, unknownRow, } from './runtime.js';
|
|
5
5
|
import { isCredentialEpoch } from './schema.js';
|
|
6
6
|
/**
|
|
7
7
|
* Merges a quota observation into a row's stored map under the store locks,
|
|
@@ -26,6 +26,7 @@ export async function recordQuota(rt, id, attribution, observation) {
|
|
|
26
26
|
// has no such triple on disk, so no reading is recorded for it.
|
|
27
27
|
if (!row.credential)
|
|
28
28
|
throw refusal('pull', id, 'no-credential', `row ${id} holds no credential`);
|
|
29
|
+
requireBound('pull', row);
|
|
29
30
|
const entry = tx.entry(id);
|
|
30
31
|
if (row.torn ||
|
|
31
32
|
!entry ||
|
package/dist/store/errors.d.ts
CHANGED
|
@@ -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' | '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' | '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
|
@@ -12,7 +12,7 @@ export type { LockEvent, PoolLockOptions, PoolLockSpec, } from './refresh-lock.j
|
|
|
12
12
|
export { POOL_LOCK_DEFAULTS } from './refresh-lock.js';
|
|
13
13
|
export type { AddInput, AddResult, FailureHook, RemoveOptions, RemoveResult, RemoveView, ReorderOptions, ReorderResult, RowOperationOptions, RowToggleOptions, } from './rows.js';
|
|
14
14
|
export type { PullReason } from './runtime.js';
|
|
15
|
-
export type { ApiKeyCredential, OAuthCredential, PoolCredential, PoolRow, QuotaCodec, RotateCredential, StoredCredential, } from './schema.js';
|
|
15
|
+
export type { ApiKeyCredential, CredentialStampStatus, OAuthCredential, PoolCredential, PoolRow, QuotaCodec, RotateCredential, StoredCredential, } from './schema.js';
|
|
16
16
|
export { fingerprintOf, LEGACY_STORE_VERSION, POOL_KEY, POOL_ROWS_KEY, POOL_SCHEMA_VERSION, REFRESH_STAMP_TOLERANCE_MS, rowLockKey, } from './schema.js';
|
|
17
17
|
export type { PoolSettings, SettingsMutator, SettingsRead, UpdateSettingsOptions, UpdateSettingsResult, } from './settings.js';
|
|
18
18
|
export { POOL_OWNED_KEYS } from './settings.js';
|
package/dist/store/mutate.d.ts
CHANGED
|
@@ -23,6 +23,12 @@ export interface StoreContext {
|
|
|
23
23
|
hold?: (point: HoldPoint, rowId: string) => void | Promise<void>;
|
|
24
24
|
/** Ids whose per-row entry a library write dropped in this process. */
|
|
25
25
|
removedIds: Set<string>;
|
|
26
|
+
/**
|
|
27
|
+
* When true, rows whose credential stamp is not bound load `unbound` and
|
|
28
|
+
* the operations that use a credential refuse them (see
|
|
29
|
+
* `OpenPoolStoreOptions.requireCredentialStamps`).
|
|
30
|
+
*/
|
|
31
|
+
requireCredentialStamps?: boolean;
|
|
26
32
|
}
|
|
27
33
|
export interface Snapshot {
|
|
28
34
|
configExists: boolean;
|
|
@@ -94,8 +100,9 @@ export declare class Transaction {
|
|
|
94
100
|
entry(id: string): Record<string, unknown> | undefined;
|
|
95
101
|
setEntry(id: string, entry: Record<string, unknown>): void;
|
|
96
102
|
/**
|
|
97
|
-
* Writes the config of every row torn between the writes of a replace
|
|
98
|
-
*
|
|
103
|
+
* Writes the config of every row torn between the writes of a replace, or
|
|
104
|
+
* of a write giving it its first identity, as that write would have left it
|
|
105
|
+
* (see `completeTornRows`), in one config
|
|
99
106
|
* write ahead of the operation's own. The write is counted apart from the
|
|
100
107
|
* operation's: it is setup, like a pull giving a row its entry, so a later
|
|
101
108
|
* refusal still reports `before-first-write`.
|
package/dist/store/mutate.js
CHANGED
|
@@ -39,7 +39,9 @@ export async function readPool(ctx) {
|
|
|
39
39
|
stateExists: state.exists,
|
|
40
40
|
config: config.config,
|
|
41
41
|
state: state.state,
|
|
42
|
-
rows: loadRows(config.config, state.state, ctx.codec
|
|
42
|
+
rows: loadRows(config.config, state.state, ctx.codec, {
|
|
43
|
+
requireCredentialStamps: ctx.requireCredentialStamps === true,
|
|
44
|
+
}),
|
|
43
45
|
};
|
|
44
46
|
}
|
|
45
47
|
/** The refusal for a pool that is not ready, as a failure value. */
|
|
@@ -86,7 +88,9 @@ export class Transaction {
|
|
|
86
88
|
* `PoolRow.torn`).
|
|
87
89
|
*/
|
|
88
90
|
rows() {
|
|
89
|
-
return loadRows(this.config, this.state, this.ctx.codec
|
|
91
|
+
return loadRows(this.config, this.state, this.ctx.codec, {
|
|
92
|
+
requireCredentialStamps: this.ctx.requireCredentialStamps === true,
|
|
93
|
+
});
|
|
90
94
|
}
|
|
91
95
|
row(id) {
|
|
92
96
|
return this.rows().find((row) => row.id === id);
|
|
@@ -123,14 +127,15 @@ export class Transaction {
|
|
|
123
127
|
setEntryIn(this.config, id, entry);
|
|
124
128
|
}
|
|
125
129
|
/**
|
|
126
|
-
* Writes the config of every row torn between the writes of a replace
|
|
127
|
-
*
|
|
130
|
+
* Writes the config of every row torn between the writes of a replace, or
|
|
131
|
+
* of a write giving it its first identity, as that write would have left it
|
|
132
|
+
* (see `completeTornRows`), in one config
|
|
128
133
|
* write ahead of the operation's own. The write is counted apart from the
|
|
129
134
|
* operation's: it is setup, like a pull giving a row its entry, so a later
|
|
130
135
|
* refusal still reports `before-first-write`.
|
|
131
136
|
*/
|
|
132
137
|
async completeTorn() {
|
|
133
|
-
const { config, torn } = completeTornRows(this.config, this.state, this.ctx.codec);
|
|
138
|
+
const { config, torn } = completeTornRows(this.config, this.state, this.ctx.codec, { requireCredentialStamps: this.ctx.requireCredentialStamps === true });
|
|
134
139
|
if (torn.length === 0)
|
|
135
140
|
return;
|
|
136
141
|
this.config = config;
|
package/dist/store/pool.d.ts
CHANGED
|
@@ -14,6 +14,19 @@ export interface OpenPoolStoreOptions {
|
|
|
14
14
|
configPath: string;
|
|
15
15
|
statePath: string;
|
|
16
16
|
quota: QuotaCodec;
|
|
17
|
+
/**
|
|
18
|
+
* Refuse every credential this store did not stamp (default false, which
|
|
19
|
+
* loads unstamped and mis-stamped credentials as older writers left them).
|
|
20
|
+
* When true, a row whose `stamp` is not `bound` loads `unbound` and is no
|
|
21
|
+
* candidate, and every operation that would use or keep its credential or
|
|
22
|
+
* what was observed about it (`refresh`, quota pulls, `recordQuota`,
|
|
23
|
+
* `recordIdentity`, `rotate`, and an `add` of the same secret or onto the
|
|
24
|
+
* same credential-less row) refuses with `unbound-credential` before any
|
|
25
|
+
* provider call or write, checked again under the locks and at commit.
|
|
26
|
+
* Nothing makes such a row bound except `replace`, which starts a new
|
|
27
|
+
* credential epoch and drops what was observed about the old one.
|
|
28
|
+
*/
|
|
29
|
+
requireCredentialStamps?: boolean;
|
|
17
30
|
/** Injected clock for leases, refresh stamps and `addedAt`. */
|
|
18
31
|
now?: () => number;
|
|
19
32
|
/**
|
package/dist/store/pool.js
CHANGED
|
@@ -59,6 +59,7 @@ export function openPoolStore(options) {
|
|
|
59
59
|
...(options.onLockStep ? { onLockStep: options.onLockStep } : {}),
|
|
60
60
|
},
|
|
61
61
|
removedIds: memory.removedIds,
|
|
62
|
+
requireCredentialStamps: options.requireCredentialStamps === true,
|
|
62
63
|
...(options.logger ? { logger: options.logger } : {}),
|
|
63
64
|
...(options.onStep ? { onStep: options.onStep } : {}),
|
|
64
65
|
...(options.hold ? { hold: options.hold } : {}),
|
package/dist/store/pull.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { PoolOperationError } from './errors.js';
|
|
2
2
|
import type { PoolLogger } from './hooks.js';
|
|
3
|
-
import type
|
|
3
|
+
import { type PullReason, type StoreRuntime } from './runtime.js';
|
|
4
4
|
import type { StoredCredential } from './schema.js';
|
|
5
5
|
/** What a pull is issued with: the credential and its attribution tuple. */
|
|
6
6
|
export interface PullRequest {
|
package/dist/store/pull.js
CHANGED
|
@@ -2,6 +2,7 @@ import { recordQuota } from './attribution.js';
|
|
|
2
2
|
import { PoolOperationError } from './errors.js';
|
|
3
3
|
import { toFailure, withTransaction } from './mutate.js';
|
|
4
4
|
import { LockStack } from './refresh-lock.js';
|
|
5
|
+
import { requireBound } from './runtime.js';
|
|
5
6
|
/**
|
|
6
7
|
* Fires quota pulls without ever making a caller wait for one. A pull first
|
|
7
8
|
* gives a row without a per-row entry its entry at epoch 1 (its own locked
|
|
@@ -50,6 +51,14 @@ export class PullScheduler {
|
|
|
50
51
|
try {
|
|
51
52
|
const request = await withTransaction(ctx, locks, progress, { operation: 'pull', rowId: id }, async (tx) => {
|
|
52
53
|
const row = tx.row(id);
|
|
54
|
+
// A row that would pull but for its unbound credential refuses, so
|
|
55
|
+
// the failure hook hears of it instead of the pull vanishing.
|
|
56
|
+
if (row?.unbound &&
|
|
57
|
+
row.enabled &&
|
|
58
|
+
row.type === 'oauth' &&
|
|
59
|
+
row.credential &&
|
|
60
|
+
!row.invalid)
|
|
61
|
+
requireBound('pull', row);
|
|
53
62
|
// Disabled, API-key and credential-less rows never pull.
|
|
54
63
|
if (!row?.candidate || row.type !== 'oauth')
|
|
55
64
|
return undefined;
|
package/dist/store/refresh.js
CHANGED
|
@@ -3,7 +3,7 @@ import { assertNotInsideHook, runInsideHook } from './hooks.js';
|
|
|
3
3
|
import { recordIdentityIn } from './identity.js';
|
|
4
4
|
import { runOperation, withTransaction } from './mutate.js';
|
|
5
5
|
import { rotateIn } from './rows.js';
|
|
6
|
-
import { readRow, refusal, rowLockSpec } from './runtime.js';
|
|
6
|
+
import { readRow, refusal, requireBound, rowLockSpec, } from './runtime.js';
|
|
7
7
|
import { rotationStamp, rotationStampUntrusted, rowLockKey, } from './schema.js';
|
|
8
8
|
function requireRefreshable(id, row) {
|
|
9
9
|
if (!row)
|
|
@@ -47,6 +47,7 @@ export async function refreshRow(rt, id, provider, options = {}) {
|
|
|
47
47
|
const captureProgress = { writes: 0 };
|
|
48
48
|
const read = await withTransaction(ctx, locks, captureProgress, { operation: 'refresh', rowId: id }, async (tx) => {
|
|
49
49
|
let row = requireRefreshable(id, tx.row(id));
|
|
50
|
+
requireBound('refresh', row);
|
|
50
51
|
if (!row.hasEntry) {
|
|
51
52
|
tx.setEntry(id, { credentialEpoch: 1, needsFirstReading: true });
|
|
52
53
|
await tx.commitConfig();
|
|
@@ -113,6 +114,10 @@ export async function refreshRow(rt, id, provider, options = {}) {
|
|
|
113
114
|
kind: 'attribution',
|
|
114
115
|
message: `row ${id} changed credential while its refresh was in flight; the rotation is discarded`,
|
|
115
116
|
});
|
|
117
|
+
// The epoch fence above does not see a credential another writer
|
|
118
|
+
// swapped in under the same epoch; the stamp does, and committing
|
|
119
|
+
// the rotation would stamp the swapped row as bound.
|
|
120
|
+
requireBound('refresh', current);
|
|
116
121
|
const reason = await options.refuse?.(current);
|
|
117
122
|
if (reason !== undefined)
|
|
118
123
|
return { refused: reason };
|
|
@@ -128,13 +133,20 @@ export async function refreshRow(rt, id, provider, options = {}) {
|
|
|
128
133
|
refresh: result.refresh,
|
|
129
134
|
expires: result.expires,
|
|
130
135
|
};
|
|
136
|
+
const learnt = current.identity === undefined && result.identity
|
|
137
|
+
? result.identity
|
|
138
|
+
: undefined;
|
|
139
|
+
// The rotated credential's stamp names a learnt identity before the
|
|
140
|
+
// config records it, so a crash between the two writes is completed
|
|
141
|
+
// forward rather than leaving an identity no stamp proves.
|
|
131
142
|
const stored = await rotateIn(rt, tx, id, credential, {
|
|
132
143
|
stamp: rotationStamp(prior, now),
|
|
144
|
+
identity: learnt,
|
|
133
145
|
});
|
|
134
146
|
let identity = current.identity;
|
|
135
|
-
if (
|
|
136
|
-
recordIdentityIn(tx, id,
|
|
137
|
-
identity =
|
|
147
|
+
if (learnt !== undefined) {
|
|
148
|
+
recordIdentityIn(tx, id, learnt);
|
|
149
|
+
identity = learnt;
|
|
138
150
|
await tx.commitConfig();
|
|
139
151
|
}
|
|
140
152
|
return { stored, identity, refused: undefined };
|
package/dist/store/rows.d.ts
CHANGED
|
@@ -89,15 +89,19 @@ export type AddResult = {
|
|
|
89
89
|
};
|
|
90
90
|
/**
|
|
91
91
|
* Writes a credential into the state file (one write), stamped with the
|
|
92
|
-
* credential epoch the row's entry holds in `tx` (1 without an entry) and
|
|
93
|
-
* for a replace, the
|
|
94
|
-
*
|
|
95
|
-
*
|
|
92
|
+
* credential epoch the row's entry holds in `tx` (1 without an entry) and a
|
|
93
|
+
* binding: for a replace, the one the config is about to get (and the stamp
|
|
94
|
+
* is marked as a replace's); for every other write, the row's config as it
|
|
95
|
+
* stands in `tx` with the identity the operation is about to record
|
|
96
|
+
* (`identity`, see `bindingInTx`). A rotation is the same lineage: no epoch
|
|
97
|
+
* bump, no identity or quota change. An API key must belong to the endpoint
|
|
98
|
+
* the row holds in `tx` (see `onRowEndpoint`).
|
|
96
99
|
*/
|
|
97
100
|
export declare function rotateIn(rt: StoreRuntime, tx: Transaction, id: string, given: RotateCredential, extra?: {
|
|
98
101
|
stamp?: number;
|
|
99
102
|
clearErrors?: boolean;
|
|
100
103
|
binding?: CredentialBinding;
|
|
104
|
+
identity?: string;
|
|
101
105
|
}): Promise<StoredCredential>;
|
|
102
106
|
export declare function addRow(rt: StoreRuntime, input: AddInput, options?: RowOperationOptions): Promise<AddResult>;
|
|
103
107
|
export declare function replaceRow(rt: StoreRuntime, id: string, credential: PoolCredential, input?: {
|
package/dist/store/rows.js
CHANGED
|
@@ -2,7 +2,7 @@ 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 { readRow, refusal, rowLockSpec, unknownRow, } from './runtime.js';
|
|
5
|
+
import { readRow, refusal, requireBound, rowLockSpec, unknownRow, } from './runtime.js';
|
|
6
6
|
import { CREDENTIAL_STAMP_KEY, credentialProblem, fingerprintOf, idProblem, isCredentialEpoch, isRecord, rosterRowFor, rotationStamp, rowLockKey, stampFor, stateFieldsFor, storedCredential, } from './schema.js';
|
|
7
7
|
import { bindReplacement } from './torn.js';
|
|
8
8
|
/** Fields of a state entry that belong to the credential it replaces. */
|
|
@@ -50,12 +50,42 @@ function onRowEndpoint(tx, id, credential) {
|
|
|
50
50
|
throw refusal(tx.info.operation, id, 'endpoint-mismatch', `row ${id} sends its key to another endpoint or header; a key for another endpoint is a replacement`);
|
|
51
51
|
return { ...credential, baseURL, authHeader };
|
|
52
52
|
}
|
|
53
|
+
/**
|
|
54
|
+
* The binding of a credential written into a row without a replace: the
|
|
55
|
+
* config as it stands in `tx`, plus the identity the same operation is about
|
|
56
|
+
* to record (`learnt`), if any. The identity is the roster row's recorded
|
|
57
|
+
* one, read the way `buildRawRows` reads it. A learnt identity is stamped
|
|
58
|
+
* before the config records it, so a crash between the two writes leaves a
|
|
59
|
+
* stamp naming an identity the config lacks, which every reader completes
|
|
60
|
+
* forward (see `torn.ts`); the reverse order would leave a config identity
|
|
61
|
+
* no stamp proves. An API key's endpoint is the one it was checked against
|
|
62
|
+
* (see `onRowEndpoint`).
|
|
63
|
+
*/
|
|
64
|
+
function bindingInTx(tx, id, stored, learnt) {
|
|
65
|
+
const raw = tx.rosterRow(id);
|
|
66
|
+
const identity = learnt ??
|
|
67
|
+
(isRecord(raw) && typeof raw.accountId === 'string' && raw.accountId
|
|
68
|
+
? raw.accountId
|
|
69
|
+
: undefined);
|
|
70
|
+
return {
|
|
71
|
+
...(identity !== undefined ? { identity } : {}),
|
|
72
|
+
...(stored.type === 'api'
|
|
73
|
+
? {
|
|
74
|
+
baseURL: stored.baseURL,
|
|
75
|
+
authHeader: stored.authHeader ?? 'authorization-bearer',
|
|
76
|
+
}
|
|
77
|
+
: {}),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
53
80
|
/**
|
|
54
81
|
* Writes a credential into the state file (one write), stamped with the
|
|
55
|
-
* credential epoch the row's entry holds in `tx` (1 without an entry) and
|
|
56
|
-
* for a replace, the
|
|
57
|
-
*
|
|
58
|
-
*
|
|
82
|
+
* credential epoch the row's entry holds in `tx` (1 without an entry) and a
|
|
83
|
+
* binding: for a replace, the one the config is about to get (and the stamp
|
|
84
|
+
* is marked as a replace's); for every other write, the row's config as it
|
|
85
|
+
* stands in `tx` with the identity the operation is about to record
|
|
86
|
+
* (`identity`, see `bindingInTx`). A rotation is the same lineage: no epoch
|
|
87
|
+
* bump, no identity or quota change. An API key must belong to the endpoint
|
|
88
|
+
* the row holds in `tx` (see `onRowEndpoint`).
|
|
59
89
|
*/
|
|
60
90
|
export async function rotateIn(rt, tx, id, given, extra = {}) {
|
|
61
91
|
const credential = onRowEndpoint(tx, id, given);
|
|
@@ -79,11 +109,29 @@ export async function rotateIn(rt, tx, id, given, extra = {}) {
|
|
|
79
109
|
tx.setStateAccount(id, {
|
|
80
110
|
...kept,
|
|
81
111
|
...stateFieldsFor(credential, stamp),
|
|
82
|
-
[CREDENTIAL_STAMP_KEY]: stampFor(stored, typeof epoch === 'number' ? epoch : 1, extra.binding),
|
|
112
|
+
[CREDENTIAL_STAMP_KEY]: stampFor(stored, typeof epoch === 'number' ? epoch : 1, extra.binding ?? bindingInTx(tx, id, stored, extra.identity), { replace: extra.binding !== undefined }),
|
|
83
113
|
});
|
|
84
114
|
await tx.commitState(stored);
|
|
85
115
|
return stored;
|
|
86
116
|
}
|
|
117
|
+
/**
|
|
118
|
+
* Restamps a bound row's credential, unchanged, so its stamp names the
|
|
119
|
+
* identity the caller is about to record in the config (same epoch, same
|
|
120
|
+
* credential, one state write). Written before the config for the reason
|
|
121
|
+
* `bindingInTx` gives. A row whose stamp is not bound (possible only without
|
|
122
|
+
* `requireCredentialStamps`) gets no new stamp, because a fresh stamp would
|
|
123
|
+
* vouch for a credential this store never proved: its identity is recorded in
|
|
124
|
+
* the config only, and the row keeps the stamp status it had.
|
|
125
|
+
*/
|
|
126
|
+
async function stampIdentityIn(tx, row, identity) {
|
|
127
|
+
if (!row.credential || row.stamp !== 'bound')
|
|
128
|
+
return;
|
|
129
|
+
tx.setStateAccount(row.id, {
|
|
130
|
+
...(tx.stateAccount(row.id) ?? {}),
|
|
131
|
+
[CREDENTIAL_STAMP_KEY]: stampFor(row.credential, row.credentialEpoch ?? 1, bindingInTx(tx, row.id, row.credential, identity)),
|
|
132
|
+
});
|
|
133
|
+
await tx.commitState();
|
|
134
|
+
}
|
|
87
135
|
function checkInput(operation, id, credential) {
|
|
88
136
|
const idIssue = operation === 'add' ? idProblem(id) : undefined;
|
|
89
137
|
if (idIssue)
|
|
@@ -129,6 +177,11 @@ export async function addRow(rt, input, options = {}) {
|
|
|
129
177
|
const fingerprint = fingerprintOf(credential);
|
|
130
178
|
const same = rows.find((row) => row.invalid === undefined && row.fingerprint === fingerprint);
|
|
131
179
|
if (same) {
|
|
180
|
+
// Re-adding a secret whose stamp is not proved would rotate it in
|
|
181
|
+
// and keep the identity and quota recorded beside it, making that
|
|
182
|
+
// unproved record look bound. The add is refused instead, and the
|
|
183
|
+
// caller replaces the row, which starts a new credential epoch.
|
|
184
|
+
requireBound('add', same);
|
|
132
185
|
// The same secret is the same credential, so re-adding it rotates
|
|
133
186
|
// that row. An identity or endpoint given with it must match the
|
|
134
187
|
// row's; a different one is refused rather than silently replaced
|
|
@@ -146,6 +199,10 @@ export async function addRow(rt, input, options = {}) {
|
|
|
146
199
|
throw refusal('add', id, 'invalid-row', `row ${id} is invalid`);
|
|
147
200
|
if (existing.credential)
|
|
148
201
|
throw refusal('add', id, 'id-exists', `row ${id} already holds a credential`);
|
|
202
|
+
// Completing a credential-less row keeps its epoch, identity and
|
|
203
|
+
// quota; when stamps are required those belong to no proved
|
|
204
|
+
// credential, so the row must be replaced instead.
|
|
205
|
+
requireBound('add', existing);
|
|
149
206
|
if (existing.type !== credential.type)
|
|
150
207
|
throw refusal('add', id, 'type-mismatch', `row ${id} is a ${existing.type} row`);
|
|
151
208
|
if (identity !== undefined &&
|
|
@@ -275,6 +332,9 @@ export async function rotateRow(rt, id, credential, input = {}, options = {}) {
|
|
|
275
332
|
const row = requireUsableRow('rotate', id, tx.row(id), credential);
|
|
276
333
|
if (rowLockKey(row) !== rowLockKey(seen))
|
|
277
334
|
throw keyChanged('rotate', id);
|
|
335
|
+
// A rotation keeps the row's epoch, identity and quota, so it must
|
|
336
|
+
// never be what stamps an unproved row as bound.
|
|
337
|
+
requireBound('rotate', row);
|
|
278
338
|
// A rotation stays with one account: it may record the first
|
|
279
339
|
// identity the row learns, but a credential of another known
|
|
280
340
|
// account is a replacement (new epoch, quota and errors dropped).
|
|
@@ -282,7 +342,12 @@ export async function rotateRow(rt, id, credential, input = {}, options = {}) {
|
|
|
282
342
|
row.identity !== undefined &&
|
|
283
343
|
input.identity !== row.identity)
|
|
284
344
|
throw identityMismatch('rotate', id);
|
|
285
|
-
const
|
|
345
|
+
const learnt = input.identity !== undefined && row.identity === undefined
|
|
346
|
+
? input.identity
|
|
347
|
+
: undefined;
|
|
348
|
+
const stored = await rotateIn(rt, tx, id, credential, {
|
|
349
|
+
identity: learnt,
|
|
350
|
+
});
|
|
286
351
|
let configChanged = false;
|
|
287
352
|
if (!row.hasEntry) {
|
|
288
353
|
tx.setEntry(id, {
|
|
@@ -553,10 +618,15 @@ export async function recordRowIdentity(rt, id, identity, attribution, options =
|
|
|
553
618
|
const row = requireUsableRow('recordIdentity', id, tx.row(id));
|
|
554
619
|
if (rowLockKey(row) !== rowLockKey(seen))
|
|
555
620
|
throw keyChanged('recordIdentity', id);
|
|
621
|
+
requireBound('recordIdentity', row);
|
|
556
622
|
if ((row.credentialEpoch ?? 1) !== captured)
|
|
557
623
|
throw refusal('recordIdentity', id, 'attribution', `the identity for ${id} was looked up for a credential the row no longer holds`, true);
|
|
558
624
|
if (row.identity !== undefined && row.identity !== identity)
|
|
559
625
|
throw identityMismatch('recordIdentity', id);
|
|
626
|
+
// The stamp names the identity first; a crash before the config
|
|
627
|
+
// write leaves a row every reader completes forward.
|
|
628
|
+
if (row.identity === undefined)
|
|
629
|
+
await stampIdentityIn(tx, row, identity);
|
|
560
630
|
const disabled = recordIdentityIn(tx, id, identity);
|
|
561
631
|
await tx.commitConfig();
|
|
562
632
|
return { id, disabled };
|
package/dist/store/runtime.d.ts
CHANGED
|
@@ -25,4 +25,12 @@ export declare function readRow(rt: StoreRuntime, operation: PoolOperation, id:
|
|
|
25
25
|
row: PoolRow;
|
|
26
26
|
}>;
|
|
27
27
|
export declare function unknownRow(operation: PoolOperation, id: string): PoolOperationError;
|
|
28
|
+
/**
|
|
29
|
+
* Refuses a row the store was told to distrust: opened with
|
|
30
|
+
* `requireCredentialStamps`, a row whose credential stamp is not bound loads
|
|
31
|
+
* `unbound` (see `PoolRow.unbound`). Called on every locked read of the row
|
|
32
|
+
* an operation acts on, before it calls a provider or writes, so a credential
|
|
33
|
+
* another writer swapped in while the operation waited is refused too.
|
|
34
|
+
*/
|
|
35
|
+
export declare function requireBound(operation: PoolOperation, row: PoolRow): void;
|
|
28
36
|
export declare function refusal(operation: PoolOperation, id: string, kind: PoolOperationError['kind'], message: string, retryable?: boolean): PoolOperationError;
|
package/dist/store/runtime.js
CHANGED
|
@@ -33,6 +33,17 @@ export function unknownRow(operation, id) {
|
|
|
33
33
|
message: `no row ${id} in the pool`,
|
|
34
34
|
});
|
|
35
35
|
}
|
|
36
|
+
/**
|
|
37
|
+
* Refuses a row the store was told to distrust: opened with
|
|
38
|
+
* `requireCredentialStamps`, a row whose credential stamp is not bound loads
|
|
39
|
+
* `unbound` (see `PoolRow.unbound`). Called on every locked read of the row
|
|
40
|
+
* an operation acts on, before it calls a provider or writes, so a credential
|
|
41
|
+
* another writer swapped in while the operation waited is refused too.
|
|
42
|
+
*/
|
|
43
|
+
export function requireBound(operation, row) {
|
|
44
|
+
if (row.unbound)
|
|
45
|
+
throw refusal(operation, row.id, 'unbound-credential', `row ${row.id}'s credential is not the one this store stamped for it (stamp ${row.stamp}); replace it with fresh material`);
|
|
46
|
+
}
|
|
36
47
|
export function refusal(operation, id, kind, message, retryable = false) {
|
|
37
48
|
return new PoolOperationError({
|
|
38
49
|
operation,
|
package/dist/store/schema.d.ts
CHANGED
|
@@ -42,6 +42,27 @@ export interface QuotaCodec {
|
|
|
42
42
|
validate(value: unknown): boolean;
|
|
43
43
|
merge(stored: unknown | undefined, observation: unknown): unknown;
|
|
44
44
|
}
|
|
45
|
+
/**
|
|
46
|
+
* What the stamp beside a row's credential proves (see `CredentialStamp`).
|
|
47
|
+
*
|
|
48
|
+
* `none`: the row holds no credential, so there is nothing to stamp.
|
|
49
|
+
* `bound`: the stamp was written with this credential (its digest matches),
|
|
50
|
+
* names the row's credential epoch (1 for a row without a per-row entry),
|
|
51
|
+
* the identity it records (or its recording none) and any endpoint it
|
|
52
|
+
* records are the row's, and its dispatch digest matches everything a
|
|
53
|
+
* request would send (the OAuth access and refresh tokens and expiry; the API
|
|
54
|
+
* key with its `baseURL` and `authHeader`).
|
|
55
|
+
* `missing`: the credential carries no stamp (written by a writer that does
|
|
56
|
+
* not know about stamps, or one that dropped it).
|
|
57
|
+
* `malformed`: the stamp is not one this store writes (wrong shape, or an
|
|
58
|
+
* epoch outside the positive safe integers).
|
|
59
|
+
* `mismatched`: the stamp was written for another credential, epoch,
|
|
60
|
+
* identity, endpoint, or token to send than the row now holds.
|
|
61
|
+
* `legacy`: a well-formed stamp written with this credential's secret but
|
|
62
|
+
* with no dispatch digest (written by 0.4.3 or earlier), so it proves
|
|
63
|
+
* neither the token sent nor the account or endpoint it goes to.
|
|
64
|
+
*/
|
|
65
|
+
export type CredentialStampStatus = 'none' | 'bound' | 'missing' | 'malformed' | 'mismatched' | 'legacy';
|
|
45
66
|
/** One row of the pool as loaded. */
|
|
46
67
|
export interface PoolRow {
|
|
47
68
|
id: string;
|
|
@@ -69,11 +90,29 @@ export interface PoolRow {
|
|
|
69
90
|
/**
|
|
70
91
|
* Set when a replace stopped between its two writes: the state file holds
|
|
71
92
|
* the new credential, stamped with the epoch and the identity or endpoint
|
|
72
|
-
* it belongs to, and the config still holds the replaced row.
|
|
73
|
-
*
|
|
74
|
-
*
|
|
93
|
+
* it belongs to, and the config still holds the replaced row. Also set when
|
|
94
|
+
* a write that gives a row its first identity (`recordIdentity`, or a
|
|
95
|
+
* `rotate` or refresh that learns one) stopped after stamping the identity
|
|
96
|
+
* and before recording it in the config. The row is shown as the write
|
|
97
|
+
* leaves it once completed, is never a candidate, and the next store write
|
|
98
|
+
* on it writes the config to match.
|
|
75
99
|
*/
|
|
76
100
|
torn?: true;
|
|
101
|
+
/**
|
|
102
|
+
* Whether the credential is the one the store last stamped for this row
|
|
103
|
+
* (see `CredentialStampStatus`). Every row the store loads carries it; a
|
|
104
|
+
* torn row reports the stamp of the row as it is shown completed. It is
|
|
105
|
+
* optional only so that rows built by hand (test fixtures) still type.
|
|
106
|
+
*/
|
|
107
|
+
stamp?: CredentialStampStatus;
|
|
108
|
+
/**
|
|
109
|
+
* Set only when the store was opened with `requireCredentialStamps` and
|
|
110
|
+
* the row's `stamp` is not `bound`: the row is never a candidate, and
|
|
111
|
+
* `refresh`, quota pulls, `recordQuota`, `recordIdentity`, `rotate` and a
|
|
112
|
+
* re-`add` onto it refuse with `unbound-credential`. Only `replace` (or a
|
|
113
|
+
* new row) makes it usable again.
|
|
114
|
+
*/
|
|
115
|
+
unbound?: true;
|
|
77
116
|
}
|
|
78
117
|
/**
|
|
79
118
|
* Key, inside a state-file account entry, of the stamp naming what the
|
|
@@ -81,8 +120,9 @@ export interface PoolRow {
|
|
|
81
120
|
*/
|
|
82
121
|
export declare const CREDENTIAL_STAMP_KEY = "commonAuthPool";
|
|
83
122
|
/**
|
|
84
|
-
*
|
|
85
|
-
* belongs to (absent: none is known) and, for an API key, its
|
|
123
|
+
* What the config holds for a credential when its stamp is written: the
|
|
124
|
+
* identity it belongs to (absent: none is known yet) and, for an API key, its
|
|
125
|
+
* endpoint. For a replace it is the config the replace is about to write.
|
|
86
126
|
*/
|
|
87
127
|
export interface CredentialBinding {
|
|
88
128
|
identity?: string;
|
|
@@ -93,14 +133,27 @@ export interface CredentialBinding {
|
|
|
93
133
|
* Written beside every credential the store puts in the state file. It names
|
|
94
134
|
* the credential epoch the credential belongs to and a digest of its secret,
|
|
95
135
|
* so a stamp left beside a credential another writer put there afterwards is
|
|
96
|
-
* recognisable and ignored.
|
|
97
|
-
*
|
|
98
|
-
*
|
|
136
|
+
* recognisable and ignored.
|
|
137
|
+
*
|
|
138
|
+
* `digest` covers only the refresh token or API key: it names the credential
|
|
139
|
+
* lineage, and torn-replace detection matches it, including against stamps
|
|
140
|
+
* older versions wrote, so it keeps that exact definition. `dispatch` covers
|
|
141
|
+
* everything a request sends (see `dispatchDigest`), so a token or endpoint
|
|
142
|
+
* changed beside an unchanged lineage secret is caught; stamps written by
|
|
143
|
+
* 0.4.3 or earlier lack it.
|
|
144
|
+
*
|
|
145
|
+
* `binding` is the config the credential was written beside (see
|
|
146
|
+
* `CredentialBinding`); every write since 0.4.4 records it, earlier versions
|
|
147
|
+
* only on replace. `replace` marks a stamp written by a replace, which is
|
|
148
|
+
* what lets a reader complete a replace that stopped after writing the
|
|
149
|
+
* credential: a stamp from any other write is never completed as torn.
|
|
99
150
|
*/
|
|
100
151
|
export interface CredentialStamp {
|
|
101
152
|
credentialEpoch: number;
|
|
102
153
|
digest: string;
|
|
154
|
+
dispatch?: string;
|
|
103
155
|
binding?: CredentialBinding;
|
|
156
|
+
replace?: true;
|
|
104
157
|
}
|
|
105
158
|
export type ConfigClassification = {
|
|
106
159
|
status: 'ready';
|
|
@@ -137,7 +190,25 @@ export declare function fingerprintOf(credential: PoolCredential | StoredCredent
|
|
|
137
190
|
* dedupe key.
|
|
138
191
|
*/
|
|
139
192
|
export declare function credentialDigest(credential: PoolCredential | StoredCredential): string;
|
|
140
|
-
|
|
193
|
+
/**
|
|
194
|
+
* The digest of everything a request made with the credential sends or is
|
|
195
|
+
* served by: the OAuth access token, refresh token and expiry (the expiry
|
|
196
|
+
* decides whether the access token is used or refreshed first), or the API
|
|
197
|
+
* key with the `baseURL` and header it is sent to. Its input prefix differs
|
|
198
|
+
* from both the fingerprint's and `credentialDigest`'s, so it never equals
|
|
199
|
+
* either. The parts are JSON-encoded as a list, so no two credentials share
|
|
200
|
+
* an input. An access token or expiry the state file cannot hold as loaded
|
|
201
|
+
* (not a string, not a finite number) counts as absent, which is how
|
|
202
|
+
* `buildRawRows` loads it back.
|
|
203
|
+
*/
|
|
204
|
+
export declare function dispatchDigest(credential: PoolCredential | StoredCredential): string;
|
|
205
|
+
/**
|
|
206
|
+
* The stamp for a credential written into a row at `credentialEpoch`, beside
|
|
207
|
+
* the config `binding` describes. `replace` is set only by a replace.
|
|
208
|
+
*/
|
|
209
|
+
export declare function stampFor(credential: PoolCredential | StoredCredential, credentialEpoch: number, binding: CredentialBinding, options?: {
|
|
210
|
+
replace?: boolean;
|
|
211
|
+
}): CredentialStamp;
|
|
141
212
|
/**
|
|
142
213
|
* A credential epoch is a positive safe integer. Above `MAX_SAFE_INTEGER`,
|
|
143
214
|
* adding one may give back the same number, so a replace would not move the
|
package/dist/store/schema.js
CHANGED
|
@@ -69,11 +69,49 @@ export function credentialDigest(credential) {
|
|
|
69
69
|
.update(`credential-stamp\0${secretOf(credential)}`)
|
|
70
70
|
.digest('hex');
|
|
71
71
|
}
|
|
72
|
-
|
|
72
|
+
/**
|
|
73
|
+
* The digest of everything a request made with the credential sends or is
|
|
74
|
+
* served by: the OAuth access token, refresh token and expiry (the expiry
|
|
75
|
+
* decides whether the access token is used or refreshed first), or the API
|
|
76
|
+
* key with the `baseURL` and header it is sent to. Its input prefix differs
|
|
77
|
+
* from both the fingerprint's and `credentialDigest`'s, so it never equals
|
|
78
|
+
* either. The parts are JSON-encoded as a list, so no two credentials share
|
|
79
|
+
* an input. An access token or expiry the state file cannot hold as loaded
|
|
80
|
+
* (not a string, not a finite number) counts as absent, which is how
|
|
81
|
+
* `buildRawRows` loads it back.
|
|
82
|
+
*/
|
|
83
|
+
export function dispatchDigest(credential) {
|
|
84
|
+
const parts = credential.type === 'oauth'
|
|
85
|
+
? [
|
|
86
|
+
'oauth',
|
|
87
|
+
typeof credential.access === 'string' ? credential.access : null,
|
|
88
|
+
credential.refresh,
|
|
89
|
+
typeof credential.expires === 'number' &&
|
|
90
|
+
Number.isFinite(credential.expires)
|
|
91
|
+
? credential.expires
|
|
92
|
+
: null,
|
|
93
|
+
]
|
|
94
|
+
: [
|
|
95
|
+
'api',
|
|
96
|
+
credential.apiKey,
|
|
97
|
+
credential.baseURL.trim(),
|
|
98
|
+
credential.authHeader ?? 'authorization-bearer',
|
|
99
|
+
];
|
|
100
|
+
return createHash('sha256')
|
|
101
|
+
.update(`credential-dispatch\0${JSON.stringify(parts)}`)
|
|
102
|
+
.digest('hex');
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* The stamp for a credential written into a row at `credentialEpoch`, beside
|
|
106
|
+
* the config `binding` describes. `replace` is set only by a replace.
|
|
107
|
+
*/
|
|
108
|
+
export function stampFor(credential, credentialEpoch, binding, options = {}) {
|
|
73
109
|
return {
|
|
74
110
|
credentialEpoch,
|
|
75
111
|
digest: credentialDigest(credential),
|
|
76
|
-
|
|
112
|
+
dispatch: dispatchDigest(credential),
|
|
113
|
+
binding: { ...binding },
|
|
114
|
+
...(options.replace ? { replace: true } : {}),
|
|
77
115
|
};
|
|
78
116
|
}
|
|
79
117
|
/**
|
|
@@ -93,8 +131,21 @@ export function parseStamp(raw) {
|
|
|
93
131
|
return undefined;
|
|
94
132
|
if (typeof raw.digest !== 'string')
|
|
95
133
|
return undefined;
|
|
134
|
+
if ('dispatch' in raw && typeof raw.dispatch !== 'string')
|
|
135
|
+
return undefined;
|
|
136
|
+
if ('replace' in raw && raw.replace !== true)
|
|
137
|
+
return undefined;
|
|
138
|
+
const marks = {
|
|
139
|
+
...(typeof raw.dispatch === 'string' ? { dispatch: raw.dispatch } : {}),
|
|
140
|
+
...(raw.replace === true ? { replace: true } : {}),
|
|
141
|
+
};
|
|
142
|
+
// Every stamp that carries a dispatch digest is written with a binding; one
|
|
143
|
+
// without is not a stamp this store writes, and it would leave the row's
|
|
144
|
+
// identity unchecked.
|
|
96
145
|
if (!('binding' in raw))
|
|
97
|
-
return
|
|
146
|
+
return 'dispatch' in raw || 'replace' in raw
|
|
147
|
+
? undefined
|
|
148
|
+
: { credentialEpoch: epoch, digest: raw.digest };
|
|
98
149
|
const binding = raw.binding;
|
|
99
150
|
if (!isRecord(binding))
|
|
100
151
|
return undefined;
|
|
@@ -110,6 +161,7 @@ export function parseStamp(raw) {
|
|
|
110
161
|
return {
|
|
111
162
|
credentialEpoch: epoch,
|
|
112
163
|
digest: raw.digest,
|
|
164
|
+
...marks,
|
|
113
165
|
binding: {
|
|
114
166
|
...(typeof binding.identity === 'string'
|
|
115
167
|
? { identity: binding.identity }
|
|
@@ -124,6 +176,61 @@ export function parseStamp(raw) {
|
|
|
124
176
|
},
|
|
125
177
|
};
|
|
126
178
|
}
|
|
179
|
+
/**
|
|
180
|
+
* Whether the binding of a stamp is the row's: the identity it names (or its
|
|
181
|
+
* naming none) must be exactly the row's recorded identity, and an endpoint
|
|
182
|
+
* it names must be the one the row sends its API key to.
|
|
183
|
+
*
|
|
184
|
+
* Every write that gives a row an identity (`add`, `replace`, `rotate` or a
|
|
185
|
+
* refresh that learns one, `recordIdentity`) writes the stamp naming it
|
|
186
|
+
* before the config, so this store never leaves a config identity beside a
|
|
187
|
+
* stamp that names none or another: that is another writer's doing and is
|
|
188
|
+
* `mismatched`. The reverse, a stamp naming an identity beside a config that
|
|
189
|
+
* has none, is such a write stopped between its two writes: the row as
|
|
190
|
+
* `buildRawRows` reads it from the files is `mismatched`, but `loadRows`,
|
|
191
|
+
* which every reader goes through, shows it with the identity recorded (see
|
|
192
|
+
* `torn.ts`), where it is `bound`.
|
|
193
|
+
*/
|
|
194
|
+
function bindingAgrees(binding, credential, identity) {
|
|
195
|
+
if (binding.identity !== identity)
|
|
196
|
+
return false;
|
|
197
|
+
if (binding.baseURL !== undefined &&
|
|
198
|
+
(credential.type !== 'api' || credential.baseURL !== binding.baseURL))
|
|
199
|
+
return false;
|
|
200
|
+
if (binding.authHeader !== undefined &&
|
|
201
|
+
(credential.type !== 'api' || credential.authHeader !== binding.authHeader))
|
|
202
|
+
return false;
|
|
203
|
+
return true;
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* The stamp status of a loaded row (see `CredentialStampStatus`).
|
|
207
|
+
* `credentialEpoch` is undefined only when the row's per-row entry exists but
|
|
208
|
+
* failed validation, in which case no stamp can match it.
|
|
209
|
+
*/
|
|
210
|
+
function stampStatusOf(credential, credentialEpoch, identity, account) {
|
|
211
|
+
if (!credential)
|
|
212
|
+
return 'none';
|
|
213
|
+
if (!isRecord(account) || !Object.hasOwn(account, CREDENTIAL_STAMP_KEY))
|
|
214
|
+
return 'missing';
|
|
215
|
+
const stamp = parseStamp(account[CREDENTIAL_STAMP_KEY]);
|
|
216
|
+
if (!stamp)
|
|
217
|
+
return 'malformed';
|
|
218
|
+
if (stamp.digest !== credentialDigest(credential))
|
|
219
|
+
return 'mismatched';
|
|
220
|
+
// A stamp from 0.4.3 or earlier proves neither the token sent nor (its
|
|
221
|
+
// binding being optional and identity-lenient) the account, so it is
|
|
222
|
+
// reported as such whatever else it says, and never as bound.
|
|
223
|
+
if (stamp.dispatch === undefined)
|
|
224
|
+
return 'legacy';
|
|
225
|
+
if (stamp.credentialEpoch !== credentialEpoch)
|
|
226
|
+
return 'mismatched';
|
|
227
|
+
// `parseStamp` refuses a stamp with a dispatch digest and no binding.
|
|
228
|
+
if (!stamp.binding || !bindingAgrees(stamp.binding, credential, identity))
|
|
229
|
+
return 'mismatched';
|
|
230
|
+
if (stamp.dispatch !== dispatchDigest(credential))
|
|
231
|
+
return 'mismatched';
|
|
232
|
+
return 'bound';
|
|
233
|
+
}
|
|
127
234
|
export function classifyConfig(read) {
|
|
128
235
|
if (!read.exists)
|
|
129
236
|
return { status: 'ready', exists: false, config: {} };
|
|
@@ -302,6 +409,7 @@ export function buildRawRows(config, state, codec) {
|
|
|
302
409
|
hasEntry: Object.hasOwn(entries, id),
|
|
303
410
|
candidate: false,
|
|
304
411
|
invalid: 'roster',
|
|
412
|
+
stamp: 'none',
|
|
305
413
|
});
|
|
306
414
|
continue;
|
|
307
415
|
}
|
|
@@ -311,6 +419,9 @@ export function buildRawRows(config, state, codec) {
|
|
|
311
419
|
const entry = hasEntry ? parseEntry(entries[id], codec) : undefined;
|
|
312
420
|
const credential = credentialFor(raw, stateAccounts[id]);
|
|
313
421
|
const enabled = raw.enabled !== false;
|
|
422
|
+
const identity = typeof raw.accountId === 'string' && raw.accountId
|
|
423
|
+
? raw.accountId
|
|
424
|
+
: undefined;
|
|
314
425
|
const row = {
|
|
315
426
|
id,
|
|
316
427
|
type,
|
|
@@ -320,9 +431,7 @@ export function buildRawRows(config, state, codec) {
|
|
|
320
431
|
candidate: false,
|
|
321
432
|
...(typeof raw.label === 'string' ? { label: raw.label } : {}),
|
|
322
433
|
...(typeof raw.addedAt === 'number' ? { addedAt: raw.addedAt } : {}),
|
|
323
|
-
...(
|
|
324
|
-
? { identity: raw.accountId }
|
|
325
|
-
: {}),
|
|
434
|
+
...(identity !== undefined ? { identity } : {}),
|
|
326
435
|
...(credential
|
|
327
436
|
? { credential, fingerprint: fingerprintOf(credential) }
|
|
328
437
|
: {}),
|
|
@@ -331,6 +440,9 @@ export function buildRawRows(config, state, codec) {
|
|
|
331
440
|
? { disabledReason: entry.disabledReason }
|
|
332
441
|
: {}),
|
|
333
442
|
...(entry && 'quota' in entry ? { quota: entry.quota } : {}),
|
|
443
|
+
// A row without a per-row entry is at credential epoch 1 (the epoch the
|
|
444
|
+
// store stamps and later gives it), so its stamp is checked against 1.
|
|
445
|
+
stamp: stampStatusOf(credential, entry ? entry.credentialEpoch : hasEntry ? undefined : 1, identity, stateAccounts[id]),
|
|
334
446
|
};
|
|
335
447
|
if (hasEntry && !entry) {
|
|
336
448
|
row.invalid = 'entry';
|
package/dist/store/torn.d.ts
CHANGED
|
@@ -1,9 +1,19 @@
|
|
|
1
1
|
import { type RowEditor } from './identity.js';
|
|
2
2
|
import { type CredentialBinding, type CredentialStamp, type PoolRow, type QuotaCodec } from './schema.js';
|
|
3
|
-
/**
|
|
4
|
-
export
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
/** How a row left between the two writes of an operation is completed. */
|
|
4
|
+
export type TornCompletion = {
|
|
5
|
+
kind: 'replace';
|
|
6
|
+
stamp: CredentialStamp & {
|
|
7
|
+
binding: CredentialBinding;
|
|
8
|
+
};
|
|
9
|
+
} | {
|
|
10
|
+
kind: 'identity';
|
|
11
|
+
identity: string;
|
|
12
|
+
};
|
|
13
|
+
/** Rows left between the two writes of an operation, by row id. */
|
|
14
|
+
export declare function tornStamps(config: Record<string, unknown>, state: Record<string, unknown>, codec: QuotaCodec, options?: {
|
|
15
|
+
requireCredentialStamps?: boolean;
|
|
16
|
+
}): Map<string, TornCompletion>;
|
|
7
17
|
/**
|
|
8
18
|
* The config half of a replacement: the row's entry moves to the new epoch,
|
|
9
19
|
* loses its quota and needs a first reading; its roster row gets the new
|
|
@@ -13,11 +23,13 @@ export declare function tornStamps(config: Record<string, unknown>, state: Recor
|
|
|
13
23
|
*/
|
|
14
24
|
export declare function bindReplacement(editor: RowEditor, id: string, credentialEpoch: number, binding: CredentialBinding): void;
|
|
15
25
|
/**
|
|
16
|
-
* The config with every torn row completed as its
|
|
17
|
-
* it, and the ids completed. The config passed in is not modified;
|
|
18
|
-
* nothing is torn it is returned as is.
|
|
26
|
+
* The config with every torn row completed as its interrupted write would
|
|
27
|
+
* have left it, and the ids completed. The config passed in is not modified;
|
|
28
|
+
* when nothing is torn it is returned as is.
|
|
19
29
|
*/
|
|
20
|
-
export declare function completeTornRows(config: Record<string, unknown>, state: Record<string, unknown>, codec: QuotaCodec
|
|
30
|
+
export declare function completeTornRows(config: Record<string, unknown>, state: Record<string, unknown>, codec: QuotaCodec, options?: {
|
|
31
|
+
requireCredentialStamps?: boolean;
|
|
32
|
+
}): {
|
|
21
33
|
config: Record<string, unknown>;
|
|
22
34
|
torn: string[];
|
|
23
35
|
};
|
|
@@ -25,5 +37,12 @@ export declare function completeTornRows(config: Record<string, unknown>, state:
|
|
|
25
37
|
* The rows every reader gets. A torn row is shown completed (the identity,
|
|
26
38
|
* endpoint and epoch its stamp names, beside the credential it stamps), is
|
|
27
39
|
* marked `torn` and is never a candidate; every other row is as on disk.
|
|
40
|
+
* With `requireCredentialStamps`, a row whose stamp is not `bound` is marked
|
|
41
|
+
* `unbound` and is never a candidate either. A torn row is shown with the
|
|
42
|
+
* stamp of the interrupted write, which binds the completed row, so it is not
|
|
43
|
+
* unbound; once a store write puts its completion on disk it is no longer
|
|
44
|
+
* torn and is a candidate again like any other row.
|
|
28
45
|
*/
|
|
29
|
-
export declare function loadRows(config: Record<string, unknown>, state: Record<string, unknown>, codec: QuotaCodec
|
|
46
|
+
export declare function loadRows(config: Record<string, unknown>, state: Record<string, unknown>, codec: QuotaCodec, options?: {
|
|
47
|
+
requireCredentialStamps?: boolean;
|
|
48
|
+
}): PoolRow[];
|
package/dist/store/torn.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { disableIdentityDuplicates } from './identity.js';
|
|
2
|
-
import { buildRawRows, CREDENTIAL_STAMP_KEY, credentialDigest, entryIn, isRecord, parseStamp, rosterRowIn, setEntryIn, } from './schema.js';
|
|
1
|
+
import { disableIdentityDuplicates, recordIdentityIn, } from './identity.js';
|
|
2
|
+
import { buildRawRows, CREDENTIAL_STAMP_KEY, credentialDigest, dispatchDigest, entryIn, isRecord, parseStamp, rosterRowIn, setEntryIn, } from './schema.js';
|
|
3
3
|
/*
|
|
4
4
|
* A replace changes both files: the state file gets the new credential and
|
|
5
5
|
* the config gets the new epoch, identity or endpoint. It writes the state
|
|
@@ -15,11 +15,37 @@ import { buildRawRows, CREDENTIAL_STAMP_KEY, credentialDigest, entryIn, isRecord
|
|
|
15
15
|
* A stamp counts only beside the credential it was written with (its digest
|
|
16
16
|
* matches): a writer that does not know about stamps may put another
|
|
17
17
|
* credential beside an old one, and that stamp then says nothing. A stamp at
|
|
18
|
-
* or behind the config's epoch is never torn: this store's own
|
|
19
|
-
* the state file ahead of the config, never behind it.
|
|
18
|
+
* or behind the config's epoch is never torn as a replace: this store's own
|
|
19
|
+
* writes leave the state file ahead of the config, never behind it.
|
|
20
|
+
*
|
|
21
|
+
* Only a replace's stamp is ever completed as a replace. Since 0.4.4 every
|
|
22
|
+
* stamp carries a binding, so a replace's says so itself (`replace: true`); a
|
|
23
|
+
* stamp written by 0.4.3 or earlier has no dispatch digest, and those
|
|
24
|
+
* versions wrote a binding only on replace, so such a stamp with a binding is
|
|
25
|
+
* a replace's. A stamp from any other write that sits ahead of the config was
|
|
26
|
+
* not left by a crash (those writes keep the row's epoch), and completing it
|
|
27
|
+
* would rewrite the row's identity and drop its quota on a foreign writer's
|
|
28
|
+
* say-so. With `requireCredentialStamps` a 0.4.3-shaped replace stamp is not
|
|
29
|
+
* completed either: it proves nothing about the token sent, so the strict
|
|
30
|
+
* store leaves the row as it is on disk (`legacy`, unbound) rather than act
|
|
31
|
+
* on it; a store without the option completes it as before.
|
|
32
|
+
*
|
|
33
|
+
* A write that gives a row its first identity (`recordIdentity`, or a
|
|
34
|
+
* `rotate` or refresh that learns one) follows the same order: the stamp
|
|
35
|
+
* naming the identity first, then the config recording it. A crash between
|
|
36
|
+
* leaves a stamp at the row's epoch, matching the credential beside it
|
|
37
|
+
* exactly (digest and dispatch digest), that names an identity the config
|
|
38
|
+
* does not record; that is completed forward the same way, by recording the
|
|
39
|
+
* identity.
|
|
20
40
|
*/
|
|
21
|
-
/**
|
|
22
|
-
|
|
41
|
+
/** Whether a well-formed stamp was written by a replace. */
|
|
42
|
+
function isReplaceStamp(stamp) {
|
|
43
|
+
if (!stamp.binding)
|
|
44
|
+
return false;
|
|
45
|
+
return stamp.replace === true || stamp.dispatch === undefined;
|
|
46
|
+
}
|
|
47
|
+
/** Rows left between the two writes of an operation, by row id. */
|
|
48
|
+
export function tornStamps(config, state, codec, options = {}) {
|
|
23
49
|
const accounts = isRecord(state.accounts) ? state.accounts : {};
|
|
24
50
|
const torn = new Map();
|
|
25
51
|
for (const row of buildRawRows(config, state, codec)) {
|
|
@@ -36,9 +62,24 @@ export function tornStamps(config, state, codec) {
|
|
|
36
62
|
if (stamp.digest !== credentialDigest(row.credential))
|
|
37
63
|
continue;
|
|
38
64
|
// A row without an entry is at epoch 1, as everywhere else.
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
65
|
+
const epoch = row.credentialEpoch ?? 1;
|
|
66
|
+
if (stamp.credentialEpoch > epoch) {
|
|
67
|
+
if (!isReplaceStamp(stamp))
|
|
68
|
+
continue;
|
|
69
|
+
if (options.requireCredentialStamps && stamp.dispatch === undefined)
|
|
70
|
+
continue;
|
|
71
|
+
torn.set(row.id, {
|
|
72
|
+
kind: 'replace',
|
|
73
|
+
stamp: { ...stamp, binding: stamp.binding },
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
else if (stamp.credentialEpoch === epoch &&
|
|
77
|
+
stamp.dispatch !== undefined &&
|
|
78
|
+
stamp.dispatch === dispatchDigest(row.credential) &&
|
|
79
|
+
stamp.binding.identity !== undefined &&
|
|
80
|
+
row.identity === undefined) {
|
|
81
|
+
torn.set(row.id, { kind: 'identity', identity: stamp.binding.identity });
|
|
82
|
+
}
|
|
42
83
|
}
|
|
43
84
|
return torn;
|
|
44
85
|
}
|
|
@@ -71,13 +112,13 @@ export function bindReplacement(editor, id, credentialEpoch, binding) {
|
|
|
71
112
|
raw.authHeader = binding.authHeader;
|
|
72
113
|
}
|
|
73
114
|
/**
|
|
74
|
-
* The config with every torn row completed as its
|
|
75
|
-
* it, and the ids completed. The config passed in is not modified;
|
|
76
|
-
* nothing is torn it is returned as is.
|
|
115
|
+
* The config with every torn row completed as its interrupted write would
|
|
116
|
+
* have left it, and the ids completed. The config passed in is not modified;
|
|
117
|
+
* when nothing is torn it is returned as is.
|
|
77
118
|
*/
|
|
78
|
-
export function completeTornRows(config, state, codec) {
|
|
79
|
-
const
|
|
80
|
-
if (
|
|
119
|
+
export function completeTornRows(config, state, codec, options = {}) {
|
|
120
|
+
const completions = tornStamps(config, state, codec, options);
|
|
121
|
+
if (completions.size === 0)
|
|
81
122
|
return { config, torn: [] };
|
|
82
123
|
const next = structuredClone(config);
|
|
83
124
|
const editor = {
|
|
@@ -86,28 +127,39 @@ export function completeTornRows(config, state, codec) {
|
|
|
86
127
|
entry: (id) => entryIn(next, id),
|
|
87
128
|
setEntry: (id, entry) => setEntryIn(next, id, entry),
|
|
88
129
|
};
|
|
89
|
-
for (const [id,
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
130
|
+
for (const [id, completion] of completions)
|
|
131
|
+
if (completion.kind === 'replace')
|
|
132
|
+
bindReplacement(editor, id, completion.stamp.credentialEpoch, completion.stamp.binding);
|
|
133
|
+
for (const [id, completion] of completions) {
|
|
134
|
+
if (completion.kind === 'identity')
|
|
135
|
+
recordIdentityIn(editor, id, completion.identity);
|
|
136
|
+
else if (completion.stamp.binding.identity !== undefined)
|
|
137
|
+
disableIdentityDuplicates(editor, completion.stamp.binding.identity);
|
|
138
|
+
}
|
|
139
|
+
return { config: next, torn: [...completions.keys()] };
|
|
95
140
|
}
|
|
96
141
|
/**
|
|
97
142
|
* The rows every reader gets. A torn row is shown completed (the identity,
|
|
98
143
|
* endpoint and epoch its stamp names, beside the credential it stamps), is
|
|
99
144
|
* marked `torn` and is never a candidate; every other row is as on disk.
|
|
145
|
+
* With `requireCredentialStamps`, a row whose stamp is not `bound` is marked
|
|
146
|
+
* `unbound` and is never a candidate either. A torn row is shown with the
|
|
147
|
+
* stamp of the interrupted write, which binds the completed row, so it is not
|
|
148
|
+
* unbound; once a store write puts its completion on disk it is no longer
|
|
149
|
+
* torn and is a candidate again like any other row.
|
|
100
150
|
*/
|
|
101
|
-
export function loadRows(config, state, codec) {
|
|
102
|
-
const { config: whole, torn } = completeTornRows(config, state, codec);
|
|
151
|
+
export function loadRows(config, state, codec, options = {}) {
|
|
152
|
+
const { config: whole, torn } = completeTornRows(config, state, codec, options);
|
|
103
153
|
const rows = buildRawRows(whole, state, codec);
|
|
104
|
-
if (torn.length === 0)
|
|
105
|
-
return rows;
|
|
106
154
|
for (const row of rows) {
|
|
107
|
-
if (
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
155
|
+
if (torn.includes(row.id)) {
|
|
156
|
+
row.torn = true;
|
|
157
|
+
row.candidate = false;
|
|
158
|
+
}
|
|
159
|
+
if (options.requireCredentialStamps && row.stamp !== 'bound') {
|
|
160
|
+
row.unbound = true;
|
|
161
|
+
row.candidate = false;
|
|
162
|
+
}
|
|
111
163
|
}
|
|
112
164
|
return rows;
|
|
113
165
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cortexkit/common-auth",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.4",
|
|
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": {
|