@nettee/beacon 0.2.8 → 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` 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,8 +67,8 @@ 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` / `no_reply` /
71
- `notify_card` contract, and an optional `submit_feedback` tool.
70
+ inbound vs Schedule vs event vs manual capabilities, the `reply_text` / `reply_card` /
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:
74
74
 
@@ -112,13 +112,174 @@ per-Run directories with mode `0700` when Pi starts.
112
112
  Configuration is strict: unknown YAML/JSON fields, YAML aliases or warnings, missing paths, duplicate Schedule IDs, invalid timezones/cron expressions, a missing complete `persona.md` + `task.md` pair, escaped Profile markdown, permissive secret or runtime-environment permissions, and missing Profile credentials all fail startup. Beacon validates all Profiles before opening a Feishu connection.
113
113
 
114
114
  Each Schedule uses a five-field cron expression and an IANA timezone. Profile
115
- `admin.chat_id` is the private chat used for schedule `reply` / `no_reply` and
116
- for failure notices. Optional `notify.chat_id` on a Schedule is the group that
117
- receives `notify_card`. Beacon deliberately does not infer or fall back to
118
- another destination. A newly discovered Schedule starts at the current time.
115
+ `admin.chat_id` is the private chat used for schedule `reply_text` /
116
+ `reply_card` / `no_reply` and for failure notices. Optional `notify.chat_id` on a
117
+ Schedule is the group that receives `notify_card`. Beacon deliberately does not
118
+ infer or fall back to another destination. A newly discovered Schedule starts at
119
+ the current time.
119
120
  After sleep or restart, overdue occurrences are reconciled and coalesced to the
120
121
  most recent one.
121
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
+
122
283
  ## Commands
123
284
 
124
285
  ```text
@@ -157,18 +318,26 @@ messages include the `run_id` whenever a Run record was created.
157
318
 
158
319
  ## Final Outcomes
159
320
 
160
- An Agent closes the conversational channel with `reply` or `no_reply`, and may
161
- also call `notify_card` on the same Run:
162
-
163
- - `reply` sends plain text. Inbound Feishu messages quote-reply the user.
164
- Schedules message the Profile admin. Inbound Runs must call `reply`, even
165
- when the message is outside the Profile's role.
166
- - `no_reply` finishes a Schedule without messaging the admin. Its `reason` is
167
- stored for audit and is never sent. Do not use it on inbound messages.
168
- - `notify_card` posts an interactive card to the Schedule's configured group.
169
- Inbound messages cannot notify.
170
-
171
- The card tool accepts a title, Feishu-compatible Markdown body, and up to five
321
+ An Agent closes the conversational channel with `reply_text`, `reply_card`, or
322
+ `no_reply`, and may also call `notify_card` when the Run has a notify target:
323
+
324
+ - `reply_text` sends plain text. Inbound Feishu messages quote-reply the user.
325
+ Schedules and event Runs message the Profile admin.
326
+ - `reply_card` sends an interactive card on the same conversational channel
327
+ (inbound quote-reply, or Schedule/event admin chat). It closes that channel by
328
+ itself.
329
+ - `no_reply` finishes without messaging the conversational channel. Its
330
+ `reason` is stored for audit and is never sent. Do not use it on inbound
331
+ messages.
332
+ - `notify_card` posts an interactive card to the Schedule or listener's configured group.
333
+ Only valid when the Run has a notify target.
334
+
335
+ Which of `reply_text` / `reply_card` / `notify_card` / `no_reply` to use for a
336
+ business result is decided by the Profile persona/task (or the situation), not
337
+ hard-coded by Beacon beyond channel availability (`notify_card` needs a notify
338
+ target; inbound cannot `no_reply`).
339
+
340
+ The card tools accept a title, Feishu-compatible Markdown body, and up to five
172
341
  HTTP(S) link buttons. The first button is styled as primary. The tools accept
173
342
  these argument shapes:
174
343
 
@@ -193,10 +362,10 @@ these argument shapes:
193
362
 
194
363
  Beacon binds destinations; the Agent never supplies a `chat_id`. Text remains
195
364
  text. Cards are sent with Feishu's `interactive` message type and include the
196
- card generation time. A schedule `no_reply` without `notify_card` records a
197
- successful Run with no Delivery. Failures `reply` a short error and never
198
- notify a group. Manual local triggers print reply text and any notify card as
199
- Markdown on stdout.
365
+ card generation time. A Schedule or event `no_reply` without `notify_card` records a
366
+ successful Run with no Delivery. Failures `reply_text` a short error and never
367
+ notify a group. Manual local triggers print reply text/card and any notify card
368
+ as Markdown on stdout.
200
369
 
201
370
  ## State and failure behavior
202
371
 
@@ -226,7 +395,7 @@ pi --export /ABSOLUTE/SESSION/PATH/TIMESTAMP_run_REPORTED_ID.jsonl run.html
226
395
  `beacon doctor` keeps using `--no-session`, so its Pi smoke tests do not create
227
396
  diagnostic session files.
228
397
 
229
- 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.
230
399
 
231
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.
232
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
+ }