Workspace-scoped integrations (GitHub App + Mattermost) — replacing personal Connected accounts
Status: design (S0). SSOT for the migration from personal Connected accounts to workspace-owned integrations. Supersedes the outbound-OAuth connection model in
docs/architecture/workspace-scoped-integrations.mdfor GitHub/GHE/Mattermost (that doc is retired in S6).
Why
Personal Connected accounts connect a member's github.com account via a GitHub OAuth App. The
repo scope is all-or-nothing — it grants the token access to every public+private repo the
member can reach. There is no per-repository selection at the GitHub grant level, and the token is
tied to one person's login (leaves with them, bills to them, invisible to teammates).
The product needs:
- Per-repository access enforced by GitHub, not by app-level filtering.
- Team-owned repo access — decoupled from any single member's personal login.
- Self-serve setup from the web (no operator involvement per workspace).
Only a GitHub App (installation model) gives (1) and (2): an org owner installs the app and picks repos; GitHub issues short-lived installation tokens scoped to exactly those repos. So we move all GitHub/GHE repo access to workspace-owned GitHub App installations, and re-scope Mattermost notifications to a workspace-level self-serve credential — then delete the personal connection feature entirely.
Decisions (locked)
- Scope: GitHub App installations are workspace-owned (org install), not personal.
- GHE parity: GitHub Enterprise works identically to github.com — one operator-env App
(
GITHUB_ENTERPRISE_APP_*) for the whole deployment, install-only (no per-workspace App registration). The admin just clicks Install → pick repos, exactly like github.com. The only difference is which env block holds the App creds (see the table below), not the UX. (This supersedes the earlier per-workspace GHE registration design — thegithubApp.registrationsfield and its routes/MCP tools/web form were removed.) - Mattermost (full two-way): the corporate Mattermost server URL is an operator env
(
MATTERMOST_HOST), shared across the deployment — the self-hosted operator registers it once, so workspaces never input a host (and no surface ever shows it: it is deployment infrastructure, not workspace configuration — it only decides whether the integration is available at all). A workspace admin then registers only the bot token (+ channel + slash-command token, all SecretStore name-refs) → outbound notifications and inbound slash commands + interactive buttons. Registration is verified against the live server (strict): the bot token must authenticate (/api/v4/users/me) and, when a channel is given, the channel must be accessible (/api/v4/channels/{id}) — a failed connection blocks the save (there is also an explicitPOST /workspace/mattermost/probe). This is Everdict's first inbound integration surface (verified, workspace-scoped) — a deliberate, contained exception to the "no inbound webhooks" stance (which still holds for GitHub App push triggers). - Mattermost is MULTI-connection (like GitHub's multiple installations): a workspace registers
one connection per team/purpose, keyed by
name(bot token + channel + optional slash-command token). What varies per connection is the bot and channel, never the host. Consequences, all keyed off the same normalized list (mattermostConnections()in@everdict/domain— plural field ∪ the legacy singular registration, so every consumer sees one list):- Outbound fans out: a completion/regression fact posts to EVERY connection that has a
defaultChannelId, each through its own bot token. Registering a connection with a channel IS the subscription — there is no separate "primary" flag. One connection's missing secret or MM outage never silences the others (per-connection best-effort, as before). - Inbound accepts any connection's token: the request carries only the token, so verification
constant-time-compares it against every connection's
commandTokenSecretNamevalue (fail-closed, and every candidate is compared so the work doesn't depend on which one matched). - Agent actions select one:
post_mattermost_message/list_mattermost_channels/get_mattermost_channel_poststake an optionalconnectionname — omitted = the first registered one; an unknown name is a 404 (never a silent post to the wrong channel).
- Outbound fans out: a completion/regression fact posts to EVERY connection that has a
- Remove personal Connected accounts entirely (github, github-enterprise, mattermost personal
connections + the applications roster + the OAuth
integrations). Done last, after the replacements are live, so no window breaks repo-clone or notifications. - No inbound GitHub webhooks. For GitHub we remain the client (mint outbound installation
tokens); GitHub App push-triggered eval stays deferred (
github-actions-trigger.md). Mattermost is the deliberate exception — full two-way needs a verified inbound surface (see the Mattermost section below).
App registration: two homes (both env), one UX
A GitHub App is registered per GitHub host, and both hosts are operator env — one App per host for the whole deployment. There is no per-workspace App registration; the admin only installs.
| Host | App credentials (App ID + slug + PEM private key), operator env | PEM encoding |
|---|---|---|
| github.com | GITHUB_APP_ID, GITHUB_APP_SLUG, GITHUB_APP_PRIVATE_KEY | base64(PEM) or raw PEM (\n restored) |
GitHub Enterprise https://ghe.host | GITHUB_ENTERPRISE_HOST, GITHUB_ENTERPRISE_APP_ID, GITHUB_ENTERPRISE_APP_SLUG, GITHUB_ENTERPRISE_APP_PRIVATE_KEY | base64(PEM) or raw PEM |
Credential resolution (GithubAppService.resolveAppCreds/resolveInstallTarget) keys off the install
host: no host → the github.com env App; a host matching GITHUB_ENTERPRISE_HOST (normalized sameHost)
→ the enterprise env App; any other host → BadRequest. The installation (the thing that grants
repo access) is workspace-owned in both cases. From the member's perspective the flow is identical:
install on org → pick repos → workspace can clone them. The status view exposes providers: { githubCom: boolean, enterprise?: { host } } so the web renders one install button per configured host.
Target data model
Everything non-secret lives in WorkspaceSettings JSONB (like integrations and ci.links today);
the only secret (GHE App private key, Mattermost webhook URL) is a SecretStore name-ref.
// packages/contracts/src/records/workspace-settings.ts — WorkspaceSettingsSchema additions
githubApp: z.object({
// Both github.com AND GitHub Enterprise App creds are operator env → NOT stored here (no registrations field).
// Workspace-owned installations (github.com + GHE). One per installed org.
installations: z.array(z.object({
host: z.string().url().optional(), // omitted = github.com; set = the enterprise host (GITHUB_ENTERPRISE_HOST)
installationId: z.number().int(), // GitHub installation id
account: z.string().min(1), // org/user login the app is installed on
connectedBy: z.string(), // audit — principal.subject of the admin who linked
connectedAt: z.string(),
})).default([]),
}).optional(),
// mattermost: the server URL is operator env (MATTERMOST_HOST) → NOT stored here (host is legacy-optional).
// A workspace stores only the bot/channel/command name-refs; the host is sourced from env at read/post time.
// The SINGULAR field is legacy read-compat (superseded by mattermostConnections): a reader lifts it in as
// name="default" and the next write persists the plural list + nulls this one (same shape as imageRegistry).
mattermost: z.object({
host: z.string().url().optional(), // legacy/optional — no longer written (env-sourced)
botTokenSecretName: z.string().min(1), // SecretStore key — bot access token (outbound posts, threads, DMs, interactive)
commandTokenSecretName: z.string().optional(), // SecretStore key — slash-command/action token (inbound verification)
defaultChannelId: z.string().optional(), // default notify channel
inboundToken: z.string().optional(), // vestigial (ws-in-URL routing superseded it)
}).nullable().optional(),
// The canonical list — one connection per team/purpose, upserted by name. Notifications fan out to every
// entry with a defaultChannelId; inbound verification accepts any entry's commandTokenSecretName.
mattermostConnections: z.array(z.object({
name: z.string().min(1), // connection name (reference/upsert key, e.g. "team-alerts")
botTokenSecretName: z.string().min(1),
defaultChannelId: z.string().min(1).optional(),
commandTokenSecretName: z.string().min(1).optional(),
})).optional(),
Installation records hold no long-lived token — installation tokens are minted on demand from the
operator-env App private key (github.com or enterprise) and are short-lived (~1h). So no new encrypted
store is needed; JSONB + SecretStore name-refs suffice. everdict_connections is dropped in S6.
Removing githubApp.registrations / stored mattermost.host needs no migration — the JSONB fields are
simply no longer read/written (old rows parse and are rewritten without them).
Token minting (the core)
apps/api/src/infrastructure/oauth/github-app.ts (new), host-aware like github.ts:
- App JWT — sign
{ iss: appId, iat, exp<=10m }with RS256 using the App private key. - Installation token —
POST {apiBase}/app/installations/{id}/access_tokenswith{ repositories: [name], permissions: { contents: "read" } }→ GitHub returns a token restricted to those repos + permissions, expiring in ~1h. installationTokenForRepo(workspace, { host?, owner, repo })resolves the workspace installation for that host+owner, loads the App private key from operator env (github.com or, when the host matchesGITHUB_ENTERPRISE_HOST, the enterprise App), mints a token scoped toowner/repo, returns it.
This is the workspace analog of repoTokenFor — resolved by workspace (not submitter
subject), so any member's run in the workspace uses it. The transient plumbing is unchanged: the
token is carried as CaseJob.repoToken (never persisted), injected into git via http.extraheader
(packages/environments/src/repo.ts, already implemented).
Repo-source wiring (S3)
Today env.source = { git, ref, connectionId? } where connectionId → personal connection resolved
against the submitter. New: reference the workspace installation instead. The resolver keys off
workspace, so it works for any member and needs no personal login.
// env.source gains a workspace-installation reference (discriminated from personal connectionId,
// which is removed in S6). Resolution: parse owner/name from `git` → installationTokenForRepo(workspace,…).
env.source = { git: "https://github.com/acme/api", ref: "main", via: "workspace-github-app" }
packages/application-control/src/execution/execute-case.ts gains one branch: if the source is a workspace-github-app source, mint
an installation token (by workspace) instead of pulling a personal connection token. The CI-link repo
picker (ci-link-service.ts listRepos) switches from the personal token to the installation's
GET {apiBase}/installation/repositories.
Auth / authz
- Install / unlink / set-mattermost / probe-mattermost = admin (
settings:write), same gate as today'sintegrations(workspace app config). Reads =members:read/settings:read. (There is no GHE-app-registration route anymore — both GitHub hosts are operator env.) - No new
Authenticator— we are the outbound client. (Contrast: GitHub Actions OIDC federation ingithub-actions-trigger.mdstays as-is.) - Mattermost registration = admin (
settings:write). "Self-serve from the web" is satisfied by admin-in-web (no operator/ops ticket). Full two-way inherently requires MM-side admin actions (create bot, register the/everdictslash command), so a lighter member-level gate wouldn't help — resolved to admin. - Posting a message = member (
mattermost:post). Registration is admin governance; using the registered integration (posting to the channel) is a member action — honestly named as its own action (likeimages:push) rather than overloading admin-onlysettings:write. This is what lets a member's conversational agent notify the team by default. - Using the GitHub App = member, on BOTH halves (
github:read+github:write). Same split as Mattermost: installing the App and picking its repos is admin governance (settings:write), and every repository the installation covers is then readable and writable by a member — which is what a workspace means when it says "we connected GitHub". The read half wassettings:read(admin-only) whilegithub:writewas already member+, so a member — and the conversational agent acting as one — could open a pull request against a repository it was forbidden to read one file of, and the shippedscorecard-fix-prskill 403'd at its "locate the code" step.github:readcovers the repo picker (GET /workspace/github-app/repos,list_github_app_repos),get_github_file,list_github_repo_files, andlist_github_issues; it rides the read api-key scope (reading a repository is reading, not governance). Installation status itself — installation ids, the callback URL,GET /workspace/github-app— stayssettings:read: that is the App's administration, not its use. The repository set is still the fence: a token is minted per repo against the owner's installation, so a repo nobody selected at install time is unreachable to reads and writes alike. - The surface on an installed repository, end to end. Read (
github:read):list_github_app_repos(which repos) →list_github_repo_files(what is in one) →get_github_file(one file) ·list_github_issues(what is open) →get_github_issue(one item WITH its comment thread) ·get_github_pull_request_changes(what a PR changes, per file, with GitHub's diff). Write (github:write):create_github_issue·comment_on_github_issue·set_github_issue_state(close/reopen — STATE ONLY, the author's title and body are never rewritten) ·open_github_pr(propose) ·commit_github_files(land it directly on a branch) ·sandbox_git_push(publish a session's branch).open_github_prproposes,commit_github_fileslands. They are siblings, not a flag on one tool: a PR is a change somebody still has to accept, while a direct commit is the change. That difference is answered in the agent's consent gate rather than in authorization —commit_github_filesis a GUARDED action (apps/agent/src/action-policy.ts), so it keeps asking the member even inautomode, exactly likesandbox_git_push, whileopen_github_prdoes not. - Every bounded read reports its bound.
truncatedon the tree and the PR diff,commentsTruncatedon an issue thread — because a partial answer taken for a complete one is how an agent concludes a file does not exist, or reviews half a diff and calls it reviewed.commitFileslikewise returns the branch's resultingheadSha, read AFTER the writes: a write nobody can name afterwards is a write nobody can verify. - Reading a repo starts with
list_github_repo_files.get_github_fileneeds an exact path, so a surface that only offers it is readable only by someone who already knows the repository. The tree read (GithubRepoTreeReader, shared with the product wizard's discovery) reports its own bound:truncatedis true when GitHub cut the tree short or when the caller'slimitdropped matches, because a partial listing mistaken for the repository is how an agent concludes a file does not exist.
Install / link flow (S2) — mirrors the connections callback
POST /workspace/github-app/install/start→{ installUrl }=https://github.com/apps/{slug}/installations/new?state={s}(github.com) or the GHE equivalent. Admin clicks → GitHub install page → picks repos → GitHub redirects to our callback.- public
GET /workspace/github-app/callback?installation_id&setup_action&state→ verify state → append an installation record to the workspace → 302 to/{ws}/settings?tab=integrations. GET /workspace/github-app→ installations +providers(github.com / enterprise, both env) + each installation's selected repos (via installation token →/installation/repositories). No secrets returned.DELETE /workspace/github-app/installations/{id}→ forget the record (actual uninstall is on GitHub). (No registration routes — both GitHub hosts are operator env.)- BFF↔MCP parity: every route has an MCP tool twin (
*_workspace_github_app), one shared service core (packages/application-controlGithubAppService, thinapps/api/src/api/github-app/*transports).
Mattermost integration (full two-way)
A corporate Mattermost whose server URL is operator env (MATTERMOST_HOST), shared across the
deployment; a workspace admin registers only the workspace's bot + channel. Mattermost is bidirectional,
so it needs both outbound bot calls and inbound endpoints.
Registration (admin, settings:write, self-serve web form). The server URL is never rendered (env — see the
decision above). The admin names the connection and picks its bot access token (SecretStore name-ref) +
channel + (for inbound) a slash-command token; the form is an upsert keyed by that name, so a workspace
builds a LIST of connections (PUT /workspace/mattermost with name, DELETE /workspace/mattermost/:name,
GET returning { host?, connections[] }). Registration is strict — verified against the live server before saving
(MattermostClient.verify: /api/v4/users/me for the token + /api/v4/channels/{id} for the channel; a
failed connection is a BadRequest). There is also an explicit POST /workspace/mattermost/probe
(probe_workspace_mattermost) that returns a classified { reachable, reason?, botUsername?, channelName? }
— the web's "Test connection" gates Save on a reachable probe. On success Everdict shows the admin the
URLs/commands to register on the MM side:
- Slash command
/everdict→POST {API_PUBLIC_URL}/integrations/mattermost/command?t={inboundToken} - Interactive actions →
{API_PUBLIC_URL}/integrations/mattermost/action?t={inboundToken}
inboundToken is an opaque Everdict-minted value embedded in those URLs; every inbound request carries
it → routes the request to the right workspace (multi-tenant inbound with no user session).
Outbound (Everdict → MM), bot token + REST API (/api/v4/posts):
- Completion / regression / CI / digest notifications (thread-aware), fanned out to every connection that has a channel — each posted with that connection's own bot token, and its Rerun button carries that connection's inbound token.
- Interactive messages: message
attachments[].actionsbuttons (Re-run / View scorecard / Compare / Acknowledge). - Agent-callable post (
POST /workspace/mattermost/messages+ MCPpost_mattermost_message, overMattermostService.postMessage): the conversational agent posts an arbitrary message to a connection's channel as its bot (e.g. "post this regression summary to the team") —connectionselects which one, omitted = the first registered. Unlike the fire-and-forget notification path, failures are surfaced (config gaps →BadRequest; a transport/non-2xx from MM → the adapter's remappedUpstreamError) so the agent (and its HITL approver) learns the post's fate. Gatedmattermost:post(member+, not admin) — using the integration is a member's job. The agent gets this tool by default (seeagent-conversations.mdP8: it is one of the curatedINTEGRATION_ACTIONS, bridged HITL-gated).
Inbound (MM → Everdict), two verified public endpoints:
POST /integrations/mattermost/command—/everdict run|leaderboard|status …→ parse → dispatch → respond (ephemeral or in-channel/threaded).POST /integrations/mattermost/action— button click → perform action → update the message.- Verification: each request carries MM's
tokenfield → constant-time compare against EVERY connection'scommandTokenSecretNamevalue (the slash command may be installed on any of them, and the request names none);?ws=selects the workspace. Fail-closed.
AuthZ for chat-triggered actions. Inbound requests have no OIDC user. Model it like CI: a
workspace-scoped chat principal (via: mattermost, roles limited to scorecards:run/read +
reads — never admin), added as a composed Authenticator branch keyed off the verified inbound
token. Optional later: map the MM user (by email) → an Everdict identity so runs are attributed to
the real person; v1 uses the service principal.
Slice plan (replace-first, remove-last)
Each slice: doc touch if it changes a convention + BFF↔MCP parity + tests. Quality gate (format/lint/typecheck/test/build) green per slice.
- S0 — this doc.
- S1 — App core (no UI): operator env (
GITHUB_APP_ID/PRIVATE_KEY/SLUG) +github-app.ts(App JWT → installation token, host-aware, repo-restricted) +WorkspaceSettings.githubAppschema- unit tests (mocked GitHub). Proof: mint a repo-scoped installation token.
- S2 — install/link API + MCP + authz: start/callback/list/unlink + GHE registration. Settings → Integrations deep-link target.
- S3 — repo-source wiring:
env.sourceworkspace-github-app source resolved by workspace inexecute-case.ts; CI-link picker →/installation/repositories. Live private-repo clone verify (github.com + one GHE if reachable). - S4 — Web UI: Settings → Integrations "GitHub App (org)" section (install / registrations / selected repos / unlink) + repo-source picker offering workspace installations.
- S5 — Mattermost M1 (outbound bot + registration): workspace registration (
mattermostblock, host + bot token, self-serve web form) + switch the completion/regression notifier from personal-token post to bot REST API (/api/v4/posts, thread-aware). Replaces the oldnotify.connectionId. Notifications keep working throughout. - S6 — Clean-migrate the personal-connection consumers to the App, then remove (last). Removing
personal connections would break two shipped features that use a personal GitHub token
(
ci-link-service: repo picker + setup-PR + runner registration token). So migrate first, then delete. Sub-slices:- S6a — GitHub App capability foundation (additive): extend
GithubAppServicewithlistRepos(workspace)(GET /installation/repositoriesacross installations),tokenForRepository(workspace, "owner/name", permissions)(configurable perms — contents:write + pull_requests:write for setup-PR), andrunnerRegistrationToken(workspace, target)(installation token w/ administration →…/actions/runners/registration-token). App permissions widen accordingly. Tests; no rewire yet. - S6b — Rewire
ci-link-service+ runner self-registration (API) to the App: picker/setup-PR/ runner token resolve by workspace installation (dropowner, connectionId); routes/MCP drop the connection param (GET /workspace/github-app/repospicker,open_ci_setup_pr,github_install_workspace_runner). The web ci-links picker + workspace-runners rewire folds into S6c (same surgery as removing the connection concept from the web; web still builds green between S6b↔S6c — the CI repo picker just 404s until S6c). - S6c — Remove personal Connected accounts: delete
ConnectionService/ConnectionStore/ OAuthintegrations/routes (/connections*,/workspace/applications,/workspace/integrations)/ MCP tools/webmanage-connections+entities/connection+ account "Connected accounts" tab + applications roster +GITHUB_OAUTH_CLIENT_ID/SECRETenv. Addeverdict_connectionsdrop migration (expand→contract; preflight note). Retiredocs/architecture/workspace-scoped-integrations.md. KeepAPI_PUBLIC_URL/WEB_BASE_URL(the App install callback + Mattermost inbound URLs use them).
- S6a — GitHub App capability foundation (additive): extend
- S7–S8 — Mattermost inbound (slash commands + button actions) — SHIPPED. Public routes
POST /integrations/mattermost/{command,action}— workspace routed by?ws=<slug>(slug not secret), authenticated by constant-time compare of the requesttokenagainst the workspace'scommandTokenSecretNamevalue (fail-closed: missing config / missing token / mismatch → 403).MattermostCommandServiceparses/everdict run <harness> <dataset>(submits a scorecard,submittedBy=mattermost:<user>) ·/everdict leaderboard <dataset>·/everdict status·help; the action endpoint handles arerunbutton context. Registration gainscommandTokenSecretName(API + MCPset_workspace_mattermost+ web form); the view exposes the inbound URLs for the admin to register on the MM side. form-urlencoded body parser added for MM slash commands.- Chose ws-in-URL routing over a separate
inboundToken(simpler, still token-verified); the schemainboundTokenfield is now vestigial (harmless, unused). - Follow-up: auto-attach the
rerunbutton to outbound completion posts (the action endpoint already handles clicks; only the outbound attachment is unwired) + MM-user→Everdict-identity mapping.
- Chose ws-in-URL routing over a separate
Rollout / safety
- Order guarantees no broken window: repo access (S1–S4) and notifications (S5) are fully live before the personal feature is removed (S6).
- DB: additive JSONB in S1–S5 (ships normally); the
everdict_connectionsDROP in S6 is contract-phase with adocs/migration/preflight/note (verify no code references the table first). - Secrets: GHE App private key + Mattermost webhook URL are SecretStore name-refs — never in git, never returned by any surface. App private key (github.com) is operator env, treated like the KEK.
Non-goals
- Inbound GitHub App webhooks / push-triggered eval (stays deferred in
github-actions-trigger.md). - Migrating existing personal tokens into installations (users re-install the App; personal tokens are simply dropped in S6).
- GitLab/Bitbucket (out of scope).
Rerun button on completion posts
A scorecard completion post carries an interactive Rerun action when the workspace has the inbound half
configured (commandTokenSecretName) and the control plane knows its public URL (API_PUBLIC_URL): the button
posts back to /integrations/mattermost/action?ws=<workspace> with the embedded context (the same verification
token the slash-command inbound checks + the dataset/harness coordinates), and the existing handleAction
re-fires dataset×harness from chat. Without either precondition the post stays a plain message — no dead
buttons. Regression alerts stay button-less for now (their payload carries scorecard ids, not rerun
coordinates).