From 253be4a105cb35693a190b5dcef5b9fda6e87d15 Mon Sep 17 00:00:00 2001 From: Contentrain Date: Wed, 13 May 2026 23:21:21 +0300 Subject: [PATCH 1/2] feat(github-app): connect-existing flow, revoke on delete, ownership verification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the "stuck after workspace delete" UX bug along with several audit findings surfaced while mapping the install/setup/webhook surface: the setup callback was vulnerable to installation_id parameter spoofing, workspace delete silently leaked active Polar/Stripe subscriptions, and `installation.suspend`/`unsuspend` webhook events were dropped. Patterns adopted from PostHog (gold-standard reference) and the legacy Contentrain CMS connect-existing UX, both inspected end-to-end before designing the fix. Highlights: - New `oauth_provider_tokens` table — encrypted GitHub user OAuth tokens (`gho_*`/`ghu_*`) captured at sign-in via the new `ProviderTokens` shape on `AuthSession`. Required because Supabase Auth surfaces these only on the immediate callback and never persists them server-side. AES-256-GCM via the existing `server/utils/encryption.ts` helper (rotation-aware via NUXT_SESSION_SECRET_PREVIOUS). - New `AuthProvider.refreshProviderToken('github', refreshToken)` — calls `POST https://github.com/login/oauth/access_token` directly (Supabase only refreshes its own JWT). Used by the lazy refresh helper `server/utils/github-token.ts:getValidGitHubUserToken` which transparently rotates the 8h-TTL user-to-server token (PostHog pattern) and emits `GITHUB_REAUTH_REQUIRED` to drive the Reconnect-GitHub UI when the refresh token has expired or been revoked. - New `GitAppService` provider (`useGitAppService()`) — App-level GitHub operations not scoped to a single installation: `listInstallationsForUser` (user OAuth token, filtered to the Studio App ID) and `verifyUserHasAccessToInstallation` (PostHog- derived ownership check via `GET /user/installations/{id}/repositories`). - `GitAppProvider.revokeInstallation()` (instance method) — App-JWT `DELETE /app/installations/{id}` call, idempotent on 404. - New endpoints `GET /api/github/installations/available` (annotated with bound status) and `POST /api/github/installations/connect` (ownership-verified attach for the "already installed → Configure" recovery flow that GitHub's `installations/new` URL cannot trigger a callback for). - `setup.get.ts` now verifies the caller actually has GitHub-side access to the installation_id they supplied — closes the application-trust invariant gap audited in `findWorkspaceByGithubInstallation` (the previous 409 collision check was the only protection against arbitrary cross-tenant attach attempts). - `DELETE /api/workspaces/[id]` accepts two opt-in body flags: `uninstallGithubApp` (revoke when no other workspace shares the installation) and `cancelSubscription` (call `payment.cancelSubscription` before CASCADE drops the payment_accounts row). Both best-effort — failure logged, not blocking. Matches the audit finding that none of the surveyed OSS projects (PostHog, Coolify, OpenSauced) revoke on tenant delete, treating it as opt-in operator action. - Webhook handler now responds to `installation.suspend` (status → 'suspended'), `installation.unsuspend` (→ 'active'), and `installation.created` (defensive status confirmation), in addition to the existing `installation.deleted` handling. The new `workspaces.github_installation_status` column lets the UI distinguish 'active' / 'suspended' / 'unbound' instead of inferring from `github_installation_id` alone. - `ConnectRepoDialog` install state offers "Or connect an existing installation" — a picker that lists the user's GitHub-visible installations (with already-bound items disabled) and reaches the `/connect` endpoint. Surfaces a Reconnect-GitHub empty state on `GITHUB_REAUTH_REQUIRED`. - Workspace delete confirmation dialog gains two opt-in switches via a new `extra` slot on `ConfirmDeleteDialog`. The slot is generic — existing callers (project delete, AI keys, etc.) are unchanged. Files touched: 20 modified, 5 new (including `004_github_installation_lifecycle.sql`). Test coverage: 11 new integration cases on top of 567 pre-existing (578 total, all passing). Lint clean (0 errors). --- .../content/system/ui-strings/en.json | 17 ++ .../molecules/ConfirmDeleteDialog.vue | 5 + .../organisms/ConnectRepoDialog.vue | 210 +++++++++++++++++- .../organisms/WorkspaceOverviewPanel.vue | 46 +++- app/composables/useWorkspaces.ts | 10 +- server/api/auth/verify.post.ts | 25 +++ .../api/github/installations/available.get.ts | 79 +++++++ .../api/github/installations/connect.post.ts | 74 ++++++ server/api/github/setup.get.ts | 29 ++- server/api/webhooks/github.post.ts | 30 ++- .../workspaces/[workspaceId]/index.delete.ts | 75 ++++++- server/providers/auth.ts | 36 +++ server/providers/database.ts | 43 ++++ server/providers/git.ts | 56 +++++ server/providers/github-app.ts | 115 ++++++++++ server/providers/supabase-auth.ts | 98 +++++++- server/providers/supabase-db/index.ts | 2 + server/providers/supabase-db/oauth-tokens.ts | 97 ++++++++ server/providers/supabase-db/workspaces.ts | 19 +- server/utils/github-token.ts | 72 ++++++ server/utils/providers.ts | 30 ++- .../004_github_installation_lifecycle.sql | 82 +++++++ .../delete-routes.integration.test.ts | 113 ++++++++++ .../github-installations.integration.test.ts | 170 ++++++++++++++ .../github-routes.integration.test.ts | 28 +++ .../github-webhook.integration.test.ts | 67 ++++++ 26 files changed, 1611 insertions(+), 17 deletions(-) create mode 100644 server/api/github/installations/available.get.ts create mode 100644 server/api/github/installations/connect.post.ts create mode 100644 server/providers/supabase-db/oauth-tokens.ts create mode 100644 server/utils/github-token.ts create mode 100644 supabase/migrations/004_github_installation_lifecycle.sql create mode 100644 tests/integration/github-installations.integration.test.ts diff --git a/.contentrain/content/system/ui-strings/en.json b/.contentrain/content/system/ui-strings/en.json index 8678012d..a5ecc438 100644 --- a/.contentrain/content/system/ui-strings/en.json +++ b/.contentrain/content/system/ui-strings/en.json @@ -220,6 +220,7 @@ "common.no": "No", "common.no_results": "No results found", "common.not_connected": "Not connected", + "common.or": "or", "common.remove": "Remove", "common.save": "Save", "common.save_changes": "Save changes", @@ -296,6 +297,8 @@ "conversation_keys.revoked": "Key revoked", "conversation_keys.role": "Role", "conversation_keys.title": "Conversation API", + "danger_zone.cancel_subscription_hint": "End your paid subscription on the billing provider side. Without this, the subscription keeps running and you keep being charged.", + "danger_zone.cancel_subscription_label": "Also cancel active subscription", "danger_zone.deleting": "Deleting...", "danger_zone.project_confirm_label": "Type the repository name to confirm", "danger_zone.project_delete_button": "Delete Project", @@ -304,6 +307,8 @@ "danger_zone.project_delete_title": "Delete this project", "danger_zone.project_deleted": "Project deleted successfully", "danger_zone.title": "Danger Zone", + "danger_zone.uninstall_app_hint": "Remove the GitHub App from your account or organization so the next install starts fresh.", + "danger_zone.uninstall_app_label": "Also uninstall GitHub App", "danger_zone.workspace_confirm_label": "Type the workspace name to confirm", "danger_zone.workspace_delete_button": "Delete Workspace", "danger_zone.workspace_delete_description": "This will permanently delete this workspace and all its projects, conversations, CDN builds, media assets, and member access. This action cannot be undone.", @@ -375,6 +380,11 @@ "forms.tab_submissions": "Submissions", "forms.user_agent": "User Agent", "github.branch_label": "Branch: {branch}", + "github.connect_existing_button": "Connect an existing installation", + "github.connect_existing_error": "Failed to connect installation", + "github.connect_existing_error_hint": "We couldn't link this installation. Please refresh and try again.", + "github.connect_existing_hint": "Already installed the app on your account or organization? Pick from existing installations.", + "github.connect_existing_picker_title": "Connect an existing installation", "github.contentrain_found": ".contentrain/ found — ready to browse", "github.contentrain_missing": ".contentrain/ not found — you can initialize via chat after connecting", "github.framework_detected": "{stack} detected", @@ -382,10 +392,17 @@ "github.install_description": "Connect your GitHub account so Studio can read your repositories and manage content through Git. This is required before creating or connecting projects.", "github.install_hint": "After installing, close this dialog and reopen it.", "github.install_title": "Install GitHub App", + "github.installation_access_denied": "You don't have access to this installation on GitHub.", + "github.installation_already_bound": "Already connected to {workspace}", + "github.no_existing_installations": "No installations found", + "github.no_existing_installations_hint": "You don't have any GitHub App installations yet. Install the app to continue.", "github.no_repos": "No repositories found.", "github.no_repos_hint": "Try adjusting your search or check your GitHub App installation permissions.", + "github.reconnect_button": "Reconnect GitHub", "github.scan_failed": "Failed to scan repository. Please try again.", "github.unknown_framework": "Unknown framework", + "github.user_token_missing": "Your GitHub authorization has expired or was never granted. Reconnect to list your installations.", + "github.user_token_missing_title": "Reconnect GitHub", "health.ask_agent": "Ask Agent to Fix", "health.critical": "Critical", "health.dismiss": "Dismiss", diff --git a/app/components/molecules/ConfirmDeleteDialog.vue b/app/components/molecules/ConfirmDeleteDialog.vue index 77086a17..391595f4 100644 --- a/app/components/molecules/ConfirmDeleteDialog.vue +++ b/app/components/molecules/ConfirmDeleteDialog.vue @@ -88,6 +88,11 @@ watch(open, (isOpen) => { autocomplete="off" /> + + +
+ +
diff --git a/app/components/organisms/ConnectRepoDialog.vue b/app/components/organisms/ConnectRepoDialog.vue index d84f0533..80834824 100644 --- a/app/components/organisms/ConnectRepoDialog.vue +++ b/app/components/organisms/ConnectRepoDialog.vue @@ -8,9 +8,25 @@ const toast = useToast() const open = defineModel('open', { default: false }) // State machine -type DialogState = 'install' | 'select' | 'confirm' +type DialogState = 'install' | 'connect-existing' | 'select' | 'confirm' + +interface AvailableInstallation { + id: number + account: { + login: string | null + avatarUrl: string | null + type: string | null + } + repositorySelection: 'all' | 'selected' | null + targetType: 'User' | 'Organization' | string | null + boundWorkspace: { id: string, name: string, slug: string } | null +} const state = ref('install') +const availableInstallations = ref([]) +const availableLoading = ref(false) +const availableError = ref<'unauthorized' | 'generic' | null>(null) +const connectingInstallation = ref(null) const repos = ref { selectedRepo.value = null scanResult.value = null searchQuery.value = '' + availableInstallations.value = [] + availableError.value = null return } @@ -145,6 +163,69 @@ function installGitHubApp() { ) } +async function openConnectExisting() { + if (!activeWorkspace.value) return + state.value = 'connect-existing' + availableLoading.value = true + availableError.value = null + availableInstallations.value = [] + + try { + const result = await $fetch<{ installations: AvailableInstallation[] }>( + '/api/github/installations/available', + { params: { workspaceId: activeWorkspace.value.id } }, + ) + availableInstallations.value = result.installations + } + catch (e: unknown) { + const status = (e as { statusCode?: number, data?: { data?: { code?: string } } }).statusCode + const code = (e as { data?: { data?: { code?: string } } }).data?.data?.code + if (status === 401 || code === 'GITHUB_REAUTH_REQUIRED') { + availableError.value = 'unauthorized' + } + else { + availableError.value = 'generic' + } + } + finally { + availableLoading.value = false + } +} + +async function connectExistingInstallation(installation: AvailableInstallation) { + if (!activeWorkspace.value || installation.boundWorkspace) return + connectingInstallation.value = installation.id + + try { + await $fetch('/api/github/installations/connect', { + method: 'POST', + body: { + workspaceId: activeWorkspace.value.id, + installationId: installation.id, + }, + }) + + // Refresh workspace so github_installation_id is current, then load repos. + const { fetchWorkspaces } = useWorkspaces() + await fetchWorkspaces() + state.value = 'select' + await loadRepos() + } + catch (e: unknown) { + toast.error(resolveApiError(e, t('github.connect_existing_error'))) + } + finally { + connectingInstallation.value = null + } +} + +function reconnectGitHub() { + // Trigger Supabase GitHub OAuth flow; on return the OAuth callback + // captures and persists provider_token. After success, the user + // re-opens this dialog and the available list resolves. + window.location.href = `/api/auth/login?provider=github&redirect_to=${encodeURIComponent(window.location.pathname + window.location.search)}` +} + // Auto-detect GitHub App installation when user returns to tab const checkingInstall = ref(false) @@ -228,6 +309,25 @@ onUnmounted(() => window.removeEventListener('focus', onWindowFocus))

{{ t('github.install_hint') }}

+ + +
+
+ {{ t('common.or') }} +
+
+ +

+ {{ t('github.connect_existing_hint') }} +

+
@@ -235,6 +335,114 @@ onUnmounted(() => window.removeEventListener('focus', onWindowFocus))
+ +
+ +
+ +

+ {{ t('github.connect_existing_picker_title') }} +

+
+ + +
+ +
+ + + +
+ + +
+ +
+ + +
+ +
+ + +
+ + + +
+ + +
    +
  • + +
  • +
+
+
+
diff --git a/app/components/organisms/WorkspaceOverviewPanel.vue b/app/components/organisms/WorkspaceOverviewPanel.vue index d920bad5..13683b09 100644 --- a/app/components/organisms/WorkspaceOverviewPanel.vue +++ b/app/components/organisms/WorkspaceOverviewPanel.vue @@ -114,15 +114,23 @@ async function saveOverview() { } } +const wsDeleteUninstallApp = ref(false) +const wsDeleteCancelSubscription = ref(false) + async function handleDeleteWorkspace() { if (!activeWorkspace.value) return wsDeleting.value = true - const ok = await deleteWorkspace(activeWorkspace.value.id) + const ok = await deleteWorkspace(activeWorkspace.value.id, { + uninstallGithubApp: wsDeleteUninstallApp.value, + cancelSubscription: wsDeleteCancelSubscription.value, + }) wsDeleting.value = false if (ok) { toast.success(t('danger_zone.workspace_deleted')) wsDeleteConfirmOpen.value = false + wsDeleteUninstallApp.value = false + wsDeleteCancelSubscription.value = false const primary = workspaces.value.find(w => w.type === 'primary') if (primary) router.push(`/w/${primary.slug}`) else router.push('/') @@ -239,7 +247,41 @@ async function handleDeleteWorkspace() { :delete-label="wsDeleting ? t('danger_zone.deleting') : t('danger_zone.workspace_delete_button')" :deleting="wsDeleting" @confirm="handleDeleteWorkspace" - /> + > + + { + async function deleteWorkspace( + workspaceId: string, + options?: { uninstallGithubApp?: boolean, cancelSubscription?: boolean }, + ): Promise { try { - await $fetch(`/api/workspaces/${workspaceId}`, { method: 'DELETE' }) + await $fetch(`/api/workspaces/${workspaceId}`, { + method: 'DELETE', + body: options ?? undefined, + }) workspaces.value = workspaces.value.filter(w => w.id !== workspaceId) // Reset active workspace to primary or first available if (activeWorkspaceId.value === workspaceId) { diff --git a/server/api/auth/verify.post.ts b/server/api/auth/verify.post.ts index 5e7f207d..69ddf42a 100644 --- a/server/api/auth/verify.post.ts +++ b/server/api/auth/verify.post.ts @@ -71,5 +71,30 @@ export default defineEventHandler(async (event) => { expiresAt: session.tokens.expiresAt, }) + // Persist provider-side OAuth tokens (GitHub `gho_*`/`ghu_*`) when the + // sign-in used GitHub OAuth. Supabase surfaces these only on the + // immediate exchange response and does NOT persist them server-side, + // so this is the only window we can capture them. Without this, later + // server-side calls to `GET /user/installations` are impossible + // without re-OAuthing the user. Failures are logged but never block + // the sign-in itself. + if (session.providerTokens?.accessToken && session.user.provider === 'github') { + try { + const db = useDatabaseProvider() + await db.upsertOAuthProviderToken({ + userId: session.user.id, + provider: 'github', + accessToken: session.providerTokens.accessToken, + refreshToken: session.providerTokens.refreshToken, + expiresAt: session.providerTokens.expiresAt, + refreshTokenExpiresAt: session.providerTokens.refreshTokenExpiresAt, + }) + } + catch (err: unknown) { + // eslint-disable-next-line no-console + console.warn('[auth] failed to persist GitHub provider token:', err instanceof Error ? err.message : err) + } + } + return { user: session.user } }) diff --git a/server/api/github/installations/available.get.ts b/server/api/github/installations/available.get.ts new file mode 100644 index 00000000..11651c5c --- /dev/null +++ b/server/api/github/installations/available.get.ts @@ -0,0 +1,79 @@ +import { GITHUB_REAUTH_REQUIRED_CODE, getValidGitHubUserToken } from '../../../utils/github-token' + +/** + * GET /api/github/installations/available?workspaceId= + * + * Enumerate GitHub App installations visible to the authenticated user, + * annotated with whether each one is already bound to a workspace + * (so the UI can disable bound items in the picker). + * + * Auth: workspace owner/admin role required (the listing is gated on + * the calling user being able to act on the target workspace — + * preventing arbitrary cross-tenant enumeration even though the + * underlying GitHub API call only sees the user's own installations). + * + * Returns 401 with code='GITHUB_REAUTH_REQUIRED' when no usable + * provider token exists (user signed in via Google / magic link, or + * the refresh token expired). Frontend handles this by prompting the + * user to reconnect their GitHub account. + */ +export default defineEventHandler(async (event) => { + const session = requireAuth(event) + const db = useDatabaseProvider() + const query = getQuery(event) as { workspaceId?: string } + + if (!query.workspaceId) + throw createError({ statusCode: 400, message: errorMessage('validation.workspace_id_required') }) + + // Authorize: caller must be owner or admin of the target workspace. + const workspace = await db.getWorkspaceForUser( + session.accessToken, + session.user.id, + query.workspaceId, + ['owner', 'admin'], + ) + if (!workspace) + throw createError({ statusCode: 404, message: errorMessage('github.workspace_not_found') }) + + const userToken = await getValidGitHubUserToken(session.user.id) + if (!userToken) { + throw createError({ + statusCode: 401, + message: errorMessage('github.user_token_missing'), + data: { code: GITHUB_REAUTH_REQUIRED_CODE }, + }) + } + + const gitAppService = useGitAppService() + const installations = await gitAppService.listInstallationsForUser(userToken) + + if (installations.length === 0) { + return { installations: [] } + } + + // Annotate each installation with `boundWorkspaceId` so the picker + // can disable items already in use. Done in a single batch query + // rather than N round trips. + const ids = installations.map(i => i.id) + const boundMap = new Map() + for (const id of ids) { + const bound = await db.findWorkspaceByGithubInstallation(id) + if (bound) { + boundMap.set(id, { + id: bound.id as string, + name: (bound.name as string) ?? '', + slug: (bound.slug as string) ?? '', + }) + } + } + + return { + installations: installations.map(inst => ({ + id: inst.id, + account: inst.account, + repositorySelection: inst.repositorySelection, + targetType: inst.targetType, + boundWorkspace: boundMap.get(inst.id) ?? null, + })), + } +}) diff --git a/server/api/github/installations/connect.post.ts b/server/api/github/installations/connect.post.ts new file mode 100644 index 00000000..4ed9e913 --- /dev/null +++ b/server/api/github/installations/connect.post.ts @@ -0,0 +1,74 @@ +import { GITHUB_REAUTH_REQUIRED_CODE, getValidGitHubUserToken } from '../../../utils/github-token' + +/** + * POST /api/github/installations/connect + * Body: { workspaceId: string, installationId: number } + * + * Bind an existing GitHub App installation to a workspace — the + * "Connect existing installation" counterpart to the install-callback + * flow at `setup.get.ts`. Used when the App is already installed on + * the user's account/org (e.g. after deleting a previous workspace) + * and GitHub's `installations/new` page short-circuits to "Configure". + * + * Security checks (in order): + * 1. Caller is owner/admin of the target workspace. + * 2. Caller has GitHub-side access to the installation + * (`verifyUserHasAccessToInstallation` — PostHog pattern). This + * defends against a user supplying an arbitrary installation_id + * they don't actually own. + * 3. The installation isn't already bound to a different workspace + * (409 — same invariant as setup.get.ts:48). + * + * On success the workspace's `github_installation_id` is set and the + * status column flips to 'active'. + */ +export default defineEventHandler(async (event) => { + const session = requireAuth(event) + const db = useDatabaseProvider() + const body = await readBody<{ workspaceId?: string, installationId?: number }>(event) + + if (!body.workspaceId) + throw createError({ statusCode: 400, message: errorMessage('validation.workspace_id_required') }) + + const installationId = typeof body.installationId === 'number' + ? body.installationId + : Number(body.installationId) + if (!installationId || Number.isNaN(installationId)) + throw createError({ statusCode: 400, message: errorMessage('github.installation_id_invalid') }) + + // 1. Role check. + const workspace = await db.getWorkspaceForUser( + session.accessToken, + session.user.id, + body.workspaceId, + ['owner', 'admin'], + ) + if (!workspace) + throw createError({ statusCode: 404, message: errorMessage('github.workspace_not_found') }) + + // 2. GitHub-side ownership verification. + const userToken = await getValidGitHubUserToken(session.user.id) + if (!userToken) { + throw createError({ + statusCode: 401, + message: errorMessage('github.user_token_missing'), + data: { code: GITHUB_REAUTH_REQUIRED_CODE }, + }) + } + + const gitAppService = useGitAppService() + const hasAccess = await gitAppService.verifyUserHasAccessToInstallation(userToken, installationId) + if (!hasAccess) + throw createError({ statusCode: 403, message: errorMessage('github.installation_access_denied') }) + + // 3. Collision check — installation must not already back a + // different workspace. The DB schema does not enforce UNIQUE on + // github_installation_id, so this check is the only protection. + const conflict = await db.findWorkspaceByGithubInstallation(installationId, body.workspaceId) + if (conflict) + throw createError({ statusCode: 409, message: errorMessage('github.installation_linked') }) + + await db.updateWorkspaceGithubInstallation(body.workspaceId, installationId) + + return { connected: true, installationId, workspaceId: body.workspaceId } +}) diff --git a/server/api/github/setup.get.ts b/server/api/github/setup.get.ts index 1df17a5c..5211a4ab 100644 --- a/server/api/github/setup.get.ts +++ b/server/api/github/setup.get.ts @@ -1,4 +1,5 @@ -import { useDatabaseProvider } from '../../utils/providers' +import { useDatabaseProvider, useGitAppService } from '../../utils/providers' +import { getValidGitHubUserToken } from '../../utils/github-token' /** * GitHub App installation callback. @@ -6,6 +7,15 @@ import { useDatabaseProvider } from '../../utils/providers' * After user installs the GitHub App on their org/account, * GitHub redirects here with installation_id. * We save it to the workspace and redirect to the dashboard. + * + * Security: in addition to the workspace-role and 409 collision checks, + * we verify the caller actually has GitHub-side access to the + * installation_id they supplied (PostHog-style ownership check). This + * is defense-in-depth against installation_id parameter spoofing. + * Without the GitHub user OAuth token we degrade to the legacy + * "trust the redirect" path — this only matters when the user signed + * in via Google/magic link AND then somehow ended up at this callback, + * which is an unsupported flow but not a hard failure. */ export default defineEventHandler(async (event) => { const session = requireAuth(event) @@ -44,6 +54,23 @@ export default defineEventHandler(async (event) => { if (!workspaceId || !workspaceSlug) throw createError({ statusCode: 500, message: errorMessage('workspace.not_found') }) + // GitHub-side ownership verification (PostHog pattern). If we have a + // GitHub user OAuth token (sign-in went through GitHub), confirm the + // user actually has access to the installation_id they supplied. + // Without this, an attacker who guesses or leaks an installation_id + // from a different tenant could attempt to attach it, blocked only + // by the 409 collision check below — and unbound stolen ids would + // succeed. When the user has no token (Google/magic-link sign-in, + // unsupported but possible), we skip the check rather than hard-fail + // and rely on the redirect-from-GitHub trust + the 409 collision. + const userToken = await getValidGitHubUserToken(session.user.id) + if (userToken) { + const gitAppService = useGitAppService() + const hasAccess = await gitAppService.verifyUserHasAccessToInstallation(userToken, installationId) + if (!hasAccess) + throw createError({ statusCode: 403, message: errorMessage('github.installation_access_denied') }) + } + // Check if another workspace already uses this installation const existingWs = await db.findWorkspaceByGithubInstallation(installationId, workspaceId) diff --git a/server/api/webhooks/github.post.ts b/server/api/webhooks/github.post.ts index 42d9d269..9787e4b4 100644 --- a/server/api/webhooks/github.post.ts +++ b/server/api/webhooks/github.post.ts @@ -117,13 +117,37 @@ export default defineEventHandler(async (event) => { const action = body.action as string const installationId = (body.installation as { id?: number })?.id - if (action === 'deleted' && installationId) { - // Clear installation from workspaces - await db.clearWorkspaceGithubInstallation(installationId) + if (!installationId) + return { ok: true, event: 'installation', action } + if (action === 'deleted') { + // Clear installation from workspaces (also flips status to 'unbound'). + await db.clearWorkspaceGithubInstallation(installationId) return { ok: true, event: 'installation', action: 'deleted' } } + if (action === 'suspend') { + // User suspended the App from GitHub settings — installation token + // calls will start failing with 403 until unsuspended. Mark workspaces + // accordingly so the UI can render a banner ("GitHub App suspended, + // reactivate to continue") instead of showing surprise 5xx errors. + await db.updateWorkspaceInstallationStatus({ installationId }, 'suspended') + return { ok: true, event: 'installation', action: 'suspend' } + } + + if (action === 'unsuspend') { + await db.updateWorkspaceInstallationStatus({ installationId }, 'active') + return { ok: true, event: 'installation', action: 'unsuspend' } + } + + if (action === 'created') { + // The setup callback writes installation_id + status='active' directly. + // The webhook arrives async and may race; defensively re-mark + // status='active' for any workspace already pointing at this id. + await db.updateWorkspaceInstallationStatus({ installationId }, 'active') + return { ok: true, event: 'installation', action: 'created' } + } + return { ok: true, event: 'installation', action } } diff --git a/server/api/workspaces/[workspaceId]/index.delete.ts b/server/api/workspaces/[workspaceId]/index.delete.ts index f8b13923..89fea3a4 100644 --- a/server/api/workspaces/[workspaceId]/index.delete.ts +++ b/server/api/workspaces/[workspaceId]/index.delete.ts @@ -2,8 +2,27 @@ * Delete a workspace and all its projects. * * Only the workspace owner can delete. Primary (personal) workspace - * cannot be deleted. Cleans R2 storage for all projects before - * DB cascade delete. + * cannot be deleted. + * + * Optional cleanup flags in the request body: + * - `uninstallGithubApp` (default false): also revoke the GitHub App + * installation from the user's account/org. Without this, GitHub + * leaves the App installed after the workspace is gone — making + * the next install on a fresh workspace land on GitHub's "Configure" + * page (which doesn't fire a callback) instead of a fresh install + * flow. The connect-existing endpoint handles this case, but + * opting into clean uninstall avoids the recovery flow entirely. + * - `cancelSubscription` (default false): also cancel the active + * Polar/Stripe subscription. Without this, deleting the workspace + * silently leaves the customer paying for nothing — the subscription + * keeps running on the provider side until the customer self-cancels + * via their billing portal. + * + * Both are best-effort: failure is logged and swallowed so the + * underlying workspace delete (DB CASCADE) still proceeds. The + * `installation.deleted` webhook will reconcile any leftover + * github_installation_id rows, and the next billing-status read will + * pick up provider-side cancellations. */ export default defineEventHandler(async (event) => { const session = requireAuth(event) @@ -12,11 +31,17 @@ export default defineEventHandler(async (event) => { if (!workspaceId) throw createError({ statusCode: 400, message: errorMessage('validation.workspace_id_required') }) + // Optional flags from body. DELETE requests in JSON are non-standard + // but supported by ofetch/native fetch when an explicit body is sent. + const body = await readBody<{ uninstallGithubApp?: boolean, cancelSubscription?: boolean } | null>(event).catch(() => null) + const uninstallGithubApp = body?.uninstallGithubApp === true + const cancelSubscription = body?.cancelSubscription === true + const db = useDatabaseProvider() await db.requireWorkspaceRole(session.accessToken, session.user.id, workspaceId, ['owner']) // Fetch workspace and verify ownership - const workspace = await db.getWorkspaceById(workspaceId, 'id, type, owner_id') + const workspace = await db.getWorkspaceById(workspaceId, 'id, type, owner_id, github_installation_id') if (!workspace) throw createError({ statusCode: 404, message: errorMessage('workspace.not_found') }) @@ -27,6 +52,50 @@ export default defineEventHandler(async (event) => { if (workspace.owner_id !== session.user.id) throw createError({ statusCode: 403, message: errorMessage('workspace.owner_only_delete') }) + // Optional: revoke the GitHub App installation before deleting the + // workspace. Done first so a failure here doesn't prevent the user + // from retrying the workspace delete later (the App is still + // installed but the workspace is gone). We only attempt revoke when + // the installation is exclusively bound to this workspace — + // findWorkspaceByGithubInstallation excludes the current workspace, + // so a non-null result means another workspace shares this id and + // revoke would orphan them too. + const installationId = typeof workspace.github_installation_id === 'number' + ? workspace.github_installation_id + : null + if (uninstallGithubApp && installationId) { + const otherBound = await db.findWorkspaceByGithubInstallation(installationId, workspaceId) + if (!otherBound) { + try { + const gitApp = useGitAppProvider(installationId) + await gitApp.revokeInstallation() + } + catch (err: unknown) { + // eslint-disable-next-line no-console + console.warn('[workspace-delete] revokeInstallation failed:', err instanceof Error ? err.message : err) + } + } + } + + // Optional: cancel the active subscription. Done before delete because + // CASCADE will drop the payment_accounts row and we lose the + // subscription_id reference. + if (cancelSubscription) { + try { + const account = await db.getActivePaymentAccount(workspaceId) + const subscriptionId = (account?.subscription_id as string | null) ?? null + if (subscriptionId) { + const payment = usePaymentProvider() + if (payment) + await payment.cancelSubscription(subscriptionId) + } + } + catch (err: unknown) { + // eslint-disable-next-line no-console + console.warn('[workspace-delete] cancelSubscription failed:', err instanceof Error ? err.message : err) + } + } + // Clean R2 storage for all projects const projects = await db.listWorkspaceProjects(session.accessToken, workspaceId) diff --git a/server/providers/auth.ts b/server/providers/auth.ts index b925408e..32bda40f 100644 --- a/server/providers/auth.ts +++ b/server/providers/auth.ts @@ -30,9 +30,33 @@ export interface AuthTokens { expiresAt: number // Unix timestamp in seconds } +/** + * OAuth provider tokens (e.g. GitHub `gho_*`/`ghu_*` user-to-server tokens + * obtained via Supabase OAuth sign-in). Surfaced separately from + * `AuthTokens` (which are the AuthProvider's own session tokens, e.g. + * Supabase JWT) because they have different lifecycles and storage + * requirements: provider tokens need encrypted persistence to enable + * later API calls against the provider (e.g. `GET /user/installations`). + */ +export interface ProviderTokens { + accessToken: string + refreshToken: string | null + /** Unix seconds. `null` = non-expiring (legacy OAuth Apps; GitHub Apps with expiring tokens off). */ + expiresAt: number | null + /** Unix seconds. `null` = non-expiring refresh token. */ + refreshTokenExpiresAt: number | null +} + export interface AuthSession { user: AuthUser tokens: AuthTokens + /** + * Provider-side OAuth tokens, when the sign-in used an external OAuth + * provider (GitHub / Google). `null` for magic-link or non-OAuth flows. + * Captured at exchange time only — AuthProvider does not persist these + * itself; caller is responsible for storing them via DatabaseProvider. + */ + providerTokens?: ProviderTokens | null } export interface OAuthRedirectResult { @@ -54,6 +78,18 @@ export interface AuthProvider { */ refreshSession: (refreshToken: string) => Promise + /** + * Refresh an expired OAuth provider token (e.g. GitHub user-to-server + * token, 8h TTL). Implementations call the provider's own refresh + * endpoint — Supabase does not refresh provider tokens itself. + * + * Returns the new provider token set, or null if refresh failed + * (refresh token expired, revoked, or provider returned an error). + * Callers must treat null as "user must re-authenticate with the + * provider" and clear any stored copy. + */ + refreshProviderToken: (provider: 'github' | 'google', refreshToken: string) => Promise + /** * Generate OAuth redirect URL for a given provider. */ diff --git a/server/providers/database.ts b/server/providers/database.ts index 399607b5..c0b45dff 100644 --- a/server/providers/database.ts +++ b/server/providers/database.ts @@ -60,6 +60,39 @@ export interface DatabaseProvider { theme?: 'light' | 'dark' | 'system' }) => Promise + // ═══════════════════════════════════════════════════ + // OAUTH PROVIDER TOKENS + // ═══════════════════════════════════════════════════ + + /** + * Persist an encrypted OAuth provider token (currently only GitHub). + * Tokens are encrypted with AES-256-GCM (NUXT_SESSION_SECRET-derived + * key) and stored in `oauth_provider_tokens`. Upsert on (user_id, provider). + */ + upsertOAuthProviderToken: (input: { + userId: string + provider: 'github' + accessToken: string + refreshToken: string | null + expiresAt: number | null + refreshTokenExpiresAt: number | null + }) => Promise + + /** + * Retrieve a stored provider token. Returns null when missing OR when + * decryption fails (secret rotation without _PREVIOUS, etc.) — the + * caller treats both as "no usable token" and drives re-auth. + */ + getOAuthProviderToken: (userId: string, provider: 'github') => Promise<{ + accessToken: string + refreshToken: string | null + expiresAt: number | null + refreshTokenExpiresAt: number | null + } | null> + + /** Remove a stored provider token (e.g. after a refresh-token failure). */ + deleteOAuthProviderToken: (userId: string, provider: 'github') => Promise + // ═══════════════════════════════════════════════════ // WORKSPACES // ═══════════════════════════════════════════════════ @@ -94,6 +127,16 @@ export interface DatabaseProvider { findWorkspaceByGithubInstallation: (installationId: number, excludeWorkspaceId?: string) => Promise updateWorkspaceGithubInstallation: (workspaceId: string, installationId: number) => Promise clearWorkspaceGithubInstallation: (installationId: number) => Promise + /** + * Update `workspaces.github_installation_status` ('active'|'suspended'|'unbound'). + * Lookup target is either a single workspace (by id) or every workspace bound + * to the given installation_id (for webhook fan-out). Multi-row update is + * intentional: the schema does not enforce UNIQUE on github_installation_id. + */ + updateWorkspaceInstallationStatus: ( + target: { workspaceId: string } | { installationId: number }, + status: 'active' | 'suspended' | 'unbound', + ) => Promise deleteWorkspace: (workspaceId: string) => Promise incrementWorkspaceStorageBytes: (workspaceId: string, deltaBytes: number) => Promise reserveStorageIfAllowed: (workspaceId: string, reserveBytes: number, limitBytes: number) => Promise<{ allowed: boolean, currentBytes: number }> diff --git a/server/providers/git.ts b/server/providers/git.ts index fd1c7182..9d77a1e2 100644 --- a/server/providers/git.ts +++ b/server/providers/git.ts @@ -108,6 +108,62 @@ export interface GitAppProvider { listInstallationRepositories: () => Promise createRepositoryFromTemplate: (input: TemplateRepositoryInput) => Promise canAccessRepository: (owner: string, repo: string) => Promise + /** + * Revoke (uninstall) the GitHub App from the account/org this + * installation is bound to. Auth context: App JWT (not the + * installation token). Returns true on success, false if GitHub + * returned 404 (installation already gone — idempotent success). + * Other errors propagate so callers can decide whether to swallow. + */ + revokeInstallation: () => Promise +} + +/** + * Summary of a GitHub App installation as seen from a user's + * perspective (via `GET /user/installations`). Note this is a + * different shape from `InstallationDetails` (which is the App-JWT + * view of a single installation) — the user-scoped listing returns + * less metadata and includes `app_id` so the caller can filter to + * the Studio App's installations only. + */ +export interface UserInstallationSummary { + id: number + appId: number + account: InstallationAccount + repositorySelection: 'all' | 'selected' | null + targetType: 'User' | 'Organization' | string | null +} + +/** + * App-level GitHub operations that are NOT scoped to a single + * installation. Uses an App JWT for App-administration calls and the + * user's OAuth access token for user-scoped calls — neither requires + * an installation_id at construction time, so this is intentionally + * separated from `GitAppProvider` (which is installation-scoped). + */ +export interface GitAppService { + /** + * Enumerate installations the authenticated user can see. + * `userAccessToken` is the GitHub user-to-server OAuth token (`gho_*` + * for legacy OAuth Apps, `ghu_*` for GitHub Apps with expiring + * user tokens). Result is filtered to the configured Studio App ID. + */ + listInstallationsForUser: (userAccessToken: string) => Promise + + /** + * Verify that the authenticated user has access to a specific + * installation. Returns true when GitHub returns 200 on + * `GET /user/installations/{id}/repositories?per_page=1`, false on + * 404. Used at attach time to prevent installation_id parameter + * spoofing (a user could otherwise attach an installation they + * have no GitHub-side access to, modulo the in-app 409 collision + * check). Pattern adapted from PostHog's + * `verify_user_installation_access` (MIT-licensed). + */ + verifyUserHasAccessToInstallation: ( + userAccessToken: string, + installationId: number, + ) => Promise } // ─── GitProvider: RepoProvider + Studio extensions ─── diff --git a/server/providers/github-app.ts b/server/providers/github-app.ts index 63242c04..f5af94ac 100644 --- a/server/providers/github-app.ts +++ b/server/providers/github-app.ts @@ -29,11 +29,13 @@ import type { BranchProtection, FrameworkDetection, GitAppProvider, + GitAppService, InstallationDetails, InstallationRepository, RepoPermissions, TemplateRepositoryInput, TreeEntry, + UserInstallationSummary, } from './git' interface GitHubAppBaseConfig { @@ -59,6 +61,25 @@ export function createInstallationOctokit(config: GitHubAppBaseConfig): Octokit }) } +/** + * Build an App-JWT-authenticated Octokit client (no installation scope). + * + * Use this for App-administration operations the installation token + * cannot perform: deleting an installation (`DELETE /app/installations/{id}`), + * suspending/unsuspending installations, reading App-level metadata. + * `@octokit/auth-app` signs a short-lived JWT (~10min TTL) with the + * App's private key. + */ +export function createAppOctokit(config: { appId: string, privateKey: string }): Octokit { + return new Octokit({ + authStrategy: createAppAuth, + auth: { + appId: config.appId, + privateKey: config.privateKey, + }, + }) +} + /** * Studio-specific operations that extend MCP's `GitHubProvider` without * reimplementing its content-ops surface. Bundled together so a single @@ -275,5 +296,99 @@ export function createGitHubAppInstallationProvider(config: GitHubAppBaseConfig) throw err } }, + + async revokeInstallation(): Promise { + // `DELETE /app/installations/{id}` is an App-administration call + // that requires a JWT, NOT an installation token. We mint a + // fresh App-JWT Octokit here rather than reusing the + // installation-scoped one above. + const appOctokit = createAppOctokit({ appId: config.appId, privateKey: config.privateKey }) + try { + await appOctokit.request('DELETE /app/installations/{installation_id}', { + installation_id: config.installationId, + }) + return true + } + catch (err: unknown) { + const status = (err as { status?: number }).status + // 404 = installation already gone (uninstalled from GitHub UI in + // parallel with our delete, or webhook lost the race). Treat as + // idempotent success so the caller doesn't need to special-case. + if (status === 404) return false + throw err + } + }, + } +} + +/** + * App-level GitHub operations that are not installation-scoped. + * Composes against an App JWT for App-administration calls and a + * raw user OAuth token for user-scoped calls — neither requires an + * installationId at construction time. + * + * Implementation note: user-scoped Octokit instances are short-lived + * (one per method call) because they're keyed by the caller's user + * token, not a long-lived credential. The App JWT instance can be + * cached safely (single App per Studio deploy) — `@octokit/auth-app` + * refreshes the JWT internally before each request. + */ +export function createGitAppService(config: { appId: string, privateKey: string }): GitAppService { + // App JWT Octokit kept available even when no current method uses it — + // factory shape matches the rest of the provider layer and leaves room + // for future App-administration methods (suspend installation, + // permission accept, etc.). Underscore prefix opts out of the + // "unused variable" lint rule. + const _appOctokit = createAppOctokit(config) + const numericAppId = Number(config.appId) + + return { + async listInstallationsForUser(userAccessToken: string): Promise { + const userOctokit = new Octokit({ auth: userAccessToken }) + const { data } = await userOctokit.request('GET /user/installations', { per_page: 100 }) + // GitHub returns every installation visible to the user, across + // every App they've authorized. Filter to the Studio App only — + // listing installations of unrelated apps would leak App identity + // and is not useful for the UI. + return data.installations + .filter(inst => inst.app_id === numericAppId) + .map(inst => ({ + id: inst.id, + appId: inst.app_id, + account: { + login: (inst.account as { login?: string } | null)?.login ?? null, + avatarUrl: (inst.account as { avatar_url?: string } | null)?.avatar_url ?? null, + type: (inst.account as { type?: string } | null)?.type ?? inst.target_type ?? null, + }, + repositorySelection: (inst.repository_selection as 'all' | 'selected' | null) ?? null, + targetType: inst.target_type ?? null, + })) + }, + + async verifyUserHasAccessToInstallation(userAccessToken: string, installationId: number): Promise { + const userOctokit = new Octokit({ auth: userAccessToken }) + try { + await userOctokit.request('GET /user/installations/{installation_id}/repositories', { + installation_id: installationId, + per_page: 1, + }) + return true + } + catch (err: unknown) { + const status = (err as { status?: number }).status + // 404 = user has no access to this installation (or it doesn't + // exist for them). 403 = installation exists but user is not + // an admin of the account. Both = "cannot bind", returned as + // false; non-404/403 errors propagate as 5xx. + if (status === 404 || status === 403) return false + // App JWT not silently used as fallback; this method is + // intentionally user-scoped. Spurious 401 should bubble up to + // expose token-storage / refresh issues rather than silently + // failing closed. + // eslint-disable-next-line no-console + if (status === 401) console.warn('[github-app] user OAuth token rejected by GET /user/installations/{id}/repositories — token may be expired or revoked') + throw err + } + }, } } diff --git a/server/providers/supabase-auth.ts b/server/providers/supabase-auth.ts index fc0be970..3c106347 100644 --- a/server/providers/supabase-auth.ts +++ b/server/providers/supabase-auth.ts @@ -1,6 +1,42 @@ -import type { AuthProvider, AuthSession, AuthTokens, AuthUser, OAuthRedirectResult } from './auth' +import type { AuthProvider, AuthSession, AuthTokens, AuthUser, OAuthRedirectResult, ProviderTokens } from './auth' import { createSupabaseAdminClient } from './supabase-client' +/** + * Extract provider-side OAuth tokens from a Supabase session, if the + * sign-in used an external OAuth provider. Supabase surfaces these as + * `provider_token` / `provider_refresh_token` on the session response + * but does NOT persist them server-side — they're only available on + * the immediate code-exchange or refresh response. Callers must persist + * the result via DatabaseProvider to enable later provider API calls. + * + * GitHub Apps with expiring user-to-server tokens also surface expiry + * fields (`provider_token_expires_in`, `provider_refresh_token_expires_in`) + * — when omitted, the OAuth App is configured for non-expiring tokens + * and `expiresAt` is null. + */ +function extractProviderTokens( + session: Record | null, +): ProviderTokens | null { + if (!session) return null + const accessToken = session.provider_token + if (typeof accessToken !== 'string' || !accessToken) return null + + const refreshToken = typeof session.provider_refresh_token === 'string' && session.provider_refresh_token + ? session.provider_refresh_token + : null + + const now = Math.floor(Date.now() / 1000) + const accessTtl = typeof session.provider_token_expires_in === 'number' ? session.provider_token_expires_in : null + const refreshTtl = typeof session.provider_refresh_token_expires_in === 'number' ? session.provider_refresh_token_expires_in : null + + return { + accessToken, + refreshToken, + expiresAt: accessTtl !== null ? now + accessTtl : null, + refreshTokenExpiresAt: refreshTtl !== null ? now + refreshTtl : null, + } +} + /** * Supabase implementation of AuthProvider. * @@ -33,6 +69,58 @@ export function createSupabaseAuthProvider(): AuthProvider { } }, + async refreshProviderToken(provider: 'github' | 'google', refreshToken: string): Promise { + // Supabase does NOT refresh provider tokens — its `refreshSession` + // refreshes only the Supabase JWT. We call the provider's own + // refresh endpoint directly. See: + // https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/refreshing-user-access-tokens + if (provider !== 'github') + return null + + const config = useRuntimeConfig() + const clientId = (config.github as { clientId?: string } | undefined)?.clientId + const clientSecret = (config.github as { clientSecret?: string } | undefined)?.clientSecret + if (!clientId || !clientSecret) + return null + + try { + const response = await $fetch<{ + access_token?: string + refresh_token?: string + expires_in?: number + refresh_token_expires_in?: number + error?: string + }>('https://github.com/login/oauth/access_token', { + method: 'POST', + headers: { Accept: 'application/json' }, + body: { + client_id: clientId, + client_secret: clientSecret, + grant_type: 'refresh_token', + refresh_token: refreshToken, + }, + }) + + // GitHub returns 200 with an `error` field on bad/expired tokens. + // The most common error codes are `bad_refresh_token` and + // `bad_verification_code` — both unrecoverable, caller must + // re-authenticate. + if (response.error || !response.access_token) + return null + + const now = Math.floor(Date.now() / 1000) + return { + accessToken: response.access_token, + refreshToken: response.refresh_token ?? null, + expiresAt: response.expires_in ? now + response.expires_in : null, + refreshTokenExpiresAt: response.refresh_token_expires_in ? now + response.refresh_token_expires_in : null, + } + } + catch { + return null + } + }, + async getOAuthRedirectUrl(provider: 'github' | 'google', redirectTo: string): Promise { const admin = createSupabaseAdminClient() const config = useRuntimeConfig() @@ -74,6 +162,7 @@ export function createSupabaseAuthProvider(): AuthProvider { refreshToken: data.session.refresh_token ?? null, expiresAt: data.session.expires_at ?? Math.floor(Date.now() / 1000) + 3600, }, + providerTokens: extractProviderTokens(data.session as unknown as Record), } }, @@ -99,6 +188,13 @@ export function createSupabaseAuthProvider(): AuthProvider { refreshToken: refreshToken ?? null, expiresAt, }, + // exchangeTokens is used by the hash-fragment callback (magic link + // and implicit-flow paths) where we receive an already-issued + // access_token + refresh_token from the URL, not a fresh Supabase + // session. `getUser` returns the AuthUser but no provider tokens. + // If the caller has provider tokens, they must come from the URL + // fragment / cookie before invoking this method. + providerTokens: null, } }, diff --git a/server/providers/supabase-db/index.ts b/server/providers/supabase-db/index.ts index 3f4cc2bd..1f223027 100644 --- a/server/providers/supabase-db/index.ts +++ b/server/providers/supabase-db/index.ts @@ -20,6 +20,7 @@ import { formMethods } from './forms' import { mcpCloudMethods } from './mcp-cloud' import { mediaMethods } from './media' import { memberMethods } from './members' +import { oauthTokenMethods } from './oauth-tokens' import { paymentAccountMethods } from './payment-accounts' import { profileMethods } from './profiles' import { projectMethods } from './projects' @@ -33,6 +34,7 @@ export { createSupabaseAdminClient, createSupabaseUserClient } from '../supabase export function createSupabaseDatabaseProvider(): DatabaseProvider { return { ...profileMethods(), + ...oauthTokenMethods(), ...workspaceMethods(), ...memberMethods(), ...conversationMethods(), diff --git a/server/providers/supabase-db/oauth-tokens.ts b/server/providers/supabase-db/oauth-tokens.ts new file mode 100644 index 00000000..3382b976 --- /dev/null +++ b/server/providers/supabase-db/oauth-tokens.ts @@ -0,0 +1,97 @@ +import type { DatabaseProvider } from '../database' +import { decryptApiKey, encryptApiKey } from '../../utils/encryption' +import { getAdmin } from './helpers' + +/** + * Encrypted storage for provider-side OAuth tokens (e.g. GitHub + * `gho_*` / `ghu_*` user-to-server tokens captured from Supabase Auth). + * + * Tokens are encrypted at rest with the same AES-256-GCM helper used + * for BYOA API keys (`server/utils/encryption.ts`) — versioned base64 + * with rotation support via NUXT_SESSION_SECRET_PREVIOUS. Decryption + * failure (rotation without the previous secret set, or row written + * by a different secret entirely) is treated as a missing token so + * the caller drives re-authentication instead of throwing 500. + */ + +type OAuthTokenMethods = Pick< + DatabaseProvider, + 'upsertOAuthProviderToken' | 'getOAuthProviderToken' | 'deleteOAuthProviderToken' +> + +function toIsoOrNull(unixSeconds: number | null): string | null { + return unixSeconds === null ? null : new Date(unixSeconds * 1000).toISOString() +} + +function fromIsoOrNull(value: unknown): number | null { + if (typeof value !== 'string') return null + const ms = Date.parse(value) + return Number.isNaN(ms) ? null : Math.floor(ms / 1000) +} + +export function oauthTokenMethods(): OAuthTokenMethods { + return { + async upsertOAuthProviderToken(input) { + const config = useRuntimeConfig() + const secret = config.sessionSecret as string + if (!secret) throw createError({ statusCode: 500, message: 'NUXT_SESSION_SECRET is not configured' }) + + const encryptedAccessToken = encryptApiKey(input.accessToken, secret) + const encryptedRefreshToken = input.refreshToken ? encryptApiKey(input.refreshToken, secret) : null + + const { error } = await getAdmin().from('oauth_provider_tokens').upsert({ + user_id: input.userId, + provider: input.provider, + encrypted_access_token: encryptedAccessToken, + encrypted_refresh_token: encryptedRefreshToken, + access_token_expires_at: toIsoOrNull(input.expiresAt), + refresh_token_expires_at: toIsoOrNull(input.refreshTokenExpiresAt), + }, { onConflict: 'user_id,provider' }) + + if (error) throw createError({ statusCode: 500, message: error.message }) + }, + + async getOAuthProviderToken(userId, provider) { + const config = useRuntimeConfig() + const secret = config.sessionSecret as string + const previousSecret = (config.sessionSecretPrevious as string) || undefined + if (!secret) throw createError({ statusCode: 500, message: 'NUXT_SESSION_SECRET is not configured' }) + + const { data, error } = await getAdmin() + .from('oauth_provider_tokens') + .select('encrypted_access_token, encrypted_refresh_token, access_token_expires_at, refresh_token_expires_at') + .eq('user_id', userId) + .eq('provider', provider) + .maybeSingle() + + if (error && error.code !== 'PGRST116') + throw createError({ statusCode: 500, message: error.message }) + if (!data) return null + + const encryptedAccess = data.encrypted_access_token as string + const encryptedRefresh = data.encrypted_refresh_token as string | null + + try { + return { + accessToken: decryptApiKey(encryptedAccess, secret, previousSecret), + refreshToken: encryptedRefresh ? decryptApiKey(encryptedRefresh, secret, previousSecret) : null, + expiresAt: fromIsoOrNull(data.access_token_expires_at), + refreshTokenExpiresAt: fromIsoOrNull(data.refresh_token_expires_at), + } + } + catch { + return null + } + }, + + async deleteOAuthProviderToken(userId, provider) { + const { error } = await getAdmin() + .from('oauth_provider_tokens') + .delete() + .eq('user_id', userId) + .eq('provider', provider) + + if (error) throw createError({ statusCode: 500, message: error.message }) + }, + } +} diff --git a/server/providers/supabase-db/workspaces.ts b/server/providers/supabase-db/workspaces.ts index 62324b81..04f27a06 100644 --- a/server/providers/supabase-db/workspaces.ts +++ b/server/providers/supabase-db/workspaces.ts @@ -19,6 +19,7 @@ type WorkspaceMethods = Pick< | 'findWorkspaceByGithubInstallation' | 'updateWorkspaceGithubInstallation' | 'clearWorkspaceGithubInstallation' + | 'updateWorkspaceInstallationStatus' | 'deleteWorkspace' | 'incrementWorkspaceStorageBytes' | 'reserveStorageIfAllowed' @@ -176,13 +177,27 @@ export function workspaceMethods(): WorkspaceMethods { async updateWorkspaceGithubInstallation(workspaceId, installationId) { const { error } = await getAdmin() - .from('workspaces').update({ github_installation_id: installationId }).eq('id', workspaceId) + .from('workspaces') + .update({ github_installation_id: installationId, github_installation_status: 'active' }) + .eq('id', workspaceId) if (error) throw createError({ statusCode: 500, message: error.message }) }, async clearWorkspaceGithubInstallation(installationId) { const { error } = await getAdmin() - .from('workspaces').update({ github_installation_id: null }).eq('github_installation_id', installationId) + .from('workspaces') + .update({ github_installation_id: null, github_installation_status: 'unbound' }) + .eq('github_installation_id', installationId) + if (error) throw createError({ statusCode: 500, message: error.message }) + }, + + async updateWorkspaceInstallationStatus(target, status) { + const admin = getAdmin() + const update = admin.from('workspaces').update({ github_installation_status: status }) + const query = 'workspaceId' in target + ? update.eq('id', target.workspaceId) + : update.eq('github_installation_id', target.installationId) + const { error } = await query if (error) throw createError({ statusCode: 500, message: error.message }) }, diff --git a/server/utils/github-token.ts b/server/utils/github-token.ts new file mode 100644 index 00000000..23ed7a19 --- /dev/null +++ b/server/utils/github-token.ts @@ -0,0 +1,72 @@ +/** + * Resolve a usable GitHub user OAuth token for the given user, refreshing + * it lazily if the stored copy has expired. + * + * Returns: + * - `string` — a valid access token, ready for `GET /user/installations` + * and similar user-scoped GitHub API calls. + * - `null` — the user has no stored token (Google sign-in / magic link / + * never went through GitHub OAuth) OR the stored token expired AND + * the refresh attempt failed. Either case requires the user to + * re-authenticate with GitHub; the stored row (if any) has already + * been deleted so subsequent reads return null until reauth completes. + * + * Refresh policy (PostHog pattern): + * - Token still has >60s before expiry → return as-is (no refresh). + * - Token expiring or expired + we have a refresh token → + * `AuthProvider.refreshProviderToken('github', ...)`; persist the + * new pair; return the fresh access token. + * - Refresh failed (provider returned `bad_refresh_token` / + * `bad_credentials`, or network error) → delete the stored row and + * return null. + * + * Concurrency note: this is not protected against two requests racing + * to refresh the same token. GitHub's refresh endpoint is idempotent in + * the success case (returns the same new pair for ~5s after first call) + * but rotates the refresh token; a race could leave one request with a + * stale refresh token and the next refresh attempt would fail. For the + * traffic this code serves (interactive user actions, not high-RPS), + * the race window is too narrow to justify a locking layer. + */ +export async function getValidGitHubUserToken(userId: string): Promise { + const db = useDatabaseProvider() + const stored = await db.getOAuthProviderToken(userId, 'github') + if (!stored) return null + + const now = Math.floor(Date.now() / 1000) + // Non-expiring (legacy OAuth Apps): use as-is. + if (stored.expiresAt === null) return stored.accessToken + // Still valid with at least 60s headroom. + if (stored.expiresAt > now + 60) return stored.accessToken + + // Expired (or about to expire). Need refresh token to recover. + if (!stored.refreshToken) { + await db.deleteOAuthProviderToken(userId, 'github') + return null + } + + const auth = useAuthProvider() + const refreshed = await auth.refreshProviderToken('github', stored.refreshToken) + if (!refreshed) { + await db.deleteOAuthProviderToken(userId, 'github') + return null + } + + await db.upsertOAuthProviderToken({ + userId, + provider: 'github', + accessToken: refreshed.accessToken, + refreshToken: refreshed.refreshToken, + expiresAt: refreshed.expiresAt, + refreshTokenExpiresAt: refreshed.refreshTokenExpiresAt, + }) + return refreshed.accessToken +} + +/** + * Standard error code returned by endpoints that require a GitHub user + * OAuth token but couldn't obtain one (no stored token or refresh + * failed). The frontend listens for this code to trigger the + * "Reconnect GitHub" reauth flow. + */ +export const GITHUB_REAUTH_REQUIRED_CODE = 'GITHUB_REAUTH_REQUIRED' diff --git a/server/utils/providers.ts b/server/utils/providers.ts index 3226445b..41fa9e92 100644 --- a/server/utils/providers.ts +++ b/server/utils/providers.ts @@ -1,7 +1,7 @@ import type { AuthProvider } from '../providers/auth' import type { AIProvider } from '../providers/ai' import type { DatabaseProvider } from '../providers/database' -import type { GitAppProvider, GitProvider } from '../providers/git' +import type { GitAppProvider, GitAppService, GitProvider } from '../providers/git' import type { CDNProvider } from '../providers/cdn' import type { MediaProvider } from '../providers/media' import type { EmailProvider } from '../providers/email' @@ -10,7 +10,7 @@ import { bootstrapPaymentPlugins, resolveDefaultPlugin } from '../providers/paym import { createSupabaseAuthProvider } from '../providers/supabase-auth' import { createSupabaseDatabaseProvider } from '../providers/supabase-db' import { createStudioGitProvider } from '../providers/git' -import { createGitHubAppInstallationProvider } from '../providers/github-app' +import { createGitAppService, createGitHubAppInstallationProvider } from '../providers/github-app' import { createAnthropicProvider } from '../providers/anthropic-ai' import { createResendEmailProvider } from '../providers/resend-email' import { getLoadedEnterpriseBridge } from './enterprise' @@ -89,6 +89,32 @@ export function useGitAppProvider(installationId: number): GitAppProvider { }) } +/** + * App-level GitHub service (not installation-scoped). + * + * Use this for operations that enumerate or verify installations + * against either an App JWT or a user's OAuth token — e.g. listing + * the installations a user can see (`GET /user/installations`), + * verifying user access before binding an installation + * (PostHog-style ownership check), or App-administration calls that + * don't fit a single installation scope. + * + * Per-installation operations (repo list, framework detect, revoke, + * repo create) still go through `useGitAppProvider(installationId)`. + */ +let _gitAppService: GitAppService | null = null +export function useGitAppService(): GitAppService { + if (_gitAppService) return _gitAppService + + const config = useRuntimeConfig() + const privateKey = Buffer.from(config.github.privateKey, 'base64').toString('utf-8') + _gitAppService = createGitAppService({ + appId: config.github.appId, + privateKey, + }) + return _gitAppService +} + /** * CDN Provider (singleton). * diff --git a/supabase/migrations/004_github_installation_lifecycle.sql b/supabase/migrations/004_github_installation_lifecycle.sql new file mode 100644 index 00000000..5fd5a4f7 --- /dev/null +++ b/supabase/migrations/004_github_installation_lifecycle.sql @@ -0,0 +1,82 @@ +-- GitHub App installation lifecycle: persisted user OAuth tokens + installation health. +-- +-- Adds two pieces of state needed for the full installation lifecycle the +-- product now supports (connect-existing flow, suspend/unsuspend tracking, +-- ownership-verified attach): +-- +-- 1. `oauth_provider_tokens` — encrypted per-(user, provider) token storage. +-- Required because Supabase Auth surfaces `provider_token` only on the +-- OAuth callback and does not persist it server-side; without storage we +-- cannot call `GET /user/installations` after the initial sign-in. +-- Tokens are encrypted at rest with AES-256-GCM via the existing +-- `server/utils/encryption.ts` helper (versioned base64 ciphertext, +-- SHA-256 key derived from NUXT_SESSION_SECRET, rotation-aware). +-- Columns are `text` to match BYOA API-key storage on +-- `ai_keys.encrypted_key`; RLS keeps rows server-only. +-- +-- 2. `workspaces.github_installation_status` — tracks installation health +-- independently of `github_installation_id` so we can distinguish +-- 'active' / 'suspended' / 'unbound'. Webhook events +-- `installation.suspend` / `unsuspend` / `deleted` update this column +-- rather than nulling the id. + +-- ========================================================================= +-- oauth_provider_tokens — encrypted user-OAuth tokens per provider +-- ========================================================================= + +CREATE TABLE public.oauth_provider_tokens ( + user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + provider text NOT NULL, + encrypted_access_token text NOT NULL, + encrypted_refresh_token text, + access_token_expires_at timestamp with time zone, + refresh_token_expires_at timestamp with time zone, + created_at timestamp with time zone NOT NULL DEFAULT now(), + updated_at timestamp with time zone NOT NULL DEFAULT now(), + PRIMARY KEY (user_id, provider), + CONSTRAINT oauth_provider_tokens_provider_check + CHECK (provider IN ('github')) +); + +CREATE OR REPLACE FUNCTION public.oauth_provider_tokens_set_updated_at() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +BEGIN + NEW.updated_at = now(); + RETURN NEW; +END; +$$; + +CREATE TRIGGER oauth_provider_tokens_updated_at + BEFORE UPDATE ON public.oauth_provider_tokens + FOR EACH ROW + EXECUTE FUNCTION public.oauth_provider_tokens_set_updated_at(); + +-- Server-only (service role). No client-facing policies — encrypted ciphertexts +-- must never leak to the browser even with a valid user JWT. +ALTER TABLE public.oauth_provider_tokens ENABLE ROW LEVEL SECURITY; + +-- ========================================================================= +-- workspaces.github_installation_status — installation health +-- ========================================================================= + +ALTER TABLE public.workspaces + ADD COLUMN github_installation_status text NOT NULL DEFAULT 'unbound'; + +ALTER TABLE public.workspaces + ADD CONSTRAINT workspaces_github_installation_status_check + CHECK (github_installation_status IN ('active', 'suspended', 'unbound')); + +-- Backfill: pre-existing workspaces with installation_id are considered +-- 'active' (webhook will correct to 'suspended' if applicable on next event). +UPDATE public.workspaces + SET github_installation_status = 'active' + WHERE github_installation_id IS NOT NULL; + +-- Webhook handlers (installation.suspend/unsuspend/deleted) look up +-- workspaces by installation_id. Without this index the lookup is a +-- full table scan on every installation event. +CREATE INDEX IF NOT EXISTS idx_workspaces_github_installation_id + ON public.workspaces (github_installation_id) + WHERE github_installation_id IS NOT NULL; diff --git a/tests/integration/delete-routes.integration.test.ts b/tests/integration/delete-routes.integration.test.ts index 1c81dd2d..c7139e77 100644 --- a/tests/integration/delete-routes.integration.test.ts +++ b/tests/integration/delete-routes.integration.test.ts @@ -42,6 +42,119 @@ describe('workspace and project delete route integration', () => { }) }) + it('revokes the GitHub installation when uninstallGithubApp flag is set and no other workspace shares it', async () => { + const revokeInstallation = vi.fn().mockResolvedValue(true) + const findWorkspaceByGithubInstallation = vi.fn().mockResolvedValue(null) + + vi.stubGlobal('getRouterParam', vi.fn(() => 'workspace-1')) + vi.stubGlobal('requireAuth', vi.fn().mockReturnValue({ + user: { id: 'owner-1' }, + accessToken: 'token-1', + })) + vi.stubGlobal('readBody', vi.fn().mockResolvedValue({ uninstallGithubApp: true })) + vi.stubGlobal('useCDNProvider', vi.fn().mockReturnValue(null)) + vi.stubGlobal('useGitAppProvider', vi.fn().mockReturnValue({ revokeInstallation })) + vi.stubGlobal('useDatabaseProvider', vi.fn().mockReturnValue({ + requireWorkspaceRole: vi.fn().mockResolvedValue('owner'), + getWorkspaceById: vi.fn().mockResolvedValue({ + id: 'workspace-1', + type: 'secondary', + owner_id: 'owner-1', + github_installation_id: 12345, + }), + findWorkspaceByGithubInstallation, + listWorkspaceProjects: vi.fn().mockResolvedValue([]), + deleteWorkspace: vi.fn().mockResolvedValue(undefined), + })) + + await withTestServer({ + routes: [{ path: '/api/workspaces/workspace-1', handler: await loadWorkspaceDeleteHandler() }], + }, async ({ request }) => { + const response = await request('/api/workspaces/workspace-1', { + method: 'DELETE', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ uninstallGithubApp: true }), + }) + expect(response.status).toBe(200) + expect(findWorkspaceByGithubInstallation).toHaveBeenCalledWith(12345, 'workspace-1') + expect(revokeInstallation).toHaveBeenCalledTimes(1) + }) + }) + + it('skips revokeInstallation when another workspace still shares the installation', async () => { + const revokeInstallation = vi.fn().mockResolvedValue(true) + + vi.stubGlobal('getRouterParam', vi.fn(() => 'workspace-1')) + vi.stubGlobal('requireAuth', vi.fn().mockReturnValue({ + user: { id: 'owner-1' }, + accessToken: 'token-1', + })) + vi.stubGlobal('readBody', vi.fn().mockResolvedValue({ uninstallGithubApp: true })) + vi.stubGlobal('useCDNProvider', vi.fn().mockReturnValue(null)) + vi.stubGlobal('useGitAppProvider', vi.fn().mockReturnValue({ revokeInstallation })) + vi.stubGlobal('useDatabaseProvider', vi.fn().mockReturnValue({ + requireWorkspaceRole: vi.fn().mockResolvedValue('owner'), + getWorkspaceById: vi.fn().mockResolvedValue({ + id: 'workspace-1', + type: 'secondary', + owner_id: 'owner-1', + github_installation_id: 12345, + }), + findWorkspaceByGithubInstallation: vi.fn().mockResolvedValue({ id: 'workspace-shared' }), + listWorkspaceProjects: vi.fn().mockResolvedValue([]), + deleteWorkspace: vi.fn().mockResolvedValue(undefined), + })) + + await withTestServer({ + routes: [{ path: '/api/workspaces/workspace-1', handler: await loadWorkspaceDeleteHandler() }], + }, async ({ request }) => { + const response = await request('/api/workspaces/workspace-1', { + method: 'DELETE', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ uninstallGithubApp: true }), + }) + expect(response.status).toBe(200) + expect(revokeInstallation).not.toHaveBeenCalled() + }) + }) + + it('cancels the active subscription when cancelSubscription flag is set', async () => { + const cancelSubscription = vi.fn().mockResolvedValue(undefined) + + vi.stubGlobal('getRouterParam', vi.fn(() => 'workspace-1')) + vi.stubGlobal('requireAuth', vi.fn().mockReturnValue({ + user: { id: 'owner-1' }, + accessToken: 'token-1', + })) + vi.stubGlobal('readBody', vi.fn().mockResolvedValue({ cancelSubscription: true })) + vi.stubGlobal('useCDNProvider', vi.fn().mockReturnValue(null)) + vi.stubGlobal('usePaymentProvider', vi.fn().mockReturnValue({ cancelSubscription })) + vi.stubGlobal('useDatabaseProvider', vi.fn().mockReturnValue({ + requireWorkspaceRole: vi.fn().mockResolvedValue('owner'), + getWorkspaceById: vi.fn().mockResolvedValue({ + id: 'workspace-1', + type: 'secondary', + owner_id: 'owner-1', + github_installation_id: null, + }), + getActivePaymentAccount: vi.fn().mockResolvedValue({ subscription_id: 'sub_test_123' }), + listWorkspaceProjects: vi.fn().mockResolvedValue([]), + deleteWorkspace: vi.fn().mockResolvedValue(undefined), + })) + + await withTestServer({ + routes: [{ path: '/api/workspaces/workspace-1', handler: await loadWorkspaceDeleteHandler() }], + }, async ({ request }) => { + const response = await request('/api/workspaces/workspace-1', { + method: 'DELETE', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ cancelSubscription: true }), + }) + expect(response.status).toBe(200) + expect(cancelSubscription).toHaveBeenCalledWith('sub_test_123') + }) + }) + it('deletes a workspace after cleaning project storage and ignores storage cleanup failures', async () => { const deletePrefix = vi.fn() .mockRejectedValueOnce(new Error('r2 unavailable')) diff --git a/tests/integration/github-installations.integration.test.ts b/tests/integration/github-installations.integration.test.ts new file mode 100644 index 00000000..78d84b09 --- /dev/null +++ b/tests/integration/github-installations.integration.test.ts @@ -0,0 +1,170 @@ +import { describe, expect, it, vi } from 'vitest' +import { withTestServer } from '../helpers/http' + +async function loadAvailableHandler() { + return (await import('../../server/api/github/installations/available.get')).default +} + +async function loadConnectHandler() { + return (await import('../../server/api/github/installations/connect.post')).default +} + +function stubGetQuery(query: Record) { + vi.stubGlobal('getQuery', vi.fn().mockReturnValue(query)) +} + +function stubReadBody(body: Record) { + vi.stubGlobal('readBody', vi.fn().mockResolvedValue(body)) +} + +describe('github installations integration', () => { + describe('GET /api/github/installations/available', () => { + it('returns 401 with reauth code when no provider token is stored', async () => { + stubGetQuery({ workspaceId: 'ws-1' }) + vi.stubGlobal('requireAuth', vi.fn().mockReturnValue({ + user: { id: 'user-1' }, + accessToken: 'token-1', + })) + vi.stubGlobal('useDatabaseProvider', vi.fn().mockReturnValue({ + getWorkspaceForUser: vi.fn().mockResolvedValue({ id: 'ws-1' }), + getOAuthProviderToken: vi.fn().mockResolvedValue(null), + })) + + await withTestServer({ + routes: [{ path: '/api/github/installations/available', handler: await loadAvailableHandler() }], + }, async ({ request }) => { + const response = await request('/api/github/installations/available?workspaceId=ws-1') + expect(response.status).toBe(401) + const body = await response.json() + expect(body.data?.code).toBe('GITHUB_REAUTH_REQUIRED') + }) + }) + + it('returns installations annotated with boundWorkspace', async () => { + stubGetQuery({ workspaceId: 'ws-1' }) + vi.stubGlobal('requireAuth', vi.fn().mockReturnValue({ + user: { id: 'user-1' }, + accessToken: 'token-1', + })) + const findWorkspaceByGithubInstallation = vi.fn() + .mockImplementation(async (id: number) => id === 222 ? { id: 'other-ws', name: 'Other', slug: 'other' } : null) + vi.stubGlobal('useDatabaseProvider', vi.fn().mockReturnValue({ + getWorkspaceForUser: vi.fn().mockResolvedValue({ id: 'ws-1' }), + getOAuthProviderToken: vi.fn().mockResolvedValue({ + accessToken: 'gho_test', + refreshToken: null, + expiresAt: null, + refreshTokenExpiresAt: null, + }), + findWorkspaceByGithubInstallation, + })) + const listInstallationsForUser = vi.fn().mockResolvedValue([ + { id: 111, account: { login: 'alice', avatarUrl: null, type: 'User' }, repositorySelection: 'all', targetType: 'User' }, + { id: 222, account: { login: 'acme', avatarUrl: null, type: 'Organization' }, repositorySelection: 'selected', targetType: 'Organization' }, + ]) + vi.stubGlobal('useGitAppService', vi.fn().mockReturnValue({ listInstallationsForUser })) + + await withTestServer({ + routes: [{ path: '/api/github/installations/available', handler: await loadAvailableHandler() }], + }, async ({ request }) => { + const response = await request('/api/github/installations/available?workspaceId=ws-1') + expect(response.status).toBe(200) + const body = await response.json() as { installations: Array<{ id: number, boundWorkspace: unknown }> } + expect(body.installations).toHaveLength(2) + expect(body.installations[0]).toMatchObject({ id: 111, boundWorkspace: null }) + expect(body.installations[1]).toMatchObject({ id: 222, boundWorkspace: { id: 'other-ws', name: 'Other', slug: 'other' } }) + }) + }) + }) + + describe('POST /api/github/installations/connect', () => { + it('rejects 403 when user has no GitHub access to the installation', async () => { + stubReadBody({ workspaceId: 'ws-1', installationId: 999 }) + vi.stubGlobal('requireAuth', vi.fn().mockReturnValue({ + user: { id: 'user-1' }, + accessToken: 'token-1', + })) + vi.stubGlobal('useDatabaseProvider', vi.fn().mockReturnValue({ + getWorkspaceForUser: vi.fn().mockResolvedValue({ id: 'ws-1' }), + getOAuthProviderToken: vi.fn().mockResolvedValue({ + accessToken: 'gho_test', refreshToken: null, expiresAt: null, refreshTokenExpiresAt: null, + }), + })) + vi.stubGlobal('useGitAppService', vi.fn().mockReturnValue({ + verifyUserHasAccessToInstallation: vi.fn().mockResolvedValue(false), + })) + + await withTestServer({ + routes: [{ path: '/api/github/installations/connect', handler: await loadConnectHandler() }], + }, async ({ request }) => { + const response = await request('/api/github/installations/connect', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ workspaceId: 'ws-1', installationId: 999 }), + }) + expect(response.status).toBe(403) + }) + }) + + it('rejects 409 when installation is already bound to another workspace', async () => { + stubReadBody({ workspaceId: 'ws-1', installationId: 222 }) + vi.stubGlobal('requireAuth', vi.fn().mockReturnValue({ + user: { id: 'user-1' }, + accessToken: 'token-1', + })) + vi.stubGlobal('useDatabaseProvider', vi.fn().mockReturnValue({ + getWorkspaceForUser: vi.fn().mockResolvedValue({ id: 'ws-1' }), + getOAuthProviderToken: vi.fn().mockResolvedValue({ + accessToken: 'gho_test', refreshToken: null, expiresAt: null, refreshTokenExpiresAt: null, + }), + findWorkspaceByGithubInstallation: vi.fn().mockResolvedValue({ id: 'other-ws' }), + })) + vi.stubGlobal('useGitAppService', vi.fn().mockReturnValue({ + verifyUserHasAccessToInstallation: vi.fn().mockResolvedValue(true), + })) + + await withTestServer({ + routes: [{ path: '/api/github/installations/connect', handler: await loadConnectHandler() }], + }, async ({ request }) => { + const response = await request('/api/github/installations/connect', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ workspaceId: 'ws-1', installationId: 222 }), + }) + expect(response.status).toBe(409) + }) + }) + + it('binds the installation when ownership verified and no collision', async () => { + stubReadBody({ workspaceId: 'ws-1', installationId: 333 }) + vi.stubGlobal('requireAuth', vi.fn().mockReturnValue({ + user: { id: 'user-1' }, + accessToken: 'token-1', + })) + const updateWorkspaceGithubInstallation = vi.fn().mockResolvedValue(undefined) + vi.stubGlobal('useDatabaseProvider', vi.fn().mockReturnValue({ + getWorkspaceForUser: vi.fn().mockResolvedValue({ id: 'ws-1' }), + getOAuthProviderToken: vi.fn().mockResolvedValue({ + accessToken: 'gho_test', refreshToken: null, expiresAt: null, refreshTokenExpiresAt: null, + }), + findWorkspaceByGithubInstallation: vi.fn().mockResolvedValue(null), + updateWorkspaceGithubInstallation, + })) + vi.stubGlobal('useGitAppService', vi.fn().mockReturnValue({ + verifyUserHasAccessToInstallation: vi.fn().mockResolvedValue(true), + })) + + await withTestServer({ + routes: [{ path: '/api/github/installations/connect', handler: await loadConnectHandler() }], + }, async ({ request }) => { + const response = await request('/api/github/installations/connect', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ workspaceId: 'ws-1', installationId: 333 }), + }) + expect(response.status).toBe(200) + expect(updateWorkspaceGithubInstallation).toHaveBeenCalledWith('ws-1', 333) + }) + }) + }) +}) diff --git a/tests/integration/github-routes.integration.test.ts b/tests/integration/github-routes.integration.test.ts index 8dccfda4..9d9ba8a0 100644 --- a/tests/integration/github-routes.integration.test.ts +++ b/tests/integration/github-routes.integration.test.ts @@ -6,16 +6,26 @@ const providerState = vi.hoisted(() => ({ getWorkspaceForUser: vi.fn(), findWorkspaceByGithubInstallation: vi.fn(), updateWorkspaceGithubInstallation: vi.fn(), + getOAuthProviderToken: vi.fn(), + }, + authProvider: { + refreshProviderToken: vi.fn(), }, gitAppProvider: { listInstallationRepositories: vi.fn(), }, + gitAppService: { + listInstallationsForUser: vi.fn(), + verifyUserHasAccessToInstallation: vi.fn(), + }, gitProviderFactory: vi.fn(), })) vi.mock('../../server/utils/providers', () => ({ useDatabaseProvider: vi.fn(() => providerState.databaseProvider), + useAuthProvider: vi.fn(() => providerState.authProvider), useGitAppProvider: vi.fn(() => providerState.gitAppProvider), + useGitAppService: vi.fn(() => providerState.gitAppService), useGitProvider: providerState.gitProviderFactory, })) @@ -33,10 +43,28 @@ async function loadScanHandler() { describe('GitHub route integration', () => { beforeEach(() => { + // The `vi.mock` above routes named imports inside setup.get / repos / + // scan to the providerState. But `server/utils/github-token.ts` (used + // by setup.get for the ownership-verification branch) consumes + // `useDatabaseProvider` and `useAuthProvider` via Nuxt auto-import, + // not via named import — auto-import bypasses module mocks. We mirror + // the mocks onto the global namespace so the helper sees the same + // providerState the named imports do. + vi.stubGlobal('useDatabaseProvider', () => providerState.databaseProvider) + vi.stubGlobal('useAuthProvider', () => providerState.authProvider) + providerState.databaseProvider.getWorkspaceForUser.mockReset() providerState.databaseProvider.findWorkspaceByGithubInstallation.mockReset() providerState.databaseProvider.updateWorkspaceGithubInstallation.mockReset() + providerState.databaseProvider.getOAuthProviderToken.mockReset() + // Default: no stored GitHub user token. setup.get.ts treats this as + // "skip ownership verification" (Google sign-in / magic-link path); + // tests that exercise the ownership-verify branch override per-case. + providerState.databaseProvider.getOAuthProviderToken.mockResolvedValue(null) + providerState.authProvider.refreshProviderToken.mockReset() providerState.gitAppProvider.listInstallationRepositories.mockReset() + providerState.gitAppService.listInstallationsForUser.mockReset() + providerState.gitAppService.verifyUserHasAccessToInstallation.mockReset() providerState.gitProviderFactory.mockReset() }) diff --git a/tests/integration/github-webhook.integration.test.ts b/tests/integration/github-webhook.integration.test.ts index a4c67147..64e5c6aa 100644 --- a/tests/integration/github-webhook.integration.test.ts +++ b/tests/integration/github-webhook.integration.test.ts @@ -42,6 +42,73 @@ describe('GitHub webhook integration', () => { }) }) + it('flips workspaces to suspended status when GitHub sends an installation.suspend event', async () => { + const updateWorkspaceInstallationStatus = vi.fn().mockResolvedValue(undefined) + vi.stubGlobal('useRuntimeConfig', vi.fn().mockReturnValue({ + github: { webhookSecret: 'webhook-secret' }, + })) + vi.stubGlobal('useDatabaseProvider', vi.fn().mockReturnValue({ + updateWorkspaceInstallationStatus, + })) + + const { raw, signature } = signGithubBody('webhook-secret', { + action: 'suspend', + installation: { id: 88 }, + }) + + await withTestServer({ + routes: [{ path: '/api/webhooks/github', handler: await loadWebhookHandler() }], + }, async ({ request }) => { + const response = await request('/api/webhooks/github', { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-github-event': 'installation', + 'x-hub-signature-256': signature, + }, + body: raw, + }) + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ + ok: true, + event: 'installation', + action: 'suspend', + }) + expect(updateWorkspaceInstallationStatus).toHaveBeenCalledWith({ installationId: 88 }, 'suspended') + }) + }) + + it('flips workspaces back to active when GitHub sends installation.unsuspend', async () => { + const updateWorkspaceInstallationStatus = vi.fn().mockResolvedValue(undefined) + vi.stubGlobal('useRuntimeConfig', vi.fn().mockReturnValue({ + github: { webhookSecret: 'webhook-secret' }, + })) + vi.stubGlobal('useDatabaseProvider', vi.fn().mockReturnValue({ + updateWorkspaceInstallationStatus, + })) + + const { raw, signature } = signGithubBody('webhook-secret', { + action: 'unsuspend', + installation: { id: 88 }, + }) + + await withTestServer({ + routes: [{ path: '/api/webhooks/github', handler: await loadWebhookHandler() }], + }, async ({ request }) => { + const response = await request('/api/webhooks/github', { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-github-event': 'installation', + 'x-hub-signature-256': signature, + }, + body: raw, + }) + expect(response.status).toBe(200) + expect(updateWorkspaceInstallationStatus).toHaveBeenCalledWith({ installationId: 88 }, 'active') + }) + }) + it('clears linked workspaces when GitHub sends an installation.deleted event', async () => { const clearWorkspaceGithubInstallation = vi.fn().mockResolvedValue(undefined) vi.stubGlobal('useRuntimeConfig', vi.fn().mockReturnValue({ From dc0597783a6ff0737f3c8b4012cd322e87ea1474 Mon Sep 17 00:00:00 2001 From: Contentrain Date: Wed, 13 May 2026 23:38:25 +0300 Subject: [PATCH 2/2] feat(github-app): repo-level access lifecycle (revoke/delete/rename handling) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the second half of the GitHub App lifecycle picture surfaced during review. Workspace-level installation lifecycle was already handled in the previous commit (suspend/unsuspend/uninstall on the App boundary). This commit adds project-level repo access tracking — needed because a user can leave the App installed but revoke access to one specific repo (manual unselect in App settings, org admin action, or repo deletion on GitHub's side). In those cases the installation token is still valid but every commit/branch call for that project would 404 with no actionable UI feedback. DB: - New migration `005_project_access_status.sql` adds `projects.access_status` ('accessible' | 'inaccessible' | 'deleted', default 'accessible') and an index on `projects.repo_full_name` for webhook lookups. The new column is orthogonal to the existing `status` column (which tracks setup state: active/setup/error) — a project can be `status='active'` AND `access_status='inaccessible'`. Webhook handlers (`server/api/webhooks/github.post.ts`): - `installation_repositories.added` — restore matching projects to `accessible` when the App regains access to a previously-revoked repo (e.g. user re-selects it in the App settings). - `installation_repositories.removed` — flip matching projects to `inaccessible`. - `repository.deleted` — flip to `deleted` (terminal state; project becomes read-only and the only recovery is disconnect). - `repository.renamed` — update `repo_full_name` so subsequent API calls hit the new path. The old name comes from `changes.repository.name.from`; we prepend the owner login from `repository.owner.login` to reconstruct the full old name. All four handlers are scoped to the matching `installation_id` to prevent cross-tenant collisions on the same `repo_full_name`. DB methods: - `updateProjectAccessStatus({installationId, repoFullName}, status)` — resolves `workspaces.id` set for the installation, then UPDATEs matching projects. Two-step because supabase-js doesn't expose JOIN-update via its builder. - `renameProjectRepo({installationId, oldFullName}, newFullName)` — same shape for the rename case. UI: - `AppSidebar.vue` project links render a small status dot when `access_status !== 'accessible'` (warning-400 for inaccessible, danger-400 for deleted) — matches the existing healthScore badge pattern. Tooltip carries the per-state label. - Project page (`pages/w/[slug]/projects/[projectId]/index.vue`) shows a banner above the chat/content panels with: - icon + title + hint per status - "Manage in GitHub" CTA linking to the App's settings page (omitted in the `deleted` case — nothing to manage there) - New i18n strings: github.repo_access_revoked_{title,hint}, github.repo_deleted_{title,hint}, github.manage_app_settings_button, projects.access_inaccessible_badge, projects.access_deleted_badge. Tests: - 4 new integration cases on `github-webhook.integration.test.ts`: installation_repositories.added/removed, repository.deleted, and repository.renamed. Full suite: 582/582 (was 578). Files: 8 modified, 1 new (337+/1-). --- .../content/system/ui-strings/en.json | 7 + app/components/organisms/AppSidebar.vue | 9 +- app/composables/useProjects.ts | 7 + .../w/[slug]/projects/[projectId]/index.vue | 47 ++++++ server/api/webhooks/github.post.ts | 70 +++++++++ server/providers/database.ts | 20 +++ server/providers/supabase-db/projects.ts | 44 ++++++ .../migrations/005_project_access_status.sql | 39 +++++ .../github-webhook.integration.test.ts | 134 ++++++++++++++++++ 9 files changed, 376 insertions(+), 1 deletion(-) create mode 100644 supabase/migrations/005_project_access_status.sql diff --git a/.contentrain/content/system/ui-strings/en.json b/.contentrain/content/system/ui-strings/en.json index a5ecc438..0815d0a9 100644 --- a/.contentrain/content/system/ui-strings/en.json +++ b/.contentrain/content/system/ui-strings/en.json @@ -394,11 +394,16 @@ "github.install_title": "Install GitHub App", "github.installation_access_denied": "You don't have access to this installation on GitHub.", "github.installation_already_bound": "Already connected to {workspace}", + "github.manage_app_settings_button": "Manage in GitHub", "github.no_existing_installations": "No installations found", "github.no_existing_installations_hint": "You don't have any GitHub App installations yet. Install the app to continue.", "github.no_repos": "No repositories found.", "github.no_repos_hint": "Try adjusting your search or check your GitHub App installation permissions.", "github.reconnect_button": "Reconnect GitHub", + "github.repo_access_revoked_hint": "GitHub revoked the App's access to this repository. Update the App's repository access in GitHub settings to restore it.", + "github.repo_access_revoked_title": "Repository access revoked", + "github.repo_deleted_hint": "This repository was deleted on GitHub. The project is read-only — you can disconnect it from workspace settings.", + "github.repo_deleted_title": "Repository deleted on GitHub", "github.scan_failed": "Failed to scan repository. Please try again.", "github.unknown_framework": "Unknown framework", "github.user_token_missing": "Your GitHub authorization has expired or was never granted. Reconnect to list your installations.", @@ -573,6 +578,8 @@ "project_settings.workflow_pro_hint": "Upgrade to Pro to enable review workflow with branch-based content approval.", "project_settings.workflow_review": "Review required", "project_settings.workflow_review_desc": "Editors create branches. Reviewers approve before merge.", + "projects.access_deleted_badge": "Repository deleted on GitHub", + "projects.access_inaccessible_badge": "GitHub access revoked", "projects.connect_repo": "Connect repository", "projects.connected_error": "Failed to connect repository", "projects.connected_success": "Repository connected successfully", diff --git a/app/components/organisms/AppSidebar.vue b/app/components/organisms/AppSidebar.vue index 10ad0406..95014645 100644 --- a/app/components/organisms/AppSidebar.vue +++ b/app/components/organisms/AppSidebar.vue @@ -74,6 +74,7 @@ const sidebarLinks = computed(() => { label: p.repo_full_name.split('/').pop() ?? p.repo_full_name, to: `/w/${slug}/projects/${p.id}`, active: p.id === currentProjectId.value, + accessStatus: (p as { access_status?: 'accessible' | 'inaccessible' | 'deleted' }).access_status ?? 'accessible', })) }) @@ -284,7 +285,13 @@ function onProjectDeleted() { class="icon-[annon--folder] size-4 shrink-0" :class="link.active ? 'opacity-100' : 'opacity-60'" aria-hidden="true" /> - {{ link.label }} + {{ link.label }} +
  • diff --git a/app/composables/useProjects.ts b/app/composables/useProjects.ts index 8b4c123b..0e9bfff7 100644 --- a/app/composables/useProjects.ts +++ b/app/composables/useProjects.ts @@ -6,6 +6,13 @@ interface Project { content_root: string detected_stack: string | null status: string + /** + * Repo-level access state on GitHub's side, separate from the setup- + * lifecycle `status` field above. Set by webhook handlers + * (`installation_repositories.removed` / `repository.deleted`) and + * reflected in the project page banner + sidebar badge. + */ + access_status?: 'accessible' | 'inaccessible' | 'deleted' created_at: string } diff --git a/app/pages/w/[slug]/projects/[projectId]/index.vue b/app/pages/w/[slug]/projects/[projectId]/index.vue index 8479ad09..14892e27 100644 --- a/app/pages/w/[slug]/projects/[projectId]/index.vue +++ b/app/pages/w/[slug]/projects/[projectId]/index.vue @@ -242,6 +242,53 @@ async function handleVocabularySave(terms: Record