@abot-ai/socket 0.0.0-stage → 0.2.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/CHANGELOG.md +38 -0
- package/LICENSE +21 -0
- package/README.md +129 -2
- package/bin/abot-socket.mjs +15 -0
- package/config.example.json +14 -0
- package/docs/APP-REVIEW.md +184 -0
- package/docs/DEPLOYMENT.md +124 -0
- package/docs/INSTALLATION.md +177 -0
- package/docs/MIGRATION.md +84 -0
- package/docs/PROTOCOL.md +371 -0
- package/docs/SECURITY.md +116 -0
- package/docs/SELF-HOSTING.md +161 -0
- package/package.json +65 -4
- package/src/auth/auth-database.mjs +105 -0
- package/src/auth/auth-limits.mjs +17 -0
- package/src/auth/auth-pairing.mjs +95 -0
- package/src/auth/auth-sessions.mjs +119 -0
- package/src/auth/auth-storage-path.mjs +70 -0
- package/src/auth/auth.mjs +45 -0
- package/src/auth/runtime-credentials.mjs +39 -0
- package/src/cli/cli-arguments.mjs +23 -0
- package/src/cli/cli-entry.mjs +84 -0
- package/src/cli/cli-environment.mjs +31 -0
- package/src/cli/cli-help.mjs +34 -0
- package/src/cli/cli-output.mjs +35 -0
- package/src/cli/cli.mjs +58 -0
- package/src/cli/client-capabilities.mjs +14 -0
- package/src/cli/doctor.mjs +106 -0
- package/src/config/config-validation.mjs +41 -0
- package/src/config/config.mjs +57 -0
- package/src/deployment/deployment-command.mjs +42 -0
- package/src/deployment/deployment-input.mjs +76 -0
- package/src/deployment/deployment-plan.mjs +62 -0
- package/src/deployment/deployment-templates.mjs +109 -0
- package/src/main.mjs +26 -0
- package/src/notifications/notification-delivery-credentials.mjs +48 -0
- package/src/notifications/notification-delivery-fcm.mjs +100 -0
- package/src/notifications/notification-delivery-gateway.mjs +36 -0
- package/src/notifications/notification-delivery-http.mjs +53 -0
- package/src/notifications/notification-delivery-payload.mjs +45 -0
- package/src/notifications/notification-delivery.mjs +43 -0
- package/src/notifications/notification-gateway-command.mjs +48 -0
- package/src/notifications/notification-gateway-store.mjs +159 -0
- package/src/notifications/notification-gateway.mjs +181 -0
- package/src/notifications/notification-http.mjs +39 -0
- package/src/notifications/notification-input.mjs +82 -0
- package/src/notifications/notification-outbox.mjs +60 -0
- package/src/notifications/notification-preview-crypto.mjs +42 -0
- package/src/notifications/notification-preview-schema.mjs +7 -0
- package/src/notifications/notification-preview-store.mjs +63 -0
- package/src/notifications/notification-preview-text.mjs +20 -0
- package/src/notifications/notification-projection.mjs +20 -0
- package/src/notifications/notification-schema.mjs +22 -0
- package/src/notifications/notification-service.mjs +147 -0
- package/src/notifications/notification-store.mjs +132 -0
- package/src/pairing/pairing-qr-payload.mjs +32 -0
- package/src/pairing/pairing-qr.mjs +42 -0
- package/src/protocol/approval-protocol.mjs +39 -0
- package/src/protocol/protocol-validation.mjs +137 -0
- package/src/protocol/protocol.mjs +120 -0
- package/src/protocol/spark-chat-protocol.mjs +51 -0
- package/src/relay/relay-approvals.mjs +86 -0
- package/src/relay/relay-client-admission.mjs +40 -0
- package/src/relay/relay-correlations.mjs +101 -0
- package/src/relay/relay-model-catalog.mjs +69 -0
- package/src/relay/relay-request-adoption.mjs +97 -0
- package/src/relay/relay-requests.mjs +211 -0
- package/src/relay/relay-runtime-registration.mjs +49 -0
- package/src/relay/relay-session-mutations.mjs +122 -0
- package/src/relay/relay.mjs +222 -0
- package/src/relay/request-ownership-schema.mjs +18 -0
- package/src/relay/request-ownership-store.mjs +115 -0
- package/src/review/review-demo-isolation.mjs +132 -0
- package/src/review/review-demo-setup.mjs +97 -0
- package/src/review/review-portal-access.mjs +64 -0
- package/src/review/review-portal-form.mjs +24 -0
- package/src/review/review-portal-page.mjs +68 -0
- package/src/review/review-portal-server.mjs +105 -0
- package/src/setup/setup-files.mjs +37 -0
- package/src/setup/setup-questions.mjs +62 -0
- package/src/setup/setup-wizard.mjs +50 -0
- package/src/setup/setup.mjs +42 -0
- package/src/transport/http-api.mjs +32 -0
- package/src/transport/server.mjs +175 -0
- package/src/transport/transport-limits.mjs +85 -0
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { normalizeClientCommand, expectedResponseType } from '../protocol/protocol.mjs';
|
|
3
|
+
import { createRelayClient, admitRelayCommand } from './relay-client-admission.mjs';
|
|
4
|
+
import { RelayCorrelations } from './relay-correlations.mjs';
|
|
5
|
+
import { RelayRequests } from './relay-requests.mjs';
|
|
6
|
+
import { RelayApprovals } from './relay-approvals.mjs';
|
|
7
|
+
import { isApprovalCommand } from '../protocol/approval-protocol.mjs';
|
|
8
|
+
import { assertSparkChatSupported } from '../protocol/spark-chat-protocol.mjs';
|
|
9
|
+
import { RelaySessionMutations, isSessionMutation } from './relay-session-mutations.mjs';
|
|
10
|
+
import { isBoundedRuntimeFrame, isRelayId, parseRuntimeRegistration,
|
|
11
|
+
validateRuntimeGrant, relayError } from './relay-runtime-registration.mjs';
|
|
12
|
+
|
|
13
|
+
export class Relay {
|
|
14
|
+
constructor({ now = Date.now, send, close, observeRequest, ownership, isClientAuthorized = () => true }) {
|
|
15
|
+
this.now = now;
|
|
16
|
+
this.send = send;
|
|
17
|
+
this.close = close;
|
|
18
|
+
this.ownership = ownership;
|
|
19
|
+
this.clients = new Map();
|
|
20
|
+
this.runtimes = new Map();
|
|
21
|
+
this.runtimePeers = new Map();
|
|
22
|
+
this.requests = new RelayRequests({ now, ownership, observe: observeRequest,
|
|
23
|
+
event: (client, runtimeId, body) => this.deliver(client, { type: 'relay.event', runtimeId, body }),
|
|
24
|
+
unknown: (client, record) => this.error(client.peer, undefined, record.runtime.runtimeId,
|
|
25
|
+
'outcome_unknown', { requestId: record.requestId }),
|
|
26
|
+
});
|
|
27
|
+
this.correlations = new RelayCorrelations({ now,
|
|
28
|
+
respond: (entry, body) => this.deliver(entry.client, { type: 'relay.response', id: entry.id,
|
|
29
|
+
runtimeId: entry.runtime.runtimeId, body }),
|
|
30
|
+
unknown: entry => this.error(entry.client.peer, entry.id, entry.runtime.runtimeId, 'outcome_unknown'),
|
|
31
|
+
});
|
|
32
|
+
this.approvals = new RelayApprovals({ ownership, requests: this.requests, correlations: this.correlations,
|
|
33
|
+
transmit: (peer, body) => this.transmit(peer, body),
|
|
34
|
+
connected: client => this.clients.get(client.peer) === client && isClientAuthorized(client),
|
|
35
|
+
currentRuntime: runtime => this.runtimes.get(runtime.runtimeId) === runtime && runtime.registered,
|
|
36
|
+
assertSessionAvailable: (runtime, sessionId) => this.sessionMutations.assertNoPending(runtime, sessionId),
|
|
37
|
+
respond: (client, runtime, id, body) => this.deliver(client, { type: 'relay.response', id,
|
|
38
|
+
runtimeId: runtime.runtimeId, body }),
|
|
39
|
+
error: (client, runtime, id, code) => this.error(client.peer, id, runtime.runtimeId, code),
|
|
40
|
+
});
|
|
41
|
+
this.sessionMutations = new RelaySessionMutations({ now, correlations: this.correlations,
|
|
42
|
+
requests: this.requests, ownership, transmit: (peer, body) => this.transmit(peer, body),
|
|
43
|
+
connected: client => this.clients.get(client.peer) === client,
|
|
44
|
+
error: (operation, code) => this.error(operation.client.peer, operation.id, operation.runtime.runtimeId, code),
|
|
45
|
+
respond: (operation, body) => this.deliver(operation.client, { type: 'relay.response',
|
|
46
|
+
id: operation.id, runtimeId: operation.runtime.runtimeId, body }),
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
transmit(peer, body) {
|
|
51
|
+
try { return this.send(peer, body) === true; } catch { return false; }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
terminate(peer, reason) {
|
|
55
|
+
this.disconnect(peer);
|
|
56
|
+
try { this.close(peer, 1008, reason); } catch { /* State is already detached. */ }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
deliver(client, body) {
|
|
60
|
+
if (this.clients.get(client.peer) !== client) return false;
|
|
61
|
+
if (this.transmit(client.peer, body)) return true;
|
|
62
|
+
this.terminate(client.peer, 'transport_unavailable');
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
error(peer, id, runtimeId, code, body) {
|
|
67
|
+
const client = this.clients.get(peer);
|
|
68
|
+
if (!client) return;
|
|
69
|
+
this.deliver(client, { type: 'relay.error', ...(isRelayId(id) ? { id } : {}),
|
|
70
|
+
...(isRelayId(runtimeId) ? { runtimeId } : {}), code, ...(body ? { body } : {}) });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
attachRuntime(peer, grant) {
|
|
74
|
+
this.prune();
|
|
75
|
+
if (this.runtimePeers.has(peer) || this.clients.has(peer)) return false;
|
|
76
|
+
try {
|
|
77
|
+
const identity = validateRuntimeGrant(grant);
|
|
78
|
+
if (this.runtimes.has(identity.runtimeId) || this.runtimes.size >= 32) return false;
|
|
79
|
+
const runtime = { ...identity, peer, generation: randomUUID(), registered: false,
|
|
80
|
+
registerBy: this.now() + 5_000, features: [], modelCatalog: null };
|
|
81
|
+
this.runtimePeers.set(peer, runtime);
|
|
82
|
+
this.runtimes.set(runtime.runtimeId, runtime);
|
|
83
|
+
return true;
|
|
84
|
+
} catch { return false; }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
attachClient(peer, identity) {
|
|
88
|
+
this.prune();
|
|
89
|
+
if (this.clients.has(peer) || this.runtimePeers.has(peer) || this.clients.size >= 128) return false;
|
|
90
|
+
try {
|
|
91
|
+
this.clients.set(peer, createRelayClient(peer, identity, this.now()));
|
|
92
|
+
return true;
|
|
93
|
+
} catch { return false; }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
runtimeSnapshot(allowedRuntimeIds) {
|
|
97
|
+
if (!Array.isArray(allowedRuntimeIds) || allowedRuntimeIds.length > 32) return [];
|
|
98
|
+
return [...new Set(allowedRuntimeIds.filter(isRelayId))].map(runtimeId => {
|
|
99
|
+
const runtime = this.runtimes.get(runtimeId);
|
|
100
|
+
const online = runtime?.registered === true;
|
|
101
|
+
return { runtimeId, online,
|
|
102
|
+
modelCatalog: online ? structuredClone(runtime.modelCatalog) : null,
|
|
103
|
+
features: online ? [...runtime.features] : [] };
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
register(runtime, body) {
|
|
108
|
+
if (runtime.registered) throw relayError('duplicate_registration');
|
|
109
|
+
const registration = parseRuntimeRegistration(body, runtime.runtimeId);
|
|
110
|
+
Object.assign(runtime, registration, { registered: true });
|
|
111
|
+
const reply = { type: 'registered', agentId: runtime.runtimeId, ok: true,
|
|
112
|
+
modelCatalog: { accepted: true, source: runtime.modelCatalog ? 'runtime' : 'legacy' },
|
|
113
|
+
features: runtime.features };
|
|
114
|
+
if (!this.transmit(runtime.peer, reply)) this.terminate(runtime.peer, 'transport_unavailable');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
runtimeMessage(peer, body) {
|
|
118
|
+
this.prune();
|
|
119
|
+
const runtime = this.runtimePeers.get(peer);
|
|
120
|
+
if (!runtime || this.runtimes.get(runtime.runtimeId) !== runtime) return;
|
|
121
|
+
if (!isBoundedRuntimeFrame(body)) return this.terminate(peer, 'invalid_runtime_frame');
|
|
122
|
+
try {
|
|
123
|
+
if (body.type === 'register') return this.register(runtime, body);
|
|
124
|
+
if (!runtime.registered) return this.terminate(peer, 'registration_required');
|
|
125
|
+
if (this.correlations.receive(runtime, body)) return;
|
|
126
|
+
this.requests.receive(runtime, body);
|
|
127
|
+
} catch (error) {
|
|
128
|
+
this.terminate(peer, error.code ?? 'invalid_runtime_frame');
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
clientMessage(peer, message) {
|
|
133
|
+
this.prune();
|
|
134
|
+
const client = this.clients.get(peer);
|
|
135
|
+
if (!client) return;
|
|
136
|
+
try {
|
|
137
|
+
admitRelayCommand(client, message, this.now());
|
|
138
|
+
const runtime = this.runtimes.get(message.runtimeId);
|
|
139
|
+
if (!runtime?.registered) throw relayError('runtime_offline');
|
|
140
|
+
const command = normalizeClientCommand(message.body, runtime.allowedModes);
|
|
141
|
+
this.dispatch(client, runtime, message.id, command);
|
|
142
|
+
} catch (error) {
|
|
143
|
+
this.error(peer, message?.id, message?.runtimeId, error.code ?? 'invalid_command');
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
acknowledge(client, runtime, id, body) {
|
|
148
|
+
this.deliver(client, { type: 'relay.ack', id, runtimeId: runtime.runtimeId, body });
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
dispatch(client, runtime, id, command) {
|
|
152
|
+
assertSparkChatSupported(command, runtime);
|
|
153
|
+
if (isSessionMutation(command.type)) {
|
|
154
|
+
this.approvals.adoption.assertNoPendingSession(runtime, command.sessionId);
|
|
155
|
+
return this.sessionMutations.dispatch(client, runtime, id, command);
|
|
156
|
+
}
|
|
157
|
+
if (['run_request', 'attachment.upload.request'].includes(command.type) || isApprovalCommand(command.type)) {
|
|
158
|
+
this.sessionMutations.assertNoPending(runtime, command.sessionId);
|
|
159
|
+
}
|
|
160
|
+
if (command.type === 'run_request') return this.dispatchRun(client, runtime, id, command);
|
|
161
|
+
if (isApprovalCommand(command.type)) return this.approvals.dispatch(client, runtime, id, command);
|
|
162
|
+
if (command.type === 'subscribe_request') {
|
|
163
|
+
this.requests.subscribe(client, runtime, command.requestId);
|
|
164
|
+
return this.acknowledge(client, runtime, id, { requestId: command.requestId, status: 'subscribed' });
|
|
165
|
+
}
|
|
166
|
+
if (command.type === 'tool_approval_response') return this.dispatchApproval(client, runtime, id, command);
|
|
167
|
+
if (command.type === 'steer_request') return this.dispatchSteering(client, runtime, id, command);
|
|
168
|
+
const expectedType = expectedResponseType(command.type);
|
|
169
|
+
if (!expectedType) throw relayError('unsupported_command');
|
|
170
|
+
const entry = this.correlations.create(client, runtime, id, command, expectedType);
|
|
171
|
+
const frame = { ...command, correlationId: entry.correlationId };
|
|
172
|
+
if (!this.transmit(runtime.peer, frame)) this.correlations.fail(entry);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
dispatchRun(client, runtime, id, command) {
|
|
176
|
+
this.approvals.adoption.assertNoPendingRequest(runtime, command.requestId);
|
|
177
|
+
const record = this.requests.create(client, runtime, command);
|
|
178
|
+
if (!this.transmit(runtime.peer, command)) {
|
|
179
|
+
this.requests.markUnknown(record, false);
|
|
180
|
+
return this.error(client.peer, id, runtime.runtimeId, 'outcome_unknown');
|
|
181
|
+
}
|
|
182
|
+
this.acknowledge(client, runtime, id, { requestId: command.requestId, status: 'dispatched' });
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
dispatchApproval(client, runtime, id, command) {
|
|
186
|
+
this.requests.consumeApproval(client, runtime, command);
|
|
187
|
+
if (!this.transmit(runtime.peer, command)) return this.error(client.peer, id, runtime.runtimeId, 'outcome_unknown');
|
|
188
|
+
this.acknowledge(client, runtime, id, { approvalId: command.approvalId, status: 'dispatched' });
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
dispatchSteering(client, runtime, id, command) {
|
|
192
|
+
this.requests.owned(client, runtime, command.requestId, true);
|
|
193
|
+
const entry = this.correlations.create(client, runtime, id, command, 'steer_ack');
|
|
194
|
+
try { this.requests.reserveSteering(client, runtime, command); }
|
|
195
|
+
catch (error) { this.correlations.remove(entry); throw error; }
|
|
196
|
+
if (!this.transmit(runtime.peer, command)) this.correlations.fail(entry);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
disconnect(peer) {
|
|
200
|
+
const client = this.clients.get(peer);
|
|
201
|
+
if (client) {
|
|
202
|
+
this.clients.delete(peer);
|
|
203
|
+
this.requests.disconnectClient(client);
|
|
204
|
+
this.correlations.disconnectClient(client);
|
|
205
|
+
}
|
|
206
|
+
const runtime = this.runtimePeers.get(peer);
|
|
207
|
+
if (!runtime) return;
|
|
208
|
+
this.runtimePeers.delete(peer);
|
|
209
|
+
if (this.runtimes.get(runtime.runtimeId) === runtime) this.runtimes.delete(runtime.runtimeId);
|
|
210
|
+
this.correlations.disconnectRuntime(runtime);
|
|
211
|
+
this.requests.disconnectRuntime(runtime);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
prune() {
|
|
215
|
+
for (const runtime of this.runtimePeers.values()) {
|
|
216
|
+
if (!runtime.registered && runtime.registerBy <= this.now()) this.terminate(runtime.peer, 'registration_timeout');
|
|
217
|
+
}
|
|
218
|
+
this.correlations.prune();
|
|
219
|
+
this.requests.prune();
|
|
220
|
+
this.ownership?.prune();
|
|
221
|
+
}
|
|
222
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** Routing identity only. Approval authority and execution state belong to the runtime. */
|
|
2
|
+
export const REQUEST_OWNERSHIP_SCHEMA = `
|
|
3
|
+
CREATE TABLE request_ownership (
|
|
4
|
+
runtime_id TEXT NOT NULL CHECK(length(runtime_id) BETWEEN 1 AND 128),
|
|
5
|
+
request_id TEXT NOT NULL CHECK(length(request_id) BETWEEN 1 AND 128),
|
|
6
|
+
session_id TEXT NOT NULL CHECK(length(session_id) BETWEEN 1 AND 128),
|
|
7
|
+
subject TEXT NOT NULL CHECK(length(subject) BETWEEN 1 AND 320),
|
|
8
|
+
device_id TEXT NOT NULL CHECK(length(device_id) BETWEEN 1 AND 256),
|
|
9
|
+
status TEXT NOT NULL CHECK(status IN ('open', 'terminal')),
|
|
10
|
+
created_at INTEGER NOT NULL CHECK(created_at >= 0),
|
|
11
|
+
terminal_at INTEGER,
|
|
12
|
+
PRIMARY KEY(runtime_id, request_id),
|
|
13
|
+
CHECK((status = 'open' AND terminal_at IS NULL)
|
|
14
|
+
OR (status = 'terminal' AND terminal_at IS NOT NULL AND terminal_at >= 0))
|
|
15
|
+
) STRICT;
|
|
16
|
+
CREATE INDEX request_ownership_session ON request_ownership(runtime_id, session_id);
|
|
17
|
+
CREATE INDEX request_ownership_terminal ON request_ownership(terminal_at) WHERE status = 'terminal';
|
|
18
|
+
`;
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { isRelayId, relayError } from './relay-runtime-registration.mjs';
|
|
2
|
+
|
|
3
|
+
const MAX_OWNERSHIP_RECORDS = 512;
|
|
4
|
+
const TERMINAL_RETENTION_MS = 12 * 60 * 60 * 1000;
|
|
5
|
+
const SELECT_RECORD = `SELECT runtime_id AS runtimeId, session_id AS sessionId,
|
|
6
|
+
request_id AS requestId, subject, device_id AS deviceId, status,
|
|
7
|
+
created_at AS createdAt, terminal_at AS terminalAt FROM request_ownership`;
|
|
8
|
+
|
|
9
|
+
function assertRequestIdentity(runtimeId, requestId) {
|
|
10
|
+
if (!isRelayId(runtimeId)) throw relayError('invalid_request_identity');
|
|
11
|
+
if (!isRelayId(requestId)) throw relayError('invalid_request_identity');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function assertOwnershipIdentity(identity, ownerId) {
|
|
15
|
+
if (!identity || typeof identity !== 'object') throw relayError('invalid_request_identity');
|
|
16
|
+
assertRequestIdentity(identity.runtimeId, identity.requestId);
|
|
17
|
+
if (!isRelayId(identity.sessionId)) throw relayError('invalid_request_identity');
|
|
18
|
+
if (identity.subject !== ownerId) throw relayError('invalid_request_identity');
|
|
19
|
+
if (typeof identity.deviceId !== 'string') throw relayError('invalid_request_identity');
|
|
20
|
+
if (!identity.deviceId.trim() || identity.deviceId.length > 256) throw relayError('invalid_request_identity');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Durable owner binding, never an approval cache or a queue for replaying runs. */
|
|
24
|
+
export class RequestOwnershipStore {
|
|
25
|
+
constructor({ database, now = Date.now }) {
|
|
26
|
+
if (typeof database?.prepare !== 'function' || typeof database?.transaction !== 'function') {
|
|
27
|
+
throw new TypeError('An authentication database is required');
|
|
28
|
+
}
|
|
29
|
+
if (typeof now !== 'function') throw new TypeError('An ownership clock is required');
|
|
30
|
+
this.database = database;
|
|
31
|
+
this.now = now;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
create(identity) {
|
|
35
|
+
assertOwnershipIdentity(identity, this.database.ownerId);
|
|
36
|
+
const time = this.#time();
|
|
37
|
+
return this.database.transaction(() => {
|
|
38
|
+
if (this.get(identity.runtimeId, identity.requestId)) throw relayError('request_id_conflict');
|
|
39
|
+
return this.#insert(identity, time, false);
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
isOwnerSubject(subject) {
|
|
44
|
+
return subject === this.database.ownerId;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The relay must first verify the exact canonical request on the authorized runtime. */
|
|
48
|
+
adopt(identity, terminal = false) {
|
|
49
|
+
assertOwnershipIdentity(identity, this.database.ownerId);
|
|
50
|
+
if (typeof terminal !== 'boolean') throw relayError('invalid_request_identity');
|
|
51
|
+
const time = this.#time();
|
|
52
|
+
return this.database.transaction(() => {
|
|
53
|
+
const existing = this.get(identity.runtimeId, identity.requestId);
|
|
54
|
+
if (!existing) return this.#insert(identity, time, terminal);
|
|
55
|
+
if (existing.subject !== identity.subject) throw relayError('request_not_owned');
|
|
56
|
+
if (existing.sessionId !== identity.sessionId) throw relayError('request_not_owned');
|
|
57
|
+
return existing;
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
get(runtimeId, requestId) {
|
|
62
|
+
assertRequestIdentity(runtimeId, requestId);
|
|
63
|
+
const record = this.database.prepare(`${SELECT_RECORD} WHERE runtime_id = ? AND request_id = ?`)
|
|
64
|
+
.get(runtimeId, requestId);
|
|
65
|
+
return record ? { ...record } : null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
listSession(runtimeId, sessionId) {
|
|
69
|
+
if (!isRelayId(runtimeId) || !isRelayId(sessionId)) throw relayError('invalid_request_identity');
|
|
70
|
+
return this.database.prepare(`${SELECT_RECORD} WHERE runtime_id = ? AND session_id = ?
|
|
71
|
+
ORDER BY created_at, request_id LIMIT ?`)
|
|
72
|
+
.all(runtimeId, sessionId, MAX_OWNERSHIP_RECORDS).map(record => ({ ...record }));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
markTerminal(runtimeId, requestId) {
|
|
76
|
+
assertRequestIdentity(runtimeId, requestId);
|
|
77
|
+
return this.database.prepare(`UPDATE request_ownership SET status = 'terminal', terminal_at = ?
|
|
78
|
+
WHERE runtime_id = ? AND request_id = ? AND status = 'open'`)
|
|
79
|
+
.run(this.#time(), runtimeId, requestId).changes > 0;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The caller must have confirmed deletion of the complete canonical session. */
|
|
83
|
+
retireSession(runtimeId, sessionId) {
|
|
84
|
+
if (!isRelayId(runtimeId) || !isRelayId(sessionId)) throw relayError('invalid_request_identity');
|
|
85
|
+
return this.database.prepare('DELETE FROM request_ownership WHERE runtime_id = ? AND session_id = ?')
|
|
86
|
+
.run(runtimeId, sessionId).changes;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
prune() {
|
|
90
|
+
return this.#deleteExpired(this.#time());
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
#insert(identity, time, terminal) {
|
|
94
|
+
this.#deleteExpired(time);
|
|
95
|
+
const count = this.database.prepare('SELECT count(*) AS count FROM request_ownership').get().count;
|
|
96
|
+
if (count >= MAX_OWNERSHIP_RECORDS) throw relayError('request_capacity');
|
|
97
|
+
this.database.prepare(`INSERT INTO request_ownership
|
|
98
|
+
(runtime_id, request_id, session_id, subject, device_id, status, created_at, terminal_at)
|
|
99
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
100
|
+
.run(identity.runtimeId, identity.requestId, identity.sessionId, identity.subject, identity.deviceId,
|
|
101
|
+
terminal ? 'terminal' : 'open', time, terminal ? time : null);
|
|
102
|
+
return this.get(identity.runtimeId, identity.requestId);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
#deleteExpired(time) {
|
|
106
|
+
return this.database.prepare("DELETE FROM request_ownership WHERE status = 'terminal' AND terminal_at <= ?")
|
|
107
|
+
.run(time - TERMINAL_RETENTION_MS).changes;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
#time() {
|
|
111
|
+
const time = this.now();
|
|
112
|
+
if (!Number.isSafeInteger(time) || time < 0) throw new TypeError('Invalid ownership clock');
|
|
113
|
+
return time;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { closeSync, constants, fstatSync, lstatSync, openSync, readSync } from 'node:fs';
|
|
3
|
+
import { basename, dirname, join, parse, resolve } from 'node:path';
|
|
4
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
5
|
+
import { assertPrivateStateDirectory, assertSameDatabaseFile } from '../auth/auth-storage-path.mjs';
|
|
6
|
+
import { validateConfiguration } from '../config/config-validation.mjs';
|
|
7
|
+
|
|
8
|
+
const MAX_PRIVATE_BYTES = 32_768;
|
|
9
|
+
const PORTAL_KEYS = ['version', 'purpose', 'demoId', 'ownerId', 'configSha256',
|
|
10
|
+
'publicOrigin', 'port', 'username', 'passwordSha256'];
|
|
11
|
+
const digest = (value) => createHash('sha256').update(value).digest('hex');
|
|
12
|
+
|
|
13
|
+
/** Bind only the demo identity; operators may configure its modes and push later. */
|
|
14
|
+
export function reviewDemoIdentitySha256(config) {
|
|
15
|
+
const runtimeGrants = config.runtimeGrants.map(({ runtimeId, tokenSha256 }) => ({ runtimeId, tokenSha256 }))
|
|
16
|
+
.sort((left, right) => left.runtimeId.localeCompare(right.runtimeId));
|
|
17
|
+
return digest(JSON.stringify({ publicOrigin: config.publicOrigin,
|
|
18
|
+
stateDirectory: config.stateDirectory, runtimeGrants }));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function assertReviewEndpointSeparation(socketConfig, publicOrigin, port) {
|
|
22
|
+
let origin;
|
|
23
|
+
try { origin = new URL(publicOrigin); } catch { /* Validated below. */ }
|
|
24
|
+
if (!origin || origin.protocol !== 'https:' || origin.origin !== publicOrigin || origin.username || origin.password) {
|
|
25
|
+
throw new Error('Review portal requires a canonical HTTPS origin');
|
|
26
|
+
}
|
|
27
|
+
if (publicOrigin === socketConfig.publicOrigin) throw new Error('Socket and review origins must differ');
|
|
28
|
+
if (!Number.isInteger(port) || port < 1024 || port > 65535 || port === socketConfig.port) {
|
|
29
|
+
throw new Error('Socket and review listener ports must differ and be unprivileged');
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function assertReviewDemoParentPath(directory) {
|
|
34
|
+
let current = parse(directory).root;
|
|
35
|
+
for (const component of directory.slice(current.length).split('/').filter(Boolean)) {
|
|
36
|
+
current = join(current, component);
|
|
37
|
+
const entry = lstatSync(current);
|
|
38
|
+
if (entry.isSymbolicLink() || !entry.isDirectory()) throw new Error('Unsafe demo installation path');
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function assertPrivateDemoDirectory(directory) {
|
|
43
|
+
assertReviewDemoParentPath(directory);
|
|
44
|
+
const stat = lstatSync(directory);
|
|
45
|
+
if (typeof process.getuid !== 'function' || stat.uid !== process.getuid() || (stat.mode & 0o7777) !== 0o700) {
|
|
46
|
+
throw new Error('Demo installation must be private and owned by the current user');
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function readPrivateDemoJson(path) {
|
|
51
|
+
let descriptor;
|
|
52
|
+
try {
|
|
53
|
+
descriptor = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
54
|
+
const stat = fstatSync(descriptor);
|
|
55
|
+
if (!stat.isFile() || stat.nlink !== 1 || stat.uid !== process.getuid() ||
|
|
56
|
+
(stat.mode & 0o7777) !== 0o600 || stat.size > MAX_PRIVATE_BYTES) throw new Error();
|
|
57
|
+
const bytes = Buffer.alloc(MAX_PRIVATE_BYTES + 1);
|
|
58
|
+
let length = 0;
|
|
59
|
+
while (length < bytes.length) {
|
|
60
|
+
const count = readSync(descriptor, bytes, length, bytes.length - length, null);
|
|
61
|
+
if (count === 0) break;
|
|
62
|
+
length += count;
|
|
63
|
+
}
|
|
64
|
+
if (length > MAX_PRIVATE_BYTES) throw new Error();
|
|
65
|
+
return JSON.parse(bytes.toString('utf8', 0, length));
|
|
66
|
+
} finally { if (descriptor !== undefined) closeSync(descriptor); }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function validatePortalRecord(value, socketConfig) {
|
|
70
|
+
if (!value || typeof value !== 'object' || Array.isArray(value) ||
|
|
71
|
+
Object.keys(value).length !== PORTAL_KEYS.length || Object.keys(value).some(key => !PORTAL_KEYS.includes(key))) {
|
|
72
|
+
throw new Error('Invalid review portal configuration');
|
|
73
|
+
}
|
|
74
|
+
if (value.version !== 1 || value.purpose !== 'review-demo' || value.username !== 'reviewer' ||
|
|
75
|
+
typeof value.demoId !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(value.demoId) ||
|
|
76
|
+
typeof value.ownerId !== 'string' || !/^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/.test(value.ownerId)) {
|
|
77
|
+
throw new Error('Invalid review demo identity');
|
|
78
|
+
}
|
|
79
|
+
for (const key of ['configSha256', 'passwordSha256']) {
|
|
80
|
+
if (typeof value[key] !== 'string' || !/^[a-f0-9]{64}$/.test(value[key])) throw new Error('Invalid review credential binding');
|
|
81
|
+
}
|
|
82
|
+
assertReviewEndpointSeparation(socketConfig, value.publicOrigin, value.port);
|
|
83
|
+
if (value.configSha256 !== reviewDemoIdentitySha256(socketConfig)) throw new Error('Demo socket identity changed');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function assertMarkedDemoDatabase(stateDirectory, portal) {
|
|
87
|
+
assertPrivateStateDirectory(stateDirectory);
|
|
88
|
+
const path = join(stateDirectory, 'auth.sqlite3');
|
|
89
|
+
const prepared = { path, stat: lstatSync(path) };
|
|
90
|
+
// Never instantiate AuthDatabase here: it can initialize or migrate state.
|
|
91
|
+
const database = new DatabaseSync(path, { readOnly: true });
|
|
92
|
+
try {
|
|
93
|
+
assertSameDatabaseFile(stateDirectory, prepared);
|
|
94
|
+
if (database.prepare('PRAGMA application_id').get().application_id !== 0x41425341) throw new Error();
|
|
95
|
+
const read = database.prepare('SELECT value FROM metadata WHERE key = ?');
|
|
96
|
+
if (read.get('review_demo_id')?.value !== portal.demoId || read.get('owner_id')?.value !== portal.ownerId) {
|
|
97
|
+
throw new Error('State does not belong to this review demo');
|
|
98
|
+
}
|
|
99
|
+
assertSameDatabaseFile(stateDirectory, prepared);
|
|
100
|
+
} finally { database.close(); }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function selectedDemoConfiguration(env) {
|
|
104
|
+
const primary = env.ABOT_SOCKET_CONFIG;
|
|
105
|
+
const legacy = env.SOCKET_V2_CONFIG;
|
|
106
|
+
if (primary && legacy && primary !== legacy) throw new Error('Conflicting demo configuration paths');
|
|
107
|
+
const value = primary || legacy;
|
|
108
|
+
if (typeof value !== 'string' || !value.trim() || value.length > 4096 || /[\x00-\x1f\x7f]/.test(value)) {
|
|
109
|
+
throw new Error('Choose a private demo config.json');
|
|
110
|
+
}
|
|
111
|
+
const path = resolve(value);
|
|
112
|
+
if (basename(path) !== 'config.json') throw new Error('Choose the demo config.json');
|
|
113
|
+
return path;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Separate-process gate: fixed siblings and existing marked state only. */
|
|
117
|
+
export function loadReviewDemoConfiguration(env = {}) {
|
|
118
|
+
try {
|
|
119
|
+
const configPath = selectedDemoConfiguration(env);
|
|
120
|
+
const directory = dirname(configPath);
|
|
121
|
+
assertPrivateDemoDirectory(directory);
|
|
122
|
+
const socketConfig = validateConfiguration(readPrivateDemoJson(configPath));
|
|
123
|
+
if (socketConfig.stateDirectory !== './state') throw new Error('Demo state must remain its fixed sibling');
|
|
124
|
+
const portal = readPrivateDemoJson(join(directory, 'review-portal.json'));
|
|
125
|
+
validatePortalRecord(portal, socketConfig);
|
|
126
|
+
const stateDirectory = join(directory, 'state');
|
|
127
|
+
assertMarkedDemoDatabase(stateDirectory, portal);
|
|
128
|
+
return Object.freeze({ ...portal, socketOrigin: socketConfig.publicOrigin, stateDirectory });
|
|
129
|
+
} catch {
|
|
130
|
+
throw new Error('Review portal requires a private marked demo installation with matching socket and state identities');
|
|
131
|
+
}
|
|
132
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
2
|
+
import * as filesystem from 'node:fs';
|
|
3
|
+
import { dirname, join, resolve } from 'node:path';
|
|
4
|
+
import { AuthDatabase } from '../auth/auth-database.mjs';
|
|
5
|
+
import { prepareInstallation } from '../setup/setup.mjs';
|
|
6
|
+
import { writePrivateInstallation } from '../setup/setup-files.mjs';
|
|
7
|
+
import { assertReviewDemoParentPath, assertReviewEndpointSeparation, reviewDemoIdentitySha256 } from './review-demo-isolation.mjs';
|
|
8
|
+
|
|
9
|
+
const OPTIONS = new Map([['--output', 'output'], ['--origin', 'origin'], ['--runtime-id', 'runtimeId'],
|
|
10
|
+
['--review-origin', 'reviewOrigin'], ['--port', 'port'], ['--review-port', 'reviewPort']]);
|
|
11
|
+
|
|
12
|
+
export function parseReviewDemoArguments(args) {
|
|
13
|
+
const options = {};
|
|
14
|
+
for (let index = 0; index < args.length; index += 2) {
|
|
15
|
+
const name = OPTIONS.get(args[index]);
|
|
16
|
+
const value = args[index + 1];
|
|
17
|
+
if (!name || Object.hasOwn(options, name) || typeof value !== 'string' || !value || value.startsWith('--')) {
|
|
18
|
+
throw new Error('Provide each review setup option once with a value');
|
|
19
|
+
}
|
|
20
|
+
if (name === 'port' || name === 'reviewPort') {
|
|
21
|
+
if (!/^\d{1,5}$/.test(value)) throw new Error('Invalid review listener port');
|
|
22
|
+
options[name] = Number(value);
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
options[name] = value;
|
|
26
|
+
}
|
|
27
|
+
for (const name of ['output', 'origin', 'runtimeId', 'reviewOrigin']) {
|
|
28
|
+
if (!options[name]) throw new Error('Missing required review setup option');
|
|
29
|
+
}
|
|
30
|
+
return options;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function writePrivateReviewFile(path, content, created, io) {
|
|
34
|
+
const descriptor = io.openSync(path, 'wx', 0o600);
|
|
35
|
+
created.push(path);
|
|
36
|
+
try { io.writeFileSync(descriptor, content); io.fsyncSync(descriptor); }
|
|
37
|
+
finally { io.closeSync(descriptor); }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function removeOwnedFailedDemo(directory, created, stateCreated, io) {
|
|
41
|
+
const failures = [];
|
|
42
|
+
for (const path of created.reverse()) {
|
|
43
|
+
try { io.unlinkSync(path); } catch (error) { failures.push(error); }
|
|
44
|
+
}
|
|
45
|
+
if (stateCreated) {
|
|
46
|
+
const state = join(directory, 'state');
|
|
47
|
+
for (const name of ['auth.sqlite3-journal', 'auth.sqlite3']) {
|
|
48
|
+
try { io.unlinkSync(join(state, name)); } catch (error) { if (error.code !== 'ENOENT') failures.push(error); }
|
|
49
|
+
}
|
|
50
|
+
try { io.rmdirSync(state); } catch (error) { failures.push(error); }
|
|
51
|
+
}
|
|
52
|
+
try { io.rmdirSync(directory); } catch (error) { failures.push(error); }
|
|
53
|
+
return failures;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Local file preparation only; no existing installation, services or runtime is reused. */
|
|
57
|
+
export function createReviewDemoInstallation(options, { io = filesystem } = {}) {
|
|
58
|
+
if (typeof options.output !== 'string' || !options.output.trim()) throw new Error('Choose a new private demo directory');
|
|
59
|
+
const bundle = prepareInstallation(options);
|
|
60
|
+
const port = options.reviewPort ?? 8789;
|
|
61
|
+
assertReviewEndpointSeparation(bundle.config, options.reviewOrigin, port);
|
|
62
|
+
const directory = resolve(options.output);
|
|
63
|
+
assertReviewDemoParentPath(dirname(directory));
|
|
64
|
+
writePrivateInstallation(directory, bundle, io);
|
|
65
|
+
const created = [join(directory, 'config.json'), join(directory, 'runtime-connection.json')];
|
|
66
|
+
let stateCreated = false;
|
|
67
|
+
try {
|
|
68
|
+
const stateDirectory = join(directory, 'state');
|
|
69
|
+
io.mkdirSync(stateDirectory, { mode: 0o700 });
|
|
70
|
+
stateCreated = true;
|
|
71
|
+
const database = new AuthDatabase(stateDirectory);
|
|
72
|
+
const demoId = randomBytes(32).toString('base64url');
|
|
73
|
+
let ownerId;
|
|
74
|
+
try {
|
|
75
|
+
ownerId = database.ownerId;
|
|
76
|
+
database.transaction(() => database.prepare('INSERT INTO metadata(key, value) VALUES (?, ?)')
|
|
77
|
+
.run('review_demo_id', demoId));
|
|
78
|
+
} finally { database.close(); }
|
|
79
|
+
const password = randomBytes(32).toString('base64url');
|
|
80
|
+
const portal = { version: 1, purpose: 'review-demo', demoId, ownerId,
|
|
81
|
+
configSha256: reviewDemoIdentitySha256(bundle.config), publicOrigin: options.reviewOrigin,
|
|
82
|
+
port, username: 'reviewer', passwordSha256: createHash('sha256').update(password).digest('hex') };
|
|
83
|
+
writePrivateReviewFile(join(directory, 'review-portal.json'), `${JSON.stringify(portal, null, 2)}\n`, created, io);
|
|
84
|
+
writePrivateReviewFile(join(directory, 'review-access.txt'),
|
|
85
|
+
`ABot isolated review demo\nPortal: ${portal.publicOrigin}/review\nUsername: reviewer\nPassword: ${password}\n\n` +
|
|
86
|
+
'Keep these reviewer credentials private. The portal issues normal single-use 5-minute pairing details.\n' +
|
|
87
|
+
'The demo runtime must use separate storage and synthetic data; this setup does not create or isolate a runtime.\n', created, io);
|
|
88
|
+
return { directory, configPath: join(directory, 'config.json'),
|
|
89
|
+
runtimeConnectionPath: join(directory, 'runtime-connection.json'),
|
|
90
|
+
reviewConfigurationPath: join(directory, 'review-portal.json'),
|
|
91
|
+
reviewAccessPath: join(directory, 'review-access.txt') };
|
|
92
|
+
} catch (error) {
|
|
93
|
+
const failures = removeOwnedFailedDemo(directory, created, stateCreated, io);
|
|
94
|
+
if (failures.length) throw new AggregateError([error, ...failures], 'Review setup failed and owned cleanup was incomplete');
|
|
95
|
+
throw error;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { WindowLimiter } from '../transport/transport-limits.mjs';
|
|
3
|
+
|
|
4
|
+
const CSRF_MS = 10 * 60 * 1000;
|
|
5
|
+
|
|
6
|
+
function matchesReviewerAuthorization(value, username, passwordSha256) {
|
|
7
|
+
if (typeof value !== 'string' || value.length > 512) return false;
|
|
8
|
+
const match = /^Basic ([A-Za-z0-9+/]+={0,2})$/.exec(value);
|
|
9
|
+
if (!match) return false;
|
|
10
|
+
const bytes = Buffer.from(match[1], 'base64');
|
|
11
|
+
if (bytes.toString('base64') !== match[1]) return false;
|
|
12
|
+
const decoded = bytes.toString('utf8');
|
|
13
|
+
const separator = decoded.indexOf(':');
|
|
14
|
+
if (separator < 0 || decoded.slice(0, separator) !== username) return false;
|
|
15
|
+
const password = decoded.slice(separator + 1);
|
|
16
|
+
if (!/^[A-Za-z0-9_-]{43}$/.test(password)) return false;
|
|
17
|
+
const digest = createHash('sha256').update(password).digest();
|
|
18
|
+
return timingSafeEqual(digest, Buffer.from(passwordSha256, 'hex'));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Browser access grants pairing issuance only, never a runtime or device token. */
|
|
22
|
+
export class ReviewPortalAccess {
|
|
23
|
+
constructor({ username, passwordSha256, now = Date.now }) {
|
|
24
|
+
if (username !== 'reviewer' || !/^[a-f0-9]{64}$/.test(passwordSha256 ?? '')) {
|
|
25
|
+
throw new Error('Invalid reviewer access configuration');
|
|
26
|
+
}
|
|
27
|
+
this.username = username;
|
|
28
|
+
this.passwordSha256 = passwordSha256;
|
|
29
|
+
this.now = now;
|
|
30
|
+
this.csrfKey = randomBytes(32);
|
|
31
|
+
this.failedSources = new WindowLimiter({ limit: 10, windowMs: 900_000, now });
|
|
32
|
+
this.failedTotal = new WindowLimiter({ limit: 200, windowMs: 900_000, capacity: 1, now });
|
|
33
|
+
this.pairSources = new WindowLimiter({ limit: 5, windowMs: 60_000, now });
|
|
34
|
+
this.pairTotal = new WindowLimiter({ limit: 20, windowMs: 60_000, capacity: 1, now });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
authenticate(authorization, sourceId) {
|
|
38
|
+
if (matchesReviewerAuthorization(authorization, this.username, this.passwordSha256)) return 200;
|
|
39
|
+
if (!this.failedSources.allow(sourceId)) return 429;
|
|
40
|
+
if (!this.failedTotal.allow('failures')) return 429;
|
|
41
|
+
return 401;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
createCsrf() {
|
|
45
|
+
const value = `${this.now()}.${randomBytes(16).toString('base64url')}`;
|
|
46
|
+
return `${value}.${createHmac('sha256', this.csrfKey).update(value).digest('base64url')}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
acceptsCsrf(token) {
|
|
50
|
+
if (typeof token !== 'string' || token.length > 128) return false;
|
|
51
|
+
const match = /^(\d{1,16})\.([A-Za-z0-9_-]{22})\.([A-Za-z0-9_-]{43})$/.exec(token);
|
|
52
|
+
if (!match) return false;
|
|
53
|
+
const issuedAt = Number(match[1]);
|
|
54
|
+
if (!Number.isSafeInteger(issuedAt) || issuedAt > this.now()) return false;
|
|
55
|
+
if (this.now() - issuedAt >= CSRF_MS) return false;
|
|
56
|
+
const expected = createHmac('sha256', this.csrfKey).update(`${match[1]}.${match[2]}`).digest('base64url');
|
|
57
|
+
return timingSafeEqual(Buffer.from(match[3]), Buffer.from(expected));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
allowPairing(sourceId) {
|
|
61
|
+
if (!this.pairSources.allow(sourceId)) return false;
|
|
62
|
+
return this.pairTotal.allow('pairings');
|
|
63
|
+
}
|
|
64
|
+
}
|