Skip to main content

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's GET /app/installations/:id with an app JWT, upserts the installation row via GitHubAppInstallationRepository.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 the code, then the platform resolves or creates a local User (via the GitHub user link repository → auth account → email lookup chain), upserts the corresponding auth_account row with providerId: '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 via GitHubAppInstallationRepository.claimOwnershipIfUnassigned. The controller then issues a session via AuthProvider.issueSession and 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 where createdByUserId === auth.userId, ordered by createdAt DESC, soft-deleted rows excluded), each enriched with the latest installation_repositories rows from GitHubAppInstallationRepoRepository.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=N until the page count drops below 100 (or total_count is met), and replaces the platform-side repo list atomically via replaceForInstallation.
  • 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 with SourceRepoAnalyzerService.analyzeRepository (using the fresh installation access token), and — only if the analyzer reports detectedType === 'data_repo' — calls WorkImportService.onboardLinkedRepository with the mode: '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 installation webhook event with a valid x-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 → mark suspendedAt; unsuspend → clear suspendedAt.
  • Given GitHub posts an installation_repositories webhook event with a valid signature, when the controller runs, then the platform calls syncInstallation(<installationId>, undefined) — no userId filter, 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_ID or GITHUB_APP_PRIVATE_KEY missing), when any code path calls GitHubAppService.getCredentials, then it throws Error('GitHub App credentials are not configured') and the controller surfaces a 500 — there is NO env-gate guard equivalent to CrmSyncGuard.
  • Given the OAuth state is missing the . separator OR the signature length differs from the expected length OR the HMAC does not match (constant-time timingSafeEqual), when GitHubAppOnboardingService.verifyState runs, then it throws BadRequestException('Invalid GitHub App state')/ 'Invalid GitHub App state signature' BEFORE any external call.
  • Given the OAuth state payload is older than 10 minutes, when verifyState reads payload.issuedAt, then it throws BadRequestException('GitHub App setup state expired').
  • Given the OAuth state payload has installationId empty or issuedAt falsy, when verifyState parses the body, then it throws BadRequestException('Invalid GitHub App state payload').
  • Given GitHub responds to the access-token exchange with { error, error_description }, when GitHubAppService.exchangeUserCode reads data.error, then it throws UnauthorizedException(error_description ?? \GitHub App authorization failed: ${error}`)`.
  • Given GitHub responds to the access-token exchange with no access_token and no error, when exchangeUserCode checks the body, then it throws BadRequestException('GitHub App authorization did not return an access token').
  • Given the resolved GitHub user's primary email is null, when the platform falls back to resolveGitHubAccountEmail(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 synthesises github-app-${githubUserId}@users.noreply.ever.works and creates the user with emailVerified: false.
  • Given an existing local user is found by email but the GitHub-resolved email is NOT verified, when findOrCreateLocalUser checks the link/auth-account chain, then the service throws UnauthorizedException('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.works but the GitHub user provides a real email, when the onboarding update path runs, then nextEmail adopts the real email; otherwise the existing email is preserved.
  • Given the requested local username is already taken, when resolveUniqueUsername runs, then it appends -2, -3, … until userRepository.findByUsername returns null. An empty/ whitespace base falls back to the literal 'github-user'.
  • Given the installation has been deleted (deletedAt != null) or suspended (suspendedAt != null), when syncInstallation/onboardInstallationRepository is called, then they short-circuit and return null.
  • Given a userId is passed to syncInstallation but the installation's createdByUserId differs, when the service checks ownership, then it returns null. The controller promotes this null into an UnauthorizedException.
  • Given the analyzer flags a repo whose detectedType is NOT 'data_repo', when onboardInstallationRepository runs, then it returns {status:'error', message: 'Only existing data repositories can be onboarded from GitHub App installations right now'} — the controller promotes this into BadRequestException.
  • Given the analyzer returns a generic error field, when onboardInstallationRepository runs, then it returns {status:'error', message: <analysis.error>} and the controller surfaces it as BadRequestException.
  • Given the webhook controller receives a payload with no x-hub-signature-256 header AND no GITHUB_APP_WEBHOOK_SECRET configured, when GitHubAppService.verifyWebhookSignature(rawBody, undefined) runs, then it returns false (because secret is empty) and the controller throws UnauthorizedException('Invalid GitHub webhook signature').
  • Given the webhook payload's req.rawBody is undefined, when the controller runs, then it throws BadRequestException('Missing raw webhook payload') BEFORE verifyWebhookSignature is called.
  • Given the webhook payload's x-github-event header is missing, when the controller runs, then it throws BadRequestException('Missing GitHub event header') BEFORE signature verification.
  • Given the webhook event name is anything other than installation or installation_repositories, when handleWebhook runs, then the method silently returns undefined — the platform does not error on unsupported events so GitHub's retry logic does not amplify them.
  • Given the installation webhook payload has no installation.id, when handleWebhook runs, then it silently returns undefined.
  • Given the installation_repositories webhook payload has no installation.id, when handleWebhook runs, then it silently returns undefined.

3. Functional Requirements​

  • FR-1 The integration MUST be a non-global Nest module registered as GitHubAppModule in github-app.module.ts. It MUST import DatabaseModule, HttpModule, AuthModule, WorkModule, and ImportModule, and MUST export GitHubAppService, GitHubAppOnboardingService, and GitHubAppSyncService so other API features (e.g. agent onboarding) can re-use them.

  • FR-2 GitHubAppService.getConfiguration MUST return the live five-field tuple {appId, clientId, slug, setupUrl, callbackUrl} from config.githubApp.

  • FR-3 Required env vars:

    • GITHUB_APP_ID (numeric app id, no default — credentials method throws if missing).
    • GITHUB_APP_CLIENT_ID and GITHUB_APP_CLIENT_SECRET (used ONLY by the user-OAuth exchangeUserCode flow).
    • GITHUB_APP_PRIVATE_KEY (PEM with literal \n sequences; config.githubApp.privateKey() MUST replace \\n with \n before passing to jsonwebtoken).
    • GITHUB_APP_WEBHOOK_SECRET (HMAC-SHA-256 secret; verifyWebhookSignature MUST return false if missing — the webhook controller treats that as 401).
  • 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 build https://github.com/login/oauth/authorize?client_id=<>&redirect_uri=<>&state=<> with client_id defaulting to '' if env var unset (so the URL encodes a blank query parameter rather than the literal 'undefined').

  • FR-6 GitHubAppService.exchangeUserCode(code) MUST POST https://github.com/login/oauth/access_token with application/x-www-form-urlencoded body (client_id, client_secret, code, redirect_uri) and Accept: application/json header. Errors MUST be coerced as per the rules in §2.2.

  • FR-7 GitHubAppService.getAuthenticatedGithubUser(token) MUST GET https://api.github.com/user with createGitHubOAuthHeaders(token), then resolve the email via resolveGitHubAccountEmail(httpService, accessToken, data.email || null) (which falls back to /user/emails when 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 call GET https://api.github.com/app/installations/:id with the app JWT (created via createGitHubAppJwt({appId, privateKey})) and createGitHubAppHeaders(jwt).

  • FR-9 GitHubAppService.createInstallationAccessToken(installationId) MUST proxy to the agent-package helper requestGitHubAppInstallationAccessToken(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=N until either (a) a page has fewer than 100 entries OR (b) repositories.length >= total_count (when total_count is a number). The page cursor MUST start at 1 and increment by 1.

  • FR-11 GitHubAppService.verifyWebhookSignature(rawBody, signature?) MUST short-circuit to false when the configured webhook secret is empty. Otherwise it MUST delegate to verifyGitHubWebhookSignature(rawBody, secret, signatureHeader).

  • FR-12 GitHubAppOnboardingService.beginSetup(input) MUST call getInstallation(input.installationId) first, then upsertFromGithub with the payload — even though no user is bound yet — so the row exists when the user returns from the OAuth handshake. The state MUST be HMAC'd with config.auth.secret() via createHmac('sha256', secret).update(encodedPayload).digest('base64url').

  • FR-13 redirectTo MUST be normalised: only strings starting with / survive — anything else (relative paths without leading slash, full URLs, null/undefined, non-string values) becomes undefined.

  • 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_accounts row with providerId: 'github', accountId: <githubUserId>, tokenType: 'Bearer', full OAuth-token fields, and metadata: {nodeId, providerUserId, login},
    • upsert the github_app_user_links row with the OAuth-app token (kept distinct from the app installation token because GitHub issues separate JWTs for each),
    • re-call getInstallation so the row payload is fresh on the second leg of the handshake,
    • call upsertFromGithub again,
    • call claimOwnershipIfUnassigned(installationId, user.id, githubUserId) which atomically writes createdByUserId/createdByGithubUserId only when the row's createdByUserId IS NULL (the WHERE clause is the race-safety guarantee — concurrent claim attempts deterministically pick a single owner).
  • FR-15 When claimOwnershipIfUnassigned returns null (no matching row), the onboarding service MUST throw BadRequestException('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). registrationProvider MUST be 'github'.

  • FR-17 When updating an existing user, the email MUST follow the noreply rule (§2.2): replace @users.noreply.ever.works emails with the real one, otherwise preserve. lastLoginAt MUST be set to new Date() and registrationProvider re-asserted as 'github' even if previously different.

  • FR-18 GitHubAppController.setup MUST be @Public() (no session required — GitHub redirects unauthenticated users here). GitHubAppController.callback MUST be @Public() for the same reason. Both MUST validate query params via class-validator DTOs (GitHubAppSetupQueryDto.installation_id is required, setup_action is 'install' | 'request' if present; GitHubAppCallbackQueryDto.code and state are both required).

  • FR-19 GitHubAppController.callback MUST issue a session via authProvider.issueSession(user.id) AFTER completeUserAuth succeeds, and the response envelope MUST be {...auth, installationId, redirectTo} so the client knows where to bounce next.

  • FR-20 GitHubAppController.listInstallations and downstream endpoints MUST rely on the global AuthSessionGuard (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.syncInstallation MUST 401 with UnauthorizedException('GitHub App installation not found for this user') when the service returns null (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.onboardRepository MUST 404 with NotFoundException('GitHub App repository not found for this user') when onboardInstallationRepository returns null. When the service returns {status:'error', message}, the controller MUST promote it to BadRequestException(message).

  • FR-23 GitHubAppSyncService.listInstallationsForUser(userId) MUST hydrate every returned installation with its repo list by fanning out via Promise.all to GitHubAppInstallationRepoRepository.listForInstallation(installation.id).

  • FR-24 GitHubAppSyncService.syncInstallation(installationId, userId?) MUST short-circuit to null for missing/deleted/suspended/ ownership-mismatch installations BEFORE calling listInstallationRepositories. The replace shape MUST set selected: true for every repo (the platform UI will toggle this later). The repo's owner MUST default to installation.accountLogin when GitHub's payload omits it; fullName MUST default to '<owner>/<repo>' when GitHub's payload omits it.

  • FR-25 GitHubAppSyncService.onboardInstallationRepository MUST mint a fresh installation access token per call (no token caching at this layer — WorkImportService is responsible for refresh) and pass it to analyzeRepository(sourceUrl, token) so the analyzer can read private repos. The auth payload forwarded to WorkImportService.onboardLinkedRepository MUST 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.handleWebhook MUST handle the installation event by:

    • returning early if payload.installation?.id is falsy,
    • on action === 'deleted', calling installationRepository.markDeleted(<id>, new Date()) and returning,
    • otherwise calling upsertFromGithub with the installation shape — including createdByGithubUserId from payload.sender?.id ONLY when action === 'created',
    • then for action in {created, new_permissions_accepted, unsuspend} firing a syncInstallation (no userId filter).
  • FR-27 The suspendedAt field on upsertFromGithub MUST follow the action-specific rules: 'suspend' → new Date(); 'unsuspend' → null; otherwise → payload.suspended_at if present, else undefined (which removeUndefinedValues will drop).

  • FR-28 GitHubAppSyncService.handleWebhook MUST handle the installation_repositories event by extracting payload.installation?.id, returning early if falsy, and calling syncInstallation(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 require req.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 GitHubAppController and GitHubAppWebhookController MUST share the prefix /api/github-app but be DISTINCT controller classes — the OAuth-flow + management endpoints live in GitHubAppController, the webhook receiver lives in GitHubAppWebhookController. 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: listInstallationRepositories paginates 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 — handleWebhook is 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. The claimOwnershipIfUnassigned query is the race-safety guarantee for concurrent two-leg OAuth handshakes — the SQL WHERE createdByUserId IS NULL ensures 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 in apps/api; the agent helper handles its own short-lived JWT). The OAuth state is HMAC-signed with config.auth.secret() and TTL-bound to 10 minutes — replays beyond that window are rejected. Webhook signature verification MUST always use a constant-time comparison (verifyGitHubWebhookSignature delegates to crypto.timingSafeEqual). The state-payload signature comparison in verifyState ALSO uses timingSafeEqual after a fixed-length guard. The user-OAuth client_secret MUST 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.works address and emailVerified: false. This prevents accidental cross-account linking via unverified emails. The user-OAuth-app token is stored in BOTH the auth_accounts row (for user-bound API calls) and the github_app_user_links row (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: GitHubAppService does NOT log per-call (HTTPS calls flow through nestjs/axios and Nest's request middleware). Failures bubble as Nest exceptions, which the global exception filter logs. Webhook signature failures surface as UnauthorizedException with '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 every firstValueFrom(httpService.*) call.

5. Key Entities & Domain Concepts​

Entity / conceptDescription
GitHubAppInstallationPlatform 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.
GitHubAppInstallationRepoPer-installation repo. installationEntityId FK → GitHubAppInstallation.id, unique (installationEntityId, githubRepoId), fields: owner, repo, fullName, isPrivate, defaultBranch, selected.
GitHubAppUserLinkMaps 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.
GitHubAppSetupQueryDtoValidated query for GET /api/github-app/setup. installation_id required; setup_action ∈ {install, request}; redirectTo optional (normalised by the service).
GitHubAppCallbackQueryDtoValidated query for GET /api/github-app/callback. code and state both required; class-validator decorators ensure they are strings.
GitHubAppServiceHTTP client + JWT minting. Stateless.
GitHubAppOnboardingServiceOAuth state signing, findOrCreateLocalUser, claimOwnershipIfUnassigned. The user-resolution chain.
GitHubAppSyncServiceInstallation lifecycle, repo-list reconciliation, webhook handler, repo onboarding handoff to WorkImportService.
WorkImportService.onboardLinkedRepositoryDown-stream consumer. The auth payload {mode:'github_app_installation', ...} lets the new Work mint installation tokens at runtime.
SourceRepoAnalyzerServiceStatic 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/:id page is the only path. When the user uninstalls there, GitHub fires installation.deleted and 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 WorkImportService enhancements.
  • Onboarding non-data-repo GitHub repos — the analyzer's detectedType !== 'data_repo' branch surfaces as a 400 BadRequest. Website / agent / pipeline repos are not yet onboardable from the GitHub App surface (they require manual Work creation today).
  • Multi-org switching UI — listInstallations returns every installation a user owns, ordered by createdAt DESC; the web client picks one.
  • Per-repo selection at sync time — the selected: true default on replaceForInstallation is intentional. Selection toggling is a future enhancement.
  • Webhook event coverage beyond installation and installation_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) lets GET /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/sync against an installation owned by a different user returns 401 (not 404).
  • POST /api/github-app/installations/:id/repositories/:repoId/onboard against a non-data-repo returns 400 with the service's verbatim message.
  • POST /api/github-app/webhooks without x-hub-signature-256 returns 401 (when secret is configured) or 401 (when secret is not configured) — both paths short-circuit via verifyWebhookSignature returning false.
  • POST /api/github-app/webhooks with an installation.deleted payload soft-deletes the platform row via markDeleted(<id>, new Date()).
  • POST /api/github-app/webhooks with an installation_repositories payload triggers a fresh syncInstallation with 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.getCredentials throws an unwrapped Error('GitHub App credentials are not configured'), not a NestException. With the global exception filter this surfaces as a 500. Should it be promoted to ServiceUnavailableException so the response code reflects the configuration gap rather than an internal server error?
  • [NEEDS CLARIFICATION: OQ-2] There is no GitHubAppSyncGuard equivalent to CrmSyncGuard. When GITHUB_APP_* is not configured, setup and callback endpoints will throw 500 on the first GitHub call. Should the integration self-disable with a ServiceUnavailableException at the controller boundary?
  • [NEEDS CLARIFICATION: OQ-3] findOrCreateLocalUser throws UnauthorizedException for 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 synthetic github-app-${githubUserId}@users.noreply.ever.works email is hard-coded. If multiple Ever Works deployments run in parallel with different webAppUrl()s, those emails could collide. Should the suffix be derived from webAppUrl() (e.g. @users.noreply.<host>) instead?
  • [NEEDS CLARIFICATION: OQ-5] claimOwnershipIfUnassigned returns the existing row when createdByUserId is already set — even if it differs from the current user.id. This means a second user installing the same GitHub App will see completeUserAuth succeed 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 a Conflict response?
  • [NEEDS CLARIFICATION: OQ-6] handleWebhook silently returns for unsupported event names. We could log the event name at debug to make payload-shape changes (e.g. GitHub renaming installation to app_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 in github_app_installations.
  • [NEEDS CLARIFICATION: OQ-8] onboardInstallationRepository refuses non-data_repo types with a hard-coded English message. Should the message be moved into a constant / i18n key for future translation?
  • [NEEDS CLARIFICATION: OQ-9] selected: true on every replaceForInstallation is 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. preserve selected: false for rows where (installationId, githubRepoId) already had selected: false).

9. Constitution Gates​

  • Plugin-first if introducing an external integration (Principle I): partial — the integration lives in apps/api/integrations/github-app/ not packages/plugins/github-app. This matches the same decision as integrations-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 a Platform capability 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/tasks instead.
  • 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-secret rules: ✅ — 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​