@cortexkit/common-auth 0.7.0 → 0.9.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/logger/engine.d.ts +24 -3
- package/dist/logger/engine.js +5 -1
- package/dist/logger/index.d.ts +1 -1
- package/dist/logger/redact.js +42 -4
- package/dist/rpc/notifications.d.ts +25 -1
- package/dist/rpc/notifications.js +28 -6
- package/dist/rpc/port-file.d.ts +10 -1
- package/dist/rpc/port-file.js +50 -16
- package/dist/rpc/rpc-client.d.ts +20 -3
- package/dist/rpc/rpc-client.js +132 -30
- package/dist/rpc/rpc-server.d.ts +18 -0
- package/dist/rpc/rpc-server.js +98 -14
- package/dist/sidebar-file/sidebar-file.d.ts +10 -0
- package/dist/sidebar-file/sidebar-file.js +1 -0
- package/dist/store/attribution.d.ts +13 -2
- package/dist/store/errors.d.ts +1 -1
- package/dist/store/identity.d.ts +2 -0
- package/dist/store/identity.js +9 -1
- package/dist/store/mutate.d.ts +5 -1
- package/dist/store/mutate.js +14 -2
- package/dist/store/pool.d.ts +7 -0
- package/dist/store/refresh.d.ts +17 -1
- package/dist/store/refresh.js +35 -2
- package/dist/store/rows.d.ts +9 -2
- package/dist/store/rows.js +28 -10
- package/dist/store/schema.d.ts +40 -0
- package/dist/store/schema.js +87 -3
- package/dist/store/torn.js +8 -1
- package/dist/tui-build/build-tui.js +39 -4
- package/package.json +7 -5
package/dist/rpc/rpc-server.js
CHANGED
|
@@ -4,15 +4,47 @@ import { createServer, } from 'node:http';
|
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { isSessionId, } from './notifications.js';
|
|
6
6
|
import { sweepRpcState, writePortFile } from './port-file.js';
|
|
7
|
+
/**
|
|
8
|
+
* Thrown by an `apply` or `drain` handler to refuse a request with a 4xx
|
|
9
|
+
* status. Its message is sent on the wire as `{error: message}`, so it must
|
|
10
|
+
* be written for the client and never quote a credential. Any other error a
|
|
11
|
+
* handler throws answers 500 with a fixed code.
|
|
12
|
+
*/
|
|
13
|
+
export class RpcRequestError extends Error {
|
|
14
|
+
status;
|
|
15
|
+
constructor(status, message) {
|
|
16
|
+
if (!Number.isInteger(status) || status < 400 || status > 499)
|
|
17
|
+
throw new RangeError(`RpcRequestError status must be 4xx, got ${status}`);
|
|
18
|
+
super(message);
|
|
19
|
+
this.name = 'RpcRequestError';
|
|
20
|
+
this.status = status;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
const MAX_BODY_BYTES = 1_000_000;
|
|
24
|
+
/** The request body exceeded the cap; answered 413. */
|
|
25
|
+
class BodyTooLargeError extends Error {
|
|
26
|
+
}
|
|
7
27
|
function readBody(req) {
|
|
8
28
|
return new Promise((resolve, reject) => {
|
|
29
|
+
const tooLarge = () => {
|
|
30
|
+
// Keep reading and discarding the rest, so the client finishes sending
|
|
31
|
+
// and can read the 413 instead of seeing a reset connection.
|
|
32
|
+
req.removeAllListeners('data');
|
|
33
|
+
req.on('data', () => { });
|
|
34
|
+
req.resume();
|
|
35
|
+
reject(new BodyTooLargeError('body too large'));
|
|
36
|
+
};
|
|
37
|
+
const declared = Number(req.headers['content-length']);
|
|
38
|
+
if (Number.isFinite(declared) && declared > MAX_BODY_BYTES) {
|
|
39
|
+
tooLarge();
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
9
42
|
const chunks = [];
|
|
10
43
|
let size = 0;
|
|
11
44
|
req.on('data', (chunk) => {
|
|
12
45
|
size += chunk.length;
|
|
13
|
-
if (size >
|
|
14
|
-
|
|
15
|
-
reject(new Error('body too large'));
|
|
46
|
+
if (size > MAX_BODY_BYTES) {
|
|
47
|
+
tooLarge();
|
|
16
48
|
return;
|
|
17
49
|
}
|
|
18
50
|
chunks.push(chunk);
|
|
@@ -21,6 +53,23 @@ function readBody(req) {
|
|
|
21
53
|
req.on('error', reject);
|
|
22
54
|
});
|
|
23
55
|
}
|
|
56
|
+
class ApplyDeadlineError extends Error {
|
|
57
|
+
}
|
|
58
|
+
/** Settle with `work`, or reject at `ms` while `work` keeps running. */
|
|
59
|
+
async function withDeadline(work, ms) {
|
|
60
|
+
let timer;
|
|
61
|
+
const deadline = new Promise((_, reject) => {
|
|
62
|
+
timer = setTimeout(() => reject(new ApplyDeadlineError('apply deadline exceeded')), ms);
|
|
63
|
+
});
|
|
64
|
+
// A handler that fails after its deadline has nobody left to answer.
|
|
65
|
+
work.catch(() => { });
|
|
66
|
+
try {
|
|
67
|
+
return await Promise.race([work, deadline]);
|
|
68
|
+
}
|
|
69
|
+
finally {
|
|
70
|
+
clearTimeout(timer);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
24
73
|
function tokenOk(header, token) {
|
|
25
74
|
if (!header?.startsWith('Bearer '))
|
|
26
75
|
return false;
|
|
@@ -45,25 +94,43 @@ export async function startRpcServer(options) {
|
|
|
45
94
|
server.requestTimeout = receiptTimeoutMs;
|
|
46
95
|
server.headersTimeout = receiptTimeoutMs;
|
|
47
96
|
async function dispatch(req, res) {
|
|
48
|
-
const json = (status, value) => {
|
|
49
|
-
// Guard against writing to a socket that
|
|
50
|
-
//
|
|
97
|
+
const json = (status, value, headers = {}) => {
|
|
98
|
+
// Guard against writing to a socket that is already gone (the
|
|
99
|
+
// inactivity timeout destroys it).
|
|
51
100
|
if (res.headersSent || res.writableEnded || res.destroyed)
|
|
52
101
|
return;
|
|
53
|
-
res.writeHead(status, { 'content-type': 'application/json' });
|
|
102
|
+
res.writeHead(status, { 'content-type': 'application/json', ...headers });
|
|
54
103
|
res.end(JSON.stringify(value));
|
|
55
104
|
};
|
|
56
105
|
try {
|
|
57
|
-
|
|
58
|
-
|
|
106
|
+
// Route on the pathname alone, so a query string does not turn a known
|
|
107
|
+
// method into a 404.
|
|
108
|
+
const path = new URL(req.url ?? '/', 'http://127.0.0.1').pathname;
|
|
109
|
+
if (req.method === 'GET' && path === '/health')
|
|
59
110
|
return json(200, { ok: true });
|
|
60
|
-
if (req.method !== 'POST' || !
|
|
111
|
+
if (req.method !== 'POST' || !path.startsWith('/rpc/'))
|
|
61
112
|
return json(404, { error: 'not found' });
|
|
62
113
|
if (!tokenOk(req.headers.authorization, token))
|
|
63
114
|
return json(401, { error: 'unauthorized' });
|
|
64
|
-
const method =
|
|
65
|
-
|
|
66
|
-
|
|
115
|
+
const method = path.slice('/rpc/'.length);
|
|
116
|
+
let body;
|
|
117
|
+
try {
|
|
118
|
+
body = await readBody(req);
|
|
119
|
+
}
|
|
120
|
+
catch (error) {
|
|
121
|
+
if (!(error instanceof BodyTooLargeError))
|
|
122
|
+
throw error;
|
|
123
|
+
// Close the connection after answering: the rest of the oversized
|
|
124
|
+
// body is not worth keeping the socket for.
|
|
125
|
+
return json(413, { error: 'body too large' }, { connection: 'close' });
|
|
126
|
+
}
|
|
127
|
+
let params;
|
|
128
|
+
try {
|
|
129
|
+
params = JSON.parse(body || '{}');
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
return json(400, { error: 'invalid json' });
|
|
133
|
+
}
|
|
67
134
|
if (method === 'pending-notifications') {
|
|
68
135
|
if (options.requireSession === true && !isSessionId(params.sessionId))
|
|
69
136
|
return json(400, { error: 'session required' });
|
|
@@ -78,12 +145,29 @@ export async function startRpcServer(options) {
|
|
|
78
145
|
return json(200, { messages });
|
|
79
146
|
}
|
|
80
147
|
if (method === 'apply') {
|
|
81
|
-
const
|
|
148
|
+
const work = Promise.resolve(options.apply(params));
|
|
149
|
+
const result = options.applyDeadlineMs === undefined
|
|
150
|
+
? await work
|
|
151
|
+
: await withDeadline(work, options.applyDeadlineMs);
|
|
82
152
|
return json(200, result);
|
|
83
153
|
}
|
|
84
154
|
return json(404, { error: 'unknown method' });
|
|
85
155
|
}
|
|
86
156
|
catch (error) {
|
|
157
|
+
if (error instanceof RpcRequestError) {
|
|
158
|
+
log.debug('rpc request refused', {
|
|
159
|
+
pid: process.pid,
|
|
160
|
+
status: error.status,
|
|
161
|
+
});
|
|
162
|
+
return json(error.status, { error: error.message });
|
|
163
|
+
}
|
|
164
|
+
if (error instanceof ApplyDeadlineError) {
|
|
165
|
+
log.warn('rpc apply deadline exceeded', {
|
|
166
|
+
pid: process.pid,
|
|
167
|
+
deadlineMs: options.applyDeadlineMs,
|
|
168
|
+
});
|
|
169
|
+
return json(504, { error: 'handler deadline exceeded' });
|
|
170
|
+
}
|
|
87
171
|
// A handler's exception can quote a request or a credential, so its
|
|
88
172
|
// text goes to the plugin's log channel only; the wire gets a fixed code.
|
|
89
173
|
log.warn('rpc request failed', {
|
|
@@ -68,6 +68,16 @@ export interface SidebarFileOptions<T> {
|
|
|
68
68
|
* that the lock was lost. Without it such a write is only reported.
|
|
69
69
|
*/
|
|
70
70
|
repair?: SidebarRepair<T>;
|
|
71
|
+
/**
|
|
72
|
+
* Name of the lock writers coordinate on, as `<path>.<lockName>.lock`.
|
|
73
|
+
* Defaults to `'sidebar-write'`. It names both the lock a write takes and
|
|
74
|
+
* the lock whose ownership is checked before the rename, so a takeover of
|
|
75
|
+
* this name refuses the write. A plugin keeping an existing writer's lock
|
|
76
|
+
* identity passes that writer's name; mutual exclusion with that writer is
|
|
77
|
+
* promised only while it holds a live lease of the same name in the same
|
|
78
|
+
* lock-file format, not for its stale-lock reclamation or renewal.
|
|
79
|
+
*/
|
|
80
|
+
lockName?: string;
|
|
71
81
|
}
|
|
72
82
|
export interface SidebarFile<T> {
|
|
73
83
|
read(): Promise<T>;
|
|
@@ -43,6 +43,7 @@ export function createSidebarFile(options) {
|
|
|
43
43
|
};
|
|
44
44
|
const lockOptions = {
|
|
45
45
|
...WRITER_LOCK_CONSTANTS.sidebar,
|
|
46
|
+
name: options.lockName ?? WRITER_LOCK_CONSTANTS.sidebar.name,
|
|
46
47
|
timeoutMs: options.timeoutMs ?? WRITER_LOCK_CONSTANTS.sidebar.timeoutMs,
|
|
47
48
|
};
|
|
48
49
|
/**
|
|
@@ -2,8 +2,19 @@ import { type StoreRuntime } from './runtime.js';
|
|
|
2
2
|
/**
|
|
3
3
|
* What a pull or refresh captured about its row (named by id alongside) when
|
|
4
4
|
* it was issued. A result applies only while the row with that id still has
|
|
5
|
-
* this credential epoch and this recorded identity; a replaced credential
|
|
6
|
-
* for the old one is discarded.
|
|
5
|
+
* this credential epoch and this recorded identity; a replaced credential
|
|
6
|
+
* bumps the epoch, so work issued for the old one is discarded.
|
|
7
|
+
*
|
|
8
|
+
* The epoch names one credential lineage of the row: a credential given by
|
|
9
|
+
* `add` or `replace`, through every refresh and `rotate` of it, which keep
|
|
10
|
+
* the epoch. Since 0.8.0 that holds across removal too: a row added under an
|
|
11
|
+
* id the pool held before starts past every epoch that id held (the store
|
|
12
|
+
* records them when it drops a row, in the config file, so every process
|
|
13
|
+
* sees it), so an attribution taken for a removed row is refused once the id
|
|
14
|
+
* is added again, even with the same identity. Writers older than 0.8.0
|
|
15
|
+
* start a re-added id at epoch 1 again, and a writer that does not know the
|
|
16
|
+
* pool can remove and re-add a row without the store seeing it; work
|
|
17
|
+
* attributed across either may still apply to the new credential.
|
|
7
18
|
*/
|
|
8
19
|
export interface Attribution {
|
|
9
20
|
credentialEpoch: number;
|
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' | 'unbound-credential' | 'attribution' | 'provider' | 'pull' | 'invalid-quota' | 'invalid-provider-state' | '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' | 'identity-contradicted' | '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/identity.d.ts
CHANGED
|
@@ -9,6 +9,8 @@ export interface RowEditor {
|
|
|
9
9
|
entry(id: string): Record<string, unknown> | undefined;
|
|
10
10
|
setEntry(id: string, entry: Record<string, unknown>): void;
|
|
11
11
|
}
|
|
12
|
+
/** Prefix of a refresh quarantine reason, followed by JSON of the expected and returned identities. */
|
|
13
|
+
export declare const IDENTITY_CONTRADICTED_REASON_PREFIX = "identity-contradicted: ";
|
|
12
14
|
/** The reason recorded on a row disabled because an earlier row is the same account. */
|
|
13
15
|
export declare const DUPLICATE_IDENTITY_REASON = "duplicate-identity";
|
|
14
16
|
/**
|
package/dist/store/identity.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
/** Prefix of a refresh quarantine reason, followed by JSON of the expected and returned identities. */
|
|
2
|
+
export const IDENTITY_CONTRADICTED_REASON_PREFIX = 'identity-contradicted: ';
|
|
1
3
|
/** The reason recorded on a row disabled because an earlier row is the same account. */
|
|
2
4
|
export const DUPLICATE_IDENTITY_REASON = 'duplicate-identity';
|
|
3
5
|
/**
|
|
@@ -22,7 +24,13 @@ export function disableIn(tx, id, reason) {
|
|
|
22
24
|
return;
|
|
23
25
|
raw.enabled = false;
|
|
24
26
|
const entry = tx.entry(id) ?? { credentialEpoch: 1, needsFirstReading: true };
|
|
25
|
-
|
|
27
|
+
// A routine disable must not erase the evidence needed to refuse enable.
|
|
28
|
+
const quarantined = typeof entry.disabledReason === 'string' &&
|
|
29
|
+
entry.disabledReason.startsWith(IDENTITY_CONTRADICTED_REASON_PREFIX);
|
|
30
|
+
tx.setEntry(id, {
|
|
31
|
+
...entry,
|
|
32
|
+
disabledReason: quarantined ? entry.disabledReason : reason,
|
|
33
|
+
});
|
|
26
34
|
}
|
|
27
35
|
/**
|
|
28
36
|
* Marks a row enabled: `enabled: true` in the roster row and no
|
package/dist/store/mutate.d.ts
CHANGED
|
@@ -118,7 +118,11 @@ export declare class Transaction {
|
|
|
118
118
|
* Writes the config: legacy `version: 1` and the legacy roster beside
|
|
119
119
|
* `commonAuthPool`, every other top-level key and every unrecognised pool
|
|
120
120
|
* key untouched. Entries for ids no longer in the roster are dropped here,
|
|
121
|
-
* and remembered so the id is not reused in this process.
|
|
121
|
+
* and remembered so the id is not reused in this process. Every id the
|
|
122
|
+
* write drops (a roster row the files held when the transaction read them,
|
|
123
|
+
* or an entry left without one) has its epoch recorded in the config (see
|
|
124
|
+
* `retireEpochsIn`), which is what keeps a later `add` of the id, from any
|
|
125
|
+
* process, past every epoch an attribution could name.
|
|
122
126
|
*/
|
|
123
127
|
commitConfig(options?: {
|
|
124
128
|
counted?: boolean;
|
package/dist/store/mutate.js
CHANGED
|
@@ -4,7 +4,7 @@ import { LockContentionError, LockOwnershipError } from '../fs/with-lock.js';
|
|
|
4
4
|
import { PoolOperationError, } from './errors.js';
|
|
5
5
|
import { callFailureHook } from './hooks.js';
|
|
6
6
|
import { LockStack, } from './refresh-lock.js';
|
|
7
|
-
import { classifyConfig, classifyState, ensureEntries, entryIn, isRecord, LEGACY_STORE_VERSION, POOL_KEY, POOL_ROWS_KEY, POOL_SCHEMA_VERSION, rosterRowIn, setEntryIn, } from './schema.js';
|
|
7
|
+
import { classifyConfig, classifyState, ensureEntries, entryIn, isRecord, LEGACY_STORE_VERSION, POOL_KEY, POOL_ROWS_KEY, POOL_SCHEMA_VERSION, retireEpochsIn, rosterOf, rosterRowIn, setEntryIn, } from './schema.js';
|
|
8
8
|
import { completeTornRows, loadRows } from './torn.js';
|
|
9
9
|
async function readJson(path) {
|
|
10
10
|
let text;
|
|
@@ -169,7 +169,11 @@ export class Transaction {
|
|
|
169
169
|
* Writes the config: legacy `version: 1` and the legacy roster beside
|
|
170
170
|
* `commonAuthPool`, every other top-level key and every unrecognised pool
|
|
171
171
|
* key untouched. Entries for ids no longer in the roster are dropped here,
|
|
172
|
-
* and remembered so the id is not reused in this process.
|
|
172
|
+
* and remembered so the id is not reused in this process. Every id the
|
|
173
|
+
* write drops (a roster row the files held when the transaction read them,
|
|
174
|
+
* or an entry left without one) has its epoch recorded in the config (see
|
|
175
|
+
* `retireEpochsIn`), which is what keeps a later `add` of the id, from any
|
|
176
|
+
* process, past every epoch an attribution could name.
|
|
173
177
|
*/
|
|
174
178
|
async commitConfig(options = {}) {
|
|
175
179
|
const roster = this.roster();
|
|
@@ -177,6 +181,14 @@ export class Transaction {
|
|
|
177
181
|
for (const raw of roster)
|
|
178
182
|
if (isRecord(raw) && typeof raw.id === 'string')
|
|
179
183
|
rosterIds.add(raw.id);
|
|
184
|
+
const dropped = new Set();
|
|
185
|
+
for (const raw of rosterOf(this.snapshot.config))
|
|
186
|
+
if (isRecord(raw) && typeof raw.id === 'string' && !rosterIds.has(raw.id))
|
|
187
|
+
dropped.add(raw.id);
|
|
188
|
+
for (const id of Object.keys(this.entries()))
|
|
189
|
+
if (!rosterIds.has(id))
|
|
190
|
+
dropped.add(id);
|
|
191
|
+
retireEpochsIn(this.config, dropped);
|
|
180
192
|
const entries = this.entries();
|
|
181
193
|
const kept = {};
|
|
182
194
|
for (const [id, entry] of Object.entries(entries)) {
|
package/dist/store/pool.d.ts
CHANGED
|
@@ -88,6 +88,13 @@ export interface PoolStore {
|
|
|
88
88
|
}): Promise<{
|
|
89
89
|
status: InitializeOutcome;
|
|
90
90
|
}>;
|
|
91
|
+
/**
|
|
92
|
+
* Adds a row, or completes or rotates the row already holding the id or
|
|
93
|
+
* the secret. A new row starts at credential epoch 1; since 0.8.0 one
|
|
94
|
+
* whose id the pool held before starts one past the highest epoch that id
|
|
95
|
+
* held (see `Attribution`), and an id that held `Number.MAX_SAFE_INTEGER`
|
|
96
|
+
* refuses (`id-removed`) before writing.
|
|
97
|
+
*/
|
|
91
98
|
add(input: AddInput, options?: RowOperationOptions): Promise<AddResult>;
|
|
92
99
|
/**
|
|
93
100
|
* Gives a row a new credential and a new credential epoch. Since 0.6.0 the
|
package/dist/store/refresh.d.ts
CHANGED
|
@@ -27,7 +27,11 @@ export interface RefreshOptions {
|
|
|
27
27
|
providerLock?: PoolLockSpec;
|
|
28
28
|
/** Taken after the provider-wide lock in this order, released in reverse. */
|
|
29
29
|
extraLocks?: readonly PoolLockSpec[];
|
|
30
|
-
/**
|
|
30
|
+
/**
|
|
31
|
+
* Awaited once an ordinary rotation is persisted and the store locks released.
|
|
32
|
+
* Never called for `identity-contradicted`: its credential must not propagate
|
|
33
|
+
* as the row's recorded account.
|
|
34
|
+
*/
|
|
31
35
|
onPersisted?: (rowId: string, credential: StoredCredential) => void | Promise<void>;
|
|
32
36
|
onFailure?: FailureHook;
|
|
33
37
|
/**
|
|
@@ -42,6 +46,18 @@ export type RefreshOutcome = {
|
|
|
42
46
|
rowId: string;
|
|
43
47
|
credential: StoredCredential;
|
|
44
48
|
identity?: string;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* The provider's successor credential is stored bound to expectedIdentity,
|
|
52
|
+
* not returnedIdentity. The row stays disabled until the adapter supplies
|
|
53
|
+
* an identity-validated replacement credential through replace.
|
|
54
|
+
*/
|
|
55
|
+
| {
|
|
56
|
+
status: 'identity-contradicted';
|
|
57
|
+
rowId: string;
|
|
58
|
+
expectedIdentity: string;
|
|
59
|
+
returnedIdentity: string;
|
|
60
|
+
credential: StoredCredential;
|
|
45
61
|
} | {
|
|
46
62
|
status: 'refused';
|
|
47
63
|
rowId: string;
|
package/dist/store/refresh.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
1
2
|
import { PoolOperationError } from './errors.js';
|
|
2
3
|
import { assertNotInsideHook, runInsideHook } from './hooks.js';
|
|
3
|
-
import { recordIdentityIn } from './identity.js';
|
|
4
|
+
import { IDENTITY_CONTRADICTED_REASON_PREFIX, recordIdentityIn, } from './identity.js';
|
|
4
5
|
import { runOperation, withTransaction } from './mutate.js';
|
|
5
6
|
import { acceptProviderState, mergedProviderState } from './provider-state.js';
|
|
6
7
|
import { rotateIn } from './rows.js';
|
|
7
8
|
import { readRow, refusal, requireBound, rowLockSpec, } from './runtime.js';
|
|
8
9
|
import { rotationStamp, rotationStampUntrusted, rowLockKey, } from './schema.js';
|
|
10
|
+
import { applyTransition } from './torn.js';
|
|
9
11
|
function requireRefreshable(id, row) {
|
|
10
12
|
if (!row)
|
|
11
13
|
throw refusal('refresh', id, 'unknown-row', `no row ${id} in the pool`);
|
|
@@ -137,6 +139,21 @@ export async function refreshRow(rt, id, provider, options = {}) {
|
|
|
137
139
|
refresh: result.refresh,
|
|
138
140
|
expires: result.expires,
|
|
139
141
|
};
|
|
142
|
+
const contradiction = current.identity !== undefined &&
|
|
143
|
+
result.identity !== undefined &&
|
|
144
|
+
current.identity !== result.identity
|
|
145
|
+
? {
|
|
146
|
+
expectedIdentity: current.identity,
|
|
147
|
+
returnedIdentity: result.identity,
|
|
148
|
+
}
|
|
149
|
+
: undefined;
|
|
150
|
+
const transition = contradiction
|
|
151
|
+
? {
|
|
152
|
+
mark: randomUUID(),
|
|
153
|
+
enabled: false,
|
|
154
|
+
reason: `${IDENTITY_CONTRADICTED_REASON_PREFIX}${JSON.stringify(contradiction)}`,
|
|
155
|
+
}
|
|
156
|
+
: undefined;
|
|
140
157
|
const learnt = current.identity === undefined && result.identity
|
|
141
158
|
? result.identity
|
|
142
159
|
: undefined;
|
|
@@ -145,6 +162,11 @@ export async function refreshRow(rt, id, provider, options = {}) {
|
|
|
145
162
|
// forward rather than leaving an identity no stamp proves.
|
|
146
163
|
const stored = await rotateIn(rt, tx, id, credential, {
|
|
147
164
|
stamp: rotationStamp(prior, now),
|
|
165
|
+
// Write state first with the disable marker, then config below.
|
|
166
|
+
// Once the successor is durable, recovery projects the disable even
|
|
167
|
+
// if config has not landed. A crash before the first rename still
|
|
168
|
+
// loses an in-memory provider reply, as in any ordinary refresh.
|
|
169
|
+
transition,
|
|
148
170
|
identity: learnt,
|
|
149
171
|
...(incoming !== undefined
|
|
150
172
|
? {
|
|
@@ -152,16 +174,27 @@ export async function refreshRow(rt, id, provider, options = {}) {
|
|
|
152
174
|
}
|
|
153
175
|
: {}),
|
|
154
176
|
});
|
|
177
|
+
if (transition !== undefined) {
|
|
178
|
+
applyTransition(tx, id, transition);
|
|
179
|
+
await tx.commitConfig();
|
|
180
|
+
}
|
|
155
181
|
let identity = current.identity;
|
|
156
182
|
if (learnt !== undefined) {
|
|
157
183
|
recordIdentityIn(tx, id, learnt);
|
|
158
184
|
identity = learnt;
|
|
159
185
|
await tx.commitConfig();
|
|
160
186
|
}
|
|
161
|
-
return { stored, identity, refused: undefined };
|
|
187
|
+
return { stored, identity, contradiction, refused: undefined };
|
|
162
188
|
});
|
|
163
189
|
if (commit.refused !== undefined)
|
|
164
190
|
return { status: 'refused', rowId: id, reason: commit.refused };
|
|
191
|
+
if (commit.contradiction !== undefined)
|
|
192
|
+
return {
|
|
193
|
+
status: 'identity-contradicted',
|
|
194
|
+
rowId: id,
|
|
195
|
+
...commit.contradiction,
|
|
196
|
+
credential: commit.stored,
|
|
197
|
+
};
|
|
165
198
|
if (options.onPersisted) {
|
|
166
199
|
try {
|
|
167
200
|
const persisted = options.onPersisted;
|
package/dist/store/rows.d.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { type ProviderStateWrite, type RowTransitionMutator, type UpdateProvider
|
|
|
5
5
|
import type { PoolLockSpec } from './refresh-lock.js';
|
|
6
6
|
import { type StoreRuntime } from './runtime.js';
|
|
7
7
|
import { type CredentialBinding, type PoolCredential, type PoolRow, type RotateCredential, type StoredCredential } from './schema.js';
|
|
8
|
+
import { type StampedTransition } from './torn.js';
|
|
8
9
|
export type FailureHook = (rowId: string, error: PoolOperationError) => void | Promise<void>;
|
|
9
10
|
export interface RowOperationOptions {
|
|
10
11
|
/** Called once, awaited, on every non-success path, before locks release. */
|
|
@@ -122,6 +123,8 @@ export declare function rotateIn(rt: StoreRuntime, tx: Transaction, id: string,
|
|
|
122
123
|
binding?: CredentialBinding;
|
|
123
124
|
identity?: string;
|
|
124
125
|
providerState?: ProviderStateWrite;
|
|
126
|
+
/** Config transition persisted with the successor credential for crash recovery. */
|
|
127
|
+
transition?: StampedTransition;
|
|
125
128
|
}): Promise<StoredCredential>;
|
|
126
129
|
export declare function addRow(rt: StoreRuntime, input: AddInput, options?: RowOperationOptions): Promise<AddResult>;
|
|
127
130
|
export declare function replaceRow(rt: StoreRuntime, id: string, credential: PoolCredential, input?: CredentialWriteInput, options?: RowOperationOptions): Promise<{
|
|
@@ -178,7 +181,9 @@ export interface RowTransitionResult {
|
|
|
178
181
|
export declare function disableRow(rt: StoreRuntime, id: string, reason: string, options?: RowTransitionOptions): Promise<RowTransitionResult>;
|
|
179
182
|
/**
|
|
180
183
|
* Clears a row's `enabled: false` and its `disabledReason` in one config
|
|
181
|
-
* write. An
|
|
184
|
+
* write. An identity-contradicted row refuses with `identity-contradicted`
|
|
185
|
+
* until the caller validates a replacement's identity and supplies it to replace.
|
|
186
|
+
* An OAuth row whose recorded identity another enabled OAuth row holds
|
|
182
187
|
* stays disabled and the call refuses (`duplicate-identity`): the same rule
|
|
183
188
|
* that makes `add` store such a row disabled. Enabling a row that is already
|
|
184
189
|
* enabled writes nothing. See `RowTransitionOptions` for the attributed
|
|
@@ -192,7 +197,9 @@ export declare function enableRow(rt: StoreRuntime, id: string, options?: RowTra
|
|
|
192
197
|
* between the two leaves a row every reader already sees as removed, with
|
|
193
198
|
* only an orphaned state entry that no reader loads; calling `remove` again
|
|
194
199
|
* drops that entry (`completed`). As with every id the store drops, the id is
|
|
195
|
-
* not reused by `add` in this process
|
|
200
|
+
* not reused by `add` in this process, and the config write records the
|
|
201
|
+
* row's credential epoch, so an `add` of the id in any other process starts
|
|
202
|
+
* past it (see `nextAddEpochIn`).
|
|
196
203
|
*/
|
|
197
204
|
export declare function removeRow(rt: StoreRuntime, id: string, options?: RemoveOptions): Promise<RemoveResult>;
|
|
198
205
|
/**
|
package/dist/store/rows.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import { PoolOperationError } from './errors.js';
|
|
3
3
|
import { assertNotInsideHook, runInsideHook } from './hooks.js';
|
|
4
|
-
import { DUPLICATE_IDENTITY_REASON, disableIdentityDuplicates, disableIn, enableIn, recordIdentityIn, } from './identity.js';
|
|
4
|
+
import { DUPLICATE_IDENTITY_REASON, disableIdentityDuplicates, disableIn, enableIn, IDENTITY_CONTRADICTED_REASON_PREFIX, recordIdentityIn, } from './identity.js';
|
|
5
5
|
import { notReadyError, readPool, runOperation, withTransaction, } from './mutate.js';
|
|
6
6
|
import { acceptProviderState, mergedProviderState, planProviderStateIn, providerStateCoverage, replacementProviderState, } from './provider-state.js';
|
|
7
7
|
import { readRow, refusal, requireBound, rowLockSpec, unknownRow, } from './runtime.js';
|
|
8
|
-
import { boundProviderStateDigest, CREDENTIAL_STAMP_KEY, credentialProblem, fingerprintOf, idProblem, isCredentialEpoch, isRecord, PROVIDER_STATE_KEY, rosterRowFor, rotationStamp, rowLockKey, stampFor, stateFieldsFor, storedCredential, } from './schema.js';
|
|
8
|
+
import { boundProviderStateDigest, CREDENTIAL_STAMP_KEY, credentialProblem, fingerprintOf, idProblem, isCredentialEpoch, isRecord, nextAddEpochIn, PROVIDER_STATE_KEY, rosterRowFor, rotationStamp, rowLockKey, stampFor, stateFieldsFor, storedCredential, } from './schema.js';
|
|
9
9
|
import { applyTransition, bindReplacement, TRANSITION_STAMP_KEY, } from './torn.js';
|
|
10
10
|
/** Fields of a state entry that belong to the credential it replaces. */
|
|
11
11
|
const CREDENTIAL_STATE_FIELDS = [
|
|
@@ -122,12 +122,17 @@ export async function rotateIn(rt, tx, id, given, extra = {}) {
|
|
|
122
122
|
tx.setStateAccount(id, {
|
|
123
123
|
...kept,
|
|
124
124
|
...stateFieldsFor(credential, stamp),
|
|
125
|
-
[CREDENTIAL_STAMP_KEY]:
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
? { providerState: providerStateBinding }
|
|
125
|
+
[CREDENTIAL_STAMP_KEY]: {
|
|
126
|
+
...(extra.transition !== undefined
|
|
127
|
+
? { [TRANSITION_STAMP_KEY]: extra.transition }
|
|
129
128
|
: {}),
|
|
130
|
-
|
|
129
|
+
...stampFor(stored, credentialEpoch, extra.binding ?? bindingInTx(tx, id, stored, extra.identity), {
|
|
130
|
+
replace: extra.binding !== undefined,
|
|
131
|
+
...(providerStateBinding !== undefined
|
|
132
|
+
? { providerState: providerStateBinding }
|
|
133
|
+
: {}),
|
|
134
|
+
}),
|
|
135
|
+
},
|
|
131
136
|
});
|
|
132
137
|
await tx.commitState(stored);
|
|
133
138
|
return stored;
|
|
@@ -259,6 +264,12 @@ export async function addRow(rt, input, options = {}) {
|
|
|
259
264
|
await tx.commitConfig();
|
|
260
265
|
return { id, outcome: 'completed', credential: stored };
|
|
261
266
|
}
|
|
267
|
+
// An id the pool held before starts past every epoch it held, so
|
|
268
|
+
// work attributed to the earlier row's credential, from this
|
|
269
|
+
// process or another, never matches the new one.
|
|
270
|
+
const credentialEpoch = nextAddEpochIn(tx.config, id);
|
|
271
|
+
if (!isCredentialEpoch(credentialEpoch))
|
|
272
|
+
throw refusal('add', id, 'id-removed', `id ${id} has held every credential epoch and is not reused; add the credential under another id`);
|
|
262
273
|
tx.roster().push(rosterRowFor({
|
|
263
274
|
id,
|
|
264
275
|
credential,
|
|
@@ -267,7 +278,7 @@ export async function addRow(rt, input, options = {}) {
|
|
|
267
278
|
addedAt: ctx.now(),
|
|
268
279
|
}));
|
|
269
280
|
tx.setEntry(id, {
|
|
270
|
-
credentialEpoch
|
|
281
|
+
credentialEpoch,
|
|
271
282
|
needsFirstReading: credential.type === 'oauth',
|
|
272
283
|
});
|
|
273
284
|
let outcome = 'added';
|
|
@@ -474,6 +485,9 @@ async function transitionRow(rt, operation, id, flag, options) {
|
|
|
474
485
|
row.identity !== fence.identity)
|
|
475
486
|
throw refusal(operation, id, 'attribution', `the ${operation} of ${id} was issued for a credential or account the row no longer holds`, true);
|
|
476
487
|
}
|
|
488
|
+
if (flag.enabled &&
|
|
489
|
+
row.disabledReason?.startsWith(IDENTITY_CONTRADICTED_REASON_PREFIX))
|
|
490
|
+
throw refusal('enable', id, 'identity-contradicted', `row ${id} needs an identity-validated credential replacement before it can be enabled`);
|
|
477
491
|
// An enable of a row that is already enabled has nothing to write
|
|
478
492
|
// to the config; a disable always rewrites it, as it always has.
|
|
479
493
|
const writesConfig = !flag.enabled || !row.enabled || row.disabledReason !== undefined;
|
|
@@ -561,7 +575,9 @@ export function disableRow(rt, id, reason, options = {}) {
|
|
|
561
575
|
}
|
|
562
576
|
/**
|
|
563
577
|
* Clears a row's `enabled: false` and its `disabledReason` in one config
|
|
564
|
-
* write. An
|
|
578
|
+
* write. An identity-contradicted row refuses with `identity-contradicted`
|
|
579
|
+
* until the caller validates a replacement's identity and supplies it to replace.
|
|
580
|
+
* An OAuth row whose recorded identity another enabled OAuth row holds
|
|
565
581
|
* stays disabled and the call refuses (`duplicate-identity`): the same rule
|
|
566
582
|
* that makes `add` store such a row disabled. Enabling a row that is already
|
|
567
583
|
* enabled writes nothing. See `RowTransitionOptions` for the attributed
|
|
@@ -577,7 +593,9 @@ export function enableRow(rt, id, options = {}) {
|
|
|
577
593
|
* between the two leaves a row every reader already sees as removed, with
|
|
578
594
|
* only an orphaned state entry that no reader loads; calling `remove` again
|
|
579
595
|
* drops that entry (`completed`). As with every id the store drops, the id is
|
|
580
|
-
* not reused by `add` in this process
|
|
596
|
+
* not reused by `add` in this process, and the config write records the
|
|
597
|
+
* row's credential epoch, so an `add` of the id in any other process starts
|
|
598
|
+
* past it (see `nextAddEpochIn`).
|
|
581
599
|
*/
|
|
582
600
|
export async function removeRow(rt, id, options = {}) {
|
|
583
601
|
assertNotInsideHook('remove');
|
package/dist/store/schema.d.ts
CHANGED
|
@@ -4,6 +4,15 @@ export declare const POOL_KEY = "commonAuthPool";
|
|
|
4
4
|
export declare const POOL_SCHEMA_VERSION = 1;
|
|
5
5
|
/** Property of `commonAuthPool` holding the per-row entries, keyed by local id. */
|
|
6
6
|
export declare const POOL_ROWS_KEY = "rows";
|
|
7
|
+
/**
|
|
8
|
+
* Property of `commonAuthPool` (since 0.8.0) holding, per id, the highest
|
|
9
|
+
* credential epoch a row with that id held when the store last dropped it
|
|
10
|
+
* from the pool. A row added later under the same id starts past it (see
|
|
11
|
+
* `nextAddEpochIn`), so an attribution taken for the dropped row never
|
|
12
|
+
* matches the new one. Older readers ignore it, and older writers keep it
|
|
13
|
+
* as they keep every pool key they do not know.
|
|
14
|
+
*/
|
|
15
|
+
export declare const POOL_RETIRED_EPOCHS_KEY = "retiredEpochs";
|
|
7
16
|
/** The `version` older readers of the same files expect at the top level. */
|
|
8
17
|
export declare const LEGACY_STORE_VERSION = 1;
|
|
9
18
|
/**
|
|
@@ -348,6 +357,37 @@ export declare function ensureEntries(config: Record<string, unknown>): Record<s
|
|
|
348
357
|
export declare function entryIn(config: Record<string, unknown>, id: string): Record<string, unknown> | undefined;
|
|
349
358
|
/** Sets an entry as an own property, so an id such as `toString` is safe. */
|
|
350
359
|
export declare function setEntryIn(config: Record<string, unknown>, id: string, entry: Record<string, unknown>): void;
|
|
360
|
+
/**
|
|
361
|
+
* The credential epoch recorded for a dropped id (see
|
|
362
|
+
* `POOL_RETIRED_EPOCHS_KEY`), or undefined when none is. A value that is not
|
|
363
|
+
* a credential epoch counts as none.
|
|
364
|
+
*/
|
|
365
|
+
export declare function retiredEpochIn(config: Record<string, unknown>, id: string): number | undefined;
|
|
366
|
+
/**
|
|
367
|
+
* The credential epoch `add` gives a new row with this id: one past the
|
|
368
|
+
* highest epoch the id is known to have held, which is the epoch recorded
|
|
369
|
+
* when the store dropped it, or the epoch of an entry left behind by a writer
|
|
370
|
+
* that removed only its roster row; 1 for an id the pool never held.
|
|
371
|
+
*
|
|
372
|
+
* An attribution names a row by id and credential epoch (and identity), and
|
|
373
|
+
* an id is chosen by the plugin, so it is often the same one again (`main`).
|
|
374
|
+
* Were a re-added row to start at epoch 1 again, an attribution taken for the
|
|
375
|
+
* removed row's credential would match the new credential exactly, in this
|
|
376
|
+
* process or any other. Starting past every earlier epoch makes such an
|
|
377
|
+
* attribution fail as it does after a `replace`. The result may lie past the
|
|
378
|
+
* safe integers (an id whose last row was at `Number.MAX_SAFE_INTEGER`);
|
|
379
|
+
* `add` refuses such an id.
|
|
380
|
+
*/
|
|
381
|
+
export declare function nextAddEpochIn(config: Record<string, unknown>, id: string): number;
|
|
382
|
+
/**
|
|
383
|
+
* Records, in a config being written, the epochs of the ids it drops: for
|
|
384
|
+
* each, the epoch its entry claims (1 for a row without one, the epoch such a
|
|
385
|
+
* row is at), kept only when above what is already recorded, so the record
|
|
386
|
+
* for an id never goes down. Valid recorded values of other ids are kept; a
|
|
387
|
+
* record that is not an object, or a value in it that is not an epoch, says
|
|
388
|
+
* nothing and is replaced. An entry whose epoch cannot be read records 1.
|
|
389
|
+
*/
|
|
390
|
+
export declare function retireEpochsIn(config: Record<string, unknown>, dropped: Iterable<string>): void;
|
|
351
391
|
/**
|
|
352
392
|
* Builds the rows of a ready pool from the files exactly as they are, without
|
|
353
393
|
* looking at credential stamps (see `loadRows` for the rows every reader
|