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, thev1asigner,docs/api/webhook-test-vectors.json, thehost[:port]entries ofwebhook.allowed_hosts), M2-24 (AppManifestafter 11 3.1,validateManifest,installApp,setAppScopes,createTokenfor apps with a scope subset), M2-23 (sdk/typescriptlayout, npm packagebothy-sdk, generated core, Vitest and coverage set-up,bootstrap-ciline for the SDK), M2-29 (the live driverservices/server/test/sdkserverwith 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, ownVERSIONandCHANGELOG.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" (rowsauth,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 withwriteon the channel"), 10.4 "Execution" (last paragraph), 11.1 "Kinds (component.v1)" (formwithfields), 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 withthread.createundermessages:write,message.readundermessages:read,interaction.respondundercomponents:write, "Event subscriptions" underevents:subscribe,self.readandinstance.readwithout scope); 4.10 rowscommand.invoke,interaction.create(leveltrigger),interaction.respond; 5 "Agent invocationagent.invoke" (last paragraph: app commands withoutagent.invokeand I2).docs/spec/03-data-model.md(core contract, read only):messages(origin,kind) and the content formatcontent_format = 1(mention tokens, rule 2: no mentions in inline code and code blocks; rule 3: only principals of kinduseroragentare resolved).docs/spec/04-sync-protocol.md(core contract, read only): 2.2 "Envelope" (delivery fieldsync_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 is403with reasonscope_missing), SR4 "Only humans trigger", 11.1 rows AZ-M32 to AZ-M34 (the app pattern).docs/spec/06-api.md: 2 "Basic rules" ("Nonull", "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: operationsgetMe,getInstance,getWebhookKeys,createThread,createMessage,getMessage,respondToInteraction, and for the live testcreateChannel,validateManifest,installApp,setAppScopes,createGrant,createToken,createWebhookSubscription,listWebhookDeliveries,listChannelCommands,invokeCommand,createInteraction,listChannelThreads,listThreadMessages; schemasAppManifest,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 (rowmessage.created:messages:readin addition toevents:subscribe), and the section on the interactions address of apps that M2-25 added (202and an owncreateMessagefor 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 rowsD-M2-23-<n>,D-M2-24-<n>,D-M2-25-<n>,D-M2-26-<n>,D-M2-29-<n>andD-M2-30-<n>on the base branch; section 10 "Rulings of the build milestones" (row formatD-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 rowswebhook.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:PackageOverridesin anosv-scanner.tomlof 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 rootMakefile(TS_PROJECTSincludesplugins/*package.json, the TypeScript lines offmt-check,lint,typecheck,test,licenses, andbootstrap-ci);tools/ci/components.pyand 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/typescriptentries.,./core,./testing;createApiClientwithtoken,dpopKey,timeoutMs,retry,logger,fetch,userAgent;ProblemError,isNotFound;verifyWebhook,verifyWebhookRequest,WebhookKeyCache,parseWebhookPublicKey,WebhookVerificationErrorwith its codes; thecomponentsbuilders withform;uuidv7;loadDpopKey;DEFAULTS;testingwithFakeServerandsignWebhook;npm run buildtodist/; the live mechanism of its Design note 12 (test/live/global-setup.ts,test/live/sdkserver.ts, live files insidenpm test); the typedoc gate andtools/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 wayinteractions_url, the jobruns.app_dispatch, the answer forms200and202,no_threadin a forum, interaction states; the fake app pattern with an in-memory test CA,Deps.RootCAsand a test resolver), M2-24 (manifest rules, Design notes 6c, 9a, 10), M2-26 (Design note 9ValidateTargetand thehost[: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/sdkserverandservices/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):
- No MVP. New from scratch and directly in the final state; stages are only the order.
- Strictly modular: clear module and service boundaries, dependencies only through defined interfaces, no cross access.
- 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).
- 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.
- Quality gates (SonarQube, linters, type checking, security scanners, test coverage) block the merge.
Rules for every brief:
- Test first: write the failing test, run it and keep the red output for the report, then make it green, then clean up.
- No en dash and no em dash in any file you write; the
text-rulespre-commit hook rejects them. Use a hyphen or rephrase. - White label: examples and test data use
example.comand the people Alex, Sam and Robin; no company, host or personal names, no paths under a home directory. - 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. - Specification first: a change to behaviour, API, event, right or configuration updates
docs/spec/ordocs/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:
- 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). - 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 withsha256=, comparison in constant time) before it parses or stores anything (11 10.1 row "Input"). - 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").
/issuewith a form creates an issue through the GitHub API and answers in the thread (11 10.1 row "Commands"; 09 10, 11; 11 9).- Scopes exactly as 11 10.1:
messages:write,commands:write,components:write; optionalmessages:readwith eventmessage.created, which by 05 2.6 row "Event subscriptions" and webhooks.md 1 point 1 also needsevents:subscribe; grantwriteon the forum (11 10.1 rows "Scopes" and "Grants"). An action without its scope is403 scope_missing(07 2.10, 05 2.6). - The app never mirrors its own messages (author = app) back (11 10.1 row "Loops"); apps never trigger agents and never hold
triggerormanage(11 6 row "Never allowed", R-EX-03, SR4). - Commands and interactions reach the app as signed
POSTrequests todelivery.interactions_url, answered withinapp.interaction_timeoutwith202and acted on through the API (11 9; webhooks.md section of M2-25); requests of the server are verified with the SDK'swebhooksmodule: raw body,webhook-timestampwithinwebhook.receiver_tolerance,v1akeys fromgetWebhookKeysonly (webhooks.md 3, 11 11.2 rowwebhooks). - Duplicates are recognised by
webhook-id, at least forwebhook.retry_max_age(webhooks.md 3 point 4), and by GitHub's delivery identifier. - 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).
- 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).
- 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
0400or0600(SEC-SEC-1 and SEC-SEC-3 applied to the app, SEC-LOG-2, SEC-LOG-4,plugins/README.md). - 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). - 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). - Own
VERSIONandCHANGELOG.mdin the folder; the app is not a release component (11 10, D-W-38 for the English CHANGELOG). - No JSON
nullin 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.jsonandpackage-lock.json(Design note 2),tsconfig.json,tsconfig.build.json,eslint.config.mjs,.prettierignore,vitest.config.ts(withglobalSetuptest/live/global-setup.tsas M2-30's),typedoc.json, each following the matching file ofsdk/typescript/.VERSION(0.0.0),CHANGELOG.md(Keep a Changelog, English, likesdk/typescript/CHANGELOG.md),README.md(English, Design note 21),manifest.json(Design note 16),config.example.json(every key of Design note 3 withhttps://chat.example.comand example paths outside a home directory).src/main.ts(CLI entry),src/app.ts(startApp(config, deps)returning the listening address andstop(); injectablefetch, 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 oncreateApiClient),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.tsandtest/live/github-app.live.test.ts(Design note 19).osv-scanner.tomlonly 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 (memberapp_frontofstart, the TLS front, the opsapp_backend,create_forumandcall) and itsREADME.md.
Change:
- Root
Makefile: recipebootstrap-ciinstalls the dependencies of every trackedplugins/*/package-lock.jsonafter the SDK (Design note 20). No new target. tools/ci/components.pyandtools/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.md10.1 and 16 (Design note 21), thenmake config-referencefordocs/operations/configuration.md.docs/security/threat-model.md5.11, register rows T-PL-01 and new T-PL-02 (Design note 22).services/server/test/harnessonly if it cannot pass the egress test seams of Design note 19.plugins/README.md(rowgithub-app/: status built, M2-49), rootCHANGELOG.mdunder "Unreleased",docs/DECISIONS.mdsection 10 rowsD-M2-49-1and 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 withVerdict: 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.closeorthread.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 ofAGENTS.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.mdentries named in this brief. - Follow the host rules supplied by the launcher (ports, machine rules).
- Tests bind only
127.0.0.1and 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 ornpmthe live test fails; it never skips. - Stop only processes you started, by PID. Temporary directories only via
mktemp -dor 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 underexample.comreach loopback only through the test resolver of the egress client. - Dependencies pinned exactly in the lock; dev tools exact in
package.json. Do not extendquality/licenses/allowed.txt; if a needed package or its tree fails the gate, stop withVerdict: 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¶
-
Start check. Run
make checkon the fresh worktree; if it is red, stop withVerdict: BLOCKED base branch redand the failing gate. Confirm on the base branch: M2-30 (npm --prefix sdk/typescript run buildproducesdist/; the package exportscreateApiClient,verifyWebhook,WebhookKeyCache, thecomponentsbuilders withform,uuidv7,loadDpopKey, and the entry./testingwithsignWebhook; its live files undersdk/typescript/test/live/run insidenpm testagainst M2-29'ssdkserver), M2-29 (services/server/test/sdkserverwith its stdin protocol), M2-25 (requests todelivery.interactions_urlfrom the jobruns.app_dispatch, the section on the interactions address in webhooks.md), M2-26 (createWebhookSubscription,getWebhookKeys), M2-24 (AppManifestwithscopes.required,scopes.optional,delivery,requested_grants,data_egress), M1-57 (getMessage,listThreadMessages). A missing one ends withVerdict: BLOCKED dependency <ticket> missing. -
Package, runtime and scripts. Ruling for docs/DECISIONS.md.
plugins/github-appis an ESM Node.js program for the Node.js line of the SDK'sengines, for Linux and macOS (the file mode checks of Design note 3 are POSIX). Package namegithub-app,"private": true,"type": "module","license": "Apache-2.0",versionequal toVERSION. 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 withnode:http, HMAC and comparison withnode:crypto, files withnode:fs/promises, outgoing requests withfetch; 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.jsonpins for TypeScript, Vitest and its V8 coverage, ESLint and its TypeScript plugins, Prettier, typedoc and@types/node, plus@octokit/webhooks-examplespinned 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 todist/, never committed);typecheck=npm run build:sdk && tsc --noEmit;lint=npm run build:sdk && eslint . && typedoc --emit none;formatandformat:checkwith 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 rootMakefileruns the sortedTS_PROJECTSwithplugins/github-appbeforesdk/typescript, and npm runs no pre-scripts underignore-scripts; tests then use the same package entry points as production. - Coverage: the threshold key M2-23 introduced for TypeScript (
quality.coverage.typescript_min_percentin its brief), enforced invitest.config.tsas the SDK does;test/live/andtest/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.
-
Configuration (new rows in 11 16 with place "GitHub app"; Ruling for docs/DECISIONS.md). A JSON file named by
--configholds one objectgithub_appwhose members are the keys without their prefix:github_app.listen: no default, required;host:portof the HTTP listener.github_app.server_url: no default, required; base URL of the instance,https,httponly for a loopback host.github_app.token_file: no default, required; file with the app's API token (M2-24createToken).github_app.dpop_key_file: empty; key file for a DPoP-bound app token (auth.dpop_required_for), loaded with the SDK'sloadDpopKey.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-idvalues, the GitHub objects the app created and open drafts are kept. The default equals the default ofwebhook.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 answer503.
Reused keys, never new numbers:
webhook.receiver_tolerance(the SDK default ofverifyWebhook),sdk.http_timeout(also the time limit of each GitHub request),sdk.retry_max_attempts,sdk.retry_backoff_baseandsdk.retry_backoff_cap(the SDK client;sdk.retry_backoff_capis 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 mode0400or0600and 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 answers503or fails the job), the app token once at start.state_diris created with mode0700; an existing one with group or other access is refused.config.example.jsonparses with the loader (test). -
HTTP surface (Ruling for docs/DECISIONS.md). Routes
POST /github/webhook,POST /chat/interactions,POST /chat/events; any other path gives404, another method405. Per request, in this order:Content-Typemust beapplication/json(parameters allowed), else415; the body is read as bytes up togithub_app.max_body_bytes(a largerContent-Lengthanswers413before reading, a stream that grows beyond it is cut and answers413); signature check (Design notes 5 and 7), failure401; JSON parse with invalid UTF-8 or JSON answering400; duplicate check; classification and, in the same store write, the seen identifier and the new job; then202when a job was queued and204when there is nothing to do;503when the queue holdsgithub_app.max_queued_jobsjobs, 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 ongithub_app.listenand runs behind the TLS-terminating proxy of the instance;delivery.interactions_urlanddelivery.events_urlarehttpsaddresses 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. -
GitHub signature (11 10.1 row "Input"; RK-83). Ruling for docs/DECISIONS.md.
X-Hub-Signature-256must 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-EventandX-GitHub-Deliverymust be present (else400), then the JSON is parsed. - Every signature failure answers
401without body and logs onlyreason(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, payloadHello, World!, expected header valuesha256=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.
-
GitHub events (closed list; Ruling for docs/DECISIONS.md). Supported:
ping(answer204, no job);issueswith actionopened,closedorreopened;issue_commentwithcreated, on issues and on pull requests (the memberissue.pull_requestmarks a pull request);pull_requestwithopened,closed(merged whenpull_request.mergedis true) orreopened;pull_request_reviewwithsubmitted. Every other event or action answers204with log reasonunsupported.repository.full_nameis compared without case withgithub_app.repositories; an unknown repository answers204with reasonrepository_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 answers400with reasoninvalid_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. -
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:verifyWebhookover the raw body with the keys of oneWebhookKeyCacheof the app's client and the default tolerancewebhook.receiver_tolerance; never an own verifier and never keys from the request. AWebhookVerificationErroranswers401and logs itscode. Nov1secret: an interactions address has no HMAC (M2-25), and the app's subscription is created withoutenable_hmac. - Duplicates:
webhook-id(the eventid) is remembered forgithub_app.dedupe_window; a repeat answers as the first time (202on/chat/interactions,202or204on/chat/events) without a second job. /chat/interactions:command.invokedwithcommand_nameissuequeues the jobissue_command;interaction.createdqueuesissue_form; both answer202.command.autocomplete_requestedanswers200with{"choices": []}(AutocompleteAnswer; the app declares no option with autocomplete). Another type or command name answers202with Warn reasonunknown_request. Reason: in a forum the server has no reply thread for a200(M2-25no_thread), and GitHub calls do not fit intoapp.interaction_timeout; the person sees the result through the API answers of Design note 11./chat/events: onlymessage.createdis handled (Design note 12); any other type answers204.sync_epochis ignored, because the app keeps noseqstate.
- Both
-
State, queue and worker (Ruling for docs/DECISIONS.md).
- Port
StorewithFileStoreingithub_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 underjobs/. Every write goes to a temporary file,fsync,rename,fsyncof the directory; files0600, directories0700; one writer at a time through an in-process mutex.MemoryStoreserves 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
/issuejob records its issue before GitHub'sopenedevent 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(SDKuuidv7) orIdempotency-Keystored 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 ofcreateThread). The SDK client retries transient failures withinsdk.retry_max_attempts; when they are exhausted, the job stays queued and the worker pausessdk.retry_backoff_cap. Permanent answers end the job (Design note 13). - A write to GitHub is never repeated: the job marks the step
attemptedin the store before it sends; a rerun that findsattemptedwithout 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_windoware 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.
- Port
-
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) withme(),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.tsimplements it oncreateApiClient(getMe,getInstance,createThread,createMessage,getMessage,respondToInteraction). - Known key:
createMessageinto its thread. Unknown key:createThreadin the repository's forum withtitleandfirst_message(the event's message) in one call, idempotent throughfirst_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_foundwhen 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 reasonthread_unreachable; a second404 not_foundends the job.- Messages (English templates; GitHub text rendered as Design note 10; "#12" stands for the number, linked to the event's
html_urlas Design note 10 allows):issuesopened"Issue #12 opened bylogin" plus the body;closedandreopenedone line each;issue_comment"logincommented on #12:" plus the comment, linked to the comment'shtml_url;pull_requestopenedwith the body,closedas "merged" or "closed",reopened;pull_request_review"loginreviewed #7:state" plus the body when present, linked to the review'shtml_url.
- Key
-
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_urlwhen it parses as an absolutehttpsURL without whitespace, angle brackets or parentheses; otherwise no link. - Messages are cut to
limits.message_max_content_bytesofgetInstance(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 tolimits.thread_title_max_charscode 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>orPR #<number>. If the server still answers400 validation_failedon the title, the job retries once with#<number>orPR #<number>alone. - Limits come from
getInstanceat start and once more after a400 validation_failedwitherrors[].codetoo_long, followed by one retry. - Text the app writes itself (the confirmation of
/issue, the form card) contains no mention.
-
/issuewith a form (11 10.1 row "Commands"; Ruling for docs/DECISIONS.md).- The manifest command
issue: description "Create a GitHub issue" withincommand.description_max_chars, optiontitle(string, required, description "Issue title"),required_leveltrigger. Reason: the form is submitted withinteraction.create, which needstrigger(05 4.10); withwritea person could run the command but never submit its form, and agents and apps, which hold at mostwrite, cannot run it. - Job
issue_command: the payload'schannel_idmust be a configured forum, else Warn reasonchannel_not_mappedand end (the README tells operators to grantwriteonly on configured forums, so the command is listed only there, 09 10.3). ThencreateThreadin that forum with the optiontitlecleaned and cut as the title (empty: "New issue") and the first message "New GitHub issue for<owner>/<name>. Fill in the form below.", thencreateMessagein the new thread withkindcardand one component built with the SDK'sformbuilder:custom_idissue:<draft id>(withincomponent.custom_id_max_chars),single_usetrue,restricted_toany, fieldsbody(textarea, label "Description", not required) and, only when more than one repository maps to the forum,repository(choiceof the configured names, required), withincomponent.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/issuealways opens its own forum post, also when it was run inside a thread. - Job
issue_form: the draft is found byinteraction.custom_id, else byinteraction.message_id; unknown or pruned:respondToInteractionwith staterejectedand text "This form expired. Run /issue again.". The repository is the only one or the chosen one, checked against the draft. Markattempted, then POST the issue withtitleandbody= 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,respondToInteractionwith stateacknowledgedand text "Created issue #", createMessageinto 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
3xxor4xx(not followed, so the issue was not created):respondToInteractionwith statefailedand 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 aftersdk.http_timeout, a transport error, orattemptedwithout result after a restart (outcome unknown):respondToInteractionwith statefailedand text "GitHub did not confirm the issue. Check the repository before you try again."; no new card, the draft is deleted.- A
409answer to a repeatedrespondToInteraction(rerun after a crash) counts as done.
- The manifest command
-
Mirroring and loop protection (11 10.1 rows "Scopes" and "Loops"; Ruling for docs/DECISIONS.md).
- Mirroring exists only when the owner granted
messages:readandevents:subscribe, addedmessage.createdto the installation'sevent_subscriptionsand created a subscription todelivery.events_urlformessage.created(webhooks.md 1); otherwise/chat/eventsreceives nothing. - Decision at receipt, in this order, each answering
204without a job and logging its reason: the author is the app itself (message.author.idequals the principal id fromgetMe; reasonown_message, the loop protection of 10.1);originis nothuman(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);kindis nottext(not_text);deleted_atis set (deleted); the thread is not mapped (thread_not_mapped). Otherwise the jobmirror, answer202. - Job
mirror: ifcontent_truncatedis set, load the message withgetMessage; 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; markattempted, POST/repos/<owner>/<name>/issues/<number>/comments;201: record the comment id as created by the app; any other outcome: logstatusor reasongithub_outcome_unknownand end without retry. - Echo protection in the other direction: GitHub's
issuesopenedevent of an issue the app created through/issueand theissue_commentcreatedevent of a comment the app created are not posted (answer204, reasonown_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.
- Mirroring exists only when the owner granted
-
Answers of the server (06 5, 07 2.10).
403 scope_missing: end the job, one Warn line withoperation,statusand reasonscope_missing, no retry; the GitHub delivery was already answered202.404 not_found: Design note 9 for threads; forrespondToInteractionandgetMessageend the job.403 forbiddenand403 channel_read_only: end the job and log the code andreason.400 validation_failed: the retries of Design note 10, otherwise end the job and log theerrors[].codevalues.401 unauthenticated(token revoked, app suspended or uninstalled, M2-24): end the job with an Error line; the endpoints answer503untilgetMesucceeds again, tried once persdk.retry_backoff_cap.422 idempotency_key_reuse: Error line (the same identifier with another body is a programming fault), end the job.429 rate_limitedand503 unavailable: retried by the SDK, then Design note 8. Unknown codes are classified by status through the SDK'sProblemError.
-
GitHub REST client (Ruling for docs/DECISIONS.md). Two calls:
POST <github_api_url>/repos/<owner>/<name>/issueswith{title, body}andPOST <github_api_url>/repos/<owner>/<name>/issues/<number>/commentswith{body}, headersAccept: application/vnd.github+json,Authorization: Bearer <token>,X-GitHub-Api-Version: 2022-11-28,User-Agent: github-app/<VERSION>andContent-Type: application/json; time limitsdk.http_timeout; redirects are not followed (redirect: "manual", a3xxis a failure); the response body is read only on201fornumber,html_urlandid; 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. -
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 asgithub.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. -
Manifest (
plugins/github-app/manifest.json, 11 3.1 as M2-24 builtAppManifest). Members:schema_version1;sluggithub;nameGitHub;description(English, one sentence);publisherReference integration;versionequal toVERSION;min_api_versionequal to major and minor ofinfo.versionofdocs/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};commandswithissueof Design note 11;components[form];requested_grantsone{target_kind: channel, level: write, capabilities: [], purpose: ...};data_egress(English): issue and pull request events of the configured repositories are posted into the forum;/issuesends title and description to the configured GitHub API; withmessages:readandevents:subscribe, messages of people in linked threads with their display name go to GitHub as comments. Nohomepage_url. Ruling: the required scopes are exactly those of 10.1;events:subscribejoinsmessages:readas optional, because 05 2.6 row "Event subscriptions" and M2-24's manifest rule require it forevents, and 10.1 is corrected in the same change. The README's installation steps: the operator adds the host of the addresses towebhook.allowed_hosts; an owner uploads the manifest withinstallApp(required scopes; the optional ones only for mirroring), grantswriteon each configured forum withcreateGrant, creates the token withcreateToken, and for mirroring creates the subscription withcreateWebhookSubscriptiontoevents_url; a later scope needs a new token (M2-24). -
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 on127.0.0.1port 0 with the two endpoints of Design note 14, checkingAuthorization(401without the test token),Accept,X-GitHub-Api-VersionandUser-Agent, answering404for an unknown repository and422for an empty title, assigning increasing numbers and ids, and recording method, path, JSON body and header names in memory. Fault injection per call: status500, status422, 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 withX-GitHub-Event,X-GitHub-Delivery,X-Hub-Signature-256,Content-Type: application/jsonand aUser-Agentof the formGitHub-Hookshot/<id>; options omit, alter or truncate the signature and reuse a delivery identifier. WithautoEcho, every issue or comment created through the fake is followed by itsissuesopenedorissue_commentcreateddelivery.- 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.mdquotes 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 undertest/fixtures/github/, each with the documentation page of its event in the README, and name this under "What is not proven".
-
No real GitHub service (PLAN row).
test/setup/no-network.ts, asetupFilesentry of the Vitest configuration, replacesglobalThis.fetchby a guard that throws for every URL whose host is not127.0.0.1orlocalhost;startAppin tests passes the guardedfetchto the SDK client and to the GitHub client; the test helper that builds configurations refuses agithub_api_urlorserver_urloutside loopback. The app opens outgoing connections only throughfetch. -
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(VitestglobalSetupofvitest.config.ts) builds M2-29'ssdkserveronce,go test -c -tags sdkserver -o <tmp>/sdkserver.test ./test/sdkserverinservices/server, and hands the binary path to the test files;test/live/sdkserver.tsruns 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 (answersSDKSERVER <json>).test/live/github-app.live.test.tsstarts it once inbeforeAllwithstart(the members M2-30'stest/live/sdkserver.tssends, plusapp_front) and stops it inafterAllwithstopand then by PID. The live file runs inside the scripttest, so the rootmake testandmake checkrun it; without Docker, Go or npm it fails and never skips.tools/ci/components.pylets a change underservices/server/selectplugins, as M2-30 did forsdk-typescript(Design note 20). - Protocol extension of
sdkserver, additive (M2-29's and M2-30's clients send none of it and stay unchanged):starttakes the optional memberapp_front {host}. With it, before the server starts, the driver opens a TLS front on127.0.0.1port 0 with a leaf certificate forhostfrom a certificate authority created in memory; the server's egress client trusts that authority throughDeps.RootCAsand resolveshostto127.0.0.1through 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 inservices/server/test/harness); the driver addswebhook.allowed_hosts=<host>:<front port>andegress.allowed_hosts=<host>:<front port>;allow_privateto the givenconfigand answersreadywith the additional memberfront_port. The server runs its jobs, soruns.app_dispatchandapps.webhook_deliverdeliver. This test useshostgithub-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
triggeron space Support through G-1) plus the forum Support/Tickets, created with the new opcreate_forum {name}(Robin callscreateChannelwith kindforumand the given name in space Support; the answer is{channel_id}) with nametickets. - New op
call {as, operation_id, path_params, query, body}withasrobinoralex: 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 mode0600in 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 throughcall, so the live test runs the documented operator steps. services/server/test/sdkserver/README.mddocumentsapp_front,front_port,app_backend,create_forumandcall.
- Mechanism: M2-30's (its Design note 12), with no Go test added.
-
Tool chain and gates.
- Root
Makefile,bootstrap-ci: after the SDK line of M2-23, for every trackedplugins/*/package-lock.json,npm ci --prefix <directory> --ignore-scripts --no-audit --no-fund --loglevel=error. CI runsbootstrap-ci, so no workflow change is needed. - typedoc gate (23 "Documentation in code"):
typedoc.jsonas M2-30's, with every module undersrc/as entry point and undocumented exports as errors; the scriptlintfails on them. Parametrizetools/ci/tests/test_sdk_typescript_doc_gate.pyof M2-30 oversdk/typescriptandplugins/github-app(a deliberately undocumented export appended to a copy ofsrc/app.tsfailsnpm run lintand is named in the output; the unchanged copy passes); if its structure does not allow it, addtools/ci/tests/test_github_app_doc_gate.pywith the same steps. .gitignore:plugins/*/coverage/.sonar-project.properties:plugins/github-app/coverage/lcov.infoadded tosonar.javascript.lcov.reportPaths.tools/ci/components.py: a change undersdk/typescript/orservices/server/also selects the gate componentplugins, in the way M2-30 madesdk/typescript/reach its live test; tests intools/ci/tests/test_components.py.- Licenses:
make licensesreadsplugins/github-app/package-lock.json. If OSV-Scanner reports the linked local packagebothy-sdkasUNKNOWN, addplugins/github-app/osv-scanner.tomlwith onePackageOverridesentry and its reason ("local package of the repository under Apache-2.0,sdk/typescript"), as licenses.md point 3 says; never a change toquality/licenses/allowed.txt. - Every
.tsand.mjsfile carriesSPDX-License-Identifier: Apache-2.0in its first lines (tools/ci/license_check.py).
- Root
-
Specification and documents (spec first; English in chapters, READMEs, CHANGELOGs and the manifest).
docs/spec/11-extensions.md10.1, table rows: "Input" (header required, formatsha256=plus 64 lower-case hex digits, check over the raw body before parsing, answer401, duplicates byX-GitHub-Deliverywithingithub_app.dedupe_window, the event list of Design note 6); "Output" (key, titles#<n>andPR #<n>, a thread out of reach is replaced once); "Commands" (/issuewith optiontitleandrequired_leveltrigger, own forum post with a form, answer202, 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 toevents_urlonly for mirroring); "Scopes" (optionalmessages:readandevents:subscribewith eventmessage.created, 05 2.6); "Loops" (author = app never mirrored; onlyorigin = humantext; the app's own GitHub objects do not come back); new row "Foreign text" (code spans and fenced blocks, no mentions and links, cutting aftergetInstance); new row "State" (state directory, lock, queue, one attempt at GitHub).docs/spec/11-extensions.md16: the twelve keys of Design note 3 with place "GitHub app"; thenmake 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 typeapplication/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: rowgithub-app/with status built in M2-49.
-
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-256in constant time, duplicates byX-GitHub-Delivery), T "Foreign text from GitHub mentions people or sets links" (code spans and code blocks per the 03 content format, links only tohttpsaddresses of the event), I "Chat content goes to GitHub" (only withmessages:read,events:subscribeand 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, onlyorigin = 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.", Lmedium, Ilow, Mitigations as the rows above, Tests the names of this ticket, Residual risk "low: an intercepted GitHub delivery can be replayed aftergithub_app.dedupe_window, because the signature carries no timestamp". Addplugins/github-app/test/live/github-app.live.test.tsto the tests of T-PL-01. -
Rulings for
docs/DECISIONS.md(section 10, one rowD-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);202for every command and interaction, SDK verifier, nov1(7); state, FIFO worker, idempotency identifiers, one attempt at GitHub (8); key, titles, unreachable thread (9); foreign text, links, cutting, titles (10);required_leveltrigger, 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 withevents:subscribe(16); fixtures from@octokit/webhooks-examples(17); live run insidenpm testover M2-29'ssdkserver, extended byapp_front,app_backend,create_forumandcall(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 checkX-Hub-Signature-256with the test values of the GitHub documentation ... with a wrong, a missing and a truncated signature, comparison in constant time"): the documented values (secretIt's a Secret to Everybody, payloadHello, World!, headersha256=757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17) verify; wrong signature (last hex digit changed, body changed by one byte, another secret) ismismatch; missing header and a loneX-Hub-Signaturearemissing; truncated values (sha256=757107ea,sha256=, the 64 hex digits without prefix), upper-case hex and an extra character aremalformed, none throws. Constant time: withnode:cryptopartly mocked throughvi.mock,timingSafeEqualis 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 throughtimingSafeEqual.github-endpoint.test.ts: with the Fake-GitHubdeliver: validissuesopenedgives202and one job; wrong, missing and truncated signatures give401, no job, no request to the chat port;pingand an unsupported action give204; a reused delivery identifier gives204without a second job; an unknown repository gives204; a missingX-GitHub-Eventwith a valid signature gives400;text/plaingives415; a body abovegithub_app.max_body_bytesgives413and reading stops; another method405; an unknown path404; a full queue (github_app.max_queued_jobsset 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 withoutnumberisinvalid_payload.threads.test.ts(PLAN row: "an event for a known issue lands in the same thread, a new one creates a thread"):issuesopened#12 creates one forum post titled#12 <title>with the event as first message and stores the map;issue_commentcreatedon #12 posts into the same thread and creates none;issuesopened#13 creates a second post; afterstop()and a newstartAppon the same state directory, an event of #12 still lands in its thread; a comment on pull request #7 createsPR #7 <title>;404 not_foundon posting into #12's thread drops the entry, creates one new post and logsthread_unreachable; a rerun of a job after a simulated crash betweencreateThreadand the map write sends the samefirst_message.client_msg_id.render.test.ts:@alexand[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 respectslimits.message_max_content_bytesat a code point boundary with the fence closed and the cut line; titles cut tolimits.thread_title_max_charscode points;javascript:,http:and a URL with a parenthesis get no link; a400 validation_failedon the title leads to one retry with#<number>.issue-command.test.ts(PLAN row: "/issuewith a form creates an issue through the fake API and replies in the thread"): aCommandInvokedEventsigned with the SDK'ssignWebhookgives202; the job creates a forum post titled fromtitleand a card whose form hasbodyand norepositoryfield; with two repositories on the forum the form has arepositorychoice; a signedInteractionCreatedEventwithvalues.bodygives202; the Fake-GitHub records exactly onePOST /repos/example-org/widgets/issueswith the title and the body ending in "Created from chat.";respondToInteractionwith stateacknowledgedand the number; one message "Created GitHub issue ..." in the draft's thread; the key maps to that thread; the fake's echoissuesopenedadds no message; a laterissue_commentcreatedfrom another login posts into the thread. Variants: Fake-GitHub422(and a302) gives statefailedand a new form card;500and a time-out give statefailedwith the check text, no new card and no second POST; a restart afterattemptedsends no second POST and answersfailed; the samewebhook-idtwice gives one job; an unknowncustom_idgives staterejected;command.autocomplete_requestedgives200with an emptychoices.loop.test.ts(PLAN row: "the app's own messages are never mirrored back (loop protection)"): a signedMessageCreatedEventwhose author is the app's own principal in a mapped thread gives204and the Fake-GitHub records no comment; messages withoriginagent,app,importandsystem, a card, a deleted message and a message in an unmapped thread give204and 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 echoissue_commentcreatedof that comment adds no message; a truncated message is loaded withgetMessage; a GitHub500on the comment is logged and not repeated.scopes.test.ts(PLAN row: "the installation requires exactly the scopes from 10.1, a missing scope yields403 scope_missing"):manifest.jsonscopes.requiredequals the required scopes of the row "Scopes" of 11 10.1, parsed fromdocs/spec/11-extensions.md, andscopes.optionalequals its optional ones; the chat port answering403 scope_missingtocreateThreadends the job after one attempt with one Warn line namingscope_missing, the GitHub delivery stayed202, and the job is gone.manifest.test.ts: the members of Design note 16;versionequalsVERSIONandpackage.json;min_api_versionis not above major and minor ofinfo.versionofdocs/api/openapi.yaml; the commandissuewith optiontitleandrequired_leveltrigger; both addresseshttpson one host; nohomepage_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 mode0644aborts the start; a state directory with group access is refused;config.example.jsonloads; everygithub_app.*default equals the "Default" column ofdocs/operations/configuration.md, and the reused keys come from the SDK'sDEFAULTS.store.test.ts(contract test forFileStoreandMemoryStore): atomic writes, modes0700and0600, a secondFileStoreon a directory locked by a live PID is refused, a stale lock is taken over, pruning aftergithub_app.dedupe_windowwith the fake clock, the thread map survives pruning, FIFO order of jobs.chat-adapter.test.ts: the SDK adapter against a recordingfetchwith schema-valid answers: the request bodies ofcreateThread,createMessageandrespondToInteractioncontain no JSONnull,client_msg_idis the stored one,Authorizationis sent;ProblemErrorreaches the job with status and code.github-client.test.ts: the headers of Design note 14,redirect: "manual", a302is a failure, the time limitsdk.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 forhttps://api.github.com/and for any non-loopback host; the configuration helper refuses a non-loopbackgithub_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; dependencybothy-sdkisfile:../../sdk/typescript; every dev dependency shared withsdk/typescript/package.jsonhas the same exact version.- License gate (PLAN row: "license gate for npm green"):
make licensesreadsplugins/github-app/package-lock.jsonand passes without a change toquality/licenses/allowed.txt;make osvpasses; keep both outputs for the report. Runmake licensesred 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):
validateManifestwith the manifest rewritten tohttps://github-app.example.com:<front port>givesvalidtrue;installAppwithoutcomponents:writegives400 validation_failedwith the pointer ofgranted_scopes; with exactlymessages:write,commands:writeandcomponents:writegives201;createGrantwriteon Support/Tickets;createTokenwrites the token file;app_backendwith the app's port.- Threads: deliveries of
issuesopened#12,issue_commentcreated#12 andissuesopened#13 give, inlistChannelThreadsof Alex, exactly two posts of the app titled#12 ...and#13 ..., andlistThreadMessagesof #12 shows two messages withoriginapp. /issue:listChannelCommandsof Alex in Support/Tickets listsissue;invokeCommandby Alex withtitlegives202; a new post with the form card appears;createInteractionby Alex withvalues.bodygives201; the Fake-GitHub records one issue; the post shows the confirmation with the number; the echoissuesopenedadds no message.- Loop protection and mirroring:
setAppScopesaddsmessages:readandevents:subscribewithevent_subscriptions[message.created];createWebhookSubscriptionfor the app toevents_url; a new token, the app restarted with it. A GitHub comment on #12 is posted by the app; itsmessage.createdreaches/chat/events(listWebhookDeliveriesof Robin shows itsucceeded), and the Fake-GitHub records no comment. Alex replies in #12's thread withcreateMessage: the Fake-GitHub records exactly one comment on #12; its echo adds no message. - Missing scope:
createTokenwithscopes[commands:write, components:write], the app restarted with it; a delivery ofissuesopened#14 gives202, the server answers403 scope_missingtocreateThread, the app log holdsscope_missingonce, 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-256is verified with the test values of the GitHub documentation; wrong, missing and truncated signatures answer401and create nothing; the comparison goes only throughtimingSafeEqualon 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.
-
/issuewith 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;
installApprefuses an installation without one of the required scopes; a missing scope at call time gives403 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.tsin the scripttest). - 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 anddocs/DECISIONS.mddescribe what was built. -
make checkis 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 inplugins/README.mdupdated; thesdkserverREADME 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.mdand the rootCHANGELOG.mdentries 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 checkgreen.- An independent code review over the diff, then the red team gate (
docs/quality/red-team-gate.md, verdictJAorNEIN), 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); onNEINfix with a test per finding and have the delta checked again. Record both verdicts. - Land the change as
AGENTS.md("How work lands onmain") describes: a pull request from this branch, squash-merged only when review, red team andmake check,ci-okand the review threads are green; thengit worktree remove.
Report¶
Write to {{REPORT_PATH}}:
- First line exactly
Verdict: DONEorVerdict: BLOCKED <reason>. - 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. - Review and red team verdicts and the findings fixed.
- What is not proven (for example behaviour against the real GitHub service, fixtures written by hand if the package was not usable).
- 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>.