@cortexkit/common-auth 0.4.3 → 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/mutate.d.ts +3 -2
- package/dist/store/mutate.js +4 -3
- package/dist/store/refresh.js +10 -3
- package/dist/store/rows.d.ts +8 -4
- package/dist/store/rows.js +63 -6
- package/dist/store/schema.d.ts +53 -12
- package/dist/store/schema.js +78 -10
- package/dist/store/torn.d.ts +23 -10
- package/dist/store/torn.js +70 -24
- package/package.json +1 -1
package/dist/store/mutate.d.ts
CHANGED
|
@@ -100,8 +100,9 @@ export declare class Transaction {
|
|
|
100
100
|
entry(id: string): Record<string, unknown> | undefined;
|
|
101
101
|
setEntry(id: string, entry: Record<string, unknown>): void;
|
|
102
102
|
/**
|
|
103
|
-
* Writes the config of every row torn between the writes of a replace
|
|
104
|
-
*
|
|
103
|
+
* Writes the config of every row torn between the writes of a replace, or
|
|
104
|
+
* of a write giving it its first identity, as that write would have left it
|
|
105
|
+
* (see `completeTornRows`), in one config
|
|
105
106
|
* write ahead of the operation's own. The write is counted apart from the
|
|
106
107
|
* operation's: it is setup, like a pull giving a row its entry, so a later
|
|
107
108
|
* refusal still reports `before-first-write`.
|
package/dist/store/mutate.js
CHANGED
|
@@ -127,14 +127,15 @@ export class Transaction {
|
|
|
127
127
|
setEntryIn(this.config, id, entry);
|
|
128
128
|
}
|
|
129
129
|
/**
|
|
130
|
-
* Writes the config of every row torn between the writes of a replace
|
|
131
|
-
*
|
|
130
|
+
* Writes the config of every row torn between the writes of a replace, or
|
|
131
|
+
* of a write giving it its first identity, as that write would have left it
|
|
132
|
+
* (see `completeTornRows`), in one config
|
|
132
133
|
* write ahead of the operation's own. The write is counted apart from the
|
|
133
134
|
* operation's: it is setup, like a pull giving a row its entry, so a later
|
|
134
135
|
* refusal still reports `before-first-write`.
|
|
135
136
|
*/
|
|
136
137
|
async completeTorn() {
|
|
137
|
-
const { config, torn } = completeTornRows(this.config, this.state, this.ctx.codec);
|
|
138
|
+
const { config, torn } = completeTornRows(this.config, this.state, this.ctx.codec, { requireCredentialStamps: this.ctx.requireCredentialStamps === true });
|
|
138
139
|
if (torn.length === 0)
|
|
139
140
|
return;
|
|
140
141
|
this.config = config;
|
package/dist/store/refresh.js
CHANGED
|
@@ -133,13 +133,20 @@ export async function refreshRow(rt, id, provider, options = {}) {
|
|
|
133
133
|
refresh: result.refresh,
|
|
134
134
|
expires: result.expires,
|
|
135
135
|
};
|
|
136
|
+
const learnt = current.identity === undefined && result.identity
|
|
137
|
+
? result.identity
|
|
138
|
+
: undefined;
|
|
139
|
+
// The rotated credential's stamp names a learnt identity before the
|
|
140
|
+
// config records it, so a crash between the two writes is completed
|
|
141
|
+
// forward rather than leaving an identity no stamp proves.
|
|
136
142
|
const stored = await rotateIn(rt, tx, id, credential, {
|
|
137
143
|
stamp: rotationStamp(prior, now),
|
|
144
|
+
identity: learnt,
|
|
138
145
|
});
|
|
139
146
|
let identity = current.identity;
|
|
140
|
-
if (
|
|
141
|
-
recordIdentityIn(tx, id,
|
|
142
|
-
identity =
|
|
147
|
+
if (learnt !== undefined) {
|
|
148
|
+
recordIdentityIn(tx, id, learnt);
|
|
149
|
+
identity = learnt;
|
|
143
150
|
await tx.commitConfig();
|
|
144
151
|
}
|
|
145
152
|
return { stored, identity, refused: undefined };
|
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
|
@@ -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)
|
|
@@ -294,7 +342,12 @@ export async function rotateRow(rt, id, credential, input = {}, options = {}) {
|
|
|
294
342
|
row.identity !== undefined &&
|
|
295
343
|
input.identity !== row.identity)
|
|
296
344
|
throw identityMismatch('rotate', id);
|
|
297
|
-
const
|
|
345
|
+
const learnt = input.identity !== undefined && row.identity === undefined
|
|
346
|
+
? input.identity
|
|
347
|
+
: undefined;
|
|
348
|
+
const stored = await rotateIn(rt, tx, id, credential, {
|
|
349
|
+
identity: learnt,
|
|
350
|
+
});
|
|
298
351
|
let configChanged = false;
|
|
299
352
|
if (!row.hasEntry) {
|
|
300
353
|
tx.setEntry(id, {
|
|
@@ -570,6 +623,10 @@ export async function recordRowIdentity(rt, id, identity, attribution, options =
|
|
|
570
623
|
throw refusal('recordIdentity', id, 'attribution', `the identity for ${id} was looked up for a credential the row no longer holds`, true);
|
|
571
624
|
if (row.identity !== undefined && row.identity !== identity)
|
|
572
625
|
throw identityMismatch('recordIdentity', id);
|
|
626
|
+
// The stamp names the identity first; a crash before the config
|
|
627
|
+
// write leaves a row every reader completes forward.
|
|
628
|
+
if (row.identity === undefined)
|
|
629
|
+
await stampIdentityIn(tx, row, identity);
|
|
573
630
|
const disabled = recordIdentityIn(tx, id, identity);
|
|
574
631
|
await tx.commitConfig();
|
|
575
632
|
return { id, disabled };
|
package/dist/store/schema.d.ts
CHANGED
|
@@ -48,15 +48,21 @@ export interface QuotaCodec {
|
|
|
48
48
|
* `none`: the row holds no credential, so there is nothing to stamp.
|
|
49
49
|
* `bound`: the stamp was written with this credential (its digest matches),
|
|
50
50
|
* names the row's credential epoch (1 for a row without a per-row entry),
|
|
51
|
-
*
|
|
51
|
+
* the identity it records (or its recording none) and any endpoint it
|
|
52
|
+
* records are the row's, and its dispatch digest matches everything a
|
|
53
|
+
* request would send (the OAuth access and refresh tokens and expiry; the API
|
|
54
|
+
* key with its `baseURL` and `authHeader`).
|
|
52
55
|
* `missing`: the credential carries no stamp (written by a writer that does
|
|
53
56
|
* not know about stamps, or one that dropped it).
|
|
54
57
|
* `malformed`: the stamp is not one this store writes (wrong shape, or an
|
|
55
58
|
* epoch outside the positive safe integers).
|
|
56
59
|
* `mismatched`: the stamp was written for another credential, epoch,
|
|
57
|
-
* identity or
|
|
60
|
+
* identity, endpoint, or token to send than the row now holds.
|
|
61
|
+
* `legacy`: a well-formed stamp written with this credential's secret but
|
|
62
|
+
* with no dispatch digest (written by 0.4.3 or earlier), so it proves
|
|
63
|
+
* neither the token sent nor the account or endpoint it goes to.
|
|
58
64
|
*/
|
|
59
|
-
export type CredentialStampStatus = 'none' | 'bound' | 'missing' | 'malformed' | 'mismatched';
|
|
65
|
+
export type CredentialStampStatus = 'none' | 'bound' | 'missing' | 'malformed' | 'mismatched' | 'legacy';
|
|
60
66
|
/** One row of the pool as loaded. */
|
|
61
67
|
export interface PoolRow {
|
|
62
68
|
id: string;
|
|
@@ -84,9 +90,12 @@ export interface PoolRow {
|
|
|
84
90
|
/**
|
|
85
91
|
* Set when a replace stopped between its two writes: the state file holds
|
|
86
92
|
* the new credential, stamped with the epoch and the identity or endpoint
|
|
87
|
-
* it belongs to, and the config still holds the replaced row.
|
|
88
|
-
*
|
|
89
|
-
*
|
|
93
|
+
* it belongs to, and the config still holds the replaced row. Also set when
|
|
94
|
+
* a write that gives a row its first identity (`recordIdentity`, or a
|
|
95
|
+
* `rotate` or refresh that learns one) stopped after stamping the identity
|
|
96
|
+
* and before recording it in the config. The row is shown as the write
|
|
97
|
+
* leaves it once completed, is never a candidate, and the next store write
|
|
98
|
+
* on it writes the config to match.
|
|
90
99
|
*/
|
|
91
100
|
torn?: true;
|
|
92
101
|
/**
|
|
@@ -111,8 +120,9 @@ export interface PoolRow {
|
|
|
111
120
|
*/
|
|
112
121
|
export declare const CREDENTIAL_STAMP_KEY = "commonAuthPool";
|
|
113
122
|
/**
|
|
114
|
-
*
|
|
115
|
-
* 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.
|
|
116
126
|
*/
|
|
117
127
|
export interface CredentialBinding {
|
|
118
128
|
identity?: string;
|
|
@@ -123,14 +133,27 @@ export interface CredentialBinding {
|
|
|
123
133
|
* Written beside every credential the store puts in the state file. It names
|
|
124
134
|
* the credential epoch the credential belongs to and a digest of its secret,
|
|
125
135
|
* so a stamp left beside a credential another writer put there afterwards is
|
|
126
|
-
* recognisable and ignored.
|
|
127
|
-
*
|
|
128
|
-
*
|
|
136
|
+
* recognisable and ignored.
|
|
137
|
+
*
|
|
138
|
+
* `digest` covers only the refresh token or API key: it names the credential
|
|
139
|
+
* lineage, and torn-replace detection matches it, including against stamps
|
|
140
|
+
* older versions wrote, so it keeps that exact definition. `dispatch` covers
|
|
141
|
+
* everything a request sends (see `dispatchDigest`), so a token or endpoint
|
|
142
|
+
* changed beside an unchanged lineage secret is caught; stamps written by
|
|
143
|
+
* 0.4.3 or earlier lack it.
|
|
144
|
+
*
|
|
145
|
+
* `binding` is the config the credential was written beside (see
|
|
146
|
+
* `CredentialBinding`); every write since 0.4.4 records it, earlier versions
|
|
147
|
+
* only on replace. `replace` marks a stamp written by a replace, which is
|
|
148
|
+
* what lets a reader complete a replace that stopped after writing the
|
|
149
|
+
* credential: a stamp from any other write is never completed as torn.
|
|
129
150
|
*/
|
|
130
151
|
export interface CredentialStamp {
|
|
131
152
|
credentialEpoch: number;
|
|
132
153
|
digest: string;
|
|
154
|
+
dispatch?: string;
|
|
133
155
|
binding?: CredentialBinding;
|
|
156
|
+
replace?: true;
|
|
134
157
|
}
|
|
135
158
|
export type ConfigClassification = {
|
|
136
159
|
status: 'ready';
|
|
@@ -167,7 +190,25 @@ export declare function fingerprintOf(credential: PoolCredential | StoredCredent
|
|
|
167
190
|
* dedupe key.
|
|
168
191
|
*/
|
|
169
192
|
export declare function credentialDigest(credential: PoolCredential | StoredCredential): string;
|
|
170
|
-
|
|
193
|
+
/**
|
|
194
|
+
* The digest of everything a request made with the credential sends or is
|
|
195
|
+
* served by: the OAuth access token, refresh token and expiry (the expiry
|
|
196
|
+
* decides whether the access token is used or refreshed first), or the API
|
|
197
|
+
* key with the `baseURL` and header it is sent to. Its input prefix differs
|
|
198
|
+
* from both the fingerprint's and `credentialDigest`'s, so it never equals
|
|
199
|
+
* either. The parts are JSON-encoded as a list, so no two credentials share
|
|
200
|
+
* an input. An access token or expiry the state file cannot hold as loaded
|
|
201
|
+
* (not a string, not a finite number) counts as absent, which is how
|
|
202
|
+
* `buildRawRows` loads it back.
|
|
203
|
+
*/
|
|
204
|
+
export declare function dispatchDigest(credential: PoolCredential | StoredCredential): string;
|
|
205
|
+
/**
|
|
206
|
+
* The stamp for a credential written into a row at `credentialEpoch`, beside
|
|
207
|
+
* the config `binding` describes. `replace` is set only by a replace.
|
|
208
|
+
*/
|
|
209
|
+
export declare function stampFor(credential: PoolCredential | StoredCredential, credentialEpoch: number, binding: CredentialBinding, options?: {
|
|
210
|
+
replace?: boolean;
|
|
211
|
+
}): CredentialStamp;
|
|
171
212
|
/**
|
|
172
213
|
* A credential epoch is a positive safe integer. Above `MAX_SAFE_INTEGER`,
|
|
173
214
|
* adding one may give back the same number, so a replace would not move the
|
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 }
|
|
@@ -125,14 +177,22 @@ export function parseStamp(raw) {
|
|
|
125
177
|
};
|
|
126
178
|
}
|
|
127
179
|
/**
|
|
128
|
-
* Whether
|
|
129
|
-
* be the row's recorded identity, and an endpoint
|
|
130
|
-
* the row sends its API key to.
|
|
131
|
-
*
|
|
132
|
-
*
|
|
180
|
+
* Whether the binding of a stamp is the row's: the identity it names (or its
|
|
181
|
+
* naming none) must be exactly the row's recorded identity, and an endpoint
|
|
182
|
+
* it names must be the one the row sends its API key to.
|
|
183
|
+
*
|
|
184
|
+
* Every write that gives a row an identity (`add`, `replace`, `rotate` or a
|
|
185
|
+
* refresh that learns one, `recordIdentity`) writes the stamp naming it
|
|
186
|
+
* before the config, so this store never leaves a config identity beside a
|
|
187
|
+
* stamp that names none or another: that is another writer's doing and is
|
|
188
|
+
* `mismatched`. The reverse, a stamp naming an identity beside a config that
|
|
189
|
+
* has none, is such a write stopped between its two writes: the row as
|
|
190
|
+
* `buildRawRows` reads it from the files is `mismatched`, but `loadRows`,
|
|
191
|
+
* which every reader goes through, shows it with the identity recorded (see
|
|
192
|
+
* `torn.ts`), where it is `bound`.
|
|
133
193
|
*/
|
|
134
194
|
function bindingAgrees(binding, credential, identity) {
|
|
135
|
-
if (binding.identity !==
|
|
195
|
+
if (binding.identity !== identity)
|
|
136
196
|
return false;
|
|
137
197
|
if (binding.baseURL !== undefined &&
|
|
138
198
|
(credential.type !== 'api' || credential.baseURL !== binding.baseURL))
|
|
@@ -157,9 +217,17 @@ function stampStatusOf(credential, credentialEpoch, identity, account) {
|
|
|
157
217
|
return 'malformed';
|
|
158
218
|
if (stamp.digest !== credentialDigest(credential))
|
|
159
219
|
return 'mismatched';
|
|
220
|
+
// A stamp from 0.4.3 or earlier proves neither the token sent nor (its
|
|
221
|
+
// binding being optional and identity-lenient) the account, so it is
|
|
222
|
+
// reported as such whatever else it says, and never as bound.
|
|
223
|
+
if (stamp.dispatch === undefined)
|
|
224
|
+
return 'legacy';
|
|
160
225
|
if (stamp.credentialEpoch !== credentialEpoch)
|
|
161
226
|
return 'mismatched';
|
|
162
|
-
|
|
227
|
+
// `parseStamp` refuses a stamp with a dispatch digest and no binding.
|
|
228
|
+
if (!stamp.binding || !bindingAgrees(stamp.binding, credential, identity))
|
|
229
|
+
return 'mismatched';
|
|
230
|
+
if (stamp.dispatch !== dispatchDigest(credential))
|
|
163
231
|
return 'mismatched';
|
|
164
232
|
return 'bound';
|
|
165
233
|
}
|
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
|
};
|
|
@@ -27,8 +39,9 @@ export declare function completeTornRows(config: Record<string, unknown>, state:
|
|
|
27
39
|
* marked `torn` and is never a candidate; every other row is as on disk.
|
|
28
40
|
* With `requireCredentialStamps`, a row whose stamp is not `bound` is marked
|
|
29
41
|
* `unbound` and is never a candidate either. A torn row is shown with the
|
|
30
|
-
*
|
|
31
|
-
*
|
|
42
|
+
* stamp of the interrupted write, which binds the completed row, so it is not
|
|
43
|
+
* unbound; once a store write puts its completion on disk it is no longer
|
|
44
|
+
* torn and is a candidate again like any other row.
|
|
32
45
|
*/
|
|
33
46
|
export declare function loadRows(config: Record<string, unknown>, state: Record<string, unknown>, codec: QuotaCodec, options?: {
|
|
34
47
|
requireCredentialStamps?: boolean;
|
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,12 +127,16 @@ 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,
|
|
@@ -99,11 +144,12 @@ export function completeTornRows(config, state, codec) {
|
|
|
99
144
|
* marked `torn` and is never a candidate; every other row is as on disk.
|
|
100
145
|
* With `requireCredentialStamps`, a row whose stamp is not `bound` is marked
|
|
101
146
|
* `unbound` and is never a candidate either. A torn row is shown with the
|
|
102
|
-
*
|
|
103
|
-
*
|
|
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.
|
|
104
150
|
*/
|
|
105
151
|
export function loadRows(config, state, codec, options = {}) {
|
|
106
|
-
const { config: whole, torn } = completeTornRows(config, state, codec);
|
|
152
|
+
const { config: whole, torn } = completeTornRows(config, state, codec, options);
|
|
107
153
|
const rows = buildRawRows(whole, state, codec);
|
|
108
154
|
for (const row of rows) {
|
|
109
155
|
if (torn.includes(row.id)) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cortexkit/common-auth",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"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": {
|