diff --git a/.contentrain/content/system/ui-strings/en.json b/.contentrain/content/system/ui-strings/en.json
index 8678012d..0815d0a9 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,22 @@
"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.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.",
+ "github.user_token_missing_title": "Reconnect GitHub",
"health.ask_agent": "Ask Agent to Fix",
"health.critical": "Critical",
"health.dismiss": "Dismiss",
@@ -556,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/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/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/components/organisms/ConnectRepoDialog.vue b/app/components/organisms/ConnectRepoDialog.vue
index fa36e5ba..7a951b3d 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
}
@@ -157,6 +175,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)
@@ -240,6 +321,25 @@ onUnmounted(() => window.removeEventListener('focus', onWindowFocus))
{{ t('github.install_hint') }}
+
+
+
+
+
{{ t('common.or') }}
+
+
+
+
+ {{ t('github.connect_existing_button') }}
+
+
+ {{ t('github.connect_existing_hint') }}
+
+
@@ -247,6 +347,114 @@ onUnmounted(() => window.removeEventListener('focus', onWindowFocus))
+
+
+
+
+
+
+ {{ t('common.back') }}
+
+
+ {{ t('github.connect_existing_picker_title') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('github.reconnect_button') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('github.install_button') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ inst.account.login ?? '—' }}
+
+
+ {{ inst.targetType === 'Organization' ? t('settings.github_org') : t('settings.github_user') }}
+
+
+
+ {{ t('github.installation_already_bound').replace('{workspace}', inst.boundWorkspace.name) }}
+
+
+
+
+
+
+
+
+
+
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"
- />
+ >
+
+
+
+
+
+ {{ t('danger_zone.uninstall_app_label') }}
+
+
+ {{ t('danger_zone.uninstall_app_hint') }}
+
+
+
+
+
+
+
+
+
+ {{ t('danger_zone.cancel_subscription_label') }}
+
+
+ {{ t('danger_zone.cancel_subscription_hint') }}
+
+
+
+
+
+
{
+ 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/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
+
+
+
+
+
+ {{ project.access_status === 'deleted' ? t('github.repo_deleted_title') : t('github.repo_access_revoked_title') }}
+
+
+ {{ project.access_status === 'deleted' ? t('github.repo_deleted_hint') : t('github.repo_access_revoked_hint') }}
+
+
+
+ {{ t('github.manage_app_settings_button') }}
+
+
+
{
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..4983ea98 100644
--- a/server/api/webhooks/github.post.ts
+++ b/server/api/webhooks/github.post.ts
@@ -117,15 +117,109 @@ 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 }
}
+ // Repo-level access changes within a single installation. Fired when
+ // the installation owner edits the App's "Repository access" setting
+ // (toggle "All repositories" ↔ "Only select repositories" or add/
+ // remove specific repos). Without this handling, a project bound to
+ // a now-revoked repo would silently 404 on every commit/branch call.
+ if (eventType === 'installation_repositories') {
+ const action = body.action as string
+ const installationId = (body.installation as { id?: number })?.id
+ if (!installationId)
+ return { ok: true, event: 'installation_repositories', action }
+
+ if (action === 'added') {
+ // Repos the installation can now access. If a project for one of
+ // these existed in a degraded state (previously flipped to
+ // 'inaccessible' by a prior `removed` event), restore it.
+ const repos = (body.repositories_added as Array<{ full_name?: string }> | undefined) ?? []
+ for (const repo of repos) {
+ if (repo.full_name)
+ await db.updateProjectAccessStatus({ installationId, repoFullName: repo.full_name }, 'accessible')
+ }
+ return { ok: true, event: 'installation_repositories', action: 'added', count: repos.length }
+ }
+
+ if (action === 'removed') {
+ const repos = (body.repositories_removed as Array<{ full_name?: string }> | undefined) ?? []
+ for (const repo of repos) {
+ if (repo.full_name)
+ await db.updateProjectAccessStatus({ installationId, repoFullName: repo.full_name }, 'inaccessible')
+ }
+ return { ok: true, event: 'installation_repositories', action: 'removed', count: repos.length }
+ }
+
+ return { ok: true, event: 'installation_repositories', action }
+ }
+
+ // Repo lifecycle on GitHub's side (independent of App installation).
+ // Fires for renames + deletes of any repo the App has access to.
+ // We don't need separate `installation_id` lookups because the event
+ // payload includes the installation context and the repo's full_name
+ // is sufficient to identify our project — but we still scope by
+ // installation_id to avoid cross-tenant collisions.
+ if (eventType === 'repository') {
+ const action = body.action as string
+ const installationId = (body.installation as { id?: number })?.id
+ if (!installationId)
+ return { ok: true, event: 'repository', action }
+
+ if (action === 'deleted') {
+ const repoFullName = (body.repository as { full_name?: string })?.full_name
+ if (repoFullName)
+ await db.updateProjectAccessStatus({ installationId, repoFullName }, 'deleted')
+ return { ok: true, event: 'repository', action: 'deleted' }
+ }
+
+ if (action === 'renamed') {
+ // GitHub's `repository.renamed` payload format:
+ // { changes: { repository: { name: { from: '' } } },
+ // repository: { full_name: '', owner: {...} } }
+ const newFullName = (body.repository as { full_name?: string })?.full_name
+ const ownerLogin = ((body.repository as { owner?: { login?: string } })?.owner)?.login
+ const oldName = ((body.changes as { repository?: { name?: { from?: string } } })?.repository?.name)?.from
+ const oldFullName = ownerLogin && oldName ? `${ownerLogin}/${oldName}` : null
+ if (newFullName && oldFullName)
+ await db.renameProjectRepo({ installationId, oldFullName }, newFullName)
+ return { ok: true, event: 'repository', action: 'renamed' }
+ }
+
+ return { ok: true, event: 'repository', action }
+ }
+
return { ok: true, event: eventType }
})
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..c57968b4 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 }>
@@ -148,6 +191,26 @@ export interface DatabaseProvider {
listWorkspaceProjectsByIds: (workspaceId: string, projectIds: string[]) => Promise
listUserAssignedProjects: (accessToken: string, userId: string) => Promise
updateProjectContentTimestamp: (repoFullName: string) => Promise
+ /**
+ * Flip a project's repo-level access state. Scoped to projects owned
+ * by a workspace bound to `installationId` (joined via workspaces),
+ * because the same `repo_full_name` could in theory exist under
+ * multiple installations and we only want to touch the ones the
+ * webhook event applies to.
+ */
+ updateProjectAccessStatus: (
+ target: { installationId: number, repoFullName: string },
+ status: 'accessible' | 'inaccessible' | 'deleted',
+ ) => Promise
+ /**
+ * Rename a project's `repo_full_name` when the underlying GitHub repo
+ * is renamed. Scoped by installation_id so a webhook for one tenant
+ * doesn't accidentally rename a same-named project in another.
+ */
+ renameProjectRepo: (
+ target: { installationId: number, oldFullName: string },
+ newFullName: string,
+ ) => Promise
listCDNEnabledProjects: (repoFullName: string) => Promise
listAllActiveProjects: (fields?: string) => Promise
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/projects.ts b/server/providers/supabase-db/projects.ts
index 5b5bd74e..11617c1c 100644
--- a/server/providers/supabase-db/projects.ts
+++ b/server/providers/supabase-db/projects.ts
@@ -19,6 +19,8 @@ type ProjectMethods = Pick<
| 'listWorkspaceProjectsByIds'
| 'listUserAssignedProjects'
| 'updateProjectContentTimestamp'
+ | 'updateProjectAccessStatus'
+ | 'renameProjectRepo'
| 'listCDNEnabledProjects'
| 'listAllActiveProjects'
| 'listProjectMembers'
@@ -196,6 +198,48 @@ export function projectMethods(): ProjectMethods {
.eq('repo_full_name', repoFullName)
},
+ async updateProjectAccessStatus(target, status) {
+ const admin = getAdmin()
+ // Resolve workspaces matching the installation_id, then UPDATE
+ // projects whose workspace_id is in that set AND whose
+ // repo_full_name matches. Two-step instead of a JOIN because
+ // PostgREST doesn't expose join updates via the supabase-js
+ // builder; a direct SQL function would be cleaner but pulls
+ // in migration churn.
+ const { data: wsRows, error: wsErr } = await admin
+ .from('workspaces')
+ .select('id')
+ .eq('github_installation_id', target.installationId)
+ if (wsErr) throw createError({ statusCode: 500, message: wsErr.message })
+ const workspaceIds = (wsRows ?? []).map(w => w.id as string)
+ if (workspaceIds.length === 0) return
+
+ const { error } = await admin
+ .from('projects')
+ .update({ access_status: status })
+ .in('workspace_id', workspaceIds)
+ .eq('repo_full_name', target.repoFullName)
+ if (error) throw createError({ statusCode: 500, message: error.message })
+ },
+
+ async renameProjectRepo(target, newFullName) {
+ const admin = getAdmin()
+ const { data: wsRows, error: wsErr } = await admin
+ .from('workspaces')
+ .select('id')
+ .eq('github_installation_id', target.installationId)
+ if (wsErr) throw createError({ statusCode: 500, message: wsErr.message })
+ const workspaceIds = (wsRows ?? []).map(w => w.id as string)
+ if (workspaceIds.length === 0) return
+
+ const { error } = await admin
+ .from('projects')
+ .update({ repo_full_name: newFullName })
+ .in('workspace_id', workspaceIds)
+ .eq('repo_full_name', target.oldFullName)
+ if (error) throw createError({ statusCode: 500, message: error.message })
+ },
+
async listCDNEnabledProjects(repoFullName) {
const admin = getAdmin()
const { data, error } = await admin
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/supabase/migrations/005_project_access_status.sql b/supabase/migrations/005_project_access_status.sql
new file mode 100644
index 00000000..973fd0bc
--- /dev/null
+++ b/supabase/migrations/005_project_access_status.sql
@@ -0,0 +1,39 @@
+-- Project-level GitHub access tracking.
+--
+-- Migration 004 added workspace-level installation lifecycle tracking
+-- (`github_installation_status` on workspaces). That handles "App
+-- suspended / uninstalled" at the workspace boundary, but it does not
+-- capture per-repo access changes:
+--
+-- - User flips an installation from "All repositories" to "Only
+-- selected" and the connected repo is no longer in the set
+-- - Org admin / second owner removes a single repo from the
+-- installation's accessible list
+-- - Repo is deleted on GitHub
+-- - Repo is renamed on GitHub
+--
+-- In each case `workspaces.github_installation_id` is still valid and
+-- `github_installation_status='active'` is correct — the App itself
+-- works, but Studio's connected project for that specific repo will
+-- start receiving 404s on commit / branch / scan operations. Without
+-- a project-level signal there's no way to tell the user *why*, and
+-- no way to lock the project to a "needs reconnect" state.
+--
+-- `access_status` is independent of the existing `status` column
+-- (which tracks setup state: active/setup/error). A project can be
+-- `status='active'` AND `access_status='inaccessible'` — the project
+-- is otherwise healthy but its underlying repo lost access.
+
+ALTER TABLE public.projects
+ ADD COLUMN access_status text NOT NULL DEFAULT 'accessible';
+
+ALTER TABLE public.projects
+ ADD CONSTRAINT projects_access_status_check
+ CHECK (access_status IN ('accessible', 'inaccessible', 'deleted'));
+
+-- Webhook handlers look up projects by (installation_id from
+-- workspaces JOIN, repo_full_name). Repo_full_name is the high-
+-- cardinality field used in WHERE; index it directly. The webhook
+-- query already filters by workspace's installation_id via JOIN.
+CREATE INDEX IF NOT EXISTS idx_projects_repo_full_name
+ ON public.projects (repo_full_name);
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..17e47389 100644
--- a/tests/integration/github-webhook.integration.test.ts
+++ b/tests/integration/github-webhook.integration.test.ts
@@ -42,6 +42,207 @@ 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('flips matching projects to inaccessible on installation_repositories.removed', async () => {
+ const updateProjectAccessStatus = vi.fn().mockResolvedValue(undefined)
+ vi.stubGlobal('useRuntimeConfig', vi.fn().mockReturnValue({
+ github: { webhookSecret: 'webhook-secret' },
+ }))
+ vi.stubGlobal('useDatabaseProvider', vi.fn().mockReturnValue({
+ updateProjectAccessStatus,
+ }))
+
+ const { raw, signature } = signGithubBody('webhook-secret', {
+ action: 'removed',
+ installation: { id: 42 },
+ repositories_removed: [
+ { full_name: 'alice/repo-one' },
+ { full_name: 'alice/repo-two' },
+ ],
+ })
+
+ 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_repositories',
+ 'x-hub-signature-256': signature,
+ },
+ body: raw,
+ })
+ expect(response.status).toBe(200)
+ expect(updateProjectAccessStatus).toHaveBeenCalledTimes(2)
+ expect(updateProjectAccessStatus).toHaveBeenCalledWith({ installationId: 42, repoFullName: 'alice/repo-one' }, 'inaccessible')
+ expect(updateProjectAccessStatus).toHaveBeenCalledWith({ installationId: 42, repoFullName: 'alice/repo-two' }, 'inaccessible')
+ })
+ })
+
+ it('restores matching projects to accessible on installation_repositories.added', async () => {
+ const updateProjectAccessStatus = vi.fn().mockResolvedValue(undefined)
+ vi.stubGlobal('useRuntimeConfig', vi.fn().mockReturnValue({
+ github: { webhookSecret: 'webhook-secret' },
+ }))
+ vi.stubGlobal('useDatabaseProvider', vi.fn().mockReturnValue({
+ updateProjectAccessStatus,
+ }))
+
+ const { raw, signature } = signGithubBody('webhook-secret', {
+ action: 'added',
+ installation: { id: 42 },
+ repositories_added: [{ full_name: 'alice/repo-one' }],
+ })
+
+ 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_repositories',
+ 'x-hub-signature-256': signature,
+ },
+ body: raw,
+ })
+ expect(response.status).toBe(200)
+ expect(updateProjectAccessStatus).toHaveBeenCalledWith({ installationId: 42, repoFullName: 'alice/repo-one' }, 'accessible')
+ })
+ })
+
+ it('flips matching project to deleted on repository.deleted', async () => {
+ const updateProjectAccessStatus = vi.fn().mockResolvedValue(undefined)
+ vi.stubGlobal('useRuntimeConfig', vi.fn().mockReturnValue({
+ github: { webhookSecret: 'webhook-secret' },
+ }))
+ vi.stubGlobal('useDatabaseProvider', vi.fn().mockReturnValue({
+ updateProjectAccessStatus,
+ }))
+
+ const { raw, signature } = signGithubBody('webhook-secret', {
+ action: 'deleted',
+ installation: { id: 42 },
+ repository: { full_name: 'alice/repo-one' },
+ })
+
+ 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': 'repository',
+ 'x-hub-signature-256': signature,
+ },
+ body: raw,
+ })
+ expect(response.status).toBe(200)
+ expect(updateProjectAccessStatus).toHaveBeenCalledWith({ installationId: 42, repoFullName: 'alice/repo-one' }, 'deleted')
+ })
+ })
+
+ it('renames matching project on repository.renamed', async () => {
+ const renameProjectRepo = vi.fn().mockResolvedValue(undefined)
+ vi.stubGlobal('useRuntimeConfig', vi.fn().mockReturnValue({
+ github: { webhookSecret: 'webhook-secret' },
+ }))
+ vi.stubGlobal('useDatabaseProvider', vi.fn().mockReturnValue({
+ renameProjectRepo,
+ }))
+
+ const { raw, signature } = signGithubBody('webhook-secret', {
+ action: 'renamed',
+ installation: { id: 42 },
+ changes: { repository: { name: { from: 'repo-old' } } },
+ repository: { full_name: 'alice/repo-new', owner: { login: 'alice' } },
+ })
+
+ 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': 'repository',
+ 'x-hub-signature-256': signature,
+ },
+ body: raw,
+ })
+ expect(response.status).toBe(200)
+ expect(renameProjectRepo).toHaveBeenCalledWith({ installationId: 42, oldFullName: 'alice/repo-old' }, 'alice/repo-new')
+ })
+ })
+
it('clears linked workspaces when GitHub sends an installation.deleted event', async () => {
const clearWorkspaceGithubInstallation = vi.fn().mockResolvedValue(undefined)
vi.stubGlobal('useRuntimeConfig', vi.fn().mockReturnValue({