Skip to content

Commit 50b5255

Browse files
committed
feat: integration details
1 parent aba7af7 commit 50b5255

File tree

25 files changed

+507
-142
lines changed

25 files changed

+507
-142
lines changed

packages/deploy/src/supabase/deploy.ts renamed to apps/deploy-worker/src/deploy.ts

+26-15
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,31 @@
1-
import type { SupabaseClient, SupabaseProviderMetadata } from './types.js'
21
import { exec as execSync } from 'node:child_process'
32
import { promisify } from 'node:util'
4-
import { createDeployedDatabase } from './create-deployed-database.js'
5-
import { getDatabaseUrl, getPoolerUrl } from './get-database-url.js'
6-
import { DeployError, IntegrationRevokedError } from '../error.js'
7-
import { generatePassword } from './generate-password.js'
8-
import { getAccessToken } from './get-access-token.js'
9-
import { createManagementApiClient } from './management-api/client.js'
3+
import { DeployError, IntegrationRevokedError } from '@database.build/deploy'
4+
import {
5+
getAccessToken,
6+
createManagementApiClient,
7+
createDeployedDatabase,
8+
generatePassword,
9+
getDatabaseUrl,
10+
getPoolerUrl,
11+
type SupabaseClient,
12+
type SupabaseDeploymentConfig,
13+
type SupabasePlatformConfig,
14+
type SupabaseProviderMetadata,
15+
} from '@database.build/deploy/supabase'
1016
const exec = promisify(execSync)
1117

1218
/**
1319
* Deploy a local database on Supabase
1420
* If the database was already deployed, it will overwrite the existing database data
1521
*/
1622
export async function deploy(
17-
ctx: { supabase: SupabaseClient },
23+
ctx: {
24+
supabase: SupabaseClient
25+
supabaseAdmin: SupabaseClient
26+
supabasePlatformConfig: SupabasePlatformConfig
27+
supabaseDeploymentConfig: SupabaseDeploymentConfig
28+
},
1829
params: { databaseId: string; integrationId: number; localDatabaseUrl: string }
1930
) {
2031
// check if the integration is still active
@@ -32,13 +43,13 @@ export async function deploy(
3243
throw new IntegrationRevokedError()
3344
}
3445

35-
const accessToken = await getAccessToken({
46+
const accessToken = await getAccessToken(ctx, {
3647
integrationId: params.integrationId,
3748
// the integration isn't revoked, so it must have credentials
3849
credentialsSecretId: integration.data.credentials!,
3950
})
4051

41-
const managementApiClient = createManagementApiClient(accessToken)
52+
const managementApiClient = createManagementApiClient(ctx, accessToken)
4253

4354
// this is just to check if the integration is still active, an IntegrationRevokedError will be thrown if not
4455
await managementApiClient.GET('/v1/organizations')
@@ -75,10 +86,10 @@ export async function deploy(
7586
let databasePassword: string | undefined
7687

7788
if (!deployedDatabase.data) {
78-
const createdDeployedDatabase = await createDeployedDatabase(
79-
{ supabase: ctx.supabase },
80-
{ databaseId: params.databaseId, integrationId: params.integrationId }
81-
)
89+
const createdDeployedDatabase = await createDeployedDatabase(ctx, {
90+
databaseId: params.databaseId,
91+
integrationId: params.integrationId,
92+
})
8293

8394
deployedDatabase.data = createdDeployedDatabase.deployedDatabase
8495
databasePassword = createdDeployedDatabase.databasePassword
@@ -186,7 +197,7 @@ export async function deploy(
186197

187198
return {
188199
name: project.name,
189-
url: `${process.env.SUPABASE_PLATFORM_URL}/dashboard/project/${project.id}`,
200+
url: `${ctx.supabasePlatformConfig.url}/dashboard/project/${project.id}`,
190201
databasePassword,
191202
databaseUrl: getDatabaseUrl({ project, databasePassword }),
192203
poolerUrl: getPoolerUrl({ project, databasePassword }),

apps/deploy-worker/src/index.ts

+37-5
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,30 @@
11
import { DeployError, IntegrationRevokedError } from '@database.build/deploy'
2-
import { createClient } from '@database.build/deploy/supabase'
3-
import { deploy } from '@database.build/deploy/supabase'
2+
import {
3+
type Database,
4+
type Region,
5+
type SupabaseDeploymentConfig,
6+
type SupabasePlatformConfig,
7+
} from '@database.build/deploy/supabase'
48
import { revokeIntegration } from '@database.build/deploy/supabase'
59
import { serve } from '@hono/node-server'
610
import { zValidator } from '@hono/zod-validator'
11+
import { createClient } from '@supabase/supabase-js'
712
import { Hono } from 'hono'
813
import { cors } from 'hono/cors'
914
import { HTTPException } from 'hono/http-exception'
1015
import { z } from 'zod'
16+
import { deploy } from './deploy.ts'
17+
18+
const supabasePlatformConfig: SupabasePlatformConfig = {
19+
url: process.env.SUPABASE_PLATFORM_URL!,
20+
apiUrl: process.env.SUPABASE_PLATFORM_API_URL!,
21+
oauthClientId: process.env.SUPABASE_OAUTH_CLIENT_ID!,
22+
oauthSecret: process.env.SUPABASE_OAUTH_SECRET!,
23+
}
24+
25+
const supabaseDeploymentConfig: SupabaseDeploymentConfig = {
26+
region: process.env.SUPABASE_PLATFORM_DEPLOY_REGION! as Region,
27+
}
1128

1229
const app = new Hono()
1330

@@ -32,7 +49,15 @@ app.post(
3249
throw new HTTPException(401, { message: 'Unauthorized' })
3350
}
3451

35-
const supabase = createClient()
52+
const supabaseAdmin = createClient<Database>(
53+
process.env.SUPABASE_URL!,
54+
process.env.SUPABASE_SERVICE_ROLE_KEY!
55+
)
56+
57+
const supabase = createClient<Database>(
58+
process.env.SUPABASE_URL!,
59+
process.env.SUPABASE_ANON_KEY!
60+
)
3661

3762
const { error } = await supabase.auth.setSession({
3863
access_token: accessToken,
@@ -43,16 +68,23 @@ app.post(
4368
throw new HTTPException(401, { message: 'Unauthorized' })
4469
}
4570

71+
const ctx = {
72+
supabase,
73+
supabaseAdmin,
74+
supabasePlatformConfig,
75+
supabaseDeploymentConfig,
76+
}
77+
4678
try {
47-
const project = await deploy({ supabase }, { databaseId, integrationId, localDatabaseUrl })
79+
const project = await deploy(ctx, { databaseId, integrationId, localDatabaseUrl })
4880
return c.json({ project })
4981
} catch (error: unknown) {
5082
console.error(error)
5183
if (error instanceof DeployError) {
5284
throw new HTTPException(500, { message: error.message })
5385
}
5486
if (error instanceof IntegrationRevokedError) {
55-
await revokeIntegration({ supabase }, { integrationId })
87+
await revokeIntegration(ctx, { integrationId })
5688
throw new HTTPException(406, { message: error.message })
5789
}
5890
throw new HTTPException(500, { message: 'Internal server error' })

apps/deploy-worker/tsconfig.json

+1
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
"extends": "@total-typescript/tsconfig/tsc/no-dom/app",
33
"include": ["src/**/*.ts"],
44
"compilerOptions": {
5+
"allowImportingTsExtensions": true,
56
"noEmit": true,
67
"outDir": "dist"
78
}

apps/web/.env.example

+1
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ NEXT_PUBLIC_SUPABASE_URL="<supabase-api-url>"
33
NEXT_PUBLIC_BROWSER_PROXY_DOMAIN="browser.dev.db.build"
44
NEXT_PUBLIC_DEPLOY_WORKER_DOMAIN="http://localhost:4000"
55
NEXT_PUBLIC_SUPABASE_OAUTH_CLIENT_ID="<supabase-oauth-client-id>"
6+
NEXT_PUBLIC_SUPABASE_PLATFORM_URL=https://supabase.com
67
NEXT_PUBLIC_SUPABASE_PLATFORM_API_URL=https://api.supabase.com
78

89
OPENAI_API_KEY="<openai-api-key>"
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import { IntegrationRevokedError } from '@database.build/deploy'
2+
import {
3+
createManagementApiClient,
4+
getAccessToken,
5+
revokeIntegration,
6+
SupabasePlatformConfig,
7+
} from '@database.build/deploy/supabase'
8+
import { createAdminClient, createClient } from '~/utils/supabase/server'
9+
10+
export type IntegrationDetails = {
11+
id: number
12+
provider: {
13+
id: number
14+
name: string
15+
}
16+
organization: {
17+
id: string
18+
name: string
19+
}
20+
}
21+
22+
const supabasePlatformConfig: SupabasePlatformConfig = {
23+
url: process.env.NEXT_PUBLIC_SUPABASE_PLATFORM_URL!,
24+
apiUrl: process.env.NEXT_PUBLIC_SUPABASE_PLATFORM_API_URL!,
25+
oauthClientId: process.env.NEXT_PUBLIC_SUPABASE_OAUTH_CLIENT_ID!,
26+
oauthSecret: process.env.SUPABASE_OAUTH_SECRET!,
27+
}
28+
29+
/**
30+
* Gets the details of an integration by querying the Supabase
31+
* management API. Details include the organization ID and name
32+
* that the integration is scoped to.
33+
*/
34+
export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) {
35+
const { id } = await params
36+
const supabase = createClient()
37+
const supabaseAdmin = createAdminClient()
38+
39+
const ctx = {
40+
supabase,
41+
supabaseAdmin,
42+
supabasePlatformConfig,
43+
}
44+
45+
const integrationId = parseInt(id, 10)
46+
47+
try {
48+
const { data: integration, error: getIntegrationError } = await supabase
49+
.from('deployment_provider_integrations')
50+
.select('*, provider:deployment_providers!inner(id, name)')
51+
.eq('id', integrationId)
52+
.single()
53+
54+
if (getIntegrationError) {
55+
throw new Error('Integration not found', { cause: getIntegrationError })
56+
}
57+
58+
if (integration.revoked_at) {
59+
throw new IntegrationRevokedError()
60+
}
61+
62+
const credentialsSecretId = integration.credentials
63+
64+
if (!credentialsSecretId) {
65+
throw new Error('Integration has no credentials')
66+
}
67+
68+
if (!integration.scope) {
69+
throw new Error('Integration has no scope')
70+
}
71+
72+
if (
73+
typeof integration.scope !== 'object' ||
74+
!('organizationId' in integration.scope) ||
75+
typeof integration.scope.organizationId !== 'string'
76+
) {
77+
throw new Error('Integration scope is invalid')
78+
}
79+
80+
const accessToken = await getAccessToken(ctx, {
81+
integrationId: integration.id,
82+
credentialsSecretId,
83+
})
84+
85+
const managementApiClient = createManagementApiClient(ctx, accessToken)
86+
87+
const { data: organization, error: getOrgError } = await managementApiClient.GET(
88+
`/v1/organizations/{slug}`,
89+
{
90+
params: {
91+
path: {
92+
slug: integration.scope.organizationId,
93+
},
94+
},
95+
}
96+
)
97+
98+
if (getOrgError) {
99+
throw new Error('Failed to retrieve organization', { cause: getOrgError })
100+
}
101+
102+
const integrationDetails: IntegrationDetails = {
103+
id: integration.id,
104+
provider: {
105+
id: integration.provider.id,
106+
name: integration.provider.name,
107+
},
108+
organization: {
109+
id: organization.id,
110+
name: organization.name,
111+
},
112+
}
113+
114+
return Response.json(integrationDetails)
115+
} catch (error: unknown) {
116+
console.error(error)
117+
118+
if (error instanceof IntegrationRevokedError) {
119+
await revokeIntegration(ctx, { integrationId })
120+
return Response.json({ message: error.message }, { status: 406 })
121+
}
122+
123+
if (error instanceof Error) {
124+
return Response.json({ message: error.message }, { status: 400 })
125+
}
126+
127+
return Response.json({ message: 'Internal server error' }, { status: 500 })
128+
}
129+
}

apps/web/app/api/oauth/supabase/callback/route.ts

+7-16
Original file line numberDiff line numberDiff line change
@@ -74,8 +74,6 @@ export async function GET(req: NextRequest) {
7474
token_type: 'Bearer'
7575
}
7676

77-
console.log({ tokens })
78-
7977
const organizationsResponse = await fetch(
8078
`${process.env.NEXT_PUBLIC_SUPABASE_PLATFORM_API_URL}/v1/organizations`,
8179
{
@@ -128,9 +126,11 @@ export async function GET(req: NextRequest) {
128126

129127
const adminClient = createAdminClient()
130128

129+
const secretName = `oauth_credentials_supabase_${organization.id}_${user.id}`
130+
131131
// store the tokens as secret
132-
const credentialsSecret = await adminClient.rpc('insert_secret', {
133-
name: `oauth_credentials_supabase_${organization.id}_${user.id}`,
132+
const credentialsSecret = await adminClient.rpc('upsert_secret', {
133+
name: secretName,
134134
secret: JSON.stringify({
135135
accessToken: tokens.access_token,
136136
expiresAt: new Date(now + tokens.expires_in * 1000).toISOString(),
@@ -139,12 +139,11 @@ export async function GET(req: NextRequest) {
139139
})
140140

141141
if (credentialsSecret.error) {
142+
console.error(credentialsSecret.error)
142143
return new Response('Failed to store the integration credentials as secret', { status: 500 })
143144
}
144145

145-
let integrationId: number
146-
147-
// if an existing revoked integration exists, update the tokens and cancel the revokation
146+
// if an existing revoked integration exists, update the tokens and cancel the revocation
148147
if (revokedIntegration) {
149148
const updateIntegrationResponse = await supabase
150149
.from('deployment_provider_integrations')
@@ -157,8 +156,6 @@ export async function GET(req: NextRequest) {
157156
if (updateIntegrationResponse.error) {
158157
return new Response('Failed to update integration', { status: 500 })
159158
}
160-
161-
integrationId = revokedIntegration.id
162159
} else {
163160
const createIntegrationResponse = await supabase
164161
.from('deployment_provider_integrations')
@@ -175,13 +172,7 @@ export async function GET(req: NextRequest) {
175172
if (createIntegrationResponse.error) {
176173
return new Response('Failed to create integration', { status: 500 })
177174
}
178-
179-
integrationId = createIntegrationResponse.data.id
180175
}
181176

182-
const params = new URLSearchParams({
183-
integration: integrationId.toString(),
184-
})
185-
186-
return NextResponse.redirect(new URL(`/deploy/${state.databaseId}?${params.toString()}`, req.url))
177+
return NextResponse.redirect(new URL(`/db/${state.databaseId}?deploy=Supabase`, req.url))
187178
}

apps/web/app/api/oauth/supabase/organizations/route.ts

-6
This file was deleted.

0 commit comments

Comments
 (0)