@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.
- package/package.json +52 -0
- package/src/azure-members.mjs +119 -0
- package/src/azure-subset.mjs +166 -0
- package/src/cli.mjs +82 -0
- package/src/config.mjs +257 -0
- package/src/filter-azure.mjs +273 -0
- package/src/filter-forgejo.mjs +206 -0
- package/src/filter-gitlab.mjs +252 -0
- package/src/filter.mjs +224 -0
- package/src/forgejo-members.mjs +90 -0
- package/src/forgejo-subset.mjs +113 -0
- package/src/gitlab-members.mjs +91 -0
- package/src/gitlab-subset.mjs +76 -0
- package/src/http-body.mjs +73 -0
- package/src/poller-config.mjs +100 -0
- package/src/poller.mjs +700 -0
- package/src/predicate.mjs +62 -0
- package/src/receiver.mjs +330 -0
- package/src/start.mjs +176 -0
- package/src/verify-azure.mjs +109 -0
- package/src/verify-gitlab.mjs +193 -0
- package/src/verify.mjs +98 -0
|
@@ -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
|
+
}
|