@cortexkit/common-auth 0.7.0 → 0.8.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/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 +19 -2
- package/dist/rpc/rpc-client.js +22 -7
- 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/mutate.d.ts +5 -1
- package/dist/store/mutate.js +14 -2
- package/dist/store/pool.d.ts +7 -0
- package/dist/store/rows.d.ts +3 -1
- package/dist/store/rows.js +11 -3
- package/dist/store/schema.d.ts +40 -0
- package/dist/store/schema.js +87 -3
- package/package.json +1 -1
package/dist/logger/engine.d.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import type { CaptureSink } from './capture-sink.js';
|
|
2
2
|
import { type RedactionOptions } from './redact.js';
|
|
3
3
|
export type Level = 'error' | 'warn' | 'info' | 'debug' | 'trace';
|
|
4
|
+
/** A logger that writes to a file, and to a capture sink when one is given. */
|
|
4
5
|
export interface InitLoggerOptions extends RedactionOptions {
|
|
6
|
+
/** Receives every emitted record, scrubbed, alongside the file. */
|
|
5
7
|
captureSink?: CaptureSink;
|
|
6
8
|
/**
|
|
7
9
|
* Path of the file lines are appended to, or a function returning it.
|
|
@@ -15,6 +17,25 @@ export interface InitLoggerOptions extends RedactionOptions {
|
|
|
15
17
|
/** Level floor applied when no `setLogLevel` call has overridden it. */
|
|
16
18
|
level?: Level | (() => Level | undefined);
|
|
17
19
|
}
|
|
20
|
+
/**
|
|
21
|
+
* A logger with no file: the capture sink is its only destination. Nothing
|
|
22
|
+
* is written to disk or printed, for a host that forwards records to its own
|
|
23
|
+
* log.
|
|
24
|
+
*/
|
|
25
|
+
export interface SinkOnlyLoggerOptions extends RedactionOptions {
|
|
26
|
+
/** Receives every emitted record, scrubbed. */
|
|
27
|
+
captureSink: CaptureSink;
|
|
28
|
+
file?: undefined;
|
|
29
|
+
/** Level floor applied when no `setLogLevel` call has overridden it. */
|
|
30
|
+
level?: Level | (() => Level | undefined);
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* What a logger is configured with: a file (`InitLoggerOptions`, the shape
|
|
34
|
+
* every release before this one accepted, kept as its own interface so a
|
|
35
|
+
* caller deriving a type from it with `Pick` or `extends` keeps compiling) or
|
|
36
|
+
* a capture sink alone.
|
|
37
|
+
*/
|
|
38
|
+
export type LoggerOptions = InitLoggerOptions | SinkOnlyLoggerOptions;
|
|
18
39
|
export interface ChannelLogger {
|
|
19
40
|
error(message: string, data?: unknown): void;
|
|
20
41
|
warn(message: string, data?: unknown): void;
|
|
@@ -37,7 +58,7 @@ export interface LoggerInstance {
|
|
|
37
58
|
* alone, because it is the operator's explicit choice and outranks the
|
|
38
59
|
* floor a host computed at start-up.
|
|
39
60
|
*/
|
|
40
|
-
configure(options:
|
|
61
|
+
configure(options: LoggerOptions): void;
|
|
41
62
|
/** Operator override of the level floor; undefined removes it. */
|
|
42
63
|
setLogLevel(level: Level | undefined): void;
|
|
43
64
|
/**
|
|
@@ -53,14 +74,14 @@ export interface LoggerInstance {
|
|
|
53
74
|
* uses this instead of `initLogger`, so neither replaces the other's file,
|
|
54
75
|
* level, redaction or capture sink.
|
|
55
76
|
*/
|
|
56
|
-
export declare function createLoggerInstance(options:
|
|
77
|
+
export declare function createLoggerInstance(options: LoggerOptions): LoggerInstance;
|
|
57
78
|
/**
|
|
58
79
|
* Point the module's default logger at a host's file and level, and return
|
|
59
80
|
* it. Idempotent: calling it again replaces both. A runtime level installed
|
|
60
81
|
* by `setLogLevel` is deliberately left alone, because it is the operator's
|
|
61
82
|
* explicit choice and outranks the floor a host computed at start-up.
|
|
62
83
|
*/
|
|
63
|
-
export declare function initLogger(options:
|
|
84
|
+
export declare function initLogger(options: LoggerOptions): LoggerInstance;
|
|
64
85
|
export declare function setLogLevel(l: Level | undefined): void;
|
|
65
86
|
/**
|
|
66
87
|
* Write whatever the default logger has buffered. Safe to call synchronously
|
package/dist/logger/engine.js
CHANGED
|
@@ -126,7 +126,8 @@ function createEngine(options) {
|
|
|
126
126
|
}, 500);
|
|
127
127
|
}
|
|
128
128
|
function emit(channel, level, message, data) {
|
|
129
|
-
|
|
129
|
+
// Unconfigured (neither a file nor a sink) stays a silent no-op.
|
|
130
|
+
if (logFileSource === undefined && captureSink === undefined)
|
|
130
131
|
return;
|
|
131
132
|
try {
|
|
132
133
|
if (ORDER[level] > ORDER[configuredLevel()])
|
|
@@ -152,6 +153,9 @@ function createEngine(options) {
|
|
|
152
153
|
});
|
|
153
154
|
}
|
|
154
155
|
catch { }
|
|
156
|
+
// A sink-only logger has no file to buffer lines for.
|
|
157
|
+
if (logFileSource === undefined)
|
|
158
|
+
return;
|
|
155
159
|
buffer.push(line);
|
|
156
160
|
if (buffer.length >= 50)
|
|
157
161
|
flushLogs();
|
package/dist/logger/index.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export type { CaptureSink, LogTestRecord } from './capture-sink.js';
|
|
2
2
|
export { createCaptureSink } from './capture-sink.js';
|
|
3
|
-
export type { ChannelLogger, InitLoggerOptions, Level, LoggerInstance, } from './engine.js';
|
|
3
|
+
export type { ChannelLogger, InitLoggerOptions, Level, LoggerInstance, LoggerOptions, SinkOnlyLoggerOptions, } from './engine.js';
|
|
4
4
|
export { createLogger, createLoggerInstance, flushForTest, flushLogs, initLogger, resetLoggerForTest, setLogLevel, } from './engine.js';
|
|
5
5
|
export type { RedactionOptions, Redactor } from './redact.js';
|
|
6
6
|
export { createRedactor, redact, redactStrings } from './redact.js';
|
|
@@ -35,6 +35,24 @@ export interface NotificationScope {
|
|
|
35
35
|
* session, which plugins that drain from one process-wide TUI rely on.
|
|
36
36
|
*/
|
|
37
37
|
requireSession?: boolean;
|
|
38
|
+
/**
|
|
39
|
+
* What a drain with no session id returns, and what it acknowledges.
|
|
40
|
+
*
|
|
41
|
+
* - `'all'` (default): every queued notification, broadcasts and every
|
|
42
|
+
* session's targeted ones; a sessionless acknowledgement removes
|
|
43
|
+
* nothing. A plugin that drains from one process-wide TUI relies on this.
|
|
44
|
+
* - `'broadcast-only'`: broadcasts only (notifications pushed without a
|
|
45
|
+
* session), never a session's targeted ones, for a TUI that polls before
|
|
46
|
+
* it knows its session. A sessionless acknowledgement removes the
|
|
47
|
+
* acknowledged broadcasts and leaves every targeted notification queued.
|
|
48
|
+
* It also answers `isTuiConnected(scope, undefined)`: true while any
|
|
49
|
+
* drain on the queue, with or without a session, happened within the
|
|
50
|
+
* connection window.
|
|
51
|
+
*
|
|
52
|
+
* The option governs each call made with this scope; it does not split the
|
|
53
|
+
* queue, so a push through a scope without it lands in the same queue.
|
|
54
|
+
*/
|
|
55
|
+
sessionlessDrain?: 'all' | 'broadcast-only';
|
|
38
56
|
}
|
|
39
57
|
/** A strict notification scope was used without a session id. */
|
|
40
58
|
export declare class RpcSessionRequiredError extends Error {
|
|
@@ -45,5 +63,11 @@ export declare class RpcSessionRequiredError extends Error {
|
|
|
45
63
|
export declare function isSessionId(value: unknown): value is string;
|
|
46
64
|
export declare function pushNotification(scope: NotificationScope, payload: OpenDialogPayload, sessionId?: string): void;
|
|
47
65
|
export declare function drainNotifications(scope: NotificationScope, lastReceivedId?: number, sessionId?: string): RpcNotification[];
|
|
48
|
-
|
|
66
|
+
/**
|
|
67
|
+
* True when a TUI drained within the last 3000 ms. With a session id, that
|
|
68
|
+
* session drained. Without one, a broadcast-only scope answers whether any
|
|
69
|
+
* drain happened; a default scope answers false, since it only tracks
|
|
70
|
+
* sessions.
|
|
71
|
+
*/
|
|
72
|
+
export declare function isTuiConnected(scope: NotificationScope, sessionId: string | undefined): boolean;
|
|
49
73
|
export declare function resetNotificationsForTest(scope: NotificationScope): void;
|
|
@@ -21,7 +21,12 @@ function state(scope) {
|
|
|
21
21
|
]);
|
|
22
22
|
let value = queues.get(key);
|
|
23
23
|
if (!value) {
|
|
24
|
-
value = {
|
|
24
|
+
value = {
|
|
25
|
+
queue: [],
|
|
26
|
+
nextId: 1,
|
|
27
|
+
lastDrainAtBySession: new Map(),
|
|
28
|
+
lastDrainAtAny: 0,
|
|
29
|
+
};
|
|
25
30
|
queues.set(key, value);
|
|
26
31
|
}
|
|
27
32
|
return value;
|
|
@@ -49,25 +54,41 @@ export function drainNotifications(scope, lastReceivedId = 0, sessionId) {
|
|
|
49
54
|
throw new RpcSessionRequiredError('drain');
|
|
50
55
|
const value = state(scope);
|
|
51
56
|
const now = Date.now();
|
|
57
|
+
value.lastDrainAtAny = now;
|
|
52
58
|
if (sessionId !== undefined)
|
|
53
59
|
value.lastDrainAtBySession.set(sessionId, now);
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
n.sessionId ===
|
|
60
|
+
const broadcastOnly = scope.sessionlessDrain === 'broadcast-only';
|
|
61
|
+
const matches = (n) => sessionId === undefined
|
|
62
|
+
? !broadcastOnly || n.sessionId === undefined
|
|
63
|
+
: n.sessionId === undefined || n.sessionId === sessionId;
|
|
57
64
|
if (lastReceivedId > 0) {
|
|
58
65
|
value.queue = value.queue.filter((n) => {
|
|
59
66
|
if (n.id > lastReceivedId)
|
|
60
67
|
return true;
|
|
68
|
+
// A sessionless ack in broadcast-only mode consumes the broadcasts it
|
|
69
|
+
// was shown; in the default mode it consumes nothing, since it was
|
|
70
|
+
// shown targeted notifications other sessions still have to receive.
|
|
61
71
|
if (sessionId === undefined)
|
|
62
|
-
return
|
|
72
|
+
return !broadcastOnly || n.sessionId !== undefined;
|
|
63
73
|
return n.sessionId !== sessionId;
|
|
64
74
|
});
|
|
65
75
|
}
|
|
66
76
|
return value.queue.filter((n) => n.id > lastReceivedId && matches(n));
|
|
67
77
|
}
|
|
78
|
+
/**
|
|
79
|
+
* True when a TUI drained within the last 3000 ms. With a session id, that
|
|
80
|
+
* session drained. Without one, a broadcast-only scope answers whether any
|
|
81
|
+
* drain happened; a default scope answers false, since it only tracks
|
|
82
|
+
* sessions.
|
|
83
|
+
*/
|
|
68
84
|
export function isTuiConnected(scope, sessionId) {
|
|
69
85
|
const now = Date.now();
|
|
70
|
-
const
|
|
86
|
+
const value = state(scope);
|
|
87
|
+
const at = sessionId !== undefined
|
|
88
|
+
? (value.lastDrainAtBySession.get(sessionId) ?? 0)
|
|
89
|
+
: scope.sessionlessDrain === 'broadcast-only'
|
|
90
|
+
? value.lastDrainAtAny
|
|
91
|
+
: 0;
|
|
71
92
|
return at > 0 && now - at < TUI_CONNECTED_WINDOW_MS;
|
|
72
93
|
}
|
|
73
94
|
export function resetNotificationsForTest(scope) {
|
|
@@ -75,4 +96,5 @@ export function resetNotificationsForTest(scope) {
|
|
|
75
96
|
value.queue = [];
|
|
76
97
|
value.nextId = 1;
|
|
77
98
|
value.lastDrainAtBySession.clear();
|
|
99
|
+
value.lastDrainAtAny = 0;
|
|
78
100
|
}
|
package/dist/rpc/port-file.d.ts
CHANGED
|
@@ -17,4 +17,13 @@ export declare function writePortFile(dir: string, entry: {
|
|
|
17
17
|
beforeWrite?: () => void | Promise<void>;
|
|
18
18
|
}): Promise<string>;
|
|
19
19
|
export declare function sweepRpcState(root: string, activeDir: string, isManagedDir: (name: string) => boolean, log?: RpcLogChannel): Promise<void>;
|
|
20
|
-
export
|
|
20
|
+
export interface DiscoverPortFileOptions {
|
|
21
|
+
/**
|
|
22
|
+
* Return only the expected PID's entry, or null; never fall back to
|
|
23
|
+
* another live server. Without an expected PID nothing matches, so the
|
|
24
|
+
* result is null. Off by default, when a missing or unmatched expected PID
|
|
25
|
+
* falls back to the newest live entry.
|
|
26
|
+
*/
|
|
27
|
+
exactPid?: boolean;
|
|
28
|
+
}
|
|
29
|
+
export declare function discoverPortFile(dir: string, expectedPid?: number, options?: DiscoverPortFileOptions): Promise<PortFileEntry | null>;
|
package/dist/rpc/port-file.js
CHANGED
|
@@ -1,7 +1,17 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { chmod, mkdir, readdir, readFile, rename, rmdir, unlink, writeFile, } from 'node:fs/promises';
|
|
3
3
|
import { join, resolve } from 'node:path';
|
|
4
|
+
/**
|
|
5
|
+
* A PID a liveness probe may address. `process.kill(0 | negative, 0)` signals
|
|
6
|
+
* a process group rather than a process, and an unsafe integer cannot name a
|
|
7
|
+
* real process, so neither is ever probed.
|
|
8
|
+
*/
|
|
9
|
+
function isProbeablePid(pid) {
|
|
10
|
+
return typeof pid === 'number' && Number.isSafeInteger(pid) && pid > 0;
|
|
11
|
+
}
|
|
4
12
|
function pidAlive(pid) {
|
|
13
|
+
if (!isProbeablePid(pid))
|
|
14
|
+
return false;
|
|
5
15
|
try {
|
|
6
16
|
process.kill(pid, 0);
|
|
7
17
|
return true;
|
|
@@ -23,14 +33,29 @@ export function getRpcDir(rpcRoot, directoryPrefix, projectDirectory) {
|
|
|
23
33
|
return join(rpcRoot, directoryPrefix +
|
|
24
34
|
createHash('sha256').update(projectDirectory).digest('hex').slice(0, 16));
|
|
25
35
|
}
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
36
|
+
/** The PID a port file's name claims, or undefined for a malformed name. */
|
|
37
|
+
function filenamePid(name) {
|
|
38
|
+
const match = /^port-(\d+)\.json$/.exec(name);
|
|
39
|
+
return match ? Number(match[1]) : undefined;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* An entry a client may connect to: its PID is probeable and matches the PID
|
|
43
|
+
* in its file name (a mismatch means the file was not written by the server
|
|
44
|
+
* it names), its port is a real TCP port, and its token is non-empty (an
|
|
45
|
+
* absent token would otherwise be sent as `Bearer undefined`).
|
|
46
|
+
*/
|
|
47
|
+
function isUsablePortFileEntry(value, name) {
|
|
48
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value))
|
|
49
|
+
return false;
|
|
50
|
+
const { pid, port, token } = value;
|
|
51
|
+
return (isProbeablePid(pid) &&
|
|
52
|
+
filenamePid(name) === pid &&
|
|
53
|
+
typeof port === 'number' &&
|
|
54
|
+
Number.isInteger(port) &&
|
|
55
|
+
port >= 1 &&
|
|
56
|
+
port <= 65_535 &&
|
|
57
|
+
typeof token === 'string' &&
|
|
58
|
+
token.length > 0);
|
|
34
59
|
}
|
|
35
60
|
async function removeCorruptPortFile(portFile, log) {
|
|
36
61
|
log?.debug('rpc corrupt port file', { pid: process.pid, portFile });
|
|
@@ -53,11 +78,18 @@ export async function writePortFile(dir, entry, options = {}) {
|
|
|
53
78
|
const full = { ...entry, startedAt: Date.now() };
|
|
54
79
|
const target = join(dir, `port-${entry.pid}.json`);
|
|
55
80
|
const tmp = `${target}.${process.pid}.tmp`;
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
81
|
+
try {
|
|
82
|
+
await writeFile(tmp, JSON.stringify(full), {
|
|
83
|
+
encoding: 'utf8',
|
|
84
|
+
mode: 0o600,
|
|
85
|
+
});
|
|
86
|
+
await rename(tmp, target);
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
// The staged file carries the server token; never leave it behind.
|
|
90
|
+
await unlink(tmp).catch(() => { });
|
|
91
|
+
throw error;
|
|
92
|
+
}
|
|
61
93
|
return target;
|
|
62
94
|
};
|
|
63
95
|
try {
|
|
@@ -116,7 +148,7 @@ export async function sweepRpcState(root, activeDir, isManagedDir, log) {
|
|
|
116
148
|
await removeCorruptPortFile(portFile, log);
|
|
117
149
|
continue;
|
|
118
150
|
}
|
|
119
|
-
if (!isUsablePortFileEntry(parsed)) {
|
|
151
|
+
if (!isUsablePortFileEntry(parsed, name)) {
|
|
120
152
|
await removeCorruptPortFile(portFile, log);
|
|
121
153
|
continue;
|
|
122
154
|
}
|
|
@@ -128,7 +160,7 @@ export async function sweepRpcState(root, activeDir, isManagedDir, log) {
|
|
|
128
160
|
await rmdir(dir).catch(() => { });
|
|
129
161
|
}
|
|
130
162
|
}
|
|
131
|
-
export async function discoverPortFile(dir, expectedPid) {
|
|
163
|
+
export async function discoverPortFile(dir, expectedPid, options = {}) {
|
|
132
164
|
let names;
|
|
133
165
|
try {
|
|
134
166
|
names = await readdir(dir);
|
|
@@ -142,7 +174,7 @@ export async function discoverPortFile(dir, expectedPid) {
|
|
|
142
174
|
continue;
|
|
143
175
|
try {
|
|
144
176
|
const parsed = JSON.parse(await readFile(join(dir, name), 'utf8'));
|
|
145
|
-
if (isUsablePortFileEntry(parsed)) {
|
|
177
|
+
if (isUsablePortFileEntry(parsed, name)) {
|
|
146
178
|
if (pidAlive(parsed.pid))
|
|
147
179
|
live.push(parsed);
|
|
148
180
|
else
|
|
@@ -156,6 +188,8 @@ export async function discoverPortFile(dir, expectedPid) {
|
|
|
156
188
|
const candidates = expectedPid !== undefined && expectedPid >= 1
|
|
157
189
|
? live.filter((entry) => entry.pid === expectedPid)
|
|
158
190
|
: [];
|
|
191
|
+
if (options.exactPid === true && candidates.length === 0)
|
|
192
|
+
return null;
|
|
159
193
|
const entries = candidates.length > 0 ? candidates : live;
|
|
160
194
|
const sortTime = (entry) => typeof entry.startedAt === 'number' && Number.isFinite(entry.startedAt)
|
|
161
195
|
? entry.startedAt
|
package/dist/rpc/rpc-client.d.ts
CHANGED
|
@@ -1,8 +1,25 @@
|
|
|
1
1
|
import type { ApplyRequest, ApplyResult, RpcNotification } from './notifications.js';
|
|
2
|
-
import { type PortFileEntry } from './port-file.js';
|
|
2
|
+
import { type DiscoverPortFileOptions, type PortFileEntry } from './port-file.js';
|
|
3
3
|
export interface RpcClient {
|
|
4
4
|
pending: (lastReceivedId: number, sessionId?: string) => Promise<RpcNotification[]>;
|
|
5
5
|
apply: (request: ApplyRequest, timeoutMs?: number) => Promise<ApplyResult>;
|
|
6
6
|
}
|
|
7
7
|
export declare const DEFAULT_RPC_TIMEOUT_MS = 2000;
|
|
8
|
-
|
|
8
|
+
/**
|
|
9
|
+
* `exactPid`: every call goes only to the expected PID's server. With no
|
|
10
|
+
* such server (or no expected PID) a call returns its fallback without
|
|
11
|
+
* opening a socket, on every call. Off by default, when a call falls back to
|
|
12
|
+
* the newest live server.
|
|
13
|
+
*/
|
|
14
|
+
export type RpcClientOptions = DiscoverPortFileOptions;
|
|
15
|
+
/**
|
|
16
|
+
* A client for the server in `dir`, preferring `expectedPid`'s.
|
|
17
|
+
*
|
|
18
|
+
* `onSelected` reports the first selection: it is told which entry (or null)
|
|
19
|
+
* discovery chose, until one call of it returns normally. It is a report, not
|
|
20
|
+
* a gate: an observer that throws rejects that call before any request is
|
|
21
|
+
* sent and is asked again on the next call, but nothing stops a later call
|
|
22
|
+
* once an observer has returned. A caller that must never reach another
|
|
23
|
+
* server passes `{ exactPid: true }`.
|
|
24
|
+
*/
|
|
25
|
+
export declare function createRpcClient(dir: string, expectedPid?: number, onSelected?: (entry: PortFileEntry | null) => void, options?: RpcClientOptions): RpcClient;
|
package/dist/rpc/rpc-client.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { discoverPortFile } from './port-file.js';
|
|
1
|
+
import { discoverPortFile, } from './port-file.js';
|
|
2
2
|
export const DEFAULT_RPC_TIMEOUT_MS = 2_000;
|
|
3
|
-
async function call(dir, expectedPid, onSelected, method, params, timeoutMs = DEFAULT_RPC_TIMEOUT_MS) {
|
|
4
|
-
const entry = await discoverPortFile(dir, expectedPid);
|
|
3
|
+
async function call(dir, expectedPid, discoverOptions, onSelected, method, params, timeoutMs = DEFAULT_RPC_TIMEOUT_MS) {
|
|
4
|
+
const entry = await discoverPortFile(dir, expectedPid, discoverOptions);
|
|
5
5
|
onSelected?.(entry);
|
|
6
6
|
if (!entry)
|
|
7
7
|
return null;
|
|
@@ -28,21 +28,36 @@ async function call(dir, expectedPid, onSelected, method, params, timeoutMs = DE
|
|
|
28
28
|
clearTimeout(timer);
|
|
29
29
|
}
|
|
30
30
|
}
|
|
31
|
-
|
|
31
|
+
/**
|
|
32
|
+
* A client for the server in `dir`, preferring `expectedPid`'s.
|
|
33
|
+
*
|
|
34
|
+
* `onSelected` reports the first selection: it is told which entry (or null)
|
|
35
|
+
* discovery chose, until one call of it returns normally. It is a report, not
|
|
36
|
+
* a gate: an observer that throws rejects that call before any request is
|
|
37
|
+
* sent and is asked again on the next call, but nothing stops a later call
|
|
38
|
+
* once an observer has returned. A caller that must never reach another
|
|
39
|
+
* server passes `{ exactPid: true }`.
|
|
40
|
+
*/
|
|
41
|
+
export function createRpcClient(dir, expectedPid, onSelected, options = {}) {
|
|
42
|
+
const discoverOptions = {
|
|
43
|
+
exactPid: options.exactPid,
|
|
44
|
+
};
|
|
32
45
|
let reportedSelection = false;
|
|
33
46
|
const reportSelected = (entry) => {
|
|
34
47
|
if (reportedSelection)
|
|
35
48
|
return;
|
|
36
|
-
reportedSelection = true;
|
|
37
49
|
onSelected?.(entry);
|
|
50
|
+
// Counted only after the observer returns, so a throwing one is not
|
|
51
|
+
// silently skipped on later calls.
|
|
52
|
+
reportedSelection = true;
|
|
38
53
|
};
|
|
39
54
|
return {
|
|
40
55
|
async pending(lastReceivedId, sessionId) {
|
|
41
|
-
const out = await call(dir, expectedPid, reportSelected, 'pending-notifications', { lastReceivedId, sessionId });
|
|
56
|
+
const out = await call(dir, expectedPid, discoverOptions, reportSelected, 'pending-notifications', { lastReceivedId, sessionId });
|
|
42
57
|
return out?.messages ?? [];
|
|
43
58
|
},
|
|
44
59
|
async apply(request, timeoutMs) {
|
|
45
|
-
const out = await call(dir, expectedPid, reportSelected, 'apply', {
|
|
60
|
+
const out = await call(dir, expectedPid, discoverOptions, reportSelected, 'apply', {
|
|
46
61
|
...request,
|
|
47
62
|
}, timeoutMs);
|
|
48
63
|
return out ?? { text: 'apply failed', knobs: {} };
|
package/dist/rpc/rpc-server.d.ts
CHANGED
|
@@ -23,5 +23,23 @@ export interface RpcServerOptions {
|
|
|
23
23
|
requireSession?: boolean;
|
|
24
24
|
timeoutMs?: number;
|
|
25
25
|
receiptTimeoutMs?: number;
|
|
26
|
+
/**
|
|
27
|
+
* Answer an `apply` call whose handler is still running after this many
|
|
28
|
+
* milliseconds with 504 `{error: 'handler deadline exceeded'}`. The handler
|
|
29
|
+
* is not cancelled; its eventual result is discarded. Unset by default,
|
|
30
|
+
* when only the socket inactivity timeout (`timeoutMs`) bounds a handler,
|
|
31
|
+
* by destroying the socket.
|
|
32
|
+
*/
|
|
33
|
+
applyDeadlineMs?: number;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Thrown by an `apply` or `drain` handler to refuse a request with a 4xx
|
|
37
|
+
* status. Its message is sent on the wire as `{error: message}`, so it must
|
|
38
|
+
* be written for the client and never quote a credential. Any other error a
|
|
39
|
+
* handler throws answers 500 with a fixed code.
|
|
40
|
+
*/
|
|
41
|
+
export declare class RpcRequestError extends Error {
|
|
42
|
+
readonly status: number;
|
|
43
|
+
constructor(status: number, message: string);
|
|
26
44
|
}
|
|
27
45
|
export declare function startRpcServer(options: RpcServerOptions): Promise<RpcServerHandle>;
|
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/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/rows.d.ts
CHANGED
|
@@ -192,7 +192,9 @@ export declare function enableRow(rt: StoreRuntime, id: string, options?: RowTra
|
|
|
192
192
|
* between the two leaves a row every reader already sees as removed, with
|
|
193
193
|
* only an orphaned state entry that no reader loads; calling `remove` again
|
|
194
194
|
* drops that entry (`completed`). As with every id the store drops, the id is
|
|
195
|
-
* not reused by `add` in this process
|
|
195
|
+
* not reused by `add` in this process, and the config write records the
|
|
196
|
+
* row's credential epoch, so an `add` of the id in any other process starts
|
|
197
|
+
* past it (see `nextAddEpochIn`).
|
|
196
198
|
*/
|
|
197
199
|
export declare function removeRow(rt: StoreRuntime, id: string, options?: RemoveOptions): Promise<RemoveResult>;
|
|
198
200
|
/**
|
package/dist/store/rows.js
CHANGED
|
@@ -5,7 +5,7 @@ import { DUPLICATE_IDENTITY_REASON, disableIdentityDuplicates, disableIn, enable
|
|
|
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 = [
|
|
@@ -259,6 +259,12 @@ export async function addRow(rt, input, options = {}) {
|
|
|
259
259
|
await tx.commitConfig();
|
|
260
260
|
return { id, outcome: 'completed', credential: stored };
|
|
261
261
|
}
|
|
262
|
+
// An id the pool held before starts past every epoch it held, so
|
|
263
|
+
// work attributed to the earlier row's credential, from this
|
|
264
|
+
// process or another, never matches the new one.
|
|
265
|
+
const credentialEpoch = nextAddEpochIn(tx.config, id);
|
|
266
|
+
if (!isCredentialEpoch(credentialEpoch))
|
|
267
|
+
throw refusal('add', id, 'id-removed', `id ${id} has held every credential epoch and is not reused; add the credential under another id`);
|
|
262
268
|
tx.roster().push(rosterRowFor({
|
|
263
269
|
id,
|
|
264
270
|
credential,
|
|
@@ -267,7 +273,7 @@ export async function addRow(rt, input, options = {}) {
|
|
|
267
273
|
addedAt: ctx.now(),
|
|
268
274
|
}));
|
|
269
275
|
tx.setEntry(id, {
|
|
270
|
-
credentialEpoch
|
|
276
|
+
credentialEpoch,
|
|
271
277
|
needsFirstReading: credential.type === 'oauth',
|
|
272
278
|
});
|
|
273
279
|
let outcome = 'added';
|
|
@@ -577,7 +583,9 @@ export function enableRow(rt, id, options = {}) {
|
|
|
577
583
|
* between the two leaves a row every reader already sees as removed, with
|
|
578
584
|
* only an orphaned state entry that no reader loads; calling `remove` again
|
|
579
585
|
* drops that entry (`completed`). As with every id the store drops, the id is
|
|
580
|
-
* not reused by `add` in this process
|
|
586
|
+
* not reused by `add` in this process, and the config write records the
|
|
587
|
+
* row's credential epoch, so an `add` of the id in any other process starts
|
|
588
|
+
* past it (see `nextAddEpochIn`).
|
|
581
589
|
*/
|
|
582
590
|
export async function removeRow(rt, id, options = {}) {
|
|
583
591
|
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
|
package/dist/store/schema.js
CHANGED
|
@@ -5,6 +5,15 @@ export const POOL_KEY = 'commonAuthPool';
|
|
|
5
5
|
export const POOL_SCHEMA_VERSION = 1;
|
|
6
6
|
/** Property of `commonAuthPool` holding the per-row entries, keyed by local id. */
|
|
7
7
|
export const POOL_ROWS_KEY = 'rows';
|
|
8
|
+
/**
|
|
9
|
+
* Property of `commonAuthPool` (since 0.8.0) holding, per id, the highest
|
|
10
|
+
* credential epoch a row with that id held when the store last dropped it
|
|
11
|
+
* from the pool. A row added later under the same id starts past it (see
|
|
12
|
+
* `nextAddEpochIn`), so an attribution taken for the dropped row never
|
|
13
|
+
* matches the new one. Older readers ignore it, and older writers keep it
|
|
14
|
+
* as they keep every pool key they do not know.
|
|
15
|
+
*/
|
|
16
|
+
export const POOL_RETIRED_EPOCHS_KEY = 'retiredEpochs';
|
|
8
17
|
/** The `version` older readers of the same files expect at the top level. */
|
|
9
18
|
export const LEGACY_STORE_VERSION = 1;
|
|
10
19
|
/**
|
|
@@ -410,11 +419,15 @@ function credentialFor(raw, stateEntry) {
|
|
|
410
419
|
export function rosterRowIn(config, id) {
|
|
411
420
|
return rosterOf(config).find((raw) => isRecord(raw) && raw.id === id);
|
|
412
421
|
}
|
|
413
|
-
/** The
|
|
414
|
-
|
|
422
|
+
/** The pool object of a config, created (empty) when absent. */
|
|
423
|
+
function ensurePool(config) {
|
|
415
424
|
if (!isRecord(config[POOL_KEY]))
|
|
416
425
|
config[POOL_KEY] = {};
|
|
417
|
-
|
|
426
|
+
return config[POOL_KEY];
|
|
427
|
+
}
|
|
428
|
+
/** The per-row entries of a config, created (empty) when absent. */
|
|
429
|
+
export function ensureEntries(config) {
|
|
430
|
+
const pool = ensurePool(config);
|
|
418
431
|
if (!isRecord(pool[POOL_ROWS_KEY]))
|
|
419
432
|
pool[POOL_ROWS_KEY] = {};
|
|
420
433
|
return pool[POOL_ROWS_KEY];
|
|
@@ -433,6 +446,77 @@ export function setEntryIn(config, id, entry) {
|
|
|
433
446
|
configurable: true,
|
|
434
447
|
});
|
|
435
448
|
}
|
|
449
|
+
/**
|
|
450
|
+
* The credential epoch recorded for a dropped id (see
|
|
451
|
+
* `POOL_RETIRED_EPOCHS_KEY`), or undefined when none is. A value that is not
|
|
452
|
+
* a credential epoch counts as none.
|
|
453
|
+
*/
|
|
454
|
+
export function retiredEpochIn(config, id) {
|
|
455
|
+
const pool = config[POOL_KEY];
|
|
456
|
+
if (!isRecord(pool))
|
|
457
|
+
return undefined;
|
|
458
|
+
const retired = pool[POOL_RETIRED_EPOCHS_KEY];
|
|
459
|
+
if (!isRecord(retired) || !Object.hasOwn(retired, id))
|
|
460
|
+
return undefined;
|
|
461
|
+
const epoch = retired[id];
|
|
462
|
+
return isCredentialEpoch(epoch) ? epoch : undefined;
|
|
463
|
+
}
|
|
464
|
+
/**
|
|
465
|
+
* The epoch a per-row entry claims, read without validating the rest of the
|
|
466
|
+
* entry, or undefined when it names none a reader would accept.
|
|
467
|
+
*/
|
|
468
|
+
function entryEpochIn(config, id) {
|
|
469
|
+
const epoch = entryIn(config, id)?.credentialEpoch;
|
|
470
|
+
return isCredentialEpoch(epoch) ? epoch : undefined;
|
|
471
|
+
}
|
|
472
|
+
/**
|
|
473
|
+
* The credential epoch `add` gives a new row with this id: one past the
|
|
474
|
+
* highest epoch the id is known to have held, which is the epoch recorded
|
|
475
|
+
* when the store dropped it, or the epoch of an entry left behind by a writer
|
|
476
|
+
* that removed only its roster row; 1 for an id the pool never held.
|
|
477
|
+
*
|
|
478
|
+
* An attribution names a row by id and credential epoch (and identity), and
|
|
479
|
+
* an id is chosen by the plugin, so it is often the same one again (`main`).
|
|
480
|
+
* Were a re-added row to start at epoch 1 again, an attribution taken for the
|
|
481
|
+
* removed row's credential would match the new credential exactly, in this
|
|
482
|
+
* process or any other. Starting past every earlier epoch makes such an
|
|
483
|
+
* attribution fail as it does after a `replace`. The result may lie past the
|
|
484
|
+
* safe integers (an id whose last row was at `Number.MAX_SAFE_INTEGER`);
|
|
485
|
+
* `add` refuses such an id.
|
|
486
|
+
*/
|
|
487
|
+
export function nextAddEpochIn(config, id) {
|
|
488
|
+
return (Math.max(retiredEpochIn(config, id) ?? 0, entryEpochIn(config, id) ?? 0) + 1);
|
|
489
|
+
}
|
|
490
|
+
/**
|
|
491
|
+
* Records, in a config being written, the epochs of the ids it drops: for
|
|
492
|
+
* each, the epoch its entry claims (1 for a row without one, the epoch such a
|
|
493
|
+
* row is at), kept only when above what is already recorded, so the record
|
|
494
|
+
* for an id never goes down. Valid recorded values of other ids are kept; a
|
|
495
|
+
* record that is not an object, or a value in it that is not an epoch, says
|
|
496
|
+
* nothing and is replaced. An entry whose epoch cannot be read records 1.
|
|
497
|
+
*/
|
|
498
|
+
export function retireEpochsIn(config, dropped) {
|
|
499
|
+
const ids = [...dropped];
|
|
500
|
+
if (ids.length === 0)
|
|
501
|
+
return;
|
|
502
|
+
const pool = ensurePool(config);
|
|
503
|
+
const previous = isRecord(pool[POOL_RETIRED_EPOCHS_KEY])
|
|
504
|
+
? pool[POOL_RETIRED_EPOCHS_KEY]
|
|
505
|
+
: {};
|
|
506
|
+
const next = {};
|
|
507
|
+
const define = (id, epoch) => Object.defineProperty(next, id, {
|
|
508
|
+
value: epoch,
|
|
509
|
+
enumerable: true,
|
|
510
|
+
writable: true,
|
|
511
|
+
configurable: true,
|
|
512
|
+
});
|
|
513
|
+
for (const [id, epoch] of Object.entries(previous))
|
|
514
|
+
if (isCredentialEpoch(epoch))
|
|
515
|
+
define(id, epoch);
|
|
516
|
+
for (const id of ids)
|
|
517
|
+
define(id, Math.max(retiredEpochIn(config, id) ?? 0, entryEpochIn(config, id) ?? 1));
|
|
518
|
+
pool[POOL_RETIRED_EPOCHS_KEY] = next;
|
|
519
|
+
}
|
|
436
520
|
/**
|
|
437
521
|
* Builds the rows of a ready pool from the files exactly as they are, without
|
|
438
522
|
* looking at credential stamps (see `loadRows` for the rows every reader
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cortexkit/common-auth",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Shared code for the CortexKit auth plugins: account pool, quota and routing, commands and auth menu, OpenCode 2 hooks, Claustrum custody, and plumbing (loopback RPC, file locks, logger, sidebar state, TUI preferences and build).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|