@nettee/beacon 0.2.9 → 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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Beacon
2
2
 
3
- Beacon is a single-host service that turns Feishu messages and configured schedules into isolated Pi agent runs. Each accepted Trigger is durably claimed, produces an explicit Final Outcome, and may `reply_text` / `reply_card` to a person, `notify_card` a group, or stay silent on the admin channel.
3
+ Beacon is a single-host service that turns Feishu messages, configured schedules, and subscribed CloudEvents into isolated Pi agent runs. Each accepted Trigger is durably claimed, produces an explicit Final Outcome, and may `reply_text` / `reply_card` to a person, `notify_card` a group, or stay silent on the admin channel.
4
4
 
5
5
  This repository currently implements the Feishu + Pi MVP described by the
6
6
  [Zest Dev Spec](https://github.com/nettee/beacon/blob/main/specs/change/20260913-beacon-feishu-pi-mvp/spec.md).
@@ -67,7 +67,7 @@ back to `prompt.md`. If neither place has a complete pair, Profile load fails
67
67
  and lists the missing paths. Both pairs may exist; the workspace pair wins.
68
68
 
69
69
  Beacon prepends an English platform template for this Run: workspace limit,
70
- inbound vs Schedule vs manual capabilities, the `reply_text` / `reply_card` /
70
+ inbound vs Schedule vs event vs manual capabilities, the `reply_text` / `reply_card` /
71
71
  `no_reply` / `notify_card` contract, and an optional `submit_feedback` tool.
72
72
 
73
73
  Protect the home and secret file before running Beacon:
@@ -120,6 +120,166 @@ the current time.
120
120
  After sleep or restart, overdue occurrences are reconciled and coalesced to the
121
121
  most recent one.
122
122
 
123
+ ## Event triggers
124
+
125
+ Beacon accepts CloudEvents on a dedicated, opt-in HTTP server. A Profile's
126
+ optional `listener` selects the events it receives; Profiles without a listener
127
+ never receive external events. Direct Feishu/manual inputs and Schedules keep
128
+ using their existing paths:
129
+
130
+ ```text
131
+ CloudEvent → authenticate → match + persist recipients ─────┐
132
+ Time → Schedule due ──────────────────────────────────┼→ Trigger → Run
133
+ Input → directly addressed Profile ────────────────────┘
134
+ ```
135
+
136
+ Enable the server in `config.yaml` (all values below except `enabled` and
137
+ `credentials_file` show defaults):
138
+
139
+ ```yaml
140
+ events:
141
+ enabled: true
142
+ listen: 127.0.0.1
143
+ port: 46184
144
+ max_body_bytes: 262144
145
+ max_pending: 1000
146
+ credentials_file: event-credentials.json
147
+ ```
148
+
149
+ `credentials_file` resolves relative to the directory containing `config.yaml`;
150
+ absolute paths also work. Copy
151
+ [`examples/event-credentials.json.example`](examples/event-credentials.json.example)
152
+ to that location and replace its token with a unique, random Bearer token of at
153
+ least 32 characters (for example, generate one with `openssl rand -hex 32`).
154
+ Each producer has a stable ID and an explicit list of allowed event sources:
155
+
156
+ ```json
157
+ {
158
+ "version": 1,
159
+ "producers": [
160
+ {
161
+ "id": "deployment-system",
162
+ "token": "REPLACE_WITH_A_UNIQUE_RANDOM_TOKEN_AT_LEAST_32_CHARACTERS",
163
+ "sources": ["https://deploy.example.com/production"]
164
+ }
165
+ ]
166
+ }
167
+ ```
168
+
169
+ The credentials file must be a regular, non-symlink file owned by the Beacon
170
+ user with mode `0600`. Its parent directory must belong to that user and grant
171
+ no group/world access. Producer IDs and tokens must be unique, and source lists
172
+ must be nonempty. Tokens use Bearer-compatible characters. Credentials are not
173
+ passed to Pi, included in event records, or loaded from `runtime.env`.
174
+
175
+ ```sh
176
+ chmod 700 "$HOME/.beacon"
177
+ chmod 600 "$HOME/.beacon/event-credentials.json"
178
+ ```
179
+
180
+ Subscribe in the desired `profile.yaml`:
181
+
182
+ ```yaml
183
+ admin:
184
+ chat_id: REPLACE_WITH_ADMIN_DIRECT_CHAT_ID
185
+ listener:
186
+ sources:
187
+ - https://deploy.example.com/production
188
+ types:
189
+ - com.example.deployment.completed.v1
190
+ notify:
191
+ chat_id: REPLACE_WITH_GROUP_CHAT_ID
192
+ ```
193
+
194
+ Both lists are required and nonempty. Values match exactly: **OR within each
195
+ list, AND between `sources` and `types`**. There are no wildcards, regular
196
+ expressions, or filters inside `data`. A listener requires `admin.chat_id`;
197
+ `listener.notify` is optional. Matching Profiles each receive one Trigger with
198
+ the complete event as input. Their `persona.md` and `task.md` define what to do.
199
+ `reply_text` and `reply_card` go to the configured admin; `no_reply` is allowed;
200
+ `notify_card` is available only when the listener has a notify target. The event
201
+ cannot choose a Profile, command, or delivery destination. All event fields
202
+ remain untrusted external data, even after producer authentication.
203
+
204
+ Restart Beacon after changing configuration or credentials. This example sends
205
+ one CloudEvents 1.0 structured JSON event. Set the token in your **sender's**
206
+ environment to the value in its producer credentials; do not put it in Beacon's
207
+ `runtime.env`:
208
+
209
+ ```sh
210
+ export BEACON_EVENT_TOKEN='YOUR_CONFIGURED_PRODUCER_TOKEN'
211
+ curl --fail-with-body --include \
212
+ -X POST http://127.0.0.1:46184/v1/events \
213
+ -H "Authorization: Bearer ${BEACON_EVENT_TOKEN}" \
214
+ -H 'Content-Type: application/cloudevents+json' \
215
+ --data-binary '{
216
+ "specversion": "1.0",
217
+ "id": "deployment-20260929-001",
218
+ "source": "https://deploy.example.com/production",
219
+ "type": "com.example.deployment.completed.v1",
220
+ "subject": "services/payment",
221
+ "time": "2026-09-29T09:00:00Z",
222
+ "datacontenttype": "application/json",
223
+ "data": {"service": "payment", "version": "v2.3.0"}
224
+ }'
225
+ ```
226
+
227
+ Use a new event `id` for each new fact. When retrying delivery of the same fact,
228
+ keep the same event body and ID. Acceptance returns a `receipt_id`, `status`,
229
+ `accepted_at`, `recipients`, and `duplicate`, with a `Location` header pointing
230
+ to the receipt. Query it using the same producer's credentials:
231
+
232
+ ```sh
233
+ export BEACON_EVENT_RECEIPT_ID='evt_REPLACE_WITH_RECEIPT_ID_FROM_RESPONSE'
234
+ curl --fail-with-body \
235
+ -H "Authorization: Bearer ${BEACON_EVENT_TOKEN}" \
236
+ "http://127.0.0.1:46184/v1/events/${BEACON_EVENT_RECEIPT_ID}"
237
+ ```
238
+
239
+ | HTTP result | Meaning |
240
+ | --- | --- |
241
+ | `202` | New event durably accepted, independently of Run execution |
242
+ | `200` on POST | Identical event from the same producer already accepted; no new Trigger |
243
+ | `400` / `415` | Invalid event or unsupported representation; fix the request |
244
+ | `401` / `403` | Invalid credentials / producer not authorized for this `source` |
245
+ | `409` | Same `source + id` already belongs to different event content or another producer |
246
+ | `413` | Request exceeds `max_body_bytes` (256 KiB by default) |
247
+ | `503` | Intake unavailable or pending inbox full; retry with backoff, honoring `Retry-After` |
248
+ | `500` | Required intake work failed; acceptance was not confirmed; retry the same event after recovery |
249
+
250
+ Receipt queries return `200` only to the originating producer while its source
251
+ remains authorized; missing or inaccessible receipts return `404`. Receipt
252
+ status is `unmatched` when no Profile matched, `pending` while recipients await
253
+ dispatch, or `dispatched` when dispatch has settled for all recipients. Each
254
+ recipient includes `profile_id` and, once dispatch settles, `trigger_id`.
255
+ **`dispatched` does not mean the Runs succeeded.** Inspect their Trigger/Run
256
+ records or Dashboard for execution and delivery outcomes.
257
+
258
+ This first version accepts a single structured JSON CloudEvent per POST.
259
+ `specversion`, `id`, `source`, and `type` are required. Sources may be valid
260
+ relative URI references or absolute URIs. Data is optional and must be JSON;
261
+ `datacontenttype`, when supplied, must be `application/json` or an
262
+ `application/*+json` type, with an optional UTF-8 charset. Extension names use
263
+ lowercase letters/digits and values are strings, booleans, or 32-bit integers.
264
+ There are no provider adapters, batch envelopes, binary-mode CloudEvents,
265
+ `data_base64`, or compressed requests.
266
+
267
+ The durable inbox lives at `<Beacon home>/state/events/`. At acceptance Beacon
268
+ snapshots matching Profiles and their admin/notify destinations. Restart resumes
269
+ pending dispatch without rematching historical events, and each recipient
270
+ Profile is claimed once. Changing a listener affects only new events. Keep
271
+ Profiles referenced by pending receipts configured until dispatch settles;
272
+ removing one causes a visible fatal dispatch failure. Dispatch is serial and
273
+ waits for the shared Run queue, while HTTP acceptance remains independent.
274
+ Active Runs interrupted by restart fail rather than execute twice; failed Runs
275
+ are not retried automatically or retriggered by duplicate event delivery. Event
276
+ records, like existing state records, have no automatic retention cleanup;
277
+ manually deleting them removes deduplication history.
278
+
279
+ For remote senders, put HTTPS authentication-preserving reverse proxying in front
280
+ of the event port. Keep its default loopback binding when the proxy is on the
281
+ same host. Expose only the event endpoint, not Beacon's unauthenticated Dashboard.
282
+
123
283
  ## Commands
124
284
 
125
285
  ```text
@@ -162,14 +322,14 @@ An Agent closes the conversational channel with `reply_text`, `reply_card`, or
162
322
  `no_reply`, and may also call `notify_card` when the Run has a notify target:
163
323
 
164
324
  - `reply_text` sends plain text. Inbound Feishu messages quote-reply the user.
165
- Schedules message the Profile admin.
325
+ Schedules and event Runs message the Profile admin.
166
326
  - `reply_card` sends an interactive card on the same conversational channel
167
- (inbound quote-reply, or Schedule admin chat). It closes that channel by
327
+ (inbound quote-reply, or Schedule/event admin chat). It closes that channel by
168
328
  itself.
169
329
  - `no_reply` finishes without messaging the conversational channel. Its
170
330
  `reason` is stored for audit and is never sent. Do not use it on inbound
171
331
  messages.
172
- - `notify_card` posts an interactive card to the Schedule's configured group.
332
+ - `notify_card` posts an interactive card to the Schedule or listener's configured group.
173
333
  Only valid when the Run has a notify target.
174
334
 
175
335
  Which of `reply_text` / `reply_card` / `notify_card` / `no_reply` to use for a
@@ -202,7 +362,7 @@ these argument shapes:
202
362
 
203
363
  Beacon binds destinations; the Agent never supplies a `chat_id`. Text remains
204
364
  text. Cards are sent with Feishu's `interactive` message type and include the
205
- card generation time. A schedule `no_reply` without `notify_card` records a
365
+ card generation time. A Schedule or event `no_reply` without `notify_card` records a
206
366
  successful Run with no Delivery. Failures `reply_text` a short error and never
207
367
  notify a group. Manual local triggers print reply text/card and any notify card
208
368
  as Markdown on stdout.
@@ -235,7 +395,7 @@ pi --export /ABSOLUTE/SESSION/PATH/TIMESTAMP_run_REPORTED_ID.jsonl run.html
235
395
  `beacon doctor` keeps using `--no-session`, so its Pi smoke tests do not create
236
396
  diagnostic session files.
237
397
 
238
- Duplicate Feishu events and duplicate Schedule occurrences do not start a second Run. Active Runs interrupted by restart fail rather than rerun. Pending Delivery can resume, while a Delivery interrupted after its external call began fails without resending. Run and Delivery success are recorded independently. Beacon does not automatically retry either one.
398
+ Duplicate Feishu events, CloudEvents, and Schedule occurrences do not start a second Run. Active Runs interrupted by restart fail rather than rerun. Pending Delivery can resume, while a Delivery interrupted after its external call began fails without resending. Run and Delivery success are recorded independently. Beacon does not automatically retry either one.
239
399
 
240
400
  Secrets and ephemeral Run Capability tokens are excluded from persisted records and from the Pi environment except for the one Run-scoped Outcome capability.
241
401
 
@@ -0,0 +1,67 @@
1
+ import { constants } from "node:fs";
2
+ import { lstat, open, realpath, stat } from "node:fs/promises";
3
+ import { dirname } from "node:path";
4
+ import { z } from "zod";
5
+ import { eventSourceSchema } from "../events/cloudevent.js";
6
+ const credentialsSchema = z
7
+ .object({
8
+ version: z.literal(1),
9
+ producers: z
10
+ .array(z
11
+ .object({
12
+ id: z.string().regex(/^[a-z0-9][a-z0-9_-]{0,62}$/),
13
+ token: z
14
+ .string()
15
+ .min(32)
16
+ .regex(/^[A-Za-z0-9._~+/-]+=*$/),
17
+ sources: z.array(eventSourceSchema).min(1),
18
+ })
19
+ .strict())
20
+ .min(1),
21
+ })
22
+ .strict();
23
+ export async function loadEventCredentials(path) {
24
+ const canonicalParent = await realpath(dirname(path));
25
+ const parent = await stat(canonicalParent);
26
+ if ((parent.mode & 0o077) !== 0 ||
27
+ (typeof process.getuid === "function" && parent.uid !== process.getuid())) {
28
+ throw new Error("Event credentials parent must be owned by the current user and grant no group/world access");
29
+ }
30
+ if ((await lstat(path)).isSymbolicLink()) {
31
+ throw new Error("Event credentials must not be a symbolic link");
32
+ }
33
+ const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
34
+ try {
35
+ const metadata = await file.stat();
36
+ if (!metadata.isFile() ||
37
+ (metadata.mode & 0o777) !== 0o600 ||
38
+ (typeof process.getuid === "function" &&
39
+ metadata.uid !== process.getuid())) {
40
+ throw new Error("Event credentials must be a regular file owned by the current user with mode 0600");
41
+ }
42
+ // Deliberately omit parser causes: malformed JSON can quote credential bytes.
43
+ let parsed;
44
+ try {
45
+ parsed = JSON.parse(await file.readFile("utf8"));
46
+ }
47
+ catch {
48
+ throw new Error("Cannot read valid JSON event credentials");
49
+ }
50
+ const result = credentialsSchema.safeParse(parsed);
51
+ if (!result.success)
52
+ throw new Error("Invalid event credentials configuration");
53
+ const ids = new Set();
54
+ const tokens = new Set();
55
+ for (const producer of result.data.producers) {
56
+ if (ids.has(producer.id) || tokens.has(producer.token)) {
57
+ throw new Error("Event producer IDs and tokens must be unique");
58
+ }
59
+ ids.add(producer.id);
60
+ tokens.add(producer.token);
61
+ }
62
+ return result.data.producers;
63
+ }
64
+ finally {
65
+ await file.close();
66
+ }
67
+ }
@@ -26,6 +26,18 @@ const globalDocumentSchema = z
26
26
  scheduler: z
27
27
  .object({ max_occurrences_per_reconciliation: positiveInteger })
28
28
  .strict(),
29
+ events: z
30
+ .object({
31
+ enabled: z.boolean().default(false),
32
+ listen: z.string().min(1).default("127.0.0.1"),
33
+ port: z.number().int().min(1).max(65535).default(46184),
34
+ max_body_bytes: positiveInteger.default(262144),
35
+ max_pending: positiveInteger.default(1000),
36
+ credentials_file: z.string().min(1).optional(),
37
+ })
38
+ .strict()
39
+ .refine((value) => !value.enabled || value.credentials_file !== undefined, "events.credentials_file is required when events are enabled")
40
+ .optional(),
29
41
  dashboard: z
30
42
  .object({
31
43
  enabled: z.boolean().optional(),
@@ -105,6 +117,17 @@ export async function loadGlobalConfig(path) {
105
117
  scheduler: {
106
118
  maxOccurrencesPerReconciliation: document.scheduler.max_occurrences_per_reconciliation,
107
119
  },
120
+ ...(document.events?.enabled
121
+ ? {
122
+ events: {
123
+ listen: document.events.listen,
124
+ port: document.events.port,
125
+ maxBodyBytes: document.events.max_body_bytes,
126
+ maxPending: document.events.max_pending,
127
+ credentialsPath: resolveFromHome(document.events.credentials_file),
128
+ },
129
+ }
130
+ : {}),
108
131
  dashboard: {
109
132
  enabled: document.dashboard?.enabled ?? defaultDashboard.enabled,
110
133
  listen: document.dashboard?.listen ?? defaultDashboard.listen,
@@ -2,6 +2,7 @@ import { readFile, realpath, stat } from "node:fs/promises";
2
2
  import { homedir } from "node:os";
3
3
  import { dirname, isAbsolute, join, relative, resolve } from "node:path";
4
4
  import { z } from "zod";
5
+ import { eventSourceSchema } from "../events/cloudevent.js";
5
6
  import { nextOccurrence } from "../schedule/cron.js";
6
7
  import { parseStrictYaml } from "./yaml.js";
7
8
  const profileIdPattern = /^[a-z0-9](?:[a-z0-9_-]{0,62})$/;
@@ -51,6 +52,17 @@ const profileDocumentSchema = z
51
52
  .strict()
52
53
  .optional(),
53
54
  schedules: z.array(scheduleSchema).default([]),
55
+ listener: z
56
+ .object({
57
+ sources: z.array(eventSourceSchema).min(1),
58
+ types: z.array(z.string().min(1)).min(1),
59
+ notify: z
60
+ .object({ chat_id: z.string().trim().min(1) })
61
+ .strict()
62
+ .optional(),
63
+ })
64
+ .strict()
65
+ .optional(),
54
66
  })
55
67
  .strict();
56
68
  function assertProfileId(profileId) {
@@ -167,6 +179,9 @@ export async function loadProfile(profileId, profilesDirectory = join(homedir(),
167
179
  if (config.schedules.length > 0 && !config.admin) {
168
180
  throw new Error(`Profile ${profileId} with schedules must declare admin.chat_id`);
169
181
  }
182
+ if (config.listener && !config.admin) {
183
+ throw new Error(`Profile ${profileId} with listener must declare admin.chat_id`);
184
+ }
170
185
  const canonicalProfileDirectory = await realpath(profileDirectory);
171
186
  const workspace = isAbsolute(config.workspace)
172
187
  ? config.workspace
@@ -193,6 +208,17 @@ export async function loadProfile(profileId, profilesDirectory = join(homedir(),
193
208
  runtime: config.runtime,
194
209
  model: config.model,
195
210
  ...(config.admin ? { admin: { chatId: config.admin.chat_id } } : {}),
211
+ ...(config.listener
212
+ ? {
213
+ listener: {
214
+ sources: config.listener.sources,
215
+ types: config.listener.types,
216
+ ...(config.listener.notify
217
+ ? { notify: { chatId: config.listener.notify.chat_id } }
218
+ : {}),
219
+ },
220
+ }
221
+ : {}),
196
222
  schedules: config.schedules.map((schedule) => ({
197
223
  id: schedule.id,
198
224
  cron: schedule.cron,
@@ -46,6 +46,12 @@ function summarize(value, hasSessionFile) {
46
46
  acceptedAt,
47
47
  kind: asString(input?.kind) ?? "unknown",
48
48
  scheduleId: asString(input?.scheduleId),
49
+ ...(input?.kind === "event"
50
+ ? {
51
+ eventSource: asString(asRecord(input.event)?.source),
52
+ eventType: asString(asRecord(input.event)?.type),
53
+ }
54
+ : {}),
49
55
  hasNotifyTarget: asRecord(record.notifyTarget) !== undefined,
50
56
  runId: asString(run?.runId),
51
57
  state: asString(run?.state),
@@ -81,7 +87,7 @@ export async function listRunSummaries(profilesDirectory) {
81
87
  if (!entries)
82
88
  continue;
83
89
  for (const entry of entries) {
84
- if (!entry.isDirectory())
90
+ if (!entry.isDirectory() || /^\.claim-[0-9a-f-]{36}$/.test(entry.name))
85
91
  continue;
86
92
  const summary = await loadSummary(join(root, entry.name, "record.json"));
87
93
  if (summary)
@@ -97,6 +103,9 @@ export function kindLabel(row) {
97
103
  if (row.kind === "schedule" && row.scheduleId) {
98
104
  return `schedule:${row.scheduleId}`;
99
105
  }
106
+ if (row.kind === "event" && row.eventType) {
107
+ return `event:${row.eventType}${row.eventSource ? ` (${row.eventSource})` : ""}`;
108
+ }
100
109
  return row.kind;
101
110
  }
102
111
  export function toRunListItem(row) {
@@ -28,6 +28,9 @@ function triggerFromRunSummary(summary) {
28
28
  notify: summary.hasNotifyTarget,
29
29
  };
30
30
  }
31
+ if (summary.kind === "event") {
32
+ return { kind: "event", notify: summary.hasNotifyTarget };
33
+ }
31
34
  if (summary.kind === "feishu_message") {
32
35
  return { kind: "feishu_message", notify: false };
33
36
  }
package/dist/doctor.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { loadEventCredentials } from "./config/event-credentials.js";
1
2
  import { loadGlobalConfig } from "./config/global.js";
2
3
  import { loadProfileRegistry } from "./config/registry.js";
3
4
  import { loadRuntimeEnvironment } from "./config/runtime-environment.js";
@@ -8,6 +9,8 @@ export async function runDoctor(configPath) {
8
9
  const global = await loadGlobalConfig(configPath);
9
10
  const runtimeEnvironment = await loadRuntimeEnvironment(global.runtimeEnvironmentPath);
10
11
  const profiles = await loadProfileRegistry(global.profilesDirectory);
12
+ if (global.events)
13
+ await loadEventCredentials(global.events.credentialsPath);
11
14
  for (const profile of profiles) {
12
15
  const credentials = await loadFeishuCredentials(profile.id, global.secretsPath);
13
16
  await createFeishuGateway(credentials).checkReady();
@@ -0,0 +1,93 @@
1
+ import { z } from "zod";
2
+ // RFC 3986 URI references, including relative paths. Do not normalize: source
3
+ // identity and listener matching use the exact value provided by the producer.
4
+ const uriReference = /^(?:([A-Za-z][A-Za-z0-9+.-]*):)?(?:\/\/([^/?#]*))?([^?#]*)(?:\?([^#]*))?(?:#(.*))?$/;
5
+ const pathCharacters = /^(?:[A-Za-z0-9\-._~:@!$&'()*+,;=/]|%[0-9A-Fa-f]{2})*$/;
6
+ const queryCharacters = /^(?:[A-Za-z0-9\-._~:@!$&'()*+,;=/?]|%[0-9A-Fa-f]{2})*$/;
7
+ const authorityCharacters = /^(?:[A-Za-z0-9\-._~:@!$&'()*+,;=[\]]|%[0-9A-Fa-f]{2})*$/;
8
+ export const eventSourceSchema = z
9
+ .string()
10
+ .min(1)
11
+ .refine((value) => {
12
+ const parts = uriReference.exec(value);
13
+ if (!parts)
14
+ return false;
15
+ const [, scheme, authority, path = "", query = "", fragment = ""] = parts;
16
+ if (!pathCharacters.test(path) ||
17
+ !queryCharacters.test(query) ||
18
+ !queryCharacters.test(fragment))
19
+ return false;
20
+ if (!scheme &&
21
+ authority === undefined &&
22
+ !path.startsWith("/") &&
23
+ (path.split("/")[0] ?? "").includes(":"))
24
+ return false;
25
+ if (authority !== undefined) {
26
+ if (!authorityCharacters.test(authority))
27
+ return false;
28
+ // Use HTTP's authority parser for host/IP/port syntax only. The original
29
+ // scheme, path and identity remain untouched, including relative sources.
30
+ try {
31
+ new URL(`http://${authority || "cloudevents.invalid"}/`);
32
+ }
33
+ catch {
34
+ return false;
35
+ }
36
+ }
37
+ return true;
38
+ }, "Must be a valid nonempty URI reference");
39
+ const knownAttributes = new Set([
40
+ "specversion",
41
+ "id",
42
+ "source",
43
+ "type",
44
+ "subject",
45
+ "time",
46
+ "dataschema",
47
+ "datacontenttype",
48
+ "data",
49
+ ]);
50
+ export const cloudEventSchema = z
51
+ .object({
52
+ specversion: z.literal("1.0"),
53
+ id: z.string().min(1),
54
+ source: eventSourceSchema,
55
+ type: z.string().min(1),
56
+ subject: z.string().min(1).optional(),
57
+ time: z.iso.datetime({ offset: true }).optional(),
58
+ dataschema: eventSourceSchema
59
+ .refine((value) => /^[A-Za-z][A-Za-z0-9+.-]*:/.test(value), "dataschema must be an absolute URI")
60
+ .optional(),
61
+ datacontenttype: z
62
+ .string()
63
+ .refine((value) => /^application\/(?:json|[a-z0-9!#$&^_.+-]+\+json)(?:\s*;\s*charset\s*=\s*(?:utf-8|"utf-8"))?$/i.test(value), "Only JSON event data is supported")
64
+ .optional(),
65
+ data: z.json().optional(),
66
+ })
67
+ .catchall(z.union([
68
+ z.string(),
69
+ z.boolean(),
70
+ z.number().int().min(-2147483648).max(2147483647),
71
+ ]))
72
+ .superRefine((event, context) => {
73
+ for (const key of Object.keys(event)) {
74
+ if (knownAttributes.has(key))
75
+ continue;
76
+ if (key === "data_base64" || !/^[a-z0-9]+$/.test(key)) {
77
+ context.addIssue({
78
+ code: "custom",
79
+ path: [key],
80
+ message: key === "data_base64"
81
+ ? "data_base64 is unsupported; send JSON data"
82
+ : "Extension names must contain only lowercase letters and digits",
83
+ });
84
+ }
85
+ }
86
+ });
87
+ export function parseCloudEvent(input) {
88
+ return cloudEventSchema.parse(input);
89
+ }
90
+ export function matchesListener(event, listener) {
91
+ return (listener.sources.includes(event.source) &&
92
+ listener.types.includes(event.type));
93
+ }
@@ -0,0 +1,99 @@
1
+ import { matchesListener } from "./cloudevent.js";
2
+ import { EventIntakeError, } from "./store.js";
3
+ export class EventPipeline {
4
+ options;
5
+ work;
6
+ requested = false;
7
+ stopped = false;
8
+ failure;
9
+ constructor(options) {
10
+ this.options = options;
11
+ }
12
+ async accept(event, producer) {
13
+ if (this.stopped || this.failure)
14
+ throw new EventIntakeError(503, "Event intake unavailable");
15
+ if (!producer.sources.includes(event.source))
16
+ throw new EventIntakeError(403, "Event source is not authorized");
17
+ const recipients = this.options.contexts
18
+ .filter(({ profile }) => profile.listener && matchesListener(event, profile.listener))
19
+ .map(({ profile }) => {
20
+ if (!profile.admin)
21
+ throw new Error(`Listener Profile ${profile.id} has no admin destination`);
22
+ return {
23
+ profileId: profile.id,
24
+ chatId: profile.admin.chatId,
25
+ ...(profile.listener?.notify
26
+ ? { notifyChatId: profile.listener.notify.chatId }
27
+ : {}),
28
+ };
29
+ });
30
+ const result = await this.options.store.accept(event, producer.id, recipients, this.options.maxPending);
31
+ this.wake();
32
+ return result;
33
+ }
34
+ /** Call after Profile orchestrator recovery and after the HTTP listener binds. */
35
+ wake() {
36
+ if (this.stopped || this.failure)
37
+ return;
38
+ this.requested = true;
39
+ if (this.work)
40
+ return;
41
+ this.work = this.pump()
42
+ .catch((error) => {
43
+ this.failure = new Error(`Event dispatch failed: ${error instanceof Error ? error.message : "unknown error"}`, { cause: error });
44
+ this.options.onFatal(this.failure);
45
+ })
46
+ .finally(() => {
47
+ this.work = undefined;
48
+ if (this.requested && !this.stopped && !this.failure)
49
+ this.wake();
50
+ });
51
+ }
52
+ async pump() {
53
+ while (this.requested && !this.stopped) {
54
+ this.requested = false;
55
+ for (const record of await this.options.store.pending()) {
56
+ for (const recipient of record.recipients) {
57
+ if (this.stopped)
58
+ return;
59
+ if (recipient.triggerId)
60
+ continue;
61
+ const context = this.options.contexts.find(({ profile }) => profile.id === recipient.profileId);
62
+ if (!context)
63
+ throw new Error(`Pending Event ${record.receiptId} requires missing Profile ${recipient.profileId}`);
64
+ const input = { kind: "event", event: record.event };
65
+ const claimed = await context.store.claim({
66
+ sourceKey: ["event", record.event.source, record.event.id],
67
+ acceptedAt: new Date(record.acceptedAt),
68
+ target: { kind: "chat", chatId: recipient.chatId },
69
+ ...(recipient.notifyChatId
70
+ ? {
71
+ notifyTarget: {
72
+ kind: "chat",
73
+ chatId: recipient.notifyChatId,
74
+ },
75
+ }
76
+ : {}),
77
+ input,
78
+ });
79
+ // Existing claims were recovered by the orchestrator before intake starts.
80
+ if (claimed.created)
81
+ await context.orchestrator.process(claimed.record.triggerKey, async () => input);
82
+ await this.options.store.markDispatched(record.receiptId, recipient.profileId, claimed.record.triggerId);
83
+ }
84
+ }
85
+ }
86
+ }
87
+ async close() {
88
+ this.stopped = true;
89
+ await this.work;
90
+ if (this.failure)
91
+ throw this.failure;
92
+ }
93
+ async drain() {
94
+ while (this.work)
95
+ await this.work;
96
+ if (this.failure)
97
+ throw this.failure;
98
+ }
99
+ }