Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .contentrain/content/system/error-messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,9 @@
"migrate.grant_bound_elsewhere": "This Studio offer is already tied to another workspace.",
"migrate.grant_not_found": "We couldn’t find this Studio offer on your account.",
"migrate.grant_used": "This Studio offer has already been used — its included days started on a subscription for this workspace.",
"migrate.grant_not_ready": "Studio is not ready for GitHub yet. Finish the Studio plan step in Migrate first.",
"migrate.install_already": "Studio is already connected to GitHub for this migration.",
"migrate.install_state_invalid": "This GitHub connection link is not valid or has expired. Start again from your Migrate page.",
"migrate.unavailable": "Studio offers from Contentrain Migrate are not available on this Studio.",
"migration.export_fetch_failed": "Could not fetch the comments export from its URL",
"migration.export_too_large": "The comments export is too large to fetch; upload it in chunks instead",
Expand Down
4 changes: 4 additions & 0 deletions nuxt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,10 @@ export default defineNuxtConfig({
// fetched from (comma-separated, e.g. https://migrate.contentrain.io).
// Empty = no fetch; the project's comments settings offer the upload.
origins: '',
// NUXT_MIGRATE_INSTALL_STATE_KEY — HS256 secret (min 32 chars) that signs
// the `state` of the GitHub App install URL Migrate hands a customer. Only
// Studio verifies it. Empty = the install-url route is off.
installStateKey: '',
},
stripe: {
secretKey: '', // NUXT_STRIPE_SECRET_KEY (optional — legacy Stripe plugin)
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@
"@aws-sdk/client-s3": "^3.1076.0",
"@contentrain/mcp": "3.9.0",
"@contentrain/query": "7.4.0",
"@contentrain/types": "1.40.0",
"@contentrain/types": "1.42.0",
"@gitbeaker/rest": "^43.8.0",
"@nuxt/eslint": "1.16.0",
"@nuxt/image": "2.0.0",
Expand Down
10 changes: 5 additions & 5 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 12 additions & 2 deletions server/api/github/setup.get.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { useDatabaseProvider, useGitAppService } from '../../utils/providers'
import { getValidGitHubUserToken } from '../../utils/github-token'
import { looksLikeMigrateInstallState } from '../../utils/migrate-install-state'
import { handleMigrateInstallCallback } from '../../utils/migrate-install-callback'

/**
* GitHub App installation callback.
Expand All @@ -16,15 +18,23 @@ import { getValidGitHubUserToken } from '../../utils/github-token'
* "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.
*
* A `state` that is a Studio-signed token (not a workspace id) is the install
* a Migrate customer started beside a live move: it carries no session and
* is handled by `handleMigrateInstallCallback`. The auth middleware lets only
* that shape through unauthenticated; everything below is unchanged.
*/
export default defineEventHandler(async (event) => {
const session = requireAuth(event)
const db = useDatabaseProvider()
const query = getQuery(event) as {
installation_id?: string
setup_action?: string
code?: string
state?: string // workspace ID passed during GitHub App install
}
if (looksLikeMigrateInstallState(query.state)) return handleMigrateInstallCallback(event, query)

const session = requireAuth(event)
const db = useDatabaseProvider()

if (!query.installation_id) {
throw createError({ statusCode: 400, message: errorMessage('github.installation_id_missing') })
Expand Down
46 changes: 46 additions & 0 deletions server/api/migrate/grants/install-url.post.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/**
* POST /api/migrate/grants/install-url
*
* Migrate asks, server to server, for the GitHub App install address the
* customer opens while their move runs (W39). Body `{ token }`: a request
* signed with Migrate's key (`MigrateInstallUrlRequest`), single-use by
* `jti`, keyed by `order_id`.
*
* Offered only once the grant is redeemed (the subscription runs: a project
* could not be opened before, 402) and Studio's App is not yet installed on
* the grant's workspace. The address carries a Studio-signed `state` naming
* the grant and workspace, and no repository: the delivery repo does not
* exist during the move. GitHub returns the customer to the setup callback,
* which binds the installation and signs them in.
*/
import { validateMigrateInstallUrlRequest, validateMigrateInstallUrlResponse } from '@contentrain/types'
import { migrateGrantInstallation, migrateGrantStateOf } from '../../../utils/migrate-grant-status'
import { migrateInstallStateKey, signMigrateInstallState } from '../../../utils/migrate-install-state'
import { readMigrateS2sRequest } from '../../../utils/migrate-s2s-route'

export default defineEventHandler(async (event) => {
const key = migrateInstallStateKey()
if (!key) throw createError({ statusCode: 404, message: errorMessage('migrate.unavailable') })

const request = await readMigrateS2sRequest(event, 'install-url', validateMigrateInstallUrlRequest)

const grant = await useDatabaseProvider().getMigrateGrantByOrderId(request.order_id)
if (!grant) throw createError({ statusCode: 404, message: errorMessage('migrate.grant_not_found') })
if (migrateGrantStateOf(grant) !== 'redeemed' || !grant.workspace_id)
throw createError({ statusCode: 409, message: errorMessage('migrate.grant_not_ready') })

const { installed } = await migrateGrantInstallation(grant)
if (installed) throw createError({ statusCode: 409, message: errorMessage('migrate.install_already') })

const { token, state } = await signMigrateInstallState({
grantId: grant.id as string,
workspaceId: grant.workspace_id as string,
userId: grant.user_id as string,
}, key)

const slug = (useRuntimeConfig().public.githubAppSlug as string | undefined) || 'contentrain-studio'
const response = { url: `https://github.com/apps/${slug}/installations/new?state=${token}`, expires_at: state.exp }
if (!validateMigrateInstallUrlResponse(response).ok)
throw createError({ statusCode: 500, message: errorMessage('migrate.s2s_invalid') })
return response
})
31 changes: 31 additions & 0 deletions server/api/migrate/grants/status.post.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/**
* POST /api/migrate/grants/status
*
* Migrate asks, server to server, where an order's Studio grant stands, so
* its page can show the Studio card (W39). Body `{ token }`: a request signed
* with Migrate's key (`MigrateGrantStatusRequest`, `@contentrain/types`),
* single-use by `jti`, keyed by `order_id`. Not a user surface: no session.
*
* Answers only the state and whether Studio's GitHub App is installed for the
* grant's workspace — never an account, workspace or email. An order Studio
* holds no grant for is a 404.
*/
import { validateMigrateGrantStatusRequest, validateMigrateGrantStatusResponse } from '@contentrain/types'
import { migrateGrantInstallation, migrateGrantStateOf } from '../../../utils/migrate-grant-status'
import { readMigrateS2sRequest } from '../../../utils/migrate-s2s-route'

export default defineEventHandler(async (event) => {
const request = await readMigrateS2sRequest(event, 'grant-status', validateMigrateGrantStatusRequest)

const grant = await useDatabaseProvider().getMigrateGrantByOrderId(request.order_id)
if (!grant) throw createError({ statusCode: 404, message: errorMessage('migrate.grant_not_found') })

const state = migrateGrantStateOf(grant)
const { installed } = await migrateGrantInstallation(grant)
// An install only counts once the subscription ran (the contract refuses it earlier).
const response = { state, installed: installed && state === 'redeemed' }
// Fail closed on our own answer: Migrate shows it to a customer.
if (!validateMigrateGrantStatusResponse(response).ok)
throw createError({ statusCode: 500, message: errorMessage('migrate.s2s_invalid') })
return response
})
15 changes: 14 additions & 1 deletion server/middleware/01.auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,14 +50,27 @@ const MIGRATE_S2S_PATHS = [
'/api/migrate/grants/install-url',
]

/**
* The GitHub App setup callback of an install Migrate started: GitHub sends
* the customer back with Studio's signed `state` and no Studio session. Only a
* token-shaped `state` passes; an in-app install (workspace-id `state`) is
* still session-protected, and the route verifies the token itself.
*/
function isMigrateInstallCallback(path: string, event: Parameters<typeof getQuery>[0]): boolean {
// `getRequestPath` keeps the query string; the callback always has one.
if (path.split('?', 1)[0] !== '/api/github/setup') return false
return looksLikeMigrateInstallState((getQuery(event) as { state?: unknown }).state)
}

// Refresh tokens 5 minutes before expiry to avoid edge-case failures
const REFRESH_BUFFER_SECONDS = 5 * 60

export default defineEventHandler(async (event) => {
const path = getRequestPath(event)

// Skip non-API routes and public paths
if (!path.startsWith('/api') || PUBLIC_PATHS.some(p => path.startsWith(p)) || MIGRATE_S2S_PATHS.includes(path))
if (!path.startsWith('/api') || PUBLIC_PATHS.some(p => path.startsWith(p)) || MIGRATE_S2S_PATHS.includes(path)
|| isMigrateInstallCallback(path, event))
return

let sessionData
Expand Down
3 changes: 3 additions & 0 deletions server/providers/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1114,6 +1114,9 @@ export interface DatabaseProvider {
origin?: string | null
}) => Promise<{ grant: DatabaseRow, created: boolean }>

/** A grant by the Migrate order it belongs to (one per order); null when Studio holds none. For Migrate's server-to-server calls, which name an order and no user. */
getMigrateGrantByOrderId: (orderId: string) => Promise<DatabaseRow | null>

/** A grant, only if `userId` owns it. */
getMigrateGrantForUser: (grantId: string, userId: string) => Promise<DatabaseRow | null>

Expand Down
15 changes: 15 additions & 0 deletions server/providers/postgres-db/migrate-grants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { getAdmin, throwDbError } from './helpers'
type MigrateGrantMethods = Pick<
DatabaseProvider,
| 'claimMigrateGrant'
| 'getMigrateGrantByOrderId'
| 'getMigrateGrantForUser'
| 'bindMigrateGrantWorkspace'
| 'markMigrateGrantRedeemed'
Expand Down Expand Up @@ -113,6 +114,20 @@ export function migrateGrantMethods(): MigrateGrantMethods {
}
},

async getMigrateGrantByOrderId(orderId) {
try {
const row = await getAdmin()
.selectFrom('migrate_grants')
.selectAll()
.where('order_id', '=', orderId)
.executeTakeFirst()
return (row as DatabaseRow | undefined) ?? null
}
catch (error) {
throwDbError(error)
}
},

async getMigrateGrantForUser(grantId, userId) {
try {
const row = await getAdmin()
Expand Down
11 changes: 11 additions & 0 deletions server/providers/supabase-db/migrate-grants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { getAdmin } from './helpers'
type MigrateGrantMethods = Pick<
DatabaseProvider,
| 'claimMigrateGrant'
| 'getMigrateGrantByOrderId'
| 'getMigrateGrantForUser'
| 'bindMigrateGrantWorkspace'
| 'markMigrateGrantRedeemed'
Expand Down Expand Up @@ -125,6 +126,16 @@ export function migrateGrantMethods(): MigrateGrantMethods {
return { grant: existing as DatabaseRow, created: false }
},

async getMigrateGrantByOrderId(orderId) {
const { data, error } = await getAdmin()
.from('migrate_grants')
.select('*')
.eq('order_id', orderId)
.maybeSingle()
if (error) fail(error.message)
return (data as DatabaseRow | null) ?? null
},

async getMigrateGrantForUser(grantId, userId) {
const { data, error } = await getAdmin()
.from('migrate_grants')
Expand Down
57 changes: 57 additions & 0 deletions server/utils/github-user-code.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/**
* Finish GitHub's "authorize during installation" for the setup callback: the
* `code` it sends back is exchanged, with the Studio App's own client
* credentials, for the installing user's tokens, and the user is read with
* them. Same client the sign-in uses (`NUXT_OAUTH_GITHUB_CLIENT_ID/SECRET`).
*/
import type { ProviderTokens } from '../providers/auth'

export interface GitHubInstallerIdentity {
/** GitHub's numeric user id, as a decimal string. */
id: string
login: string
tokens: ProviderTokens
}

const toUnixOrNull = (seconds: unknown): number | null =>
typeof seconds === 'number' ? Math.floor(Date.now() / 1000) + seconds : null

/** The installer, or null when the code is not accepted or GitHub cannot be asked. */
export async function exchangeGitHubInstallCode(code: string): Promise<GitHubInstallerIdentity | null> {
const github = (useRuntimeConfig().oauth as { github?: { clientId?: string, clientSecret?: string } } | undefined)?.github
if (!github?.clientId || !github.clientSecret) return null

try {
const token = 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: github.clientId, client_secret: github.clientSecret, code },
})
if (token.error || !token.access_token) return null

const user = await $fetch<{ id?: number, login?: string }>('https://api.github.com/user', {
headers: { Authorization: `Bearer ${token.access_token}`, Accept: 'application/vnd.github+json' },
})
if (typeof user.id !== 'number') return null

return {
id: String(user.id),
login: user.login ?? '',
tokens: {
accessToken: token.access_token,
refreshToken: token.refresh_token ?? null,
expiresAt: toUnixOrNull(token.expires_in),
refreshTokenExpiresAt: toUnixOrNull(token.refresh_token_expires_in),
},
}
}
catch {
return null
}
}
20 changes: 20 additions & 0 deletions server/utils/migrate-grant-status.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/**
* Where a Migrate grant stands, for Migrate's status call and its install-URL
* gate. Lifecycle: migration 031 (claimed → bound → redeemed). `revoked` is
* part of the contract; no stored status maps to it until the revoke column
* exists.
*/
import type { MigrateGrantState } from '@contentrain/types'
import type { DatabaseRow } from '../providers/database'

export function migrateGrantStateOf(grant: DatabaseRow): MigrateGrantState {
return grant.redeemed_at ? 'redeemed' : grant.bound_at ? 'bound' : 'claimed'
}

/** The workspace row for a bound grant, with whether Studio's GitHub App is installed on it. */
export async function migrateGrantInstallation(grant: DatabaseRow): Promise<{ workspace: DatabaseRow | null, installed: boolean }> {
const workspaceId = grant.workspace_id as string | null
if (!workspaceId) return { workspace: null, installed: false }
const workspace = await useDatabaseProvider().getWorkspaceById(workspaceId, 'id, slug, github_installation_id')
return { workspace, installed: workspace?.github_installation_id != null }
}
Loading
Loading