openclaw-brokerkit 0.1.0 → 0.3.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/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 (url.search)
23
- return json(res, 400, { error: { code: "invalid_input" } });
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|cancel|revoke)$/);
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(header, capability) {
77
- const token = header?.startsWith("Bearer ") ? header.slice(7) : "";
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 !== "reason" && key !== "constraints") ||
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,
@@ -1,92 +1,38 @@
1
- import { z } from "zod";
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 descriptorSchema.parse(value);
3
+ return validated(validateDescriptor, value);
76
4
  }
77
5
  export function parseHealth(value) {
78
- return healthSchema.parse(value);
6
+ return validated(validateHealth, value);
79
7
  }
80
8
  export function parseRequest(value) {
81
- return brokerRequestSchema.parse(value);
9
+ return validated(validateBrokerRequest, value);
82
10
  }
83
11
  export function parseRequestPage(value) {
84
- return requestPageSchema.parse(value);
12
+ return validated(validateRequestPage, value);
85
13
  }
86
14
  export function parseBrokerEvent(value) {
87
- return brokerEventSchema.parse(value);
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
- const result = errorEnvelopeSchema.safeParse(value);
91
- return result.success ? result.data : undefined;
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
+ }
@@ -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
- synchronizedAt: new Date().toISOString(),
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 > bounds.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.sourceId === source.config.id && !seen.has(request.id)) {
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
- lastSyncAt: new Date().toISOString(),
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.sourceId === source.config.id && current.id === request.id)
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.sourceId === source.config.id &&
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
- ...request,
240
- sourceId: source.config.id,
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.sourceLabel}: ${request.presentation.title}`,
306
- request.presentation.summary ?? "",
340
+ `${request.source_label}: ${value.presentation.title}`,
341
+ value.presentation.summary ?? "",
307
342
  `Handle: ${request.handle}`,
308
- ...request.allowed_actions.map((action) => `/brokerkit ${action} ${request.handle}`),
343
+ ...value.allowed_actions.map((action) => `/brokerkit ${action} ${request.handle}`),
309
344
  ]
310
345
  .filter(Boolean)
311
346
  .join("\n");