openclaw-brokerkit 0.1.0 → 0.2.1
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/README.md +128 -93
- package/dist/src/browser-session.js +7 -0
- package/dist/src/commands.js +8 -9
- package/dist/src/generated/openapi-operator-v1.js +5 -0
- package/dist/src/generated/operator-v1.js +14 -10
- package/dist/src/generated/operator-validators.js +2757 -0
- package/dist/src/http.js +92 -18
- package/dist/src/operator-v1.js +26 -80
- package/dist/src/revisions.js +108 -0
- package/dist/src/runtime.js +52 -17
- package/dist/ui/assets/index-BpyayXQz.js +144 -0
- package/dist/ui/index.html +1 -2
- package/package.json +24 -7
- package/dist/ui/assets/index-8w0_bcoC.js +0 -143
- package/dist/ui/assets/index-D8Ypbs3j.css +0 -1
package/dist/src/http.js
CHANGED
|
@@ -2,6 +2,7 @@ import { timingSafeEqual } from "node:crypto";
|
|
|
2
2
|
import { createReadStream, existsSync, statSync } from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { pluginErrorCode } from "./errors.js";
|
|
5
|
+
import { BROWSER_SESSION_HEADER, validBrowserSession, } from "./browser-session.js";
|
|
5
6
|
export function createHttpHandler(runtime, rootDir, capability) {
|
|
6
7
|
const uiDir = path.join(rootDir, "dist", "ui");
|
|
7
8
|
const rateLimit = createRateLimiter(120, 60_000);
|
|
@@ -15,18 +16,44 @@ export function createHttpHandler(runtime, rootDir, capability) {
|
|
|
15
16
|
async function handleApi(req, res, url, runtime, capability, rateLimit) {
|
|
16
17
|
if (!runtime || !capability)
|
|
17
18
|
return json(res, 404, { error: { code: "not_found" } });
|
|
18
|
-
if (!authorized(req.headers.authorization, capability))
|
|
19
|
-
return json(res, 401, { error: { code: "not_authorized" } });
|
|
20
19
|
if (req.headers.origin !== "null")
|
|
21
20
|
return json(res, 403, { error: { code: "not_authorized" } });
|
|
22
|
-
if (
|
|
23
|
-
return
|
|
21
|
+
if (req.method === "OPTIONS")
|
|
22
|
+
return preflight(req, res);
|
|
23
|
+
corsHeaders(res);
|
|
24
|
+
if (!authorized(browserSession(req), capability))
|
|
25
|
+
return json(res, 401, { error: { code: "not_authorized" } });
|
|
24
26
|
if (!rateLimit(req.socket.remoteAddress ?? "local"))
|
|
25
27
|
return json(res, 429, { error: { code: "rate_limited" } });
|
|
26
28
|
try {
|
|
29
|
+
if (req.method === "GET" &&
|
|
30
|
+
url.pathname === "/plugins/brokerkit/api/v1/events") {
|
|
31
|
+
const input = parseEventQuery(url);
|
|
32
|
+
const controller = new AbortController();
|
|
33
|
+
const close = () => controller.abort();
|
|
34
|
+
res.once("close", close);
|
|
35
|
+
try {
|
|
36
|
+
return json(res, 200, await runtime().waitForSnapshot(input.cursor, input.waitSeconds, controller.signal));
|
|
37
|
+
}
|
|
38
|
+
finally {
|
|
39
|
+
res.off("close", close);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (url.search)
|
|
43
|
+
return json(res, 400, { error: { code: "invalid_input" } });
|
|
27
44
|
if (req.method === "GET" &&
|
|
28
45
|
url.pathname === "/plugins/brokerkit/api/v1/snapshot")
|
|
29
46
|
return json(res, 200, runtime().snapshot());
|
|
47
|
+
if (req.method === "GET" &&
|
|
48
|
+
url.pathname === "/plugins/brokerkit/api/v1/summary") {
|
|
49
|
+
const snapshot = runtime().snapshot();
|
|
50
|
+
return json(res, 200, {
|
|
51
|
+
api_version: snapshot.api_version,
|
|
52
|
+
cursor: snapshot.cursor,
|
|
53
|
+
pending: snapshot.requests.filter((request) => request.request.status === "pending").length,
|
|
54
|
+
healthy: snapshot.sources.every((source) => source.healthy),
|
|
55
|
+
});
|
|
56
|
+
}
|
|
30
57
|
const detail = url.pathname.match(/^\/plugins\/brokerkit\/api\/v1\/requests\/([^/]+)$/);
|
|
31
58
|
if (req.method === "GET" && detail) {
|
|
32
59
|
const handle = decodeHandle(detail[1]);
|
|
@@ -37,7 +64,7 @@ async function handleApi(req, res, url, runtime, capability, rateLimit) {
|
|
|
37
64
|
? json(res, 200, request)
|
|
38
65
|
: json(res, 404, { error: { code: "request_not_found" } });
|
|
39
66
|
}
|
|
40
|
-
const decision = url.pathname.match(/^\/plugins\/brokerkit\/api\/v1\/requests\/([^/]+)\/(approve|deny|
|
|
67
|
+
const decision = url.pathname.match(/^\/plugins\/brokerkit\/api\/v1\/requests\/([^/]+)\/(approve|deny|revoke)$/);
|
|
41
68
|
if (req.method === "POST" && decision) {
|
|
42
69
|
if (!isJSON(req.headers["content-type"]))
|
|
43
70
|
throw new Error("invalid_input");
|
|
@@ -70,15 +97,55 @@ function serveUi(req, res, url, uiDir) {
|
|
|
70
97
|
: "public, max-age=31536000, immutable");
|
|
71
98
|
res.setHeader("content-security-policy", "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; img-src 'self' data:; frame-ancestors 'self'");
|
|
72
99
|
securityHeaders(res);
|
|
100
|
+
if (!file.endsWith("index.html")) {
|
|
101
|
+
res.setHeader("cross-origin-resource-policy", "cross-origin");
|
|
102
|
+
}
|
|
73
103
|
createReadStream(file).pipe(res);
|
|
74
104
|
return true;
|
|
75
105
|
}
|
|
76
|
-
function authorized(
|
|
77
|
-
const
|
|
78
|
-
const left = Buffer.from(token);
|
|
106
|
+
function authorized(token, capability) {
|
|
107
|
+
const left = Buffer.from(token ?? "");
|
|
79
108
|
const right = Buffer.from(capability);
|
|
80
109
|
return left.length === right.length && timingSafeEqual(left, right);
|
|
81
110
|
}
|
|
111
|
+
function browserSession(req) {
|
|
112
|
+
const values = [];
|
|
113
|
+
for (let index = 0; index < req.rawHeaders.length; index += 2) {
|
|
114
|
+
if (req.rawHeaders[index]?.toLowerCase() ===
|
|
115
|
+
BROWSER_SESSION_HEADER.toLowerCase())
|
|
116
|
+
values.push(req.rawHeaders[index + 1] ?? "");
|
|
117
|
+
}
|
|
118
|
+
if (values.length !== 1 || !validBrowserSession(values[0] ?? ""))
|
|
119
|
+
return undefined;
|
|
120
|
+
return values[0];
|
|
121
|
+
}
|
|
122
|
+
function preflight(req, res) {
|
|
123
|
+
const method = req.headers["access-control-request-method"];
|
|
124
|
+
const requested = String(req.headers["access-control-request-headers"] ?? "")
|
|
125
|
+
.split(",")
|
|
126
|
+
.map((value) => value.trim().toLowerCase())
|
|
127
|
+
.filter(Boolean);
|
|
128
|
+
const allowedHeaders = new Set([
|
|
129
|
+
BROWSER_SESSION_HEADER.toLowerCase(),
|
|
130
|
+
"content-type",
|
|
131
|
+
]);
|
|
132
|
+
if ((method !== "GET" && method !== "POST") ||
|
|
133
|
+
requested.length === 0 ||
|
|
134
|
+
requested.some((value) => !allowedHeaders.has(value)))
|
|
135
|
+
return json(res, 403, { error: { code: "not_authorized" } });
|
|
136
|
+
res.statusCode = 204;
|
|
137
|
+
corsHeaders(res);
|
|
138
|
+
res.setHeader("access-control-allow-methods", "GET, POST");
|
|
139
|
+
res.setHeader("access-control-allow-headers", `${BROWSER_SESSION_HEADER}, Content-Type`);
|
|
140
|
+
res.setHeader("cache-control", "no-store");
|
|
141
|
+
securityHeaders(res);
|
|
142
|
+
res.end();
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
function corsHeaders(res) {
|
|
146
|
+
res.setHeader("access-control-allow-origin", "null");
|
|
147
|
+
res.setHeader("vary", "Origin");
|
|
148
|
+
}
|
|
82
149
|
async function readJSON(req) {
|
|
83
150
|
const chunks = [];
|
|
84
151
|
let size = 0;
|
|
@@ -218,29 +285,36 @@ function mapHttpError(error) {
|
|
|
218
285
|
return { code, status: 409 };
|
|
219
286
|
if (code === "action_not_allowed")
|
|
220
287
|
return { code, status: 422 };
|
|
288
|
+
if (code === "cursor_expired")
|
|
289
|
+
return { code, status: 410 };
|
|
221
290
|
if (code === "source_unavailable")
|
|
222
291
|
return { code, status: 503 };
|
|
223
292
|
return { code: "internal_error", status: 500 };
|
|
224
293
|
}
|
|
294
|
+
function parseEventQuery(url) {
|
|
295
|
+
if ([...url.searchParams.keys()].some((key) => !["cursor", "wait_seconds"].includes(key)))
|
|
296
|
+
throw new Error("invalid_input");
|
|
297
|
+
const cursor = url.searchParams.get("cursor") ?? "";
|
|
298
|
+
const wait = url.searchParams.get("wait_seconds") ?? "25";
|
|
299
|
+
if (cursor.length < 1 ||
|
|
300
|
+
cursor.length > 128 ||
|
|
301
|
+
!/^[A-Za-z0-9_.-]+$/u.test(cursor) ||
|
|
302
|
+
!/^(?:[1-9]|1[0-9]|2[0-5])$/u.test(wait))
|
|
303
|
+
throw new Error("invalid_input");
|
|
304
|
+
return { cursor, waitSeconds: Number(wait) };
|
|
305
|
+
}
|
|
225
306
|
function parseDecisionInput(body, action) {
|
|
226
|
-
if (Object.keys(body).some((key) => key !== "expectedRevision" && key !== "
|
|
227
|
-
!positiveSafeInteger(body.expectedRevision)
|
|
228
|
-
(body.reason !== undefined && typeof body.reason !== "string"))
|
|
307
|
+
if (Object.keys(body).some((key) => key !== "expectedRevision" && key !== "constraints") ||
|
|
308
|
+
!positiveSafeInteger(body.expectedRevision))
|
|
229
309
|
throw new Error("invalid_input");
|
|
230
310
|
const options = {};
|
|
231
|
-
if (typeof body.reason === "string" && body.reason.trim()) {
|
|
232
|
-
const reason = body.reason.trim();
|
|
233
|
-
if (Buffer.byteLength(reason, "utf8") > 2000)
|
|
234
|
-
throw new Error("invalid_input");
|
|
235
|
-
options.reason = reason;
|
|
236
|
-
}
|
|
237
311
|
if (body.constraints !== undefined) {
|
|
238
312
|
if (action !== "approve" || !record(body.constraints))
|
|
239
313
|
throw new Error("invalid_input");
|
|
240
314
|
const constraints = body.constraints;
|
|
241
315
|
if (Object.keys(constraints).some((key) => key !== "durationSeconds" && key !== "maxUses") ||
|
|
242
316
|
!positiveSafeInteger(constraints.durationSeconds) ||
|
|
243
|
-
!positiveSafeInteger(constraints.maxUses))
|
|
317
|
+
!(constraints.maxUses === null || positiveSafeInteger(constraints.maxUses)))
|
|
244
318
|
throw new Error("invalid_input");
|
|
245
319
|
options.constraints = {
|
|
246
320
|
duration_seconds: constraints.durationSeconds,
|
package/dist/src/operator-v1.js
CHANGED
|
@@ -1,92 +1,38 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { operatorV1 } from "./generated/operator-v1.js";
|
|
3
|
-
const timestamp = z.iso.datetime({ offset: true });
|
|
4
|
-
const nonNegativeInteger = z.number().int().nonnegative().safe();
|
|
5
|
-
const positiveInteger = z.number().int().positive().safe();
|
|
6
|
-
const status = z.enum(operatorV1.statuses);
|
|
7
|
-
const action = z.enum(operatorV1.actions);
|
|
8
|
-
const fact = z.object({
|
|
9
|
-
label: z.string().min(1).max(operatorV1.limits.factLabel),
|
|
10
|
-
value: z.string().min(1).max(operatorV1.limits.factValue),
|
|
11
|
-
});
|
|
12
|
-
const presentation = z.object({
|
|
13
|
-
risk: z.enum(operatorV1.risks),
|
|
14
|
-
title: z.string().min(1).max(operatorV1.limits.title),
|
|
15
|
-
summary: z.string().max(operatorV1.limits.summary).optional(),
|
|
16
|
-
facts: z.array(fact).max(operatorV1.limits.facts).optional(),
|
|
17
|
-
});
|
|
18
|
-
export const brokerRequestSchema = z.object({
|
|
19
|
-
id: z.string().min(1).max(operatorV1.limits.id),
|
|
20
|
-
revision: positiveInteger,
|
|
21
|
-
requester: z.string().min(1).max(operatorV1.limits.requester),
|
|
22
|
-
operation: z.string().min(1).max(operatorV1.limits.operation),
|
|
23
|
-
status,
|
|
24
|
-
requested_at: timestamp,
|
|
25
|
-
pending_expires_at: timestamp.optional(),
|
|
26
|
-
active_expires_at: timestamp.optional(),
|
|
27
|
-
requested_duration_seconds: positiveInteger,
|
|
28
|
-
requested_max_uses: positiveInteger,
|
|
29
|
-
granted_max_uses: positiveInteger.nullable(),
|
|
30
|
-
used_count: nonNegativeInteger,
|
|
31
|
-
request_reason: z.string().max(operatorV1.limits.reason).optional(),
|
|
32
|
-
decided_at: timestamp.optional(),
|
|
33
|
-
decided_by: z.string().max(operatorV1.limits.actor).optional(),
|
|
34
|
-
decided_on_behalf_of: z.string().max(operatorV1.limits.actor).optional(),
|
|
35
|
-
decision_reason: z.string().max(operatorV1.limits.reason).optional(),
|
|
36
|
-
presentation,
|
|
37
|
-
presentation_unavailable: z.boolean().optional(),
|
|
38
|
-
allowed_actions: z
|
|
39
|
-
.array(action)
|
|
40
|
-
.max(operatorV1.actions.length)
|
|
41
|
-
.refine((value) => new Set(value).size === value.length),
|
|
42
|
-
approval_bounds: z
|
|
43
|
-
.object({
|
|
44
|
-
max_duration_seconds: nonNegativeInteger,
|
|
45
|
-
max_uses: positiveInteger,
|
|
46
|
-
})
|
|
47
|
-
.optional(),
|
|
48
|
-
});
|
|
49
|
-
const requestPageSchema = z.object({
|
|
50
|
-
requests: z.array(brokerRequestSchema).max(operatorV1.limits.page),
|
|
51
|
-
next_cursor: z.string().min(1).max(operatorV1.limits.cursor).optional(),
|
|
52
|
-
event_cursor: z.string().min(1).max(operatorV1.limits.cursor).optional(),
|
|
53
|
-
});
|
|
54
|
-
const brokerEventSchema = z.object({
|
|
55
|
-
cursor: z.string().min(1).max(operatorV1.limits.cursor),
|
|
56
|
-
kind: z.enum(operatorV1.eventKinds),
|
|
57
|
-
request_id: z.string().min(1).max(operatorV1.limits.id),
|
|
58
|
-
revision: positiveInteger,
|
|
59
|
-
status,
|
|
60
|
-
occurred_at: timestamp,
|
|
61
|
-
used_count: nonNegativeInteger,
|
|
62
|
-
});
|
|
63
|
-
const descriptorSchema = z.object({
|
|
64
|
-
api_version: z.literal(operatorV1.apiVersion),
|
|
65
|
-
});
|
|
66
|
-
const healthSchema = z.object({ status: z.string().min(1).max(128) });
|
|
67
|
-
const errorEnvelopeSchema = z.object({
|
|
68
|
-
error: z.object({
|
|
69
|
-
code: z.enum(operatorV1.errorCodes),
|
|
70
|
-
message: z.string().min(1).max(operatorV1.limits.errorMessage),
|
|
71
|
-
correlation_id: z.string().min(1).max(operatorV1.limits.correlationId),
|
|
72
|
-
}),
|
|
73
|
-
});
|
|
1
|
+
import { validateBrokerEvent, validateBrokerRequest, validateDescriptor, validateErrorEnvelope, validateHealth, validateRequestPage, validateUIRequest, validateUISnapshot, validateUISnapshotEvent, validateUISummary, } from "./generated/operator-validators.js";
|
|
74
2
|
export function parseDescriptor(value) {
|
|
75
|
-
return
|
|
3
|
+
return validated(validateDescriptor, value);
|
|
76
4
|
}
|
|
77
5
|
export function parseHealth(value) {
|
|
78
|
-
return
|
|
6
|
+
return validated(validateHealth, value);
|
|
79
7
|
}
|
|
80
8
|
export function parseRequest(value) {
|
|
81
|
-
return
|
|
9
|
+
return validated(validateBrokerRequest, value);
|
|
82
10
|
}
|
|
83
11
|
export function parseRequestPage(value) {
|
|
84
|
-
return
|
|
12
|
+
return validated(validateRequestPage, value);
|
|
85
13
|
}
|
|
86
14
|
export function parseBrokerEvent(value) {
|
|
87
|
-
return
|
|
15
|
+
return validated(validateBrokerEvent, value);
|
|
16
|
+
}
|
|
17
|
+
export function parseUIRequest(value) {
|
|
18
|
+
return validated(validateUIRequest, value);
|
|
19
|
+
}
|
|
20
|
+
export function parseUISnapshot(value) {
|
|
21
|
+
return validated(validateUISnapshot, value);
|
|
22
|
+
}
|
|
23
|
+
export function parseUISnapshotEvent(value) {
|
|
24
|
+
return validated(validateUISnapshotEvent, value);
|
|
25
|
+
}
|
|
26
|
+
export function parseUISummary(value) {
|
|
27
|
+
return validated(validateUISummary, value);
|
|
88
28
|
}
|
|
89
29
|
export function parseErrorEnvelope(value) {
|
|
90
|
-
|
|
91
|
-
|
|
30
|
+
return validateErrorEnvelope(value)
|
|
31
|
+
? value
|
|
32
|
+
: undefined;
|
|
33
|
+
}
|
|
34
|
+
function validated(validate, value) {
|
|
35
|
+
if (!validate(value))
|
|
36
|
+
throw new Error("Operator V1 response is invalid");
|
|
37
|
+
return value;
|
|
92
38
|
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
export class RevisionPublisher {
|
|
3
|
+
epoch;
|
|
4
|
+
revision = 0;
|
|
5
|
+
material = "";
|
|
6
|
+
current;
|
|
7
|
+
waiters = new Set();
|
|
8
|
+
constructor(epoch = randomBytes(16).toString("base64url")) {
|
|
9
|
+
if (!/^[A-Za-z0-9_-]{22}$/u.test(epoch))
|
|
10
|
+
throw new Error("invalid revision epoch");
|
|
11
|
+
this.epoch = epoch;
|
|
12
|
+
}
|
|
13
|
+
publish(value) {
|
|
14
|
+
const material = JSON.stringify(value);
|
|
15
|
+
if (this.current && material === this.material)
|
|
16
|
+
return this.current;
|
|
17
|
+
this.material = material;
|
|
18
|
+
this.revision += 1;
|
|
19
|
+
this.current = {
|
|
20
|
+
api_version: "brokerkit.io/operator-ui/v1",
|
|
21
|
+
cursor: this.cursor(),
|
|
22
|
+
synchronized_at: new Date().toISOString(),
|
|
23
|
+
...value,
|
|
24
|
+
};
|
|
25
|
+
for (const waiter of [...this.waiters])
|
|
26
|
+
this.finish(waiter, {
|
|
27
|
+
api_version: "brokerkit.io/operator-ui/v1",
|
|
28
|
+
cursor: this.current.cursor,
|
|
29
|
+
changed: true,
|
|
30
|
+
});
|
|
31
|
+
return this.current;
|
|
32
|
+
}
|
|
33
|
+
snapshot() {
|
|
34
|
+
if (!this.current)
|
|
35
|
+
throw new Error("source_unavailable");
|
|
36
|
+
return this.current;
|
|
37
|
+
}
|
|
38
|
+
wait(cursor, waitSeconds, signal) {
|
|
39
|
+
const observed = this.parseCursor(cursor);
|
|
40
|
+
if (observed === undefined)
|
|
41
|
+
return Promise.reject(cursorExpired());
|
|
42
|
+
if (observed !== this.revision)
|
|
43
|
+
return Promise.resolve({
|
|
44
|
+
api_version: "brokerkit.io/operator-ui/v1",
|
|
45
|
+
cursor: this.cursor(),
|
|
46
|
+
changed: true,
|
|
47
|
+
});
|
|
48
|
+
if (signal?.aborted)
|
|
49
|
+
return Promise.reject(aborted());
|
|
50
|
+
return new Promise((resolve, reject) => {
|
|
51
|
+
const waiter = {
|
|
52
|
+
resolve,
|
|
53
|
+
reject,
|
|
54
|
+
timer: setTimeout(() => {
|
|
55
|
+
this.finish(waiter, {
|
|
56
|
+
api_version: "brokerkit.io/operator-ui/v1",
|
|
57
|
+
cursor: this.cursor(),
|
|
58
|
+
changed: false,
|
|
59
|
+
});
|
|
60
|
+
}, waitSeconds * 1000),
|
|
61
|
+
...(signal ? { signal } : {}),
|
|
62
|
+
};
|
|
63
|
+
waiter.timer.unref();
|
|
64
|
+
if (signal) {
|
|
65
|
+
waiter.abort = () => this.fail(waiter, aborted());
|
|
66
|
+
signal.addEventListener("abort", waiter.abort, { once: true });
|
|
67
|
+
}
|
|
68
|
+
this.waiters.add(waiter);
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
close() {
|
|
72
|
+
for (const waiter of [...this.waiters])
|
|
73
|
+
this.fail(waiter, new Error("source_unavailable"));
|
|
74
|
+
}
|
|
75
|
+
cursor() {
|
|
76
|
+
return `${this.epoch}.${this.revision.toString(36)}`;
|
|
77
|
+
}
|
|
78
|
+
parseCursor(value) {
|
|
79
|
+
const match = /^([A-Za-z0-9_-]{22})\.([0-9a-z]{1,13})$/u.exec(value);
|
|
80
|
+
if (!match || match[1] !== this.epoch)
|
|
81
|
+
return undefined;
|
|
82
|
+
const revision = Number.parseInt(match[2] ?? "", 36);
|
|
83
|
+
return Number.isSafeInteger(revision) && revision <= this.revision
|
|
84
|
+
? revision
|
|
85
|
+
: undefined;
|
|
86
|
+
}
|
|
87
|
+
finish(waiter, value) {
|
|
88
|
+
this.cleanup(waiter);
|
|
89
|
+
waiter.resolve(value);
|
|
90
|
+
}
|
|
91
|
+
fail(waiter, error) {
|
|
92
|
+
this.cleanup(waiter);
|
|
93
|
+
waiter.reject(error);
|
|
94
|
+
}
|
|
95
|
+
cleanup(waiter) {
|
|
96
|
+
if (!this.waiters.delete(waiter))
|
|
97
|
+
return;
|
|
98
|
+
clearTimeout(waiter.timer);
|
|
99
|
+
if (waiter.signal && waiter.abort)
|
|
100
|
+
waiter.signal.removeEventListener("abort", waiter.abort);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
function cursorExpired() {
|
|
104
|
+
return Object.assign(new Error("cursor_expired"), { code: "cursor_expired" });
|
|
105
|
+
}
|
|
106
|
+
function aborted() {
|
|
107
|
+
return new DOMException("The operation was aborted", "AbortError");
|
|
108
|
+
}
|
package/dist/src/runtime.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { BrokerClient, BrokerError } from "./client.js";
|
|
3
3
|
import { StateStore } from "./store.js";
|
|
4
|
+
import { RevisionPublisher } from "./revisions.js";
|
|
4
5
|
export class BrokerRuntime {
|
|
5
6
|
config;
|
|
6
7
|
hooks;
|
|
@@ -10,6 +11,8 @@ export class BrokerRuntime {
|
|
|
10
11
|
store;
|
|
11
12
|
timer;
|
|
12
13
|
delivering = false;
|
|
14
|
+
ready = false;
|
|
15
|
+
revisions = new RevisionPublisher();
|
|
13
16
|
constructor(config, hooks) {
|
|
14
17
|
this.config = config;
|
|
15
18
|
this.hooks = hooks;
|
|
@@ -25,6 +28,8 @@ export class BrokerRuntime {
|
|
|
25
28
|
this.store.retainSources([...this.sources.keys()]);
|
|
26
29
|
this.store.pruneExpired();
|
|
27
30
|
await Promise.all([...this.sources.values()].map((source) => this.startSource(source)));
|
|
31
|
+
this.ready = true;
|
|
32
|
+
this.publishSnapshot();
|
|
28
33
|
this.timer = setInterval(() => {
|
|
29
34
|
void this.reconcileAll();
|
|
30
35
|
void this.deliverPending();
|
|
@@ -32,21 +37,32 @@ export class BrokerRuntime {
|
|
|
32
37
|
this.timer.unref();
|
|
33
38
|
}
|
|
34
39
|
async stop() {
|
|
40
|
+
this.ready = false;
|
|
35
41
|
if (this.timer)
|
|
36
42
|
clearInterval(this.timer);
|
|
37
43
|
for (const source of this.sources.values())
|
|
38
44
|
source.abort?.abort();
|
|
45
|
+
this.revisions.close();
|
|
39
46
|
this.store?.close();
|
|
40
47
|
this.store = undefined;
|
|
41
48
|
}
|
|
42
49
|
snapshot() {
|
|
50
|
+
return this.revisions.snapshot();
|
|
51
|
+
}
|
|
52
|
+
waitForSnapshot(cursor, waitSeconds, signal) {
|
|
53
|
+
return this.revisions.wait(cursor, waitSeconds, signal);
|
|
54
|
+
}
|
|
55
|
+
snapshotMaterial() {
|
|
43
56
|
return {
|
|
44
57
|
sources: [...this.health.values()].sort((a, b) => a.id.localeCompare(b.id)),
|
|
45
|
-
requests: [...this.requests.values()].sort((a, b) => b.requested_at.localeCompare(a.requested_at)),
|
|
46
|
-
|
|
47
|
-
deliveryFailures: this.store?.failedDeliveryCount() ?? 0,
|
|
58
|
+
requests: [...this.requests.values()].sort((a, b) => b.request.requested_at.localeCompare(a.request.requested_at)),
|
|
59
|
+
delivery_failures: this.store?.failedDeliveryCount() ?? 0,
|
|
48
60
|
};
|
|
49
61
|
}
|
|
62
|
+
publishSnapshot() {
|
|
63
|
+
if (this.ready)
|
|
64
|
+
this.revisions.publish(this.snapshotMaterial());
|
|
65
|
+
}
|
|
50
66
|
async decide(handle, action, expectedRevision, actor, options = {}) {
|
|
51
67
|
const resolved = this.requireStore().resolve(handle);
|
|
52
68
|
if (!resolved)
|
|
@@ -67,14 +83,13 @@ export class BrokerRuntime {
|
|
|
67
83
|
(options.constraints.duration_seconds !== undefined &&
|
|
68
84
|
options.constraints.duration_seconds > bounds.max_duration_seconds) ||
|
|
69
85
|
(options.constraints.max_uses !== undefined &&
|
|
70
|
-
options.constraints.max_uses
|
|
86
|
+
!validUseConstraint(options.constraints.max_uses, bounds.max_uses)))
|
|
71
87
|
throw new Error("action_not_allowed");
|
|
72
88
|
}
|
|
73
89
|
const decision = {
|
|
74
90
|
expected_revision: expectedRevision,
|
|
75
91
|
idempotency_key: deterministicDecisionKey(resolved.sourceId, resolved.requestId, expectedRevision, action, actor),
|
|
76
92
|
on_behalf_of: actor,
|
|
77
|
-
...(options.reason ? { decision_reason: options.reason } : {}),
|
|
78
93
|
...(options.constraints ? { constraints: options.constraints } : {}),
|
|
79
94
|
};
|
|
80
95
|
const updated = await this.decideWithRecovery(source, resolved.requestId, action, decision);
|
|
@@ -149,15 +164,17 @@ export class BrokerRuntime {
|
|
|
149
164
|
} while (cursor);
|
|
150
165
|
}
|
|
151
166
|
for (const request of [...this.requests.values()])
|
|
152
|
-
if (request.
|
|
167
|
+
if (request.source_id === source.config.id &&
|
|
168
|
+
!seen.has(request.request.id)) {
|
|
153
169
|
this.requests.delete(request.handle);
|
|
154
|
-
this.requireStore().remove(source.config.id, request.id);
|
|
170
|
+
this.requireStore().remove(source.config.id, request.request.id);
|
|
155
171
|
}
|
|
156
172
|
this.requireStore().retainRequests(source.config.id, seen);
|
|
157
173
|
this.requireStore().pruneExpired();
|
|
158
174
|
if (eventCursor)
|
|
159
175
|
this.requireStore().setCursor(source.config.id, eventCursor);
|
|
160
176
|
this.markHealthy(source);
|
|
177
|
+
this.publishSnapshot();
|
|
161
178
|
}
|
|
162
179
|
watch(source) {
|
|
163
180
|
source.abort?.abort();
|
|
@@ -206,19 +223,25 @@ export class BrokerRuntime {
|
|
|
206
223
|
source.discovered = true;
|
|
207
224
|
}
|
|
208
225
|
markHealthy(source) {
|
|
226
|
+
const current = this.health.get(source.config.id);
|
|
227
|
+
if (current?.healthy)
|
|
228
|
+
return;
|
|
209
229
|
this.health.set(source.config.id, {
|
|
210
230
|
id: source.config.id,
|
|
211
231
|
label: source.config.label,
|
|
212
232
|
healthy: true,
|
|
213
|
-
|
|
233
|
+
last_sync_at: new Date().toISOString(),
|
|
214
234
|
});
|
|
235
|
+
this.publishSnapshot();
|
|
215
236
|
}
|
|
216
237
|
accept(source, request) {
|
|
217
238
|
if (request.status !== "pending" && request.status !== "active") {
|
|
218
239
|
this.requireStore().remove(source.config.id, request.id);
|
|
219
240
|
for (const [handle, current] of this.requests)
|
|
220
|
-
if (current.
|
|
241
|
+
if (current.source_id === source.config.id &&
|
|
242
|
+
current.request.id === request.id)
|
|
221
243
|
this.requests.delete(handle);
|
|
244
|
+
this.publishSnapshot();
|
|
222
245
|
return;
|
|
223
246
|
}
|
|
224
247
|
const expires = Date.parse(request.pending_expires_at ??
|
|
@@ -226,20 +249,21 @@ export class BrokerRuntime {
|
|
|
226
249
|
new Date(Date.now() + 86_400_000).toISOString());
|
|
227
250
|
const handle = this.requireStore().handle(source.config.id, request.id, request.revision, expires);
|
|
228
251
|
for (const [old, current] of this.requests)
|
|
229
|
-
if (current.
|
|
230
|
-
current.id === request.id &&
|
|
252
|
+
if (current.source_id === source.config.id &&
|
|
253
|
+
current.request.id === request.id &&
|
|
231
254
|
old !== handle)
|
|
232
255
|
this.requests.delete(old);
|
|
233
256
|
this.requests.set(handle, this.project(source, request, handle));
|
|
234
257
|
this.requireStore().enqueue(handle);
|
|
258
|
+
this.publishSnapshot();
|
|
235
259
|
void this.deliverPending();
|
|
236
260
|
}
|
|
237
261
|
project(source, request, handle) {
|
|
238
262
|
return {
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
sourceLabel: source.config.label,
|
|
263
|
+
source_id: source.config.id,
|
|
264
|
+
source_label: source.config.label,
|
|
242
265
|
handle,
|
|
266
|
+
request,
|
|
243
267
|
};
|
|
244
268
|
}
|
|
245
269
|
markUnhealthy(source, error) {
|
|
@@ -250,6 +274,7 @@ export class BrokerRuntime {
|
|
|
250
274
|
healthy: false,
|
|
251
275
|
error: safeError(message),
|
|
252
276
|
});
|
|
277
|
+
this.publishSnapshot();
|
|
253
278
|
this.hooks.log("warn", `BrokerKit source ${source.config.id} unavailable: ${safeError(message)}`);
|
|
254
279
|
}
|
|
255
280
|
requireStore() {
|
|
@@ -271,9 +296,11 @@ export class BrokerRuntime {
|
|
|
271
296
|
try {
|
|
272
297
|
await this.hooks.deliver(delivery, notificationText(request));
|
|
273
298
|
this.store?.markDelivered(delivery.id, delivery.handle);
|
|
299
|
+
this.publishSnapshot();
|
|
274
300
|
}
|
|
275
301
|
catch {
|
|
276
302
|
this.store?.markDeliveryError(delivery.id, delivery.handle, delivery.attempts);
|
|
303
|
+
this.publishSnapshot();
|
|
277
304
|
}
|
|
278
305
|
}));
|
|
279
306
|
}
|
|
@@ -282,6 +309,13 @@ export class BrokerRuntime {
|
|
|
282
309
|
}
|
|
283
310
|
}
|
|
284
311
|
}
|
|
312
|
+
function validUseConstraint(value, maximum) {
|
|
313
|
+
if (value === null)
|
|
314
|
+
return maximum === null;
|
|
315
|
+
return (Number.isSafeInteger(value) &&
|
|
316
|
+
value > 0 &&
|
|
317
|
+
(maximum === null || value <= maximum));
|
|
318
|
+
}
|
|
285
319
|
function deterministicDecisionKey(source, request, revision, action, actor) {
|
|
286
320
|
return createHash("sha256")
|
|
287
321
|
.update([
|
|
@@ -301,11 +335,12 @@ function sleep(ms) {
|
|
|
301
335
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
302
336
|
}
|
|
303
337
|
function notificationText(request) {
|
|
338
|
+
const value = request.request;
|
|
304
339
|
return [
|
|
305
|
-
`${request.
|
|
306
|
-
|
|
340
|
+
`${request.source_label}: ${value.presentation.title}`,
|
|
341
|
+
value.presentation.summary ?? "",
|
|
307
342
|
`Handle: ${request.handle}`,
|
|
308
|
-
...
|
|
343
|
+
...value.allowed_actions.map((action) => `/brokerkit ${action} ${request.handle}`),
|
|
309
344
|
]
|
|
310
345
|
.filter(Boolean)
|
|
311
346
|
.join("\n");
|