@edgehero/pi-dispatch-receiver 0.1.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.
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Poller configuration (issue #81, polling producer). The polling producer shares almost everything
3
+ * with the webhook receiver -- the triggers file, the GitHub auth block, the queue URL -- and differs
4
+ * on exactly one requirement: WEBHOOK_SECRET.
5
+ *
6
+ * The receiver HARD-REQUIRES that secret because an inbound delivery is a forgeable paid-agent
7
+ * trigger until the HMAC over the raw body proves otherwise (CONST-HMAC-OVER-RAW-BODY). The poller
8
+ * has no inbound anything: it originates every request itself, over TLS, to api.github.com, carrying
9
+ * the operator's own credential -- the authentication runs in the OTHER direction, so there is
10
+ * nothing for a webhook secret to verify and demanding one would block exactly the deployment this
11
+ * mode exists for (no public URL, no DNS, no tunnel).
12
+ *
13
+ * REUSE, NEVER RE-DERIVE: this loader does not fork `loadTriggers`/`loadGitHubAuth`. It calls the
14
+ * receiver's own `loadReceiverConfig` -- the single place the trigger file is grouped for the filter
15
+ * and the auth block is validated -- satisfying its WEBHOOK_SECRET precondition with an inert
16
+ * placeholder when the env has none, and then DROPS the field from the returned object so nothing
17
+ * downstream can ever read the placeholder and believe it is armed. A forked grouping loader would be
18
+ * the classic drift: two parsers of one file, one of them quietly behind on the next rule field.
19
+ *
20
+ * Poller-specific env:
21
+ * - POLL_REPOS: comma-separated `owner/name` list -- the explicit answer to "which repos". When
22
+ * unset AND GITHUB_AUTH_SOURCE=app, the poller instead lists the App installation's repositories
23
+ * at boot (and refreshes at low frequency). Unset with any other auth source is a config error:
24
+ * a PAT/gh credential names no repo set, and a poller that watched nothing would look exactly
25
+ * like a poller that was working.
26
+ * - POLL_INTERVAL_SECONDS: the cycle delay, default 60, floored at 30. The floor is a courtesy to
27
+ * GitHub's polling guidance (the API asks pollers to respect `X-Poll-Interval`, typically 60s);
28
+ * a typo'd `1` must not turn the harness into a hammer.
29
+ *
30
+ * Errors are tagged `piDispatchConfig` (shared `configError`), so the receiver bin's entryExitCode
31
+ * maps them to EXIT_POLICY (2) and a supervisor never restart-loops a config that cannot parse.
32
+ */
33
+
34
+ import { existsSync, readFileSync } from "node:fs";
35
+ import { configError, positiveInt } from "@edgehero/pi-dispatch/config";
36
+ import { loadReceiverConfig } from "./config.mjs";
37
+
38
+ // Never a real secret and never returned: it exists only to satisfy loadReceiverConfig's fail-loud
39
+ // precondition when the deployment is pure-polling and legitimately has no WEBHOOK_SECRET at all.
40
+ const PLACEHOLDER_SECRET = "poller-has-no-webhook-to-verify";
41
+
42
+ const POLL_INTERVAL_FLOOR_SECONDS = 30;
43
+ const POLL_INTERVAL_DEFAULT_SECONDS = 60;
44
+
45
+ /**
46
+ * Parse the poller's config from `env`. Filesystem access is injected (`readFile`, `fileExists`) and
47
+ * forwarded to the receiver loader, so the whole thing is hermetically testable.
48
+ *
49
+ * Returns `{ valkeyUrl, triggers, github, repos, intervalSeconds }` -- and deliberately nothing else.
50
+ * `webhookSecret`, `port`, `bind` and the other-forge blocks are receiver-only concerns: the poller
51
+ * binds no port and speaks only GitHub (the other forges' producers stay webhook-armed).
52
+ * `repos === null` means "discover from the App installation each boot" and is only reachable when
53
+ * the auth source is `app`.
54
+ */
55
+ export function loadPollerConfig(env = process.env, { readFile = readFileSync, fileExists = existsSync } = {}) {
56
+ // A present WEBHOOK_SECRET is passed through untouched (a deployment may run serve AND poll from
57
+ // one env file); only its ABSENCE is papered over, and only for the duration of this call.
58
+ const base = loadReceiverConfig(
59
+ env.WEBHOOK_SECRET ? env : { ...env, WEBHOOK_SECRET: PLACEHOLDER_SECRET },
60
+ { readFile, fileExists },
61
+ );
62
+
63
+ const repos = parsePollRepos(env.POLL_REPOS);
64
+ if (repos === null && base.github.source !== "app") {
65
+ // Fail-loud at boot naming BOTH mechanisms: a poller with an empty repo set would cycle forever,
66
+ // log healthy summaries, and trigger nothing -- indistinguishable from working until someone
67
+ // labels an issue and waits.
68
+ throw configError(
69
+ "nothing to poll: set POLL_REPOS=owner/name[,owner/name...] or use GITHUB_AUTH_SOURCE=app so the poller can list the App installation's repositories",
70
+ );
71
+ }
72
+
73
+ return {
74
+ valkeyUrl: base.valkeyUrl, // mirrors the receiver: producer and consumer share one queue
75
+ triggers: base.triggers, // the SAME grouped rules the webhook filter reads -- one gate, two feeds
76
+ github: base.github, // validated by the shared loadGitHubAuth, exactly as serve validates it
77
+ repos,
78
+ intervalSeconds: Math.max(POLL_INTERVAL_FLOOR_SECONDS, positiveInt(env, "POLL_INTERVAL_SECONDS", POLL_INTERVAL_DEFAULT_SECONDS)),
79
+ };
80
+ }
81
+
82
+ /**
83
+ * `POLL_REPOS` -> `["owner/name", ...]`, `null` when unset/empty (falsy-is-unset, matching the
84
+ * receiver's forge blocks). Set-but-garbled fails loud: a silently dropped entry is a repo the
85
+ * operator believes is watched. Duplicates are collapsed so a repeated entry cannot double-poll
86
+ * (and double-enqueue-attempt) one repo.
87
+ */
88
+ function parsePollRepos(raw) {
89
+ if (raw === undefined || raw.trim() === "") return null;
90
+ const entries = [...new Set(raw.split(",").map((s) => s.trim()).filter((s) => s !== ""))];
91
+ if (entries.length === 0) {
92
+ throw configError(`POLL_REPOS is set but names no repositories: ${JSON.stringify(raw)}`);
93
+ }
94
+ for (const entry of entries) {
95
+ if (!/^[^\s/]+\/[^\s/]+$/.test(entry)) {
96
+ throw configError(`POLL_REPOS entries must be owner/name, got ${JSON.stringify(entry)}`);
97
+ }
98
+ }
99
+ return entries;
100
+ }