Feature Specification: Integrations — GitHub App
Feature ID: integrations-github-app
Branch: docs/spec-integrations-github-app
Status: Retrospective
Created: 2026-05-08
Last updated: 2026-05-08
Owner: Ever Works Team
1. Overview
The GitHub App integration is the platform's primary onboarding path
for repositories that the user does not already authorise via the
classic OAuth github social login. A configured GitHub App lets a
workspace owner install the app on their personal account or
organisation, grant repository-level access without exposing a PAT,
and have the platform discover, persist, and onboard those repositories
as Works through WorkImportService.onboardLinkedRepository. The
integration also reconciles installation state via GitHub webhooks
(installation and installation_repositories events) so deletions,
suspensions, and repository-list changes propagate without operator
action.
The integration is implemented inside apps/api/src/integrations/github-app/
as a Nest module and depends on three databases entities owned by the
agent package — GitHubAppInstallation, GitHubAppInstallationRepo,
and GitHubAppUserLink. It is gated by five environment variables
(GITHUB_APP_ID, GITHUB_APP_CLIENT_ID, GITHUB_APP_CLIENT_SECRET,
GITHUB_APP_PRIVATE_KEY, GITHUB_APP_WEBHOOK_SECRET); the slug,
setup URL, and callback URL are derived with sensible defaults from
web app URL.
2. User Scenarios
2.1 Primary scenarios
- Given I am a workspace owner with the GitHub App installed,
when GitHub redirects me to
GET /api/github-app/setup?installation_id=<id>&setup_action=install, then the platform calls GitHub'sGET /app/installations/:idwith an app JWT, upserts the installation row viaGitHubAppInstallationRepository.upsertFromGithub, signs an HMAC state payload (10-minute TTL), and returns a JSON envelope{ url: 'https://github.com/login/oauth/authorize?...' }so the client can complete the OAuth handshake. - Given GitHub redirects me back to
GET /api/github-app/callback?code=<c>&state=<s>after the user authorises the app, when the controller verifies the HMAC state and exchanges thecode, then the platform resolves or creates a localUser(via the GitHub user link repository → auth account → email lookup chain), upserts the correspondingauth_accountrow withproviderId: 'github', persists the GitHub-App user-link row with the OAuth-app token (separate from the app installation token), then claims ownership of the installation row for the resolved user viaGitHubAppInstallationRepository.claimOwnershipIfUnassigned. The controller then issues a session viaAuthProvider.issueSessionand returns{ ...auth, installationId, redirectTo }. - Given I am signed in, when I
GET /api/github-app/installations, then the controller returns every installation I own (only rows wherecreatedByUserId === auth.userId, ordered bycreatedAt DESC, soft-deleted rows excluded), each enriched with the latestinstallation_repositoriesrows fromGitHubAppInstallationRepoRepository.listForInstallation. - Given I have a stale repository list, when I
POST /api/github-app/installations/:installationId/sync, then the platform mints a fresh installation access token (/app/installations/:id/access_tokens), pages through/installation/repositories?per_page=100&page=Nuntil the page count drops below100(ortotal_countis met), and replaces the platform-side repo list atomically viareplaceForInstallation. - Given I want to onboard a discovered repository, when I
POST /api/github-app/installations/:installationId/repositories/:repositoryId/onboard, then the platform analyses the repo withSourceRepoAnalyzerService.analyzeRepository(using the fresh installation access token), and — only if the analyzer reportsdetectedType === 'data_repo'— callsWorkImportService.onboardLinkedRepositorywith themode: 'github_app_installation'auth payload so the resulting Work knows to mint installation tokens at runtime instead of carrying a user-bound PAT. - Given GitHub posts an
installationwebhook event with a validx-hub-signature-256, when the public webhook controller runs, then the platform applies the right action:created/new_permissions_accepted/unsuspend→ upsert + sync;deleted→ soft-delete;suspend→ marksuspendedAt;unsuspend→ clearsuspendedAt. - Given GitHub posts an
installation_repositorieswebhook event with a valid signature, when the controller runs, then the platform callssyncInstallation(<installationId>, undefined)— nouserIdfilter, since GitHub is the trigger — to reconcile the per-installation repo list.
2.2 Edge cases & failures
- Given the GitHub App credentials are unset (
GITHUB_APP_IDorGITHUB_APP_PRIVATE_KEYmissing), when any code path callsGitHubAppService.getCredentials, then it throwsError('GitHub App credentials are not configured')and the controller surfaces a 500 — there is NO env-gate guard equivalent toCrmSyncGuard. - Given the OAuth
stateis missing the.separator OR the signature length differs from the expected length OR the HMAC does not match (constant-timetimingSafeEqual), whenGitHubAppOnboardingService.verifyStateruns, then it throwsBadRequestException('Invalid GitHub App state')/'Invalid GitHub App state signature'BEFORE any external call. - Given the OAuth
statepayload is older than 10 minutes, whenverifyStatereadspayload.issuedAt, then it throwsBadRequestException('GitHub App setup state expired'). - Given the OAuth
statepayload hasinstallationIdempty orissuedAtfalsy, whenverifyStateparses the body, then it throwsBadRequestException('Invalid GitHub App state payload'). - Given GitHub responds to the access-token exchange with
{ error, error_description }, whenGitHubAppService.exchangeUserCodereadsdata.error, then it throwsUnauthorizedException(error_description ?? \GitHub App authorization failed: ${error}`)`. - Given GitHub responds to the access-token exchange with no
access_tokenand noerror, whenexchangeUserCodechecks the body, then it throwsBadRequestException('GitHub App authorization did not return an access token'). - Given the resolved GitHub user's primary
emailisnull, when the platform falls back toresolveGitHubAccountEmail(httpService, accessToken, null), then the helper hits/user/emails, picks the verified primary, and returns{email, emailVerified}— when the helper returns no email, the onboarding service synthesisesgithub-app-${githubUserId}@users.noreply.ever.worksand creates the user withemailVerified: false. - Given an existing local user is found by email but the
GitHub-resolved email is NOT verified, when
findOrCreateLocalUserchecks the link/auth-account chain, then the service throwsUnauthorizedException('Unable to link this GitHub App user because the provider email is not verified'). - Given a local user already has an email under
@users.noreply.ever.worksbut the GitHub user provides a real email, when the onboarding update path runs, thennextEmailadopts the real email; otherwise the existing email is preserved. - Given the requested local username is already taken, when
resolveUniqueUsernameruns, then it appends-2,-3, … untiluserRepository.findByUsernamereturnsnull. An empty/ whitespace base falls back to the literal'github-user'. - Given the installation has been deleted (
deletedAt != null) or suspended (suspendedAt != null), whensyncInstallation/onboardInstallationRepositoryis called, then they short-circuit and returnnull. - Given a
userIdis passed tosyncInstallationbut the installation'screatedByUserIddiffers, when the service checks ownership, then it returnsnull. The controller promotes thisnullinto anUnauthorizedException. - Given the analyzer flags a repo whose
detectedTypeis NOT'data_repo', whenonboardInstallationRepositoryruns, then it returns{status:'error', message: 'Only existing data repositories can be onboarded from GitHub App installations right now'}— the controller promotes this intoBadRequestException. - Given the analyzer returns a generic
errorfield, whenonboardInstallationRepositoryruns, then it returns{status:'error', message: <analysis.error>}and the controller surfaces it asBadRequestException. - Given the webhook controller receives a payload with no
x-hub-signature-256header AND noGITHUB_APP_WEBHOOK_SECRETconfigured, whenGitHubAppService.verifyWebhookSignature(rawBody, undefined)runs, then it returnsfalse(becausesecretis empty) and the controller throwsUnauthorizedException('Invalid GitHub webhook signature'). - Given the webhook payload's
req.rawBodyis undefined, when the controller runs, then it throwsBadRequestException('Missing raw webhook payload')BEFOREverifyWebhookSignatureis called. - Given the webhook payload's
x-github-eventheader is missing, when the controller runs, then it throwsBadRequestException('Missing GitHub event header')BEFORE signature verification. - Given the webhook event name is anything other than
installationorinstallation_repositories, whenhandleWebhookruns, then the method silently returnsundefined— the platform does not error on unsupported events so GitHub's retry logic does not amplify them. - Given the
installationwebhook payload has noinstallation.id, whenhandleWebhookruns, then it silently returnsundefined. - Given the
installation_repositorieswebhook payload has noinstallation.id, whenhandleWebhookruns, then it silently returnsundefined.
3. Functional Requirements
-
FR-1 The integration MUST be a non-global Nest module registered as
GitHubAppModuleingithub-app.module.ts. It MUST importDatabaseModule,HttpModule,AuthModule,WorkModule, andImportModule, and MUST exportGitHubAppService,GitHubAppOnboardingService, andGitHubAppSyncServiceso other API features (e.g. agent onboarding) can re-use them. -
FR-2
GitHubAppService.getConfigurationMUST return the live five-field tuple{appId, clientId, slug, setupUrl, callbackUrl}fromconfig.githubApp. -
FR-3 Required env vars:
GITHUB_APP_ID(numeric app id, no default — credentials method throws if missing).GITHUB_APP_CLIENT_IDandGITHUB_APP_CLIENT_SECRET(used ONLY by the user-OAuthexchangeUserCodeflow).GITHUB_APP_PRIVATE_KEY(PEM with literal\nsequences;config.githubApp.privateKey()MUST replace\\nwith\nbefore passing tojsonwebtoken).GITHUB_APP_WEBHOOK_SECRET(HMAC-SHA-256 secret;verifyWebhookSignatureMUST returnfalseif missing — the webhook controller treats that as401).
-
FR-4 Optional env vars (with defaults):
GITHUB_APP_SLUG(default'ever-works').GITHUB_APP_SETUP_URL(default${webAppUrl}/api/github-app/setup).GITHUB_APP_CALLBACK_URL(default${webAppUrl}/api/github-app/callback).
-
FR-5
GitHubAppService.getUserAuthorizationUrl(state)MUST buildhttps://github.com/login/oauth/authorize?client_id=<>&redirect_uri=<>&state=<>withclient_iddefaulting to''if env var unset (so the URL encodes a blank query parameter rather than the literal'undefined'). -
FR-6
GitHubAppService.exchangeUserCode(code)MUSTPOSThttps://github.com/login/oauth/access_tokenwithapplication/x-www-form-urlencodedbody (client_id,client_secret,code,redirect_uri) andAccept: application/jsonheader. Errors MUST be coerced as per the rules in §2.2. -
FR-7
GitHubAppService.getAuthenticatedGithubUser(token)MUST GEThttps://api.github.com/userwithcreateGitHubOAuthHeaders(token), then resolve the email viaresolveGitHubAccountEmail(httpService, accessToken, data.email || null)(which falls back to/user/emailswhen the primary is null). The returned shape MUST be{githubUserId: String(data.id), login, displayName: data.name || data.login, email, emailVerified, avatarUrl: data.avatar_url || null, nodeId: data.node_id || null, accessToken}. -
FR-8
GitHubAppService.getInstallation(installationId)MUST callGET https://api.github.com/app/installations/:idwith the app JWT (created viacreateGitHubAppJwt({appId, privateKey})) andcreateGitHubAppHeaders(jwt). -
FR-9
GitHubAppService.createInstallationAccessToken(installationId)MUST proxy to the agent-package helperrequestGitHubAppInstallationAccessToken(installationId, {appId, privateKey})so JWT minting + token exchange logic stays in one place. -
FR-10
GitHubAppService.listInstallationRepositories(installationId)MUST page through/installation/repositories?per_page=100&page=Nuntil either (a) a page has fewer than 100 entries OR (b)repositories.length >= total_count(whentotal_countis a number). The page cursor MUST start at1and increment by1. -
FR-11
GitHubAppService.verifyWebhookSignature(rawBody, signature?)MUST short-circuit tofalsewhen the configured webhook secret is empty. Otherwise it MUST delegate toverifyGitHubWebhookSignature(rawBody, secret, signatureHeader). -
FR-12
GitHubAppOnboardingService.beginSetup(input)MUST callgetInstallation(input.installationId)first, thenupsertFromGithubwith the payload — even though no user is bound yet — so the row exists when the user returns from the OAuth handshake. ThestateMUST be HMAC'd withconfig.auth.secret()viacreateHmac('sha256', secret).update(encodedPayload).digest('base64url'). -
FR-13
redirectToMUST be normalised: only strings starting with/survive — anything else (relative paths without leading slash, full URLs,null/undefined, non-string values) becomesundefined. -
FR-14
GitHubAppOnboardingService.completeUserAuth(input)MUST- verify the state (HMAC + 10-minute TTL),
- exchange the user code,
- resolve the GitHub user (via
getAuthenticatedGithubUser), - find-or-create the local user (the four-step chain in §2.1
- email-not-verified rejection),
- upsert the
auth_accountsrow withproviderId: 'github',accountId: <githubUserId>,tokenType: 'Bearer', full OAuth-token fields, andmetadata: {nodeId, providerUserId, login}, - upsert the
github_app_user_linksrow with the OAuth-app token (kept distinct from the app installation token because GitHub issues separate JWTs for each), - re-call
getInstallationso the row payload is fresh on the second leg of the handshake, - call
upsertFromGithubagain, - call
claimOwnershipIfUnassigned(installationId, user.id, githubUserId)which atomically writescreatedByUserId/createdByGithubUserIdonly when the row'screatedByUserId IS NULL(the WHERE clause is the race-safety guarantee — concurrent claim attempts deterministically pick a single owner).
-
FR-15 When
claimOwnershipIfUnassignedreturnsnull(no matching row), the onboarding service MUST throwBadRequestException('GitHub App installation could not be persisted'). -
FR-16 When the find-or-create chain creates a new user, the password MUST be a
bcrypt.hash(randomUUID(), 10)placeholder (the user can't sign in with credentials — they always come back through the GitHub App OAuth flow).registrationProviderMUST be'github'. -
FR-17 When updating an existing user, the email MUST follow the noreply rule (§2.2): replace
@users.noreply.ever.worksemails with the real one, otherwise preserve.lastLoginAtMUST be set tonew Date()andregistrationProviderre-asserted as'github'even if previously different. -
FR-18
GitHubAppController.setupMUST be@Public()(no session required — GitHub redirects unauthenticated users here).GitHubAppController.callbackMUST be@Public()for the same reason. Both MUST validate query params viaclass-validatorDTOs (GitHubAppSetupQueryDto.installation_idis required,setup_actionis'install' | 'request'if present;GitHubAppCallbackQueryDto.codeandstateare both required). -
FR-19
GitHubAppController.callbackMUST issue a session viaauthProvider.issueSession(user.id)AFTERcompleteUserAuthsucceeds, and the response envelope MUST be{...auth, installationId, redirectTo}so the client knows where to bounce next. -
FR-20
GitHubAppController.listInstallationsand downstream endpoints MUST rely on the globalAuthSessionGuard(no class- or method-level@UseGuards) — the guard is applied app-wide, so these routes are private by virtue of NOT carrying@Public(). -
FR-21
GitHubAppController.syncInstallationMUST 401 withUnauthorizedException('GitHub App installation not found for this user')when the service returnsnull(covers the not-found, deleted, suspended, and ownership-mismatch cases — the controller does NOT distinguish them, by design, to avoid leaking ownership info). -
FR-22
GitHubAppController.onboardRepositoryMUST 404 withNotFoundException('GitHub App repository not found for this user')whenonboardInstallationRepositoryreturnsnull. When the service returns{status:'error', message}, the controller MUST promote it toBadRequestException(message). -
FR-23
GitHubAppSyncService.listInstallationsForUser(userId)MUST hydrate every returned installation with its repo list by fanning out viaPromise.alltoGitHubAppInstallationRepoRepository.listForInstallation(installation.id). -
FR-24
GitHubAppSyncService.syncInstallation(installationId, userId?)MUST short-circuit tonullfor missing/deleted/suspended/ ownership-mismatch installations BEFORE callinglistInstallationRepositories. The replace shape MUST setselected: truefor every repo (the platform UI will toggle this later). The repo'sownerMUST default toinstallation.accountLoginwhen GitHub's payload omits it;fullNameMUST default to'<owner>/<repo>'when GitHub's payload omits it. -
FR-25
GitHubAppSyncService.onboardInstallationRepositoryMUST mint a fresh installation access token per call (no token caching at this layer —WorkImportServiceis responsible for refresh) and pass it toanalyzeRepository(sourceUrl, token)so the analyzer can read private repos. The auth payload forwarded toWorkImportService.onboardLinkedRepositoryMUST be{mode: 'github_app_installation', providerId: 'github', installationId, installationRepositoryId: repository.id, repoFullName: repository.fullName}— explicitly carrying the platform-side repo entity id so the Work can re-resolve the row for token refresh. -
FR-26
GitHubAppSyncService.handleWebhookMUST handle theinstallationevent by:- returning early if
payload.installation?.idis falsy, - on
action === 'deleted', callinginstallationRepository.markDeleted(<id>, new Date())and returning, - otherwise calling
upsertFromGithubwith the installation shape — includingcreatedByGithubUserIdfrompayload.sender?.idONLY whenaction === 'created', - then for
action in {created, new_permissions_accepted, unsuspend}firing asyncInstallation(nouserIdfilter).
- returning early if
-
FR-27 The
suspendedAtfield onupsertFromGithubMUST follow the action-specific rules:'suspend'→new Date();'unsuspend'→null; otherwise →payload.suspended_atif present, elseundefined(whichremoveUndefinedValueswill drop). -
FR-28
GitHubAppSyncService.handleWebhookMUST handle theinstallation_repositoriesevent by extractingpayload.installation?.id, returning early if falsy, and callingsyncInstallation(String(installationId))with NO user filter. -
FR-29 The webhook controller route MUST be
POST /api/github-app/webhooks, marked@Public()(signature verification IS the auth gate). It MUST requirereq.rawBody(set up by the global raw-body parser in main.ts) — without a raw body, signature verification is impossible and the controller MUST 400. -
FR-30
GitHubAppControllerandGitHubAppWebhookControllerMUST share the prefix/api/github-appbut be DISTINCT controller classes — the OAuth-flow + management endpoints live inGitHubAppController, the webhook receiver lives inGitHubAppWebhookController. This split exists so the auth-bearing endpoints can later be moved under a different prefix without affecting the public webhook path.
4. Non-Functional Requirements
- Performance:
listInstallationRepositoriespaginates server-side 100-at-a-time. The default cap matches GitHub's documented max page size; for installations with > 1 000 repos, the page count is unbounded — by design, since GitHub's documented per-installation cap is 1 000 repos and exceeding it would be an upstream issue rather than a platform constraint. Webhook handlers MUST be non-blocking —handleWebhookis fire-and-forget from the caller's perspective; the controller still awaits to ensure errors are logged in the same request span. - Reliability: The integration relies on GitHub's webhook
delivery retry semantics for installation reconciliation. If a
webhook delivery fails, GitHub will retry; the operator can also
re-sync manually via
POST /api/github-app/installations/:id/sync. TheclaimOwnershipIfUnassignedquery is the race-safety guarantee for concurrent two-leg OAuth handshakes — the SQLWHERE createdByUserId IS NULLensures only the first claim succeeds. - Security: GitHub App credentials live in env vars only. The
app JWT is minted on every call to
getInstallation/createInstallationAccessToken(no JWT caching inapps/api; the agent helper handles its own short-lived JWT). The OAuth state is HMAC-signed withconfig.auth.secret()and TTL-bound to 10 minutes — replays beyond that window are rejected. Webhook signature verification MUST always use a constant-time comparison (verifyGitHubWebhookSignaturedelegates tocrypto.timingSafeEqual). The state-payload signature comparison inverifyStateALSO usestimingSafeEqualafter a fixed-length guard. The user-OAuthclient_secretMUST be sent in the request body, never in a URL query. - Privacy: Auto-created users without a verified email get a
synthetic
@users.noreply.ever.worksaddress andemailVerified: false. This prevents accidental cross-account linking via unverified emails. The user-OAuth-app token is stored in BOTH theauth_accountsrow (for user-bound API calls) and thegithub_app_user_linksrow (for installation-time bookkeeping) — the duplication is intentional because the two tables have different lifecycle owners. The webhook secret is read once per call; the constant-time signature check prevents secret-extraction via timing oracles. - Observability:
GitHubAppServicedoes NOT log per-call (HTTPS calls flow throughnestjs/axiosand Nest's request middleware). Failures bubble as Nest exceptions, which the global exception filter logs. Webhook signature failures surface asUnauthorizedExceptionwith'Invalid GitHub webhook signature'— operators see the rejection in the API request log. - Compatibility: The integration targets the GitHub REST API
v3 (
api.github.com). The base URL is currently hard-coded; GitHub Enterprise Server support would require new env vars (GITHUB_APP_BASE_URL,GITHUB_APP_API_BASE_URL) and threading them through everyfirstValueFrom(httpService.*)call.
5. Key Entities & Domain Concepts
| Entity / concept | Description |
|---|---|
GitHubAppInstallation | Platform row mirroring GitHub's installation. PK id (uuid), unique installationId (string from GitHub), appSlug, accountLogin, accountType, targetType, createdByUserId (nullable), createdByGithubUserId (nullable), suspendedAt (nullable), deletedAt (nullable), rawPayload jsonb. |
GitHubAppInstallationRepo | Per-installation repo. installationEntityId FK → GitHubAppInstallation.id, unique (installationEntityId, githubRepoId), fields: owner, repo, fullName, isPrivate, defaultBranch, selected. |
GitHubAppUserLink | Maps GitHub-App-OAuth users to platform users. Unique on githubUserId. Fields: userId, githubLogin, githubNodeId, accessToken, refreshToken, accessTokenExpiresAt, refreshTokenExpiresAt, scope. Distinct from auth_accounts.providerId='github' because the GH-App OAuth client is a separate app from the social-login OAuth app. |
auth_accounts (providerId='github') | Platform-wide social-login row. The GitHub-App callback also writes here so the user's session-level GitHub auth survives even if the App is uninstalled. |
SetupStatePayload | {installationId, redirectTo?, setupAction?, issuedAt} — HMAC-signed and base64url-encoded into the OAuth state parameter. 10-minute TTL. |
GitHubAppSetupQueryDto | Validated query for GET /api/github-app/setup. installation_id required; setup_action ∈ {install, request}; redirectTo optional (normalised by the service). |
GitHubAppCallbackQueryDto | Validated query for GET /api/github-app/callback. code and state both required; class-validator decorators ensure they are strings. |
GitHubAppService | HTTP client + JWT minting. Stateless. |
GitHubAppOnboardingService | OAuth state signing, findOrCreateLocalUser, claimOwnershipIfUnassigned. The user-resolution chain. |
GitHubAppSyncService | Installation lifecycle, repo-list reconciliation, webhook handler, repo onboarding handoff to WorkImportService. |
WorkImportService.onboardLinkedRepository | Down-stream consumer. The auth payload {mode:'github_app_installation', ...} lets the new Work mint installation tokens at runtime. |
SourceRepoAnalyzerService | Static analyser. Reads the repo's .works/works.yml to determine detectedType ∈ {data_repo, website, …}. Only 'data_repo' is onboardable today. |
6. Out of Scope
- GitHub App creation (no automation for
/settings/apps/new— the operator registers the app in GitHub and copies the credentials into env vars). - GitHub Enterprise Server — base URLs are hard-coded to
api.github.com/github.com. - App installation uninstall initiated from the platform UI —
GitHub's
/settings/installations/:idpage is the only path. When the user uninstalls there, GitHub firesinstallation.deletedand the platform soft-deletes the row. - Installation token caching at the API layer — the agent-package helper handles short-lived JWT caching internally; the API re-mints on every endpoint call.
- Bulk repo onboarding — the controller endpoint is per-repo;
bulk onboarding is a future feature gated behind
WorkImportServiceenhancements. - Onboarding non-data-repo GitHub repos — the analyzer's
detectedType !== 'data_repo'branch surfaces as a400 BadRequest. Website / agent / pipeline repos are not yet onboardable from the GitHub App surface (they require manual Work creation today). - Multi-org switching UI —
listInstallationsreturns every installation a user owns, ordered bycreatedAt DESC; the web client picks one. - Per-repo selection at sync time — the
selected: truedefault onreplaceForInstallationis intentional. Selection toggling is a future enhancement. - Webhook event coverage beyond
installationandinstallation_repositories—installation_target,repository,push, etc. are silently ignored today.
7. Acceptance Criteria
- Setting all five env vars (
GITHUB_APP_ID,GITHUB_APP_CLIENT_ID,GITHUB_APP_CLIENT_SECRET,GITHUB_APP_PRIVATE_KEY,GITHUB_APP_WEBHOOK_SECRET) letsGET /api/github-app/setup?installation_id=…return a well-formed authorize URL. - An expired (>10-minute) state payload yields
BadRequestException('GitHub App setup state expired'). - A tampered state payload yields
BadRequestException('Invalid GitHub App state signature'). -
POST /api/github-app/installations/:id/syncagainst an installation owned by a different user returns 401 (not 404). -
POST /api/github-app/installations/:id/repositories/:repoId/onboardagainst a non-data-repo returns 400 with the service's verbatim message. -
POST /api/github-app/webhookswithoutx-hub-signature-256returns 401 (when secret is configured) or 401 (when secret is not configured) — both paths short-circuit viaverifyWebhookSignaturereturningfalse. -
POST /api/github-app/webhookswith aninstallation.deletedpayload soft-deletes the platform row viamarkDeleted(<id>, new Date()). -
POST /api/github-app/webhookswith aninstallation_repositoriespayload triggers a freshsyncInstallationwith no user filter. - All 22 unit tests in PR #502 remain green (controller surface + the three pre-existing service-level suites).
8. Open Questions
[NEEDS CLARIFICATION: OQ-1]GitHubAppService.getCredentialsthrows an unwrappedError('GitHub App credentials are not configured'), not aNestException. With the global exception filter this surfaces as a 500. Should it be promoted toServiceUnavailableExceptionso the response code reflects the configuration gap rather than an internal server error?[NEEDS CLARIFICATION: OQ-2]There is noGitHubAppSyncGuardequivalent toCrmSyncGuard. WhenGITHUB_APP_*is not configured,setupandcallbackendpoints will throw 500 on the first GitHub call. Should the integration self-disable with aServiceUnavailableExceptionat the controller boundary?[NEEDS CLARIFICATION: OQ-3]findOrCreateLocalUserthrowsUnauthorizedExceptionfor unverified-email link refusals but surfaces it as the callback response — the user sees a generic 401 instead of a redirect to a friendlier UI. A web-only follow-up would render this case as a setup-error page.[NEEDS CLARIFICATION: OQ-4]The syntheticgithub-app-${githubUserId}@users.noreply.ever.worksemail is hard-coded. If multiple Ever Works deployments run in parallel with differentwebAppUrl()s, those emails could collide. Should the suffix be derived fromwebAppUrl()(e.g.@users.noreply.<host>) instead?[NEEDS CLARIFICATION: OQ-5]claimOwnershipIfUnassignedreturns the existing row whencreatedByUserIdis already set — even if it differs from the currentuser.id. This means a second user installing the same GitHub App will seecompleteUserAuthsucceed for them but the installation remains owned by the first user. The user-link row is still written, so the second user can still authenticate with GitHub — but they can't manage the installation. Is this intended? Or should the second user receive aConflictresponse?[NEEDS CLARIFICATION: OQ-6]handleWebhooksilently returns for unsupported event names. We could log the event name atdebugto make payload-shape changes (e.g. GitHub renaminginstallationtoapp_installation) easier to spot.[NEEDS CLARIFICATION: OQ-7]The webhook controller does NOT emit any activity-log entry when an installation is created/deleted/suspended. Should it? Currently, the only visible artefact is the changed row ingithub_app_installations.[NEEDS CLARIFICATION: OQ-8]onboardInstallationRepositoryrefuses non-data_repotypes with a hard-coded English message. Should the message be moved into a constant / i18n key for future translation?[NEEDS CLARIFICATION: OQ-9]selected: trueon everyreplaceForInstallationis an aggressive default — operators who add the GitHub App to an org with hundreds of repos will see every repo in the platform. A future enhancement should respect the user's prior selection (e.g. preserveselected: falsefor rows where(installationId, githubRepoId)already hadselected: false).
9. Constitution Gates
- Plugin-first if introducing an external integration
(Principle I): partial — the integration lives in
apps/api/integrations/github-app/notpackages/plugins/github-app. This matches the same decision asintegrations-twenty-crm(the integration touches platform-side user/auth resolution chains, which are core API responsibilities). A future migration is plausible once the plugin SDK exposes aPlatformcapability for session-issuance. - Capability-driven resolution: N/A — no plugin capability is declared.
- Source-of-truth repos preserved: GitHub remains the source of truth for repo state; the platform mirrors a snapshot via the sync endpoint and the two webhook events. ✅
- Long-running work via Trigger.dev: N/A — installation +
onboarding flows are request-scoped. Bulk onboarding (future)
would belong in
packages/tasksinstead. - Schema changes ship as forward-only migrations: ✅ — the
three GitHub-App tables already shipped via prior migrations
in
packages/agent/src/database/migrations/. - Tests accompany the change: ✅ — 22 controller-level + the
three pre-existing service-level suites in
apps/api/src/integrations/github-app/. See PR #502. - Secrets handled per
x-secretrules: ✅ — App ID, client secret, private key, and webhook secret all read from env vars. None are echoed in responses or logs. - Plugin counts touch the canonical doc only: N/A — not a plugin yet.
- Behaviour-first — no implementation in this spec. ✅
- Backwards-compatible API/SDK/schema changes: ✅ — additive, env-gated. No existing endpoint is affected.
10. References
- Source:
apps/api/src/integrations/github-app/apps/api/src/integrations/github-app/github-app.module.tsapps/api/src/integrations/github-app/github-app.service.tsapps/api/src/integrations/github-app/github-app-onboarding.service.tsapps/api/src/integrations/github-app/github-app-sync.service.tsapps/api/src/integrations/github-app/github-app.controller.tsapps/api/src/integrations/github-app/github-app-webhook.controller.tsapps/api/src/integrations/github-app/dto/github-app.dto.tsapps/api/src/config/constants.ts(config.githubApp)apps/api/src/auth/utils/github-email.utils.tspackages/agent/src/database/repositories/github-app-installation.repository.tspackages/agent/src/database/repositories/github-app-installation-repository.repository.tspackages/agent/src/database/repositories/github-app-user-link.repository.ts
- Tests: 22 controller-level unit tests + 3 service-level unit suites
in
apps/api/src/integrations/github-app/*.spec.ts— see PR #502. - External:
- Related specs:
auth-jwt-oauth— the platform's session-issuance contract that this integration plugs into.agent-zero-friction-onboarding— the higher-level onboarding flow that consumes the installation + repo lists.work-import— the consumer ofWorkImportService.onboardLinkedRepository.integrations-twenty-crm— sibling integration with the sameapps/api/integrations/module pattern.