@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 ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@edgehero/pi-dispatch-receiver",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Webhook receiver for pi-dispatch: the always-on edge that verifies GitHub, GitLab, Forgejo and Azure DevOps deliveries and enqueues (at most) one job per event for the worker.",
6
+ "keywords": [
7
+ "pi",
8
+ "pi-coding-agent",
9
+ "coding-agent",
10
+ "ai-agent",
11
+ "self-hosted",
12
+ "webhook",
13
+ "github",
14
+ "gitlab",
15
+ "forgejo",
16
+ "gitea",
17
+ "azure-devops",
18
+ "job-queue",
19
+ "bullmq"
20
+ ],
21
+ "license": "MIT",
22
+ "author": "Rob Boerman",
23
+ "homepage": "https://github.com/edgehero/pi-dispatch/tree/main/receiver",
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "git+https://github.com/edgehero/pi-dispatch.git",
27
+ "directory": "receiver"
28
+ },
29
+ "bugs": "https://github.com/edgehero/pi-dispatch/issues",
30
+ "files": [
31
+ "src"
32
+ ],
33
+ "main": "src/start.mjs",
34
+ "bin": {
35
+ "pi-dispatch-receiver": "src/cli.mjs"
36
+ },
37
+ "exports": {
38
+ "./start": "./src/start.mjs"
39
+ },
40
+ "engines": {
41
+ "node": ">=22.19.0"
42
+ },
43
+ "scripts": {
44
+ "test": "node --test \"test/*.test.mjs\""
45
+ },
46
+ "dependencies": {
47
+ "@octokit/webhooks": "14.2.0",
48
+ "@edgehero/pi-dispatch": "^0.1.0",
49
+ "bullmq": "5.80.4",
50
+ "ioredis": "5.11.1"
51
+ }
52
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Resolve an Azure DevOps actor's project membership -- the enforcement half of
3
+ * `CONST-TRIGGER-AUTHOR-GATE` on the Azure side.
4
+ *
5
+ * Azure has no `author_association` and no numeric access level. What it has is a graph of subjects and
6
+ * groups, and "is this person a member of this project" is answered by walking it. That is TWO calls, not
7
+ * one, which is a real cost this arm carries and the other three do not:
8
+ *
9
+ * 1. the actor -> a subject DESCRIPTOR. A pull-request payload gives a GUID, a work-item payload gives
10
+ * only an email address, so there are two lookups depending on which the event carried.
11
+ * 2. the descriptor -> membership of the project's own group.
12
+ *
13
+ * Both can go indeterminate, so the indeterminate surface here is wider than GitLab's, not merely equal to
14
+ * it. `OQ-013` is amended to say so rather than leaving it implied.
15
+ *
16
+ * The verdict shape is every other forge's: `{ authorized }` | `{ indeterminate }`. A determinate "not a
17
+ * member" refuses; anything that could not be answered is a 503 so Azure redelivers.
18
+ *
19
+ * WHY MEMBERSHIP AND NOT A PERMISSION EVALUATION. Azure's Security namespace API can answer "may this
20
+ * subject contribute to this repository" exactly, which is closer to the property the constitution wants.
21
+ * It is not used, for one reason worth stating: it requires the caller to construct a security-namespace
22
+ * token by hand, and a token constructed slightly wrong returns a confident answer about a DIFFERENT
23
+ * object. Project membership is coarser and readable, and coarser-but-right beats exact-but-fragile on a
24
+ * gate that decides whether a stranger can spend money.
25
+ *
26
+ * The email is never logged or returned; it is personal data with exactly one consumer, like Forgejo's
27
+ * login and GitLab's username.
28
+ */
29
+
30
+ import { fetchFailureReason } from "@edgehero/pi-dispatch/gitlab-identity";
31
+
32
+ /**
33
+ * Build the resolver.
34
+ *
35
+ * `orgUrl` is `https://dev.azure.com/<org>`; the Graph API lives on a DIFFERENT host
36
+ * (`https://vssps.dev.azure.com/<org>`), which is derived here rather than asked for, so an operator
37
+ * configures one URL and cannot get the pair inconsistent.
38
+ *
39
+ * Returns `resolveAuthority(projectId, actor)` where `actor` is `{ id }` or `{ email }`.
40
+ */
41
+ export function makeResolveAzureAuthority({ orgUrl, token, fetchFn = fetch }) {
42
+ const root = String(orgUrl ?? "").replace(/\/+$/, "");
43
+ const vssps = root.replace("https://dev.azure.com", "https://vssps.dev.azure.com");
44
+ // Azure authenticates a PAT as HTTP Basic with an empty username.
45
+ const auth = `Basic ${Buffer.from(`:${token}`, "utf8").toString("base64")}`;
46
+
47
+ async function get(url) {
48
+ let res;
49
+ try {
50
+ res = await fetchFn(url, { headers: { Authorization: auth, accept: "application/json" }, redirect: "error" });
51
+ } catch (err) {
52
+ return { indeterminate: fetchFailureReason(err) };
53
+ }
54
+ if (!res.ok) {
55
+ // Status only. An Azure error body can echo the request, and the request carried the token.
56
+ return { indeterminate: `azure lookup returned ${res.status}` };
57
+ }
58
+ try {
59
+ return { body: await res.json() };
60
+ } catch (err) {
61
+ return { indeterminate: `azure lookup returned unparseable JSON: ${err?.message ?? "unknown"}` };
62
+ }
63
+ }
64
+
65
+ return async function resolveAuthority(projectId, actor) {
66
+ if (typeof projectId !== "string" || projectId === "") {
67
+ // The payload named no project, so there is nothing to ask about. Determinate, and refused.
68
+ return { authorized: false };
69
+ }
70
+
71
+ const descriptor = await resolveDescriptor(actor);
72
+ if (descriptor.indeterminate) return descriptor;
73
+ if (descriptor.value === null) return { authorized: false };
74
+
75
+ // The project's own scope descriptor -- the container every project member belongs to.
76
+ const scope = await get(`${vssps}/_apis/graph/descriptors/${encodeURIComponent(projectId)}?api-version=7.1-preview.1`);
77
+ if (scope.indeterminate) return scope;
78
+ const container = scope.body?.value;
79
+ if (typeof container !== "string" || container === "") {
80
+ // A 200 whose shape we do not recognise is not a refusal: answering `false` here would turn an
81
+ // upstream schema change into a silent, permanent refusal of every trigger.
82
+ return { indeterminate: "azure project descriptor lookup returned no descriptor" };
83
+ }
84
+
85
+ // Membership is TRANSITIVE via `direction=up`: a member of a team inside the project is a member of
86
+ // the project, and asking only for direct membership would refuse most real organisations -- the same
87
+ // mistake `members/all` avoids on GitLab.
88
+ const memberships = await get(`${vssps}/_apis/graph/memberships/${encodeURIComponent(descriptor.value)}?direction=up&api-version=7.1-preview.1`);
89
+ if (memberships.indeterminate) return memberships;
90
+ const list = memberships.body?.value;
91
+ if (!Array.isArray(list)) {
92
+ return { indeterminate: "azure memberships lookup returned no array" };
93
+ }
94
+ return { authorized: list.some((m) => m?.containerDescriptor === container) };
95
+ };
96
+
97
+ /** The actor's subject descriptor: by GUID for a pull request, by email for a work item. */
98
+ async function resolveDescriptor(actor) {
99
+ if (typeof actor?.id === "string" && actor.id !== "") {
100
+ const res = await get(`${vssps}/_apis/graph/descriptors/${encodeURIComponent(actor.id)}?api-version=7.1-preview.1`);
101
+ if (res.indeterminate) return res;
102
+ const value = res.body?.value;
103
+ return { value: typeof value === "string" && value !== "" ? value : null };
104
+ }
105
+ if (typeof actor?.email === "string" && actor.email !== "") {
106
+ // There is no lookup-by-email endpoint, so the users list is filtered. `subjectTypes=aad,msa`
107
+ // excludes groups and service principals, which cannot be the human this gate is about.
108
+ const res = await get(`${vssps}/_apis/graph/users?subjectTypes=aad,msa&api-version=7.1-preview.1`);
109
+ if (res.indeterminate) return res;
110
+ const users = res.body?.value;
111
+ if (!Array.isArray(users)) return { indeterminate: "azure users lookup returned no array" };
112
+ const wanted = actor.email.toLowerCase();
113
+ const hit = users.find((u) => String(u?.principalName ?? "").toLowerCase() === wanted || String(u?.mailAddress ?? "").toLowerCase() === wanted);
114
+ return { value: typeof hit?.descriptor === "string" ? hit.descriptor : null };
115
+ }
116
+ // Neither a GUID nor a parseable address: the delivery named nobody this gate can ask about.
117
+ return { value: null };
118
+ }
119
+ }
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Project ONLY the fields the Azure DevOps gate and job are allowed to see (`INT-AZURE-PAYLOAD-SUBSET`).
3
+ *
4
+ * The third sibling of `parseSubset` and `parseGitLabSubset`, and the least like either. Where Forgejo's
5
+ * payload is GitHub's with three differences, Azure's shares almost nothing:
6
+ *
7
+ * 1. NO DELIVERY-ID HEADER. Azure sends none at all. The only per-delivery unique value is the body's
8
+ * top-level `id`, a GUID. (Issue #43 proposes `notificationId`, which is WRONG: it is a
9
+ * per-subscription integer sequence -- 1, 2, 3 -- so two subscriptions collide on delivery 1.)
10
+ * 2. TWO ACTOR REPRESENTATIONS, inside one forge. A pull-request event carries
11
+ * `resource.createdBy.id`, a GUID. A work-item event carries the actor only as the string
12
+ * `"Display Name <email>"` in `System.CreatedBy`/`System.ChangedBy`, with no id anywhere. So both the
13
+ * author gate and the bot-loop guard need two extractions on one forge -- see `actorOf`.
14
+ * 3. TAGS ARE A SEMICOLON STRING, not `[{name}]`, and on an update they arrive as a DIFF. The diff is
15
+ * the trigger; see `tagChange`.
16
+ * 4. THE SCOPE IS A TRIPLE. `org/project/repo`, not `owner/repo`.
17
+ *
18
+ * Everything else in the delivery is ignored -- including `message`/`detailedMessage`, which are
19
+ * pre-rendered prose containing the work item's title and are exactly the kind of field that looks
20
+ * convenient and drags untrusted text into places it was never classified for.
21
+ */
22
+
23
+ /** The Service Hook event ids this project consumes. Anything else is an unhandled event. */
24
+ export const WORK_ITEM_EVENTS = new Set(["workitem.created", "workitem.updated", "workitem.commented"]);
25
+ export const PR_EVENTS = new Set(["git.pullrequest.created", "git.pullrequest.updated"]);
26
+ export const PR_COMMENT_EVENT = "ms.vss-code.git-pullrequest-comment-event";
27
+
28
+ /**
29
+ * Split Azure's `System.Tags` into the `[{ name }]` shape `predicate.mjs` reads.
30
+ *
31
+ * Azure renders tags as one string. Which SEPARATOR spacing it uses has varied (`"a;b"` and `"a; b"` both
32
+ * occur across versions and resource versions), so every part is trimmed and empties dropped -- a tag list
33
+ * that silently matched nothing because of a space would look exactly like a trigger nobody armed.
34
+ */
35
+ export function parseTags(raw) {
36
+ if (typeof raw !== "string") return [];
37
+ return raw
38
+ .split(";")
39
+ .map((s) => s.trim())
40
+ .filter((s) => s !== "")
41
+ .map((name) => ({ name }));
42
+ }
43
+
44
+ /**
45
+ * The tag change on a work-item update, as `{ previous, current }`, or `undefined` when this event carries
46
+ * no tag change at all.
47
+ *
48
+ * THE DIFF IS THE TRIGGER, and this is the single most expensive thing to get wrong on this forge. Azure
49
+ * has no `labeled` event: a tag change arrives as `workitem.updated` with a `fields` map of
50
+ * `{ oldValue, newValue }` pairs. Matching the CURRENT tag set instead of the change would fire the
51
+ * trigger again on every later edit of any field on that work item -- a paid run per typo fix, forever.
52
+ *
53
+ * `filter-gitlab.mjs` documents this exact trap for GitLab's `changes.labels`, and its `no-label-change`
54
+ * drop is the existing machinery. Azure is its second customer.
55
+ */
56
+ export function tagChange(fields) {
57
+ const entry = fields?.["System.Tags"];
58
+ if (entry === null || typeof entry !== "object") return undefined;
59
+ // `oldValue` is absent when the field had no previous value -- a first tag on an untagged work item --
60
+ // which is a real change and must read as "previously empty", not as "no change".
61
+ return { previous: parseTags(entry.oldValue), current: parseTags(entry.newValue) };
62
+ }
63
+
64
+ /**
65
+ * The actor, in whichever form this event carries one: `{ id }` for a GUID, `{ email }` for a work item's
66
+ * `"Display Name <email>"` string, or `{}` when neither is present.
67
+ *
68
+ * The address is extracted with an ANCHORED trailing `<...>` match and lowercased. The display-name half is
69
+ * attacker-settable -- a user can call themselves anything -- so a substring test would let
70
+ * `"pi-bot@example.com is not me <mallory@evil.test>"` read as the harness and defeat the bot-loop guard,
71
+ * or read as a member and defeat the author gate. Neither is a theoretical concern: both gates compare
72
+ * against this value.
73
+ */
74
+ export function actorOf(payload) {
75
+ const resource = payload?.resource;
76
+ const guid = resource?.createdBy?.id ?? resource?.comment?.author?.id ?? resource?.pullRequest?.createdBy?.id;
77
+ if (typeof guid === "string" && guid !== "") return { id: guid };
78
+
79
+ const fields = resource?.fields;
80
+ // On an update, `System.ChangedBy` is a `{ oldValue, newValue }` pair like every other changed field;
81
+ // on a create it is a bare string. The person who acted is the NEW value.
82
+ const changedBy = fields?.["System.ChangedBy"];
83
+ const raw = typeof changedBy === "object" && changedBy !== null ? changedBy.newValue : (changedBy ?? fields?.["System.CreatedBy"]);
84
+ const email = extractEmail(raw);
85
+ return email === null ? {} : { email };
86
+ }
87
+
88
+ /** The address inside a trailing `<...>`, lowercased, or `null`. Anchored -- see `actorOf`. */
89
+ export function extractEmail(raw) {
90
+ if (typeof raw !== "string") return null;
91
+ const match = raw.trim().match(/<([^<>]+)>$/);
92
+ if (match) return match[1].trim().toLowerCase();
93
+ // A bare address with no display name is also legal, and is accepted only when the WHOLE string is one.
94
+ const bare = raw.trim();
95
+ return /^[^\s<>@]+@[^\s<>@]+$/.test(bare) ? bare.toLowerCase() : null;
96
+ }
97
+
98
+ /**
99
+ * The field value for a work item, unwrapping the `{ oldValue, newValue }` shape an update uses. A create
100
+ * carries bare values; an update carries pairs for the fields that changed and nothing for the rest.
101
+ */
102
+ function fieldValue(fields, name) {
103
+ const v = fields?.[name];
104
+ return typeof v === "object" && v !== null ? v.newValue : v;
105
+ }
106
+
107
+ export function parseAzureSubset(payload) {
108
+ const eventType = payload?.eventType;
109
+ const resource = payload?.resource ?? {};
110
+ const containers = payload?.resourceContainers ?? {};
111
+
112
+ if (WORK_ITEM_EVENTS.has(eventType)) {
113
+ const fields = resource.fields ?? {};
114
+ // `resource.revision.fields` carries the full post-change state on an update, where `resource.fields`
115
+ // carries only the diff. Read the full state for the CURRENT tag set, and the diff separately for
116
+ // whether tags changed at all -- conflating the two is how the diff-not-set trap gets reintroduced.
117
+ const full = resource.revision?.fields ?? fields;
118
+ return {
119
+ eventType,
120
+ id: payload?.id,
121
+ actor: actorOf(payload),
122
+ project: { id: containers.project?.id, name: fieldValue(fields, "System.TeamProject") ?? fieldValue(full, "System.TeamProject") },
123
+ // `resource.workItemId` on a comment event, `resource.id` on create/update. Work item ids are
124
+ // ORGANIZATION-scoped on Azure DevOps, which is why the dedup key namespaces them on the org
125
+ // rather than on the repository.
126
+ target: {
127
+ number: resource.workItemId ?? resource.id,
128
+ title: fieldValue(full, "System.Title"),
129
+ // System.Description is a rich-text (HTML) field on most work item types. It stays DATA either
130
+ // way -- it is fenced below the delimiter like every other payload string -- but a reader of
131
+ // the envelope should not be surprised to find markup there.
132
+ body: fieldValue(full, "System.Description"),
133
+ },
134
+ tags: parseTags(fieldValue(full, "System.Tags")),
135
+ tagChanges: tagChange(fields),
136
+ // `System.History` is where a work-item comment's text arrives.
137
+ comment: eventType === "workitem.commented" ? fieldValue(fields, "System.History") : undefined,
138
+ };
139
+ }
140
+
141
+ const isPrComment = eventType === PR_COMMENT_EVENT;
142
+ const pr = isPrComment ? (resource.pullRequest ?? {}) : resource;
143
+ const repository = pr.repository ?? resource.repository ?? {};
144
+ return {
145
+ eventType,
146
+ id: payload?.id,
147
+ actor: actorOf(payload),
148
+ project: { id: containers.project?.id ?? repository.project?.id, name: repository.project?.name },
149
+ repository: { id: repository.id, name: repository.name },
150
+ target: {
151
+ number: pr.pullRequestId,
152
+ title: pr.title,
153
+ body: pr.description,
154
+ // Azure refs are fully qualified (`refs/heads/main`). They are attacker-controlled DATA,
155
+ // projected for the flow's event.json and NEVER used as a clone ref.
156
+ head: { ref: stripRefsHeads(pr.sourceRefName) },
157
+ base: { ref: stripRefsHeads(pr.targetRefName) },
158
+ },
159
+ comment: isPrComment ? resource.comment?.content : undefined,
160
+ };
161
+ }
162
+
163
+ /** `refs/heads/main` -> `main`. Azure qualifies its refs; the rest of this codebase does not. */
164
+ export function stripRefsHeads(ref) {
165
+ return typeof ref === "string" ? ref.replace(/^refs\/heads\//, "") : undefined;
166
+ }
package/src/cli.mjs ADDED
@@ -0,0 +1,82 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * The receiver's own bin (issue #80). Before it, the only start command in the repo lived inside
4
+ * deploy/receiver.service -- an operator without systemd had nothing documented to type.
5
+ *
6
+ * It is a separate bin rather than a `receiver` case in the worker CLI because the dependency points
7
+ * the other way: the receiver depends on `@edgehero/pi-dispatch` (queue, config, the shared triggers
8
+ * schema), so teaching the worker CLI to start the receiver would invert that into a circular
9
+ * workspace dependency. And the receiver is the always-on public trigger surface that lives OUTSIDE
10
+ * pi (DES-TRIGGER-OUTSIDE-PI) -- the edge deserves its own entry point, not a mode of the thing it
11
+ * feeds.
12
+ *
13
+ * Thin by design, mirroring worker/src/cli.mjs: recognise the command, lazy-import the real work.
14
+ */
15
+
16
+ import { EXIT_POLICY } from "@edgehero/pi-dispatch/exit-code";
17
+
18
+ const USAGE = `pi-dispatch-receiver — the always-on trigger edge: turns GitHub activity into queued jobs
19
+
20
+ pi-dispatch-receiver serve start the webhook receiver (the default when no command is given)
21
+ pi-dispatch-receiver poll start the polling producer — no public URL, no DNS, no tunnel: it
22
+ reads api.github.com with the operator's own credential instead
23
+
24
+ Config comes from the environment (see .env.example): WEBHOOK_SECRET is required for serve
25
+ (poll needs none — there is no inbound delivery to verify), PI_TRIGGERS_FILE overrides the
26
+ ./triggers.json default, VALKEY_URL names the queue, RECEIVER_PORT/RECEIVER_BIND choose where
27
+ serve listens, and POLL_REPOS / POLL_INTERVAL_SECONDS shape what poll watches and how often.`;
28
+
29
+ /**
30
+ * `start`/`startPoll` are injection seams defaulting to the lazy imports of ./start.mjs and
31
+ * ./poller.mjs, so tests can run the command dispatch without resolving identity, opening a socket,
32
+ * or touching Valkey -- and the help/unknown paths stay runnable even where the queue deps are not
33
+ * installed.
34
+ */
35
+ export async function main(argv = process.argv.slice(2), env = process.env, { start, startPoll } = {}) {
36
+ const cmd = argv[0];
37
+
38
+ if (cmd === undefined || cmd === "serve") {
39
+ const startReceiver = start ?? (await import("./start.mjs")).startReceiver;
40
+ await startReceiver(env);
41
+ return 0; // the server keeps the process alive until SIGTERM
42
+ }
43
+
44
+ if (cmd === "poll") {
45
+ // The polling producer (issue #81): the same gate and the same queue as serve, fed by reading
46
+ // api.github.com instead of by being reachable from it. Awaiting `done` is what keeps the
47
+ // process alive -- unlike serve there is no listening socket holding the loop open, only the
48
+ // loop itself, and returning early would let the bin exit under a healthy poller.
49
+ const startPoller = startPoll ?? (await import("./poller.mjs")).startPoller;
50
+ const poller = await startPoller(env);
51
+ await poller?.done;
52
+ return 0;
53
+ }
54
+
55
+ process.stdout.write(`${USAGE}\n`);
56
+ return cmd === "--help" || cmd === "-h" ? 0 : 1; // asked-for help is success; a typo is not
57
+ }
58
+
59
+ /**
60
+ * Exit code for an error that escaped main() as a rejection. A tagged config error
61
+ * (`piDispatchConfig`, from loadReceiverConfig or the HARD-FAIL identity boot gate) is a determinate
62
+ * refusal -> EXIT_POLICY (2, never retried); anything else is infra -> 1 (retryable). The same
63
+ * mapping as the worker CLI's entryExitCode, for the same reason: a supervisor restarting on exit 2
64
+ * would loop on a config that can never parse.
65
+ */
66
+ export function entryExitCode(err) {
67
+ return err?.piDispatchConfig ? EXIT_POLICY : 1;
68
+ }
69
+
70
+ // Entry point when run as a bin. Kept out of the exported main so tests can call main() directly.
71
+ // The error line mirrors start.mjs's own entry guard: `err.message` only -- never a secret or PII.
72
+ // (start.mjs's guard keys on argv[1] ending in start.mjs, so importing it from here never double-boots.)
73
+ if (import.meta.url === `file://${process.argv[1]}` || process.argv[1]?.endsWith("cli.mjs")) {
74
+ main()
75
+ .then((code) => {
76
+ if (code) process.exitCode = code;
77
+ })
78
+ .catch((err) => {
79
+ process.stderr.write(`${JSON.stringify({ event: "receiver_start_failed", reason: err?.message })}\n`);
80
+ process.exitCode = entryExitCode(err);
81
+ });
82
+ }