Skip to content

M2-49: Reference app for code hosting (GitHub)

Status: ready | Depends on: M2-25, M2-30 | Milestone: M2

Goal

After this ticket plugins/github-app is the reference app for code hosting of 11 10.1: a Node.js program in TypeScript on the SDK of M2-30 that receives GitHub webhooks at its own address, verifies X-Hub-Signature-256 (HMAC-SHA256 over the raw body, value sha256= plus hex, compared in constant time) before it reads anything else, and posts issue and pull request events into a forum, one thread per issue or pull request, with the mapping kept in the app's own state: an event of a known issue or pull request lands in its thread, an event of a new one opens a forum post. A person with trigger on the forum runs /issue with a title; the app opens a forum post with a form, and when the form is submitted it creates the issue through the GitHub REST API and answers in that thread. With the optional scopes for mirroring, replies of people in a linked thread go to GitHub as comments, while the app's own messages are never mirrored back and its own GitHub objects never come back into the chat (loop protection). The manifest requests exactly the scopes of 11 10.1, installApp refuses an installation without one of its required scopes, and a scope missing at call time surfaces as 403 scope_missing from the server, which the app logs once and never retries. The tests run against an in-process Fake-GitHub API with webhook payloads from GitHub's documented examples and the test values of the GitHub documentation, against an in-memory chat port, and inside make check against the real server through M2-29's sdkserver, driven as M2-30's live files drive it; no test reaches a real GitHub service, and the npm license gate stays green. Why: requirement 19 asks for working integrations (D-W-10), R-EX-11 asks for one real app per SDK as the proof that the SDK is enough for apps, and 11 10 makes this app the pattern that M2-50 (Jira) and owners' own apps copy, so its signature check, its duplicate and loop protection and its handling of foreign text must be right (RK-83, T-PL-01).

Read first

  • Dependencies: the header names M2-25 and M2-30, as the plan row does; D-W-102 removed M2-24 from the row because it is implied through M2-25 and M2-26. Implied through them and therefore merged before this brief starts: M2-26 (createWebhookSubscription, getWebhookKeys, the v1a signer, docs/api/webhook-test-vectors.json, the host[:port] entries of webhook.allowed_hosts), M2-24 (AppManifest after 11 3.1, validateManifest, installApp, setAppScopes, createToken for apps with a scope subset), M2-23 (sdk/typescript layout, npm package bothy-sdk, generated core, Vitest and coverage set-up, bootstrap-ci line for the SDK), M2-29 (the live driver services/server/test/sdkserver with its world and stdin protocol, sdk/python/tests/realserver/conftest.py), M2-17, M2-16 (listChannelCommands, invokeCommand), M2-15 (cards, createInteraction, respondToInteraction), M2-10, M2-09, M2-08, M2-01, M1-71 (services/server/test/harness), M1-66, M1-65, M1-64, M1-63, M1-57 (getMessage, listThreadMessages), M1-56 (createMessage, createThread), M1-55 (listChannelThreads), M1-53 (createChannel), M1-48 (createGrant), M1-46 (getInstance, the test of the places with the product name), M1-44 (API tokens), M1-31 (getMe), M1-22 (egress client and its test seams) and the rest of their closure.
  • docs/PLAN.md: section 3 "Way of working per ticket"; section 5 "Test strategy" (row "Test doubles": "fake GitHub and fake Jira API (M2-49, M2-50)"); section 8 "M2 Agent platform, extensions and terminal": Goal ("Apps and webhooks use the same public API (P-18)"), Scope ("reference apps for code hosting and a ticket system (11, section 10)"), Exit criteria 9 ("The reference apps M2-49 and M2-50 pass their tests against the real server and the fake APIs") and 10, Test strategy ("fake APIs for GitHub and Jira with webhook fixtures from the vendors' documentation"), Red team ("exceeding the scopes of an app", "apps and webhook targets as boundaries"), Risks; block "agents and extensions" rows M2-24, M2-25, M2-26, M2-29, M2-30; block "reference integrations" (introduction: "No integration calls a real service in tests; the counterparts are fakes with fixtures from the vendor's public documentation"; row M2-49, this ticket; row M2-50, which copies this pattern); section 19 P-09, P-10, P-11, P-16, P-18.
  • docs/spec/11-extensions.md (English): 1 "Principles" (points 1 to 4); 2 "Agent or app"; 3.1 "Format" (delivery.mode, delivery.events_url, delivery.interactions_url, commands, components, requested_grants, data_egress); 3.2 "Validation rules"; 3.3 "Example"; 4.1 to 4.3; 5 "Scopes and grants"; 6 "Isolation and limits" (row "Never allowed"); 7 "Outgoing webhooks" (rows "Signature", "Key", "Check"); 9 "Commands and interactions for apps"; 10 (introduction: tests never call a real service, own VERSION and CHANGELOG.md, no release component of 18); 10.1 "Code hosting (GitHub)" (normative for this ticket); 10.2 (what M2-50 copies); 11.2 "Hand-written layer" (rows auth, retry, components, webhooks); 11.3; 14 R-EX-01 to R-EX-04 and R-EX-11; 16 "Configuration values of this chapter".
  • docs/spec/09-agents.md: 10.1 "Built-in commands" (reserved names), 10.2 "Registration", 10.3 "Visibility and name conflicts" ("commands of apps with write on the channel"), 10.4 "Execution" (last paragraph), 11.1 "Kinds (component.v1)" (form with fields), 11.2 "Who may interact", 11.3 "Single-use rule per message", 11.4 "Flow of an interaction", 16.1 "Test doubles".
  • docs/spec/05-actions.md (core contract, read only): 2.6 "Scopes for apps" (rows with thread.create under messages:write, message.read under messages:read, interaction.respond under components:write, "Event subscriptions" under events:subscribe, self.read and instance.read without scope); 4.10 rows command.invoke, interaction.create (level trigger), interaction.respond; 5 "Agent invocation agent.invoke" (last paragraph: app commands without agent.invoke and I2).
  • docs/spec/03-data-model.md (core contract, read only): messages (origin, kind) and the content format content_format = 1 (mention tokens, rule 2: no mentions in inline code and code blocks; rule 3: only principals of kind user or agent are resolved).
  • docs/spec/04-sync-protocol.md (core contract, read only): 2.2 "Envelope" (delivery field sync_epoch), 5.1 "Messages: client_msg_id", 5.2, 9 "Delivery guarantee" (at least once).
  • docs/spec/07-permissions.md: 2.10 "Apps" (a missing scope with a grant is 403 with reason scope_missing), SR4 "Only humans trigger", 11.1 rows AZ-M32 to AZ-M34 (the app pattern).
  • docs/spec/06-api.md: 2 "Basic rules" ("No null", "Unknown values"), 5 "Errors", 7 "Idempotency", 12 "Webhooks". docs/spec/19-security.md: SEC-IN-4, SEC-OUT-1, SEC-OUT-3, SEC-LOG-2, SEC-LOG-4, SEC-SEC-1, SEC-SEC-3, SEC-SC-1. docs/spec/23-quality.md: 3 "Component contract" with "Documentation in code" (row TypeScript). docs/spec/02-architecture.md: 6 "Code layout", list "Places that carry the product name".
  • docs/api/openapi.yaml: operations getMe, getInstance, getWebhookKeys, createThread, createMessage, getMessage, respondToInteraction, and for the live test createChannel, validateManifest, installApp, setAppScopes, createGrant, createToken, createWebhookSubscription, listWebhookDeliveries, listChannelCommands, invokeCommand, createInteraction, listChannelThreads, listThreadMessages; schemas AppManifest, AppInstall, AppScope, CommandRegister, CommandOption, CommandLevel, ThreadCreate, ThreadCreated, MessageCreate, Message, MessageOrigin, MessageKind, MessagePayload, MessageCreatedEvent, CommandInvokedEvent, CommandInvokedPayload, InteractionCreatedEvent, InteractionCreatedPayload, Interaction, InteractionRespond, ComponentCreate, CommandAutocompleteRequestedEvent, AutocompleteAnswer, Me, PrincipalRef, InstanceInfo, InstanceLimits, WebhookSubscriptionCreate, ApiTokenCreate, Problem (x-problem-codes), FieldError.
  • docs/api/webhooks.md: 1 "Subscribe", 2 "Delivery", 2.1 "Signing keys", 3 "Verification by the receiver", 5 "Order and duplicates", 7 (row message.created: messages:read in addition to events:subscribe), and the section on the interactions address of apps that M2-25 added (202 and an own createMessage for a command in a forum without thread).
  • ADRs 0013, 0016, 0022, 0026, 0044, 0052, 0053.
  • docs/DECISIONS.md: D-W-10, D-W-38 (language of files), D-W-100, D-W-102; the rows D-M2-23-<n>, D-M2-24-<n>, D-M2-25-<n>, D-M2-26-<n>, D-M2-29-<n> and D-M2-30-<n> on the base branch; section 10 "Rulings of the build milestones" (row format D-M2-49-<n>).
  • docs/security/threat-model.md: 2 (TB6), 5.11 "Apps and webhooks", T-PL-01, T-TK-02, T-SC-01, 9 "Maintenance rule" point 2 ("a new app feature without an entry here is a finding that blocks the merge"). docs/RISKS.md: RK-83 (format or signature changes; "fixtures with source and retrieval date"), RK-04, RK-20. docs/OPEN-QUESTIONS.md: OQ-17 (nothing published, no name reserved).
  • docs/operations/configuration.md: section 2 (formats), section 4 rows webhook.receiver_tolerance, webhook.retry_max_age, webhook.allowed_hosts, egress.allowed_hosts, app.interaction_timeout, sdk.http_timeout, sdk.retry_max_attempts, sdk.retry_backoff_base, sdk.retry_backoff_cap, component.custom_id_max_chars, component.form_max_fields, component.default_ttl, command.description_max_chars, thread.title_max_chars, message.max_content_bytes, auth.dpop_required_for.
  • docs/quality/gates.md, docs/quality/licenses.md (point 3: PackageOverrides in an osv-scanner.toml of the component), docs/quality/toolchain.md, docs/quality/push-discipline.md, docs/quality/red-team-gate.md; quality/licenses/allowed.txt; sonar-project.properties; .gitignore; the root Makefile (TS_PROJECTS includes plugins/*package.json, the TypeScript lines of fmt-check, lint, typecheck, test, licenses, and bootstrap-ci); tools/ci/components.py and its test; tools/ci/license_check.py (SPDX headers); plugins/README.md ("Rules for all programs here").
  • Briefs of the dependencies (merged names win, D-W-100): M2-30 (sdk/typescript entries ., ./core, ./testing; createApiClient with token, dpopKey, timeoutMs, retry, logger, fetch, userAgent; ProblemError, isNotFound; verifyWebhook, verifyWebhookRequest, WebhookKeyCache, parseWebhookPublicKey, WebhookVerificationError with its codes; the components builders with form; uuidv7; loadDpopKey; DEFAULTS; testing with FakeServer and signWebhook; npm run build to dist/; the live mechanism of its Design note 12 (test/live/global-setup.ts, test/live/sdkserver.ts, live files inside npm test); the typedoc gate and tools/ci/tests/test_sdk_typescript_doc_gate.py), M2-29 (Design note 16: sdkserver, its world and stdin protocol, "no token is ever printed"), M2-25 (Design notes 5 to 10: the way interactions_url, the job runs.app_dispatch, the answer forms 200 and 202, no_thread in a forum, interaction states; the fake app pattern with an in-memory test CA, Deps.RootCAs and a test resolver), M2-24 (manifest rules, Design notes 6c, 9a, 10), M2-26 (Design note 9 ValidateTarget and the host[:port] rule; its TLS test receivers), M2-23 (Design notes 1, 8, 11, 13).
  • Code on the base branch (merged names win): sdk/typescript/ (package, src/, vitest.config.ts, eslint.config.mjs, typedoc.json, .prettierignore), sdk/typescript/test/live/, services/server/test/sdkserver and services/server/test/harness, services/server/internal/platform/egress (Deps{Resolver, RootCAs}), plugins/README.md.

Fixed requirements that apply

Owner's fixed build rules (apply verbatim):

  1. No MVP. New from scratch and directly in the final state; stages are only the order.
  2. Strictly modular: clear module and service boundaries, dependencies only through defined interfaces, no cross access.
  3. Documentation in code: every public module, class, function and interface with a docstring/doc comment (purpose, parameters, return value, errors, example where useful); comments explain the why, not the what. API documentation is generated from code (OpenAPI from the routes, dartdoc, Python API docs in MkDocs).
  4. Documentation in the repo: README per module/service, ADR for every architecture decision, CHANGELOG per release, keep the documentation site current. A change without matching documentation is not done.
  5. Quality gates (SonarQube, linters, type checking, security scanners, test coverage) block the merge.

Rules for every brief:

  1. Test first: write the failing test, run it and keep the red output for the report, then make it green, then clean up.
  2. No en dash and no em dash in any file you write; the text-rules pre-commit hook rejects them. Use a hyphen or rephrase.
  3. White label: examples and test data use example.com and the people Alex, Sam and Robin; no company, host or personal names, no paths under a home directory.
  4. Limits, windows and thresholds only through named configuration keys listed in docs/operations/configuration.md; code comments and docs name the key, never the number.
  5. Specification first: a change to behaviour, API, event, right or configuration updates docs/spec/ or docs/api/ in the same change. The core contract (docs/spec/00-glossary.md, 03-data-model.md, 04-sync-protocol.md, 05-actions.md) is not edited by this ticket.

Note on rule 3: the app offers no API of its own; its doc comments are held by the typedoc gate of 23 "Documentation in code" (row TypeScript) in its script lint, and the generated reference of the documentation site stays the SDK's (M2-30). The app's README documents configuration, endpoints and operation.

Product requirements implemented here:

  1. The app is its own process outside the server and uses only the public API, through the TypeScript SDK of M2-30 and never through server-internal packages (11 1 points 1 to 3, 11 10, R-EX-11, ADR 0022, ADR 0026, plugins/README.md).
  2. GitHub webhooks go to the app's own address, never to an incoming webhook of the server; the app checks X-Hub-Signature-256 (HMAC-SHA256 over the raw body, value starting with sha256=, comparison in constant time) before it parses or stores anything (11 10.1 row "Input").
  3. One thread per issue or pull request in the forum, with the mapping in the app's state; messages per event (11 10.1 row "Output").
  4. /issue with a form creates an issue through the GitHub API and answers in the thread (11 10.1 row "Commands"; 09 10, 11; 11 9).
  5. Scopes exactly as 11 10.1: messages:write, commands:write, components:write; optional messages:read with event message.created, which by 05 2.6 row "Event subscriptions" and webhooks.md 1 point 1 also needs events:subscribe; grant write on the forum (11 10.1 rows "Scopes" and "Grants"). An action without its scope is 403 scope_missing (07 2.10, 05 2.6).
  6. The app never mirrors its own messages (author = app) back (11 10.1 row "Loops"); apps never trigger agents and never hold trigger or manage (11 6 row "Never allowed", R-EX-03, SR4).
  7. Commands and interactions reach the app as signed POST requests to delivery.interactions_url, answered within app.interaction_timeout with 202 and acted on through the API (11 9; webhooks.md section of M2-25); requests of the server are verified with the SDK's webhooks module: raw body, webhook-timestamp within webhook.receiver_tolerance, v1a keys from getWebhookKeys only (webhooks.md 3, 11 11.2 row webhooks).
  8. Duplicates are recognised by webhook-id, at least for webhook.retry_max_age (webhooks.md 3 point 4), and by GitHub's delivery identifier.
  9. Foreign text never resolves a mention or becomes a link (03 content format rules 2 and 3, SEC-OUT-1); titles carry no control or bidi characters (SEC-IN-4).
  10. No test calls a real GitHub service; the counterpart is a Fake-GitHub API with webhook fixtures from the public GitHub documentation (PLAN 8 "Test strategy", D-W-10, 11 10).
  11. Secrets (the app's API token, the GitHub token, the GitHub webhook secret) never appear in logs, error messages or test output, and their files have mode 0400 or 0600 (SEC-SEC-1 and SEC-SEC-3 applied to the app, SEC-LOG-2, SEC-LOG-4, plugins/README.md).
  12. Dependencies locked with checksums; the license gate for npm and OSV pass without a change to quality/licenses/allowed.txt (SEC-SC-1, licenses.md, PLAN row).
  13. The app's own names do not carry the product name; the product name appears only as the SDK package bothy-sdk (02 6, ADR 0044).
  14. Own VERSION and CHANGELOG.md in the folder; the app is not a release component (11 10, D-W-38 for the English CHANGELOG).
  15. No JSON null in requests to the server (ADR 0053); unknown members of server events are tolerated through the SDK (06 2).

Scope

Paths are relative to the repository root. Names of M2-23, M2-24, M2-25, M2-26, M2-29 and M2-30 below are the expected ones; where the merged code names them differently, follow the code (Boundaries).

Create, under plugins/github-app/:

  • package.json and package-lock.json (Design note 2), tsconfig.json, tsconfig.build.json, eslint.config.mjs, .prettierignore, vitest.config.ts (with globalSetup test/live/global-setup.ts as M2-30's), typedoc.json, each following the matching file of sdk/typescript/.
  • VERSION (0.0.0), CHANGELOG.md (Keep a Changelog, English, like sdk/typescript/CHANGELOG.md), README.md (English, Design note 21), manifest.json (Design note 16), config.example.json (every key of Design note 3 with https://chat.example.com and example paths outside a home directory).
  • src/main.ts (CLI entry), src/app.ts (startApp(config, deps) returning the listening address and stop(); injectable fetch, clock and logger), src/config.ts (Design note 3), src/log.ts (Design note 15), src/http/server.ts (Design note 4), src/github/signature.ts (Design note 5), src/github/events.ts (Design note 6), src/github/client.ts (Design note 14), src/chat/port.ts (the app's port to the server, Design note 9), src/chat/sdk-adapter.ts (the port on createApiClient), src/chat/receive.ts (Design note 7), src/domain/keys.ts, src/domain/render.ts (Design note 10), src/domain/mirror.ts (Design note 12), src/store/store.ts, src/store/file-store.ts, src/store/memory-store.ts (Design note 8), src/worker/worker.ts, src/worker/jobs.ts (Design notes 8, 9, 11, 12).
  • test/ with the tests of the section "Tests", test/fakes/github-api.ts (Design note 17), test/fakes/chat.ts (in-memory port), test/setup/no-network.ts (Design note 18), test/fixtures/README.md (source, package version, access date; Design note 17), test/live/global-setup.ts, test/live/sdkserver.ts and test/live/github-app.live.test.ts (Design note 19).
  • osv-scanner.toml only if the license gate needs an override (Design note 20).

Change elsewhere:

  • M2-29's live driver services/server/test/sdkserver: the additive protocol parts of Design note 19 (member app_front of start, the TLS front, the ops app_backend, create_forum and call) and its README.md.

Change:

  • Root Makefile: recipe bootstrap-ci installs the dependencies of every tracked plugins/*/package-lock.json after the SDK (Design note 20). No new target.
  • tools/ci/components.py and tools/ci/tests/test_components.py (Design note 20); the doc gate test of M2-30 (Design note 20).
  • sonar-project.properties (sonar.javascript.lcov.reportPaths), .gitignore (plugins/*/coverage/).
  • docs/spec/11-extensions.md 10.1 and 16 (Design note 21), then make config-reference for docs/operations/configuration.md.
  • docs/security/threat-model.md 5.11, register rows T-PL-01 and new T-PL-02 (Design note 22).
  • services/server/test/harness only if it cannot pass the egress test seams of Design note 19.
  • plugins/README.md (row github-app/: status built, M2-49), root CHANGELOG.md under "Unreleased", docs/DECISIONS.md section 10 rows D-M2-49-1 and following (Design note 23).

This ticket adds no operation, event, action, problem code or server configuration key and creates no new top-level make target; its new configuration keys are values of the app (place "GitHub app").

Out of scope

  • The Jira app (M2-50), which follows the pattern of this ticket; monitoring alarms and cron messages (M2-27, M7-20).
  • Server behaviour: manifests, installation and scopes (M2-24), app commands, interactions and the requests to interactions_url (M2-25), outgoing webhook subscriptions and deliveries (M2-26). Where the server deviates from its specification, stop with Verdict: BLOCKED <reason>; do not work around it in the app.
  • SDK changes (M2-29, M2-30); if a needed SDK function is missing, stop with Verdict: BLOCKED dependency M2-30 incomplete: <function>.
  • GitHub App authentication with a private key and installation tokens, GitHub events beyond the list of Design note 6, closing or renaming chat threads (apps have no scope for thread.close or thread.title.update, 05 2.6), labels, assignees and edits of issues.
  • Publishing to npm or a container image of the app (OQ-17; 11 10: operators build it from the repository); a release chain.
  • The console view of app installations (M6-08).
  • Changes to the core contract.

Boundaries

  • Work in a dedicated git worktree on the branch feat/M2-49-github-app; one writer per worktree.
  • Conventional Commits with the component as scope (for example feat(github-app): ..., test(server): ..., build(ci): ...) and a DCO sign-off on every commit (git commit -s); squash into meaningful commits before the merge (docs/quality/push-discipline.md).
  • Never push to main, never tag and never create remote repositories; the only remote steps are the branch push and the pull request of AGENTS.md. Never publish a package to npm and never reserve a name there (OQ-17); the package stays "private": true.
  • Do not modify files outside the scope above, except the CHANGELOG, README and docs/DECISIONS.md entries named in this brief.
  • Follow the host rules supplied by the launcher (ports, machine rules).
  • Tests bind only 127.0.0.1 and port 0; PostgreSQL runs only as a throwaway container on a random port through the live driver (P-09, P-10). Without a Docker-compatible runtime, Go or npm the live test fails; it never skips.
  • Stop only processes you started, by PID. Temporary directories only via mktemp -d or the test framework's temporary directory, removed at the end.
  • Never read or print secrets. No network calls except package downloads for pinned tools, npm packages and Go modules; npm installs run with --ignore-scripts (T-SC-01).
  • No real third-party service and no real DNS name in any test: GitHub is the Fake-GitHub API on 127.0.0.1, the server is the live driver's, names under example.com reach loopback only through the test resolver of the egress client.
  • Dependencies pinned exactly in the lock; dev tools exact in package.json. Do not extend quality/licenses/allowed.txt; if a needed package or its tree fails the gate, stop with Verdict: BLOCKED <package and license>.
  • Never lower a threshold, disable a gate or delete a test.
  • If the core contract lacks something this ticket needs, stop with Verdict: BLOCKED <reason> instead of building a deviation (P-11).
  • This brief was written before milestone M1 closed (D-W-100). Where the merged code of an M1 ticket names a package, type, function, file or make target differently from this brief, follow the code and list each difference in the report; where it behaves differently from the specification, stop with Verdict: BLOCKED <reason>.
  • The same holds for the merged code of M2-23, M2-24, M2-25, M2-26, M2-29 and M2-30: follow its names and list each difference.
  • Never log, print or persist outside the app's state directory a title, body, comment, form value, display name, handle, GitHub login, token, secret or signature, not even in test output; tests compare such values in memory.

Design notes

  1. Start check. Run make check on the fresh worktree; if it is red, stop with Verdict: BLOCKED base branch red and the failing gate. Confirm on the base branch: M2-30 (npm --prefix sdk/typescript run build produces dist/; the package exports createApiClient, verifyWebhook, WebhookKeyCache, the components builders with form, uuidv7, loadDpopKey, and the entry ./testing with signWebhook; its live files under sdk/typescript/test/live/ run inside npm test against M2-29's sdkserver), M2-29 (services/server/test/sdkserver with its stdin protocol), M2-25 (requests to delivery.interactions_url from the job runs.app_dispatch, the section on the interactions address in webhooks.md), M2-26 (createWebhookSubscription, getWebhookKeys), M2-24 (AppManifest with scopes.required, scopes.optional, delivery, requested_grants, data_egress), M1-57 (getMessage, listThreadMessages). A missing one ends with Verdict: BLOCKED dependency <ticket> missing.

  2. Package, runtime and scripts. Ruling for docs/DECISIONS.md.

    • plugins/github-app is an ESM Node.js program for the Node.js line of the SDK's engines, for Linux and macOS (the file mode checks of Design note 3 are POSIX). Package name github-app, "private": true, "type": "module", "license": "Apache-2.0", version equal to VERSION. Entry: node dist/main.js --config <file>; exit code 0 after a clean stop, non-zero on any start failure.
    • One runtime dependency: "bothy-sdk": "file:../../sdk/typescript". HTTP with node:http, HMAC and comparison with node:crypto, files with node:fs/promises, outgoing requests with fetch; the two GitHub calls are written by hand (Design note 14), no Octokit. Reason: the fewest third-party packages in the security path (SEC-SC-1), and the license gate then sees the SDK's tree only.
    • Dev dependencies at exactly the versions sdk/typescript/package.json pins for TypeScript, Vitest and its V8 coverage, ESLint and its TypeScript plugins, Prettier, typedoc and @types/node, plus @octokit/webhooks-examples pinned exactly (Design note 17). Same versions, same license tree as the SDK.
    • Scripts: build:sdk = npm --prefix ../../sdk/typescript run build; build = npm run build:sdk && tsc -p tsconfig.build.json (ESM and declarations to dist/, never committed); typecheck = npm run build:sdk && tsc --noEmit; lint = npm run build:sdk && eslint . && typedoc --emit none; format and format:check with Prettier; test = npm run build:sdk && vitest run --coverage (it includes the live file of Design note 19); start = node dist/main.js. Ruling: every script that imports the SDK builds it first, because the root Makefile runs the sorted TS_PROJECTS with plugins/github-app before sdk/typescript, and npm runs no pre-scripts under ignore-scripts; tests then use the same package entry points as production.
    • Coverage: the threshold key M2-23 introduced for TypeScript (quality.coverage.typescript_min_percent in its brief), enforced in vitest.config.ts as the SDK does; test/live/ and test/fakes/ excluded.
    • The product name: the app's names, paths, log codes and keys never contain it; it appears only as the dependency and import bothy-sdk, a listed kind of 02 6. If the product-name test of M1-46 flags these lines, extend its allowance to references of the listed SDK package name, never to a new name of the app.
  3. Configuration (new rows in 11 16 with place "GitHub app"; Ruling for docs/DECISIONS.md). A JSON file named by --config holds one object github_app whose members are the keys without their prefix:

    • github_app.listen: no default, required; host:port of the HTTP listener.
    • github_app.server_url: no default, required; base URL of the instance, https, http only for a loopback host.
    • github_app.token_file: no default, required; file with the app's API token (M2-24 createToken).
    • github_app.dpop_key_file: empty; key file for a DPoP-bound app token (auth.dpop_required_for), loaded with the SDK's loadDpopKey.
    • github_app.webhook_secret_file: no default, required; file with the secret of the GitHub webhook.
    • github_app.github_token_file: no default, required; file with the token for the GitHub REST API.
    • github_app.github_api_url: https://api.github.com; base URL of the GitHub REST API.
    • github_app.repositories: no default, required, not empty; list of {"repository": "<owner>/<name>", "channel_id": "<forum id>"}; each repository once, compared without case; several repositories may share one forum.
    • github_app.state_dir: no default, required; state directory of Design note 8.
    • github_app.max_body_bytes: 1048576; largest request body on each endpoint.
    • github_app.dedupe_window: 72h; how long GitHub delivery identifiers, webhook-id values, the GitHub objects the app created and open drafts are kept. The default equals the default of webhook.retry_max_age; the README tells operators to keep it at least as long as that server key (webhooks.md 3 point 4).
    • github_app.max_queued_jobs: 10000; open jobs, above which the endpoints answer 503.

    Reused keys, never new numbers: webhook.receiver_tolerance (the SDK default of verifyWebhook), sdk.http_timeout (also the time limit of each GitHub request), sdk.retry_max_attempts, sdk.retry_backoff_base and sdk.retry_backoff_cap (the SDK client; sdk.retry_backoff_cap is also the worker's pause after a transient failure). Durations follow configuration.md 2. Unknown members, wrong types, bad durations and missing required keys abort the start with a message that names key and file and never a value. Secret files (token_file, dpop_key_file, webhook_secret_file, github_token_file) must have mode 0400 or 0600 and belong to the process user, otherwise the start aborts (the rule of SEC-SEC-1); the GitHub token and the webhook secret are read at each use with the same check, so rotation needs no restart (a failing check answers 503 or fails the job), the app token once at start. state_dir is created with mode 0700; an existing one with group or other access is refused. config.example.json parses with the loader (test).

  4. HTTP surface (Ruling for docs/DECISIONS.md). Routes POST /github/webhook, POST /chat/interactions, POST /chat/events; any other path gives 404, another method 405. Per request, in this order: Content-Type must be application/json (parameters allowed), else 415; the body is read as bytes up to github_app.max_body_bytes (a larger Content-Length answers 413 before reading, a stream that grows beyond it is cut and answers 413); signature check (Design notes 5 and 7), failure 401; JSON parse with invalid UTF-8 or JSON answering 400; duplicate check; classification and, in the same store write, the seen identifier and the new job; then 202 when a job was queued and 204 when there is nothing to do; 503 when the queue holds github_app.max_queued_jobs jobs, the store fails, a secret file fails its check or the app is stopping. Bodies are empty except for the autocomplete answer of Design note 7. The app speaks plain HTTP on github_app.listen and runs behind the TLS-terminating proxy of the instance; delivery.interactions_url and delivery.events_url are https addresses of that proxy (11 3.2 rule 3), and the README shows the route. Node.js keeps its default header and request time limits; the proxy bounds slow clients.

  5. GitHub signature (11 10.1 row "Input"; RK-83). Ruling for docs/DECISIONS.md.

    • X-Hub-Signature-256 must be present and match ^sha256=[0-9a-f]{64}$; a missing, truncated, upper-case or otherwise malformed value is refused without computing anything. X-Hub-Signature (SHA-1) is never used.
    • The app computes HMAC-SHA256 with the secret over the raw body bytes and compares the 32-byte digest with the 32 bytes decoded from the header through crypto.timingSafeEqual; both have the same length, so it never throws. No string comparison of a signature anywhere.
    • Only after a valid signature: X-GitHub-Event and X-GitHub-Delivery must be present (else 400), then the JSON is parsed.
    • Every signature failure answers 401 without body and logs only reason (missing, malformed, mismatch).
    • Test values of the GitHub documentation (the page cited in 11 10.1, section on testing the payload validation): secret It's a Secret to Everybody, payload Hello, World!, expected header value sha256=757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17 (the HMAC was recomputed while this brief was written). The test names the URL and the access date of 11 10.1 in a comment.
  6. GitHub events (closed list; Ruling for docs/DECISIONS.md). Supported: ping (answer 204, no job); issues with action opened, closed or reopened; issue_comment with created, on issues and on pull requests (the member issue.pull_request marks a pull request); pull_request with opened, closed (merged when pull_request.merged is true) or reopened; pull_request_review with submitted. Every other event or action answers 204 with log reason unsupported. repository.full_name is compared without case with github_app.repositories; an unknown repository answers 204 with reason repository_not_configured. The parser keeps only the fields used: key (Design note 9), kind (issue or pull request), number, title, html_url, the actor login, the text (body of issue, pull request, comment or review), comment id, review state, merged flag; a missing required field answers 400 with reason invalid_payload. The job stores this parsed event, never the raw payload. Reason: the list covers the life of an issue or pull request; more events are a later change with their fixtures.

  7. Requests of the server (11 9; webhooks.md 3 and the section of M2-25). Ruling for docs/DECISIONS.md.

    • Both /chat/ endpoints verify with the SDK: verifyWebhook over the raw body with the keys of one WebhookKeyCache of the app's client and the default tolerance webhook.receiver_tolerance; never an own verifier and never keys from the request. A WebhookVerificationError answers 401 and logs its code. No v1 secret: an interactions address has no HMAC (M2-25), and the app's subscription is created without enable_hmac.
    • Duplicates: webhook-id (the event id) is remembered for github_app.dedupe_window; a repeat answers as the first time (202 on /chat/interactions, 202 or 204 on /chat/events) without a second job.
    • /chat/interactions: command.invoked with command_name issue queues the job issue_command; interaction.created queues issue_form; both answer 202. command.autocomplete_requested answers 200 with {"choices": []} (AutocompleteAnswer; the app declares no option with autocomplete). Another type or command name answers 202 with Warn reason unknown_request. Reason: in a forum the server has no reply thread for a 200 (M2-25 no_thread), and GitHub calls do not fit into app.interaction_timeout; the person sees the result through the API answers of Design note 11.
    • /chat/events: only message.created is handled (Design note 12); any other type answers 204. sync_epoch is ignored, because the app keeps no seq state.
  8. State, queue and worker (Ruling for docs/DECISIONS.md).

    • Port Store with FileStore in github_app.state_dir: state.json (thread map in both directions, drafts, the GitHub objects the app created, seen identifiers with their time) and one file per job under jobs/. Every write goes to a temporary file, fsync, rename, fsync of the directory; files 0600, directories 0700; one writer at a time through an in-process mutex. MemoryStore serves unit tests; one contract test runs against both.
    • A lock file lock, created exclusively and holding the PID, keeps a second process away; a lock whose PID is not alive is taken over with a Warn line.
    • A job is written before its request is answered; the worker processes jobs strictly in creation order, one at a time, so events of one issue keep their order and a /issue job records its issue before GitHub's opened event of that issue is handled (Design note 12).
    • A job records its progress per step. Every write to the server gets its client_msg_id (SDK uuidv7) or Idempotency-Key stored in the job before the first attempt, so a rerun after a crash repeats the same request and the server answers the original (04 5.1 and 5.2; the description of createThread). The SDK client retries transient failures within sdk.retry_max_attempts; when they are exhausted, the job stays queued and the worker pauses sdk.retry_backoff_cap. Permanent answers end the job (Design note 13).
    • A write to GitHub is never repeated: the job marks the step attempted in the store before it sends; a rerun that finds attempted without a stored result treats the outcome as unknown (Design notes 11 and 12). Reason: GitHub's create calls are not idempotent, and a second issue is worse than a missing comment.
    • Finished jobs are deleted; seen identifiers, created objects and drafts older than github_app.dedupe_window are pruned at each write; the thread map stays.
    • SIGTERM and SIGINT: stop accepting (requests after the signal get 503), let the running step finish, release the lock, exit 0.
  9. One thread per issue or pull request (11 10.1 row "Output"; Ruling for docs/DECISIONS.md).

    • Key <owner>/<name>#<number> in lower case, built from the configured repository name; issues and pull requests share the numbers of a repository. The map holds key to {thread_id, channel_id} and thread to key.
    • Port ChatPort (src/chat/port.ts) with me(), limits(), createForumThread(...), postMessage(...), getMessage(...), respondInteraction(...), typed with the schema types the SDK exports from the generated core (no own type repeats a schema, ADR 0026 "Confirmation"); src/chat/sdk-adapter.ts implements it on createApiClient (getMe, getInstance, createThread, createMessage, getMessage, respondToInteraction).
    • Known key: createMessage into its thread. Unknown key: createThread in the repository's forum with title and first_message (the event's message) in one call, idempotent through first_message.client_msg_id; the map entry is stored with the answer.
    • Title #<number> <title> for an issue, PR #<number> <title> for a pull request (also when the first event is a comment on it), cleaned and cut as Design note 10 says.
    • 404 not_found when posting into a mapped thread (it moved out of reach or the grant there is gone): drop the entry, open a new forum post for the key once, Warn reason thread_unreachable; a second 404 not_found ends the job.
    • Messages (English templates; GitHub text rendered as Design note 10; "#12" stands for the number, linked to the event's html_url as Design note 10 allows): issues opened "Issue #12 opened by login" plus the body; closed and reopened one line each; issue_comment "login commented on #12:" plus the comment, linked to the comment's html_url; pull_request opened with the body, closed as "merged" or "closed", reopened; pull_request_review "login reviewed #7: state" plus the body when present, linked to the review's html_url.
  10. Foreign text (03 content format, SEC-IN-4, SEC-OUT-1; Ruling for docs/DECISIONS.md).

    • GitHub text never resolves a mention or becomes a link in the chat: logins and titles inside messages go into a code span, bodies of issues, pull requests, comments and reviews into a fenced code block; the delimiter of each is one backtick longer than the longest backtick run of its text (CommonMark 0.31.2, the version of 03), so the text cannot close it (03 content format rule 2: no mentions in code).
    • A link is written only for the event's html_url when it parses as an absolute https URL without whitespace, angle brackets or parentheses; otherwise no link.
    • Messages are cut to limits.message_max_content_bytes of getInstance (UTF-8 bytes, at a code point boundary) by cutting the fenced text and adding the line "(cut, full text on GitHub)", with the fence always closed; titles to limits.thread_title_max_chars code points.
    • Titles: Unicode control characters (category Cc) and the bidi controls U+200E, U+200F, U+061C, U+202A to U+202E and U+2066 to U+2069 are removed, line breaks become spaces, the result is trimmed; an empty result leaves #<number> or PR #<number>. If the server still answers 400 validation_failed on the title, the job retries once with #<number> or PR #<number> alone.
    • Limits come from getInstance at start and once more after a 400 validation_failed with errors[].code too_long, followed by one retry.
    • Text the app writes itself (the confirmation of /issue, the form card) contains no mention.
  11. /issue with a form (11 10.1 row "Commands"; Ruling for docs/DECISIONS.md).

    • The manifest command issue: description "Create a GitHub issue" within command.description_max_chars, option title (string, required, description "Issue title"), required_level trigger. Reason: the form is submitted with interaction.create, which needs trigger (05 4.10); with write a person could run the command but never submit its form, and agents and apps, which hold at most write, cannot run it.
    • Job issue_command: the payload's channel_id must be a configured forum, else Warn reason channel_not_mapped and end (the README tells operators to grant write only on configured forums, so the command is listed only there, 09 10.3). Then createThread in that forum with the option title cleaned and cut as the title (empty: "New issue") and the first message "New GitHub issue for <owner>/<name>. Fill in the form below.", then createMessage in the new thread with kind card and one component built with the SDK's form builder: custom_id issue:<draft id> (within component.custom_id_max_chars), single_use true, restricted_to any, fields body (textarea, label "Description", not required) and, only when more than one repository maps to the forum, repository (choice of the configured names, required), within component.form_max_fields. Store the draft {draft id, thread id, card message id, title, repositories}. Reason: a thread belongs to exactly one issue and an app cannot retitle a thread (05 2.6), so /issue always opens its own forum post, also when it was run inside a thread.
    • Job issue_form: the draft is found by interaction.custom_id, else by interaction.message_id; unknown or pruned: respondToInteraction with state rejected and text "This form expired. Run /issue again.". The repository is the only one or the chosen one, checked against the draft. Mark attempted, then POST the issue with title and body = the form text plus a last line "Created from chat.". Ruling: nothing about the person (name, handle, id) goes to GitHub from /issue, and the text is not neutralized, because the person wrote it for GitHub.
    • GitHub answers 201: store number and URL, record the issue as created by the app (Design note 12), map the key to the draft's thread, respondToInteraction with state acknowledged and text "Created issue #", createMessage into the thread "Created GitHub issue #." with the number linked to the issue's html_url, delete the draft. This is the answer in the thread.
    • GitHub answers 3xx or 4xx (not followed, so the issue was not created): respondToInteraction with state failed and text "GitHub refused the issue (status ).", then a new form card in the same thread; the draft stays with the new card.
    • 5xx, a time-out after sdk.http_timeout, a transport error, or attempted without result after a restart (outcome unknown): respondToInteraction with state failed and text "GitHub did not confirm the issue. Check the repository before you try again."; no new card, the draft is deleted.
    • A 409 answer to a repeated respondToInteraction (rerun after a crash) counts as done.
  12. Mirroring and loop protection (11 10.1 rows "Scopes" and "Loops"; Ruling for docs/DECISIONS.md).

    • Mirroring exists only when the owner granted messages:read and events:subscribe, added message.created to the installation's event_subscriptions and created a subscription to delivery.events_url for message.created (webhooks.md 1); otherwise /chat/events receives nothing.
    • Decision at receipt, in this order, each answering 204 without a job and logging its reason: the author is the app itself (message.author.id equals the principal id from getMe; reason own_message, the loop protection of 10.1); origin is not human (not_human: agent, app, import and system messages are never mirrored, so two mirroring apps cannot feed each other and no agent output leaves the instance without a person); kind is not text (not_text); deleted_at is set (deleted); the thread is not mapped (thread_not_mapped). Otherwise the job mirror, answer 202.
    • Job mirror: if content_truncated is set, load the message with getMessage; the comment body is "<display name> wrote in chat:" followed by the content in a fenced block (Design note 10 delimiter rule), because chat mentions name chat people and must not become GitHub mentions or links; mark attempted, POST /repos/<owner>/<name>/issues/<number>/comments; 201: record the comment id as created by the app; any other outcome: log status or reason github_outcome_unknown and end without retry.
    • Echo protection in the other direction: GitHub's issues opened event of an issue the app created through /issue and the issue_comment created event of a comment the app created are not posted (answer 204, reason own_github_object); later events of these objects are posted. Reason: the chat already shows the original, and without the rule every mirrored reply would come back as an app message.
  13. Answers of the server (06 5, 07 2.10).

    • 403 scope_missing: end the job, one Warn line with operation, status and reason scope_missing, no retry; the GitHub delivery was already answered 202.
    • 404 not_found: Design note 9 for threads; for respondToInteraction and getMessage end the job.
    • 403 forbidden and 403 channel_read_only: end the job and log the code and reason.
    • 400 validation_failed: the retries of Design note 10, otherwise end the job and log the errors[].code values.
    • 401 unauthenticated (token revoked, app suspended or uninstalled, M2-24): end the job with an Error line; the endpoints answer 503 until getMe succeeds again, tried once per sdk.retry_backoff_cap.
    • 422 idempotency_key_reuse: Error line (the same identifier with another body is a programming fault), end the job.
    • 429 rate_limited and 503 unavailable: retried by the SDK, then Design note 8. Unknown codes are classified by status through the SDK's ProblemError.
  14. GitHub REST client (Ruling for docs/DECISIONS.md). Two calls: POST <github_api_url>/repos/<owner>/<name>/issues with {title, body} and POST <github_api_url>/repos/<owner>/<name>/issues/<number>/comments with {body}, headers Accept: application/vnd.github+json, Authorization: Bearer <token>, X-GitHub-Api-Version: 2022-11-28, User-Agent: github-app/<VERSION> and Content-Type: application/json; time limit sdk.http_timeout; redirects are not followed (redirect: "manual", a 3xx is a failure); the response body is read only on 201 for number, html_url and id; errors carry the status only, never a body. The README names the GitHub REST pages for issues, issue comments and API versions and says that the token needs only the right to create issues and issue comments in the configured repositories.

  15. Logs (SEC-LOG-2, SEC-LOG-4 and SEC-SEC-3 as the rule for this app). JSON lines on stdout with time, level, msg (a fixed code such as github.delivery.accepted) and only these attributes: event, action, delivery_id, webhook_id, job_id, job_kind, step, operation, status, outcome, reason, code, number, thread_id, channel_id. Never titles, bodies, comments, form values, display names, handles, GitHub logins, repository names, URLs, header values, tokens, secrets, signatures or file contents. The SDK client gets this logger. Start errors name key and file, never a value.

  16. Manifest (plugins/github-app/manifest.json, 11 3.1 as M2-24 built AppManifest). Members: schema_version 1; slug github; name GitHub; description (English, one sentence); publisher Reference integration; version equal to VERSION; min_api_version equal to major and minor of info.version of docs/api/openapi.yaml; scopes.required [messages:write, commands:write, components:write]; scopes.optional [messages:read, events:subscribe]; events [message.created]; delivery {mode: webhook, events_url: https://github-app.example.com/chat/events, interactions_url: https://github-app.example.com/chat/interactions}; commands with issue of Design note 11; components [form]; requested_grants one {target_kind: channel, level: write, capabilities: [], purpose: ...}; data_egress (English): issue and pull request events of the configured repositories are posted into the forum; /issue sends title and description to the configured GitHub API; with messages:read and events:subscribe, messages of people in linked threads with their display name go to GitHub as comments. No homepage_url. Ruling: the required scopes are exactly those of 10.1; events:subscribe joins messages:read as optional, because 05 2.6 row "Event subscriptions" and M2-24's manifest rule require it for events, and 10.1 is corrected in the same change. The README's installation steps: the operator adds the host of the addresses to webhook.allowed_hosts; an owner uploads the manifest with installApp (required scopes; the optional ones only for mirroring), grants write on each configured forum with createGrant, creates the token with createToken, and for mirroring creates the subscription with createWebhookSubscription to events_url; a later scope needs a new token (M2-24).

  17. Fake-GitHub API and fixtures (PLAN 8 "Test strategy", D-W-10, RK-83; Ruling for docs/DECISIONS.md).

    • test/fakes/github-api.ts: an HTTP server on 127.0.0.1 port 0 with the two endpoints of Design note 14, checking Authorization (401 without the test token), Accept, X-GitHub-Api-Version and User-Agent, answering 404 for an unknown repository and 422 for an empty title, assigning increasing numbers and ids, and recording method, path, JSON body and header names in memory. Fault injection per call: status 500, status 422, an answer later than the client's time limit, a dropped connection.
    • deliver(event, payload, options) signs with HMAC-SHA256 like GitHub and posts to the app with X-GitHub-Event, X-GitHub-Delivery, X-Hub-Signature-256, Content-Type: application/json and a User-Agent of the form GitHub-Hookshot/<id>; options omit, alter or truncate the signature and reuse a delivery identifier. With autoEcho, every issue or comment created through the fake is followed by its issues opened or issue_comment created delivery.
    • Fixtures: the payloads of @octokit/webhooks-examples (license MIT, checked in its LICENSE file and by the gate), a package that collects the example payloads of the GitHub webhook documentation; test/fixtures/README.md quotes the package's own statement of that origin and names package version, the documentation URL and the access date. Examples are chosen by event and action and changed only in repository name, numbers and ids; nothing is copied into the repository. If the package is unavailable, does not state that its examples come from the GitHub documentation, or fails the license gate, write minimal fixtures with only the fields of Design note 6 under test/fixtures/github/, each with the documentation page of its event in the README, and name this under "What is not proven".
  18. No real GitHub service (PLAN row). test/setup/no-network.ts, a setupFiles entry of the Vitest configuration, replaces globalThis.fetch by a guard that throws for every URL whose host is not 127.0.0.1 or localhost; startApp in tests passes the guarded fetch to the SDK client and to the GitHub client; the test helper that builds configurations refuses a github_api_url or server_url outside loopback. The app opens outgoing connections only through fetch.

  19. Live run against the real server (PLAN 8 Exit criteria 9; Ruling for docs/DECISIONS.md).

    • Mechanism: M2-30's (its Design note 12), with no Go test added. test/live/global-setup.ts (Vitest globalSetup of vitest.config.ts) builds M2-29's sdkserver once, go test -c -tags sdkserver -o <tmp>/sdkserver.test ./test/sdkserver in services/server, and hands the binary path to the test files; test/live/sdkserver.ts runs it with -test.run '^TestSDKServer$' inside the test worker, because only the process that owns its stdin can drive it, and drives it over its JSON-lines protocol (answers SDKSERVER <json>). test/live/github-app.live.test.ts starts it once in beforeAll with start (the members M2-30's test/live/sdkserver.ts sends, plus app_front) and stops it in afterAll with stop and then by PID. The live file runs inside the script test, so the root make test and make check run it; without Docker, Go or npm it fails and never skips. tools/ci/components.py lets a change under services/server/ select plugins, as M2-30 did for sdk-typescript (Design note 20).
    • Protocol extension of sdkserver, additive (M2-29's and M2-30's clients send none of it and stay unchanged): start takes the optional member app_front {host}. With it, before the server starts, the driver opens a TLS front on 127.0.0.1 port 0 with a leaf certificate for host from a certificate authority created in memory; the server's egress client trusts that authority through Deps.RootCAs and resolves host to 127.0.0.1 through a test resolver, wired as the integration tests of M2-25 and M2-26 wire them (if the harness offers no option to pass them, add one in services/server/test/harness); the driver adds webhook.allowed_hosts = <host>:<front port> and egress.allowed_hosts = <host>:<front port>;allow_private to the given config and answers ready with the additional member front_port. The server runs its jobs, so runs.app_dispatch and apps.webhook_deliver deliver. This test uses host github-app.example.com.
    • The front forwards each accepted TLS connection as plain TCP to the backend port that the app registers with the new op app_backend {port}; before that, it closes connections. This is the production layout of Design note 4 in small.
    • World: the driver's world (owner Robin, Alex with trigger on space Support through G-1) plus the forum Support/Tickets, created with the new op create_forum {name} (Robin calls createChannel with kind forum and the given name in space Support; the answer is {channel_id}) with name tickets.
    • New op call {as, operation_id, path_params, query, body} with as robin or alex: the driver performs the operation through the real HTTP chain with that person's session (Robin with a fresh step-up before each call) and writes status and body into a file with mode 0600 in its directory; the answer names status and file only, so no token is ever printed. The TypeScript side performs installation, grant, token, scopes, subscription, command and form submission through call, so the live test runs the documented operator steps.
    • services/server/test/sdkserver/README.md documents app_front, front_port, app_backend, create_forum and call.
  20. Tool chain and gates.

    • Root Makefile, bootstrap-ci: after the SDK line of M2-23, for every tracked plugins/*/package-lock.json, npm ci --prefix <directory> --ignore-scripts --no-audit --no-fund --loglevel=error. CI runs bootstrap-ci, so no workflow change is needed.
    • typedoc gate (23 "Documentation in code"): typedoc.json as M2-30's, with every module under src/ as entry point and undocumented exports as errors; the script lint fails on them. Parametrize tools/ci/tests/test_sdk_typescript_doc_gate.py of M2-30 over sdk/typescript and plugins/github-app (a deliberately undocumented export appended to a copy of src/app.ts fails npm run lint and is named in the output; the unchanged copy passes); if its structure does not allow it, add tools/ci/tests/test_github_app_doc_gate.py with the same steps.
    • .gitignore: plugins/*/coverage/. sonar-project.properties: plugins/github-app/coverage/lcov.info added to sonar.javascript.lcov.reportPaths.
    • tools/ci/components.py: a change under sdk/typescript/ or services/server/ also selects the gate component plugins, in the way M2-30 made sdk/typescript/ reach its live test; tests in tools/ci/tests/test_components.py.
    • Licenses: make licenses reads plugins/github-app/package-lock.json. If OSV-Scanner reports the linked local package bothy-sdk as UNKNOWN, add plugins/github-app/osv-scanner.toml with one PackageOverrides entry and its reason ("local package of the repository under Apache-2.0, sdk/typescript"), as licenses.md point 3 says; never a change to quality/licenses/allowed.txt.
    • Every .ts and .mjs file carries SPDX-License-Identifier: Apache-2.0 in its first lines (tools/ci/license_check.py).
  21. Specification and documents (spec first; English in chapters, READMEs, CHANGELOGs and the manifest).

    • docs/spec/11-extensions.md 10.1, table rows: "Input" (header required, format sha256= plus 64 lower-case hex digits, check over the raw body before parsing, answer 401, duplicates by X-GitHub-Delivery within github_app.dedupe_window, the event list of Design note 6); "Output" (key, titles #<n> and PR #<n>, a thread out of reach is replaced once); "Commands" (/issue with option title and required_level trigger, own forum post with a form, answer 202, issue over the GitHub API, answer in the thread, no data of the person to GitHub); new row "Delivery to the app" (interactions_url; a webhook subscription to events_url only for mirroring); "Scopes" (optional messages:read and events:subscribe with event message.created, 05 2.6); "Loops" (author = app never mirrored; only origin = human text; the app's own GitHub objects do not come back); new row "Foreign text" (code spans and fenced blocks, no mentions and links, cutting after getInstance); new row "State" (state directory, lock, queue, one attempt at GitHub).
    • docs/spec/11-extensions.md 16: the twelve keys of Design note 3 with place "GitHub app"; then make config-reference.
    • plugins/github-app/README.md (English): purpose, layout, configuration with every key, endpoints and the TLS proxy, installation steps of Design note 16, the GitHub webhook set-up (content type application/json, secret, the events of Design note 6), the GitHub token, mirroring, loop and echo rules, logs, state and lock, how to run the tests and the live test, and that the processor register of the operator lists GitHub (docs/compliance/processors-template.md, row "App <name>").
    • plugins/README.md: row github-app/ with status built in M2-49.
  22. Threat model (threat-model.md 9 point 2: a new data flow between GitHub, the app and the chat). Add to 5.11 the rows S "Forged or replayed GitHub delivery to the app" (X-Hub-Signature-256 in constant time, duplicates by X-GitHub-Delivery), T "Foreign text from GitHub mentions people or sets links" (code spans and code blocks per the 03 content format, links only to https addresses of the event), I "Chat content goes to GitHub" (only with messages:read, events:subscribe and a subscription, only messages of people in linked threads, data_egress), D "Flood of GitHub deliveries" (github_app.max_body_bytes, github_app.max_queued_jobs, one worker), E "Loop between chat and GitHub" (author = app never mirrored, own GitHub objects do not come back, only origin = human), each with register T-PL-02 except the I row (T-PL-01). New register row T-PL-02 "Forged, replayed or looping events of an integration.", L medium, I low, Mitigations as the rows above, Tests the names of this ticket, Residual risk "low: an intercepted GitHub delivery can be replayed after github_app.dedupe_window, because the signature carries no timestamp". Add plugins/github-app/test/live/github-app.live.test.ts to the tests of T-PL-01.

  23. Rulings for docs/DECISIONS.md (section 10, one row D-M2-49-<n> each with date, decision, reason and place): package, runtime and scripts (note 2); configuration and secret files (3); HTTP surface behind the TLS proxy (4); signature rules (5); event list (6); 202 for every command and interaction, SDK verifier, no v1 (7); state, FIFO worker, idempotency identifiers, one attempt at GitHub (8); key, titles, unreachable thread (9); foreign text, links, cutting, titles (10); required_level trigger, own forum post, no person data to GitHub, failure handling (11); mirroring only human text, loop and echo protection (12); handling of server answers (13); GitHub client and API version (14); log allowlist (15); manifest scopes with events:subscribe (16); fixtures from @octokit/webhooks-examples (17); live run inside npm test over M2-29's sdkserver, extended by app_front, app_backend, create_forum and call (19).

Tests (write them first)

Unit and fake tests, plugins/github-app/test/ (Vitest, run by the root make test through the script test; servers on 127.0.0.1 port 0; a fake clock where time matters; the no-network guard of Design note 18 in every file):

  • signature.test.ts (PLAN row: "signature check X-Hub-Signature-256 with the test values of the GitHub documentation ... with a wrong, a missing and a truncated signature, comparison in constant time"): the documented values (secret It's a Secret to Everybody, payload Hello, World!, header sha256=757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17) verify; wrong signature (last hex digit changed, body changed by one byte, another secret) is mismatch; missing header and a lone X-Hub-Signature are missing; truncated values (sha256=757107ea, sha256=, the 64 hex digits without prefix), upper-case hex and an extra character are malformed, none throws. Constant time: with node:crypto partly mocked through vi.mock, timingSafeEqual is called exactly once with two 32-byte buffers for the documented values and for a wrong signature of the same length; a mock that always returns true makes a wrong signature pass and one that returns false makes the documented values fail, which proves the decision goes only through timingSafeEqual.
  • github-endpoint.test.ts: with the Fake-GitHub deliver: valid issues opened gives 202 and one job; wrong, missing and truncated signatures give 401, no job, no request to the chat port; ping and an unsupported action give 204; a reused delivery identifier gives 204 without a second job; an unknown repository gives 204; a missing X-GitHub-Event with a valid signature gives 400; text/plain gives 415; a body above github_app.max_body_bytes gives 413 and reading stops; another method 405; an unknown path 404; a full queue (github_app.max_queued_jobs set small) 503.
  • github-events.test.ts: every supported event and action from the fixtures parses to the expected fields (issue, pull request, comment on an issue, comment on a pull request, review, merged and closed pull request); an unsupported action is ignored; a payload without number is invalid_payload.
  • threads.test.ts (PLAN row: "an event for a known issue lands in the same thread, a new one creates a thread"): issues opened #12 creates one forum post titled #12 <title> with the event as first message and stores the map; issue_comment created on #12 posts into the same thread and creates none; issues opened #13 creates a second post; after stop() and a new startApp on the same state directory, an event of #12 still lands in its thread; a comment on pull request #7 creates PR #7 <title>; 404 not_found on posting into #12's thread drops the entry, creates one new post and logs thread_unreachable; a rerun of a job after a simulated crash between createThread and the map write sends the same first_message.client_msg_id.
  • render.test.ts: @alex and [x](https://example.com) in GitHub text end up only inside code; a body with backtick runs keeps its fence closed; a title with a bidi override, a control character and a line break is cleaned; cutting respects limits.message_max_content_bytes at a code point boundary with the fence closed and the cut line; titles cut to limits.thread_title_max_chars code points; javascript:, http: and a URL with a parenthesis get no link; a 400 validation_failed on the title leads to one retry with #<number>.
  • issue-command.test.ts (PLAN row: "/issue with a form creates an issue through the fake API and replies in the thread"): a CommandInvokedEvent signed with the SDK's signWebhook gives 202; the job creates a forum post titled from title and a card whose form has body and no repository field; with two repositories on the forum the form has a repository choice; a signed InteractionCreatedEvent with values.body gives 202; the Fake-GitHub records exactly one POST /repos/example-org/widgets/issues with the title and the body ending in "Created from chat."; respondToInteraction with state acknowledged and the number; one message "Created GitHub issue ..." in the draft's thread; the key maps to that thread; the fake's echo issues opened adds no message; a later issue_comment created from another login posts into the thread. Variants: Fake-GitHub 422 (and a 302) gives state failed and a new form card; 500 and a time-out give state failed with the check text, no new card and no second POST; a restart after attempted sends no second POST and answers failed; the same webhook-id twice gives one job; an unknown custom_id gives state rejected; command.autocomplete_requested gives 200 with an empty choices.
  • loop.test.ts (PLAN row: "the app's own messages are never mirrored back (loop protection)"): a signed MessageCreatedEvent whose author is the app's own principal in a mapped thread gives 204 and the Fake-GitHub records no comment; messages with origin agent, app, import and system, a card, a deleted message and a message in an unmapped thread give 204 and no comment; a human text message in a mapped thread gives exactly one comment whose body holds the display name in a code span and the text in a fenced block; the fake's echo issue_comment created of that comment adds no message; a truncated message is loaded with getMessage; a GitHub 500 on the comment is logged and not repeated.
  • scopes.test.ts (PLAN row: "the installation requires exactly the scopes from 10.1, a missing scope yields 403 scope_missing"): manifest.json scopes.required equals the required scopes of the row "Scopes" of 11 10.1, parsed from docs/spec/11-extensions.md, and scopes.optional equals its optional ones; the chat port answering 403 scope_missing to createThread ends the job after one attempt with one Warn line naming scope_missing, the GitHub delivery stayed 202, and the job is gone.
  • manifest.test.ts: the members of Design note 16; version equals VERSION and package.json; min_api_version is not above major and minor of info.version of docs/api/openapi.yaml; the command issue with option title and required_level trigger; both addresses https on one host; no homepage_url; no member outside the field table of 11 3.1.
  • config.test.ts: required keys, unknown member, bad duration, wrong type; the error text names key and file and no value; a secret file with mode 0644 aborts the start; a state directory with group access is refused; config.example.json loads; every github_app.* default equals the "Default" column of docs/operations/configuration.md, and the reused keys come from the SDK's DEFAULTS.
  • store.test.ts (contract test for FileStore and MemoryStore): atomic writes, modes 0700 and 0600, a second FileStore on a directory locked by a live PID is refused, a stale lock is taken over, pruning after github_app.dedupe_window with the fake clock, the thread map survives pruning, FIFO order of jobs.
  • chat-adapter.test.ts: the SDK adapter against a recording fetch with schema-valid answers: the request bodies of createThread, createMessage and respondToInteraction contain no JSON null, client_msg_id is the stored one, Authorization is sent; ProblemError reaches the job with status and code.
  • github-client.test.ts: the headers of Design note 14, redirect: "manual", a 302 is a failure, the time limit sdk.http_timeout, no body read on errors.
  • no-network.test.ts (PLAN row: "no call to a real GitHub service in the test"): the guard throws for https://api.github.com/ and for any non-loopback host; the configuration helper refuses a non-loopback github_api_url.
  • logs.test.ts: canary values in titles, bodies, comments, form values, display names, logins, repository names, the token and the secrets run through every flow above; no captured log line contains any of them.
  • fake-github.test.ts: the fake's deliveries verify with the app's check; faults and recording; autoEcho.
  • package.test.ts: "private": true; dependency bothy-sdk is file:../../sdk/typescript; every dev dependency shared with sdk/typescript/package.json has the same exact version.
  • License gate (PLAN row: "license gate for npm green"): make licenses reads plugins/github-app/package-lock.json and passes without a change to quality/licenses/allowed.txt; make osv passes; keep both outputs for the report. Run make licenses red once by tracking the lock file before an override that the gate needs (only if one is needed, Design note 20).

Live, plugins/github-app/test/live/github-app.live.test.ts (part of the script test, so the root make test and make check run it; against sdkserver with the protocol parts of Design note 19; the app and the Fake-GitHub run in the test process; waits poll until a deadline derived from app.interaction_timeout and webhook.delivery_timeout of the test configuration):

  • validateManifest with the manifest rewritten to https://github-app.example.com:<front port> gives valid true; installApp without components:write gives 400 validation_failed with the pointer of granted_scopes; with exactly messages:write, commands:write and components:write gives 201; createGrant write on Support/Tickets; createToken writes the token file; app_backend with the app's port.
  • Threads: deliveries of issues opened #12, issue_comment created #12 and issues opened #13 give, in listChannelThreads of Alex, exactly two posts of the app titled #12 ... and #13 ..., and listThreadMessages of #12 shows two messages with origin app.
  • /issue: listChannelCommands of Alex in Support/Tickets lists issue; invokeCommand by Alex with title gives 202; a new post with the form card appears; createInteraction by Alex with values.body gives 201; the Fake-GitHub records one issue; the post shows the confirmation with the number; the echo issues opened adds no message.
  • Loop protection and mirroring: setAppScopes adds messages:read and events:subscribe with event_subscriptions [message.created]; createWebhookSubscription for the app to events_url; a new token, the app restarted with it. A GitHub comment on #12 is posted by the app; its message.created reaches /chat/events (listWebhookDeliveries of Robin shows it succeeded), and the Fake-GitHub records no comment. Alex replies in #12's thread with createMessage: the Fake-GitHub records exactly one comment on #12; its echo adds no message.
  • Missing scope: createToken with scopes [commands:write, components:write], the app restarted with it; a delivery of issues opened #14 gives 202, the server answers 403 scope_missing to createThread, the app log holds scope_missing once, and no post #14 appears.
  • No request leaves loopback (guard), and no request reaches anything but the Fake-GitHub and the live server.

No Go test is added (Design note 19); the protocol parts of sdkserver are exercised by the live file.

Repository tools: tools/ci/tests/test_components.py (a path under sdk/typescript/ and under services/server/ selects plugins); the doc gate test of Design note 20 for plugins/github-app.

Red runs: keep the red output of signature.test.ts, threads.test.ts, issue-command.test.ts, loop.test.ts, scopes.test.ts and no-network.test.ts before the code exists, and of github-app.live.test.ts before the app answers.

Commands that must pass, from the repository root:

npm --prefix sdk/typescript run build
npm --prefix plugins/github-app run build
npm --prefix plugins/github-app run lint
npm --prefix plugins/github-app run typecheck
npm --prefix plugins/github-app test
cd tools && uv run --locked --quiet pytest ci/tests/test_components.py ci/tests/test_sdk_typescript_doc_gate.py docs/tests/test_config_reference.py
make fmt-check
make lint
make typecheck
make test
make coverage
make docs
make linkcheck
make licenses
make osv
make check

Run test/live/github-app.live.test.ts once on its own (Vitest file filter, without coverage) and quote the result in the report.

Acceptance criteria

  • X-Hub-Signature-256 is verified with the test values of the GitHub documentation; wrong, missing and truncated signatures answer 401 and create nothing; the comparison goes only through timingSafeEqual on the digests.
  • An event of a known issue or pull request lands in its thread; a new one creates exactly one forum post; the mapping survives a restart.
  • /issue with the form creates exactly one issue through the Fake-GitHub API and answers in the thread; failures follow Design note 11, never with a second issue.
  • The app's own messages are never mirrored back; mirrored human replies and the app's own issues do not come back; GitHub text never becomes a mention or link.
  • The manifest requests exactly the scopes of 11 10.1; installApp refuses an installation without one of the required scopes; a missing scope at call time gives 403 scope_missing, logged once and not retried.
  • No test calls a real GitHub service.
  • The app passes its live test against the real server inside make check (test/live/github-app.live.test.ts in the script test).
  • The npm license gate and OSV pass without a change to the allowlist; the typedoc gate fails on an undocumented export of the app.
  • 11 10.1 and 16, configuration.md, the threat model, plugins/README.md, the app's README and CHANGELOG, the root CHANGELOG and docs/DECISIONS.md describe what was built.
  • make check is green.

Definition of done

  • Code and tests as specified; doc comment on every public symbol (purpose, parameters, return value, errors, example where useful); comments explain why.
  • README of plugins/github-app (English) and the row in plugins/README.md updated; the sdkserver README section for the protocol parts of Design note 19.
  • Rulings of Design note 23 recorded in docs/DECISIONS.md (one row each with reason).
  • plugins/github-app/CHANGELOG.md and the root CHANGELOG.md entries under 'Unreleased'.
  • An ADR only if you take an architecture decision beyond this brief (MADR 4 as in docs/adr/0000-template-madr-4.md, headings and text in English, next free number, make adr-index).
  • make check green.
  • An independent code review over the diff, then the red team gate (docs/quality/red-team-gate.md, verdict JA or NEIN), with attention to the M2 "Red team" items "exceeding the scopes of an app" and "apps and webhook targets as boundaries", forged and replayed GitHub deliveries, foreign text in the chat and loops (T-PL-01, T-PL-02); on NEIN fix with a test per finding and have the delta checked again. Record both verdicts.
  • Land the change as AGENTS.md ("How work lands on main") describes: a pull request from this branch, squash-merged only when review, red team and make check, ci-ok and the review threads are green; then git worktree remove.

Report

Write to {{REPORT_PATH}}:

  1. First line exactly Verdict: DONE or Verdict: BLOCKED <reason>.
  2. Commits (SHA and subject), changed files with line counts, test results with counts (the red run before the change and the green run after), the summary of make check.
  3. Review and red team verdicts and the findings fixed.
  4. What is not proven (for example behaviour against the real GitHub service, fixtures written by hand if the package was not usable).
  5. Follow-ups that could not be done in this scope, each with its reason; the pinned versions of the dev dependencies and of @octokit/webhooks-examples; every difference to the names or behaviour of M2-23 to M2-26, M2-29 and M2-30 (D-W-100); spec gaps found.

Then write {{DONE_PATH}} with exit=0 sha=<sha> or exit=1 <reason>.