diff --git a/apps/backend/src/app/api/latest/integrations/domains/[domain]/route.tsx b/apps/backend/src/app/api/latest/integrations/domains/[domain]/route.tsx new file mode 100644 index 000000000..4e7aa14f1 --- /dev/null +++ b/apps/backend/src/app/api/latest/integrations/domains/[domain]/route.tsx @@ -0,0 +1,3 @@ +import { domainCrudHandlers } from "../crud"; + +export const DELETE = domainCrudHandlers.deleteHandler; diff --git a/apps/backend/src/app/api/latest/integrations/domains/crud.tsx b/apps/backend/src/app/api/latest/integrations/domains/crud.tsx new file mode 100644 index 000000000..418ea3fcb --- /dev/null +++ b/apps/backend/src/app/api/latest/integrations/domains/crud.tsx @@ -0,0 +1,82 @@ +import { createCrudHandlers } from "@/route-handlers/crud-handler"; +import { CrudTypeOf, createCrud } from "@stackframe/stack-shared/dist/crud"; +import * as schemaFields from "@stackframe/stack-shared/dist/schema-fields"; +import { yupMixed, yupObject } from "@stackframe/stack-shared/dist/schema-fields"; +import { StatusError } from "@stackframe/stack-shared/dist/utils/errors"; +import { createLazyProxy } from "@stackframe/stack-shared/dist/utils/proxies"; +import { projectsCrudHandlers } from "../../internal/projects/current/crud"; + +const domainSchema = schemaFields.urlSchema.defined() + .matches(/^https?:\/\//, 'URL must start with http:// or https://') + .meta({ openapiField: { description: 'URL. Must start with http:// or https://', exampleValue: 'https://example.com' } }); + +const domainReadSchema = yupObject({ + domain: domainSchema, +}); + +const domainCreateSchema = yupObject({ + domain: domainSchema, +}); + +export const domainDeleteSchema = yupMixed(); + +export const domainCrud = createCrud({ + adminReadSchema: domainReadSchema, + adminCreateSchema: domainCreateSchema, + adminDeleteSchema: domainDeleteSchema, + docs: { + adminList: { + hidden: true, + }, + adminRead: { + hidden: true, + }, + adminCreate: { + hidden: true, + }, + adminUpdate: { + hidden: true, + }, + adminDelete: { + hidden: true, + }, + }, +}); +export type DomainCrud = CrudTypeOf; + + +export const domainCrudHandlers = createLazyProxy(() => createCrudHandlers(domainCrud, { + paramsSchema: yupObject({ + domain: domainSchema.optional(), + }), + onCreate: async ({ auth, data, params }) => { + const oldDomains = auth.tenancy.config.domains; + await projectsCrudHandlers.adminUpdate({ + data: { + config: { + domains: [...oldDomains, { domain: data.domain, handler_path: "/handler" }], + }, + }, + tenancy: auth.tenancy, + allowedErrorTypes: [StatusError], + }); + + return { domain: data.domain }; + }, + onDelete: async ({ auth, params }) => { + const oldDomains = auth.tenancy.config.domains; + await projectsCrudHandlers.adminUpdate({ + data: { + config: { domains: oldDomains.filter((domain) => domain.domain !== params.domain) }, + }, + tenancy: auth.tenancy, + allowedErrorTypes: [StatusError], + }); + }, + onList: async ({ auth }) => { + return { + items: auth.tenancy.config.domains.map((domain) => ({ domain: domain.domain })), + is_paginated: false, + }; + }, +})); diff --git a/apps/backend/src/app/api/latest/integrations/domains/route.tsx b/apps/backend/src/app/api/latest/integrations/domains/route.tsx new file mode 100644 index 000000000..4e738eb76 --- /dev/null +++ b/apps/backend/src/app/api/latest/integrations/domains/route.tsx @@ -0,0 +1,4 @@ +import { domainCrudHandlers } from "./crud"; + +export const GET = domainCrudHandlers.listHandler; +export const POST = domainCrudHandlers.createHandler; diff --git a/apps/backend/src/app/api/latest/integrations/internal/confirm/route.tsx b/apps/backend/src/app/api/latest/integrations/internal/confirm/route.tsx new file mode 100644 index 000000000..13b46047a --- /dev/null +++ b/apps/backend/src/app/api/latest/integrations/internal/confirm/route.tsx @@ -0,0 +1,65 @@ +import { prismaClient } from "@/prisma-client"; +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { serverOrHigherAuthTypeSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; +import { generateSecureRandomString } from "@stackframe/stack-shared/dist/utils/crypto"; + +export const POST = createSmartRouteHandler({ + metadata: { + hidden: true, + }, + request: yupObject({ + url: yupString().defined(), + auth: yupObject({ + project: yupObject({ + id: yupString().oneOf(["internal"]).defined(), + }).defined(), + type: serverOrHigherAuthTypeSchema.defined(), + }).defined(), + body: yupObject({ + interaction_uid: yupString().defined(), + project_id: yupString().defined(), + neon_project_name: yupString().optional(), + }).defined(), + }), + response: yupObject({ + statusCode: yupNumber().oneOf([200]).defined(), + bodyType: yupString().oneOf(["json"]).defined(), + body: yupObject({ + authorization_code: yupString().defined(), + }).defined(), + }), + handler: async (req) => { + // Create an admin API key for the tenancy + const set = await prismaClient.apiKeySet.create({ + data: { + projectId: req.body.project_id, + description: `Auto-generated for Neon${req.body.neon_project_name ? ` (${req.body.neon_project_name})` : ""}`, + expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 365 * 100), + superSecretAdminKey: `sak_${generateSecureRandomString()}`, + }, + }); + + // Create authorization code + const authorizationCode = generateSecureRandomString(); + await prismaClient.projectWrapperCodes.create({ + data: { + idpId: "stack-preconfigured-idp:integrations/neon", + interactionUid: req.body.interaction_uid, + authorizationCode, + cdfcResult: { + access_token: set.superSecretAdminKey, + token_type: "api_key", + project_id: req.body.project_id, + }, + }, + }); + + return { + statusCode: 200, + bodyType: "json", + body: { + authorization_code: authorizationCode, + }, + }; + }, +}); diff --git a/apps/backend/src/app/api/latest/integrations/neon/oauth/idp/[[...route]]/route.tsx b/apps/backend/src/app/api/latest/integrations/neon/oauth/idp/[[...route]]/route.tsx index c7fc50594..a8ee17c34 100644 --- a/apps/backend/src/app/api/latest/integrations/neon/oauth/idp/[[...route]]/route.tsx +++ b/apps/backend/src/app/api/latest/integrations/neon/oauth/idp/[[...route]]/route.tsx @@ -3,7 +3,7 @@ import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env"; import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors"; import { createNodeHttpServerDuplex } from "@stackframe/stack-shared/dist/utils/node-http"; import { NextRequest, NextResponse } from "next/server"; -import { createOidcProvider } from "./idp"; +import { createOidcProvider } from "../../../../oauth/idp/[[...route]]/idp"; export const dynamic = "force-dynamic"; diff --git a/apps/backend/src/app/api/latest/integrations/oauth/authorize/route.tsx b/apps/backend/src/app/api/latest/integrations/oauth/authorize/route.tsx new file mode 100644 index 000000000..68707322f --- /dev/null +++ b/apps/backend/src/app/api/latest/integrations/oauth/authorize/route.tsx @@ -0,0 +1,31 @@ +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { yupNever, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; +import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors"; +import { redirect } from "next/navigation"; + +export const GET = createSmartRouteHandler({ + metadata: { + hidden: true, + }, + request: yupObject({ + url: yupString().defined(), + query: yupObject({ + client_id: yupString().defined(), + redirect_uri: yupString().defined(), + state: yupString().defined(), + code_challenge: yupString().defined(), + code_challenge_method: yupString().oneOf(["S256"]).defined(), + response_type: yupString().oneOf(["code"]).defined(), + }).defined(), + }), + response: yupNever(), + handler: async (req) => { + const url = new URL(req.url); + if (url.pathname !== "/api/v1/integrations/neon/oauth/authorize") { + throw new StackAssertionError(`Expected pathname to be authorize endpoint but got ${JSON.stringify(url.pathname)}`, { url }); + } + url.pathname = "/api/v1/integrations/neon/oauth/idp/auth"; + url.search = new URLSearchParams({ ...req.query, scope: "openid" }).toString(); + redirect(url.toString()); + }, +}); diff --git a/apps/backend/src/app/api/latest/integrations/neon/oauth/idp/[[...route]]/idp.ts b/apps/backend/src/app/api/latest/integrations/oauth/idp/[[...route]]/idp.ts similarity index 100% rename from apps/backend/src/app/api/latest/integrations/neon/oauth/idp/[[...route]]/idp.ts rename to apps/backend/src/app/api/latest/integrations/oauth/idp/[[...route]]/idp.ts diff --git a/apps/backend/src/app/api/latest/integrations/oauth/idp/[[...route]]/route.tsx b/apps/backend/src/app/api/latest/integrations/oauth/idp/[[...route]]/route.tsx new file mode 100644 index 000000000..c7fc50594 --- /dev/null +++ b/apps/backend/src/app/api/latest/integrations/oauth/idp/[[...route]]/route.tsx @@ -0,0 +1,79 @@ +import { handleApiRequest } from "@/route-handlers/smart-route-handler"; +import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env"; +import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors"; +import { createNodeHttpServerDuplex } from "@stackframe/stack-shared/dist/utils/node-http"; +import { NextRequest, NextResponse } from "next/server"; +import { createOidcProvider } from "./idp"; + +export const dynamic = "force-dynamic"; + +const pathPrefix = "/api/v1/integrations/neon/oauth/idp"; + +// we want to initialize the OIDC provider lazily so it's not initiated at build time +let _oidcCallbackPromiseCache: Promise | undefined; +function getOidcCallbackPromise() { + if (!_oidcCallbackPromiseCache) { + const apiBaseUrl = new URL(getEnvVariable("NEXT_PUBLIC_STACK_API_URL")); + const idpBaseUrl = new URL(pathPrefix, apiBaseUrl); + _oidcCallbackPromiseCache = (async () => { + const oidc = await createOidcProvider({ + id: "stack-preconfigured-idp:integrations/neon", + baseUrl: idpBaseUrl.toString(), + }); + return oidc.callback(); + })(); + } + return _oidcCallbackPromiseCache; +} + +const handler = handleApiRequest(async (req: NextRequest) => { + const newUrl = req.url.replace(pathPrefix, ""); + if (newUrl === req.url) { + throw new StackAssertionError("No path prefix found in request URL. Is the pathPrefix correct?", { newUrl, url: req.url, pathPrefix }); + } + const newHeaders = new Headers(req.headers); + const incomingBody = new Uint8Array(await req.arrayBuffer()); + const [incomingMessage, serverResponse] = await createNodeHttpServerDuplex({ + method: req.method, + originalUrl: new URL(req.url), + url: new URL(newUrl), + headers: newHeaders, + body: incomingBody, + }); + + await (await getOidcCallbackPromise())(incomingMessage, serverResponse); + + const body = new Uint8Array(serverResponse.bodyChunks.flatMap(chunk => [...chunk])); + + let headers: [string, string][] = []; + for (const [k, v] of Object.entries(serverResponse.getHeaders())) { + if (Array.isArray(v)) { + for (const vv of v) { + headers.push([k, vv]); + } + } else { + headers.push([k, `${v}`]); + } + } + + // filter out session cookies; we don't want to keep sessions open, every OAuth flow should start a new session + headers = headers.filter(([k, v]) => k !== "set-cookie" || !v.toString().match(/^_session\.?/)); + + return new NextResponse(body, { + headers: headers, + status: { + // our API never returns 301 or 302 by convention, so transform them to 307 or 308 + 301: 308, + 302: 307, + }[serverResponse.statusCode] ?? serverResponse.statusCode, + statusText: serverResponse.statusMessage, + }); +}); + +export const GET = handler; +export const POST = handler; +export const PUT = handler; +export const PATCH = handler; +export const DELETE = handler; +export const OPTIONS = handler; +export const HEAD = handler; diff --git a/apps/backend/src/app/api/latest/integrations/oauth/route.tsx b/apps/backend/src/app/api/latest/integrations/oauth/route.tsx new file mode 100644 index 000000000..8037b90af --- /dev/null +++ b/apps/backend/src/app/api/latest/integrations/oauth/route.tsx @@ -0,0 +1,28 @@ +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; +import { deindent } from "@stackframe/stack-shared/dist/utils/strings"; + +export const GET = createSmartRouteHandler({ + metadata: { + hidden: true, + }, + request: yupObject({ + url: yupString().defined(), + method: yupString().oneOf(["GET"]).defined(), + }), + response: yupObject({ + statusCode: yupNumber().oneOf([200]).defined(), + bodyType: yupString().oneOf(["text"]).defined(), + body: yupString().defined(), + }), + handler: async (req) => { + return { + statusCode: 200, + bodyType: "text", + body: deindent` + Authorization endpoint: ${new URL("authorize", req.url + "/").toString()} + Token endpoint: ${new URL("token", req.url + "/").toString()} + `, + }; + }, +}); diff --git a/apps/backend/src/app/api/latest/integrations/oauth/token/route.tsx b/apps/backend/src/app/api/latest/integrations/oauth/token/route.tsx new file mode 100644 index 000000000..f4d99aaa0 --- /dev/null +++ b/apps/backend/src/app/api/latest/integrations/oauth/token/route.tsx @@ -0,0 +1,86 @@ +import { prismaClient } from "@/prisma-client"; +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { neonAuthorizationHeaderSchema, yupMixed, yupNumber, yupObject, yupString, yupTuple, yupUnion } from "@stackframe/stack-shared/dist/schema-fields"; +import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env"; +import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors"; + +export const POST = createSmartRouteHandler({ + metadata: { + hidden: true, + }, + request: yupObject({ + url: yupString().defined(), + body: yupObject({ + grant_type: yupString().oneOf(["authorization_code"]).defined(), + code: yupString().defined(), + code_verifier: yupString().defined(), + redirect_uri: yupString().defined(), + }).defined(), + headers: yupObject({ + authorization: yupTuple([neonAuthorizationHeaderSchema.defined()]).defined(), + }).defined(), + }), + response: yupUnion( + yupObject({ + statusCode: yupNumber().oneOf([200]).defined(), + bodyType: yupString().oneOf(["json"]).defined(), + body: yupObject({ + access_token: yupString().defined(), + token_type: yupString().oneOf(["api_key"]).defined(), + project_id: yupString().defined(), + }).defined(), + }), + yupObject({ + statusCode: yupNumber().defined(), + bodyType: yupString().oneOf(["text"]).defined(), + body: yupMixed().defined(), + }), + ), + handler: async (req) => { + const tokenResponse = await fetch(new URL("/api/v1/integrations/neon/oauth/idp/token", getEnvVariable("NEXT_PUBLIC_STACK_API_URL")), { + method: "POST", + body: new URLSearchParams(req.body).toString(), + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Authorization: req.headers.authorization[0], + }, + }); + if (!tokenResponse.ok) { + return { + statusCode: tokenResponse.status, + bodyType: "text", + body: await tokenResponse.text(), + }; + } + const tokenResponseBody = await tokenResponse.json(); + + const userInfoResponse = await fetch(new URL("/api/v1/integrations/neon/oauth/idp/me", getEnvVariable("NEXT_PUBLIC_STACK_API_URL")), { + method: "GET", + headers: { + Authorization: `Bearer ${tokenResponseBody.access_token}`, + }, + }); + if (!userInfoResponse.ok) { + const text = await userInfoResponse.text(); + throw new StackAssertionError("Failed to fetch user info? This should never happen", { text, userInfoResponse }); + } + const userInfoResponseBody = await userInfoResponse.json(); + + const accountId = userInfoResponseBody.sub; + const mapping = await prismaClient.idPAccountToCdfcResultMapping.findUnique({ + where: { + idpId: "stack-preconfigured-idp:integrations/neon", + idpAccountId: accountId, + }, + }); + if (!mapping) { + throw new StackAssertionError("No mapping found for account", { accountId }); + } + + return { + statusCode: 200, + bodyType: "json", + body: mapping.cdfcResult as any, + }; + }, +}); diff --git a/apps/backend/src/app/api/latest/integrations/projects/provision/route.tsx b/apps/backend/src/app/api/latest/integrations/projects/provision/route.tsx new file mode 100644 index 000000000..57b6945d8 --- /dev/null +++ b/apps/backend/src/app/api/latest/integrations/projects/provision/route.tsx @@ -0,0 +1,79 @@ +import { createApiKeySet } from "@/lib/internal-api-keys"; +import { createOrUpdateProject } from "@/lib/projects"; +import { prismaClient } from "@/prisma-client"; +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { neonAuthorizationHeaderSchema, projectDisplayNameSchema, yupNumber, yupObject, yupString, yupTuple } from "@stackframe/stack-shared/dist/schema-fields"; +import { decodeBasicAuthorizationHeader } from "@stackframe/stack-shared/dist/utils/http"; + +export const POST = createSmartRouteHandler({ + metadata: { + hidden: true, + }, + request: yupObject({ + body: yupObject({ + display_name: projectDisplayNameSchema.defined(), + }).defined(), + headers: yupObject({ + authorization: yupTuple([neonAuthorizationHeaderSchema.defined()]).defined(), + }).defined(), + }), + response: yupObject({ + statusCode: yupNumber().oneOf([200]).defined(), + bodyType: yupString().oneOf(["json"]).defined(), + body: yupObject({ + project_id: yupString().defined(), + super_secret_admin_key: yupString().defined(), + }).defined(), + }), + handler: async (req) => { + const [clientId] = decodeBasicAuthorizationHeader(req.headers.authorization[0])!; + + const createdProject = await createOrUpdateProject({ + ownerIds: [], + type: 'create', + data: { + display_name: req.body.display_name, + description: "Created with Neon", + config: { + oauth_providers: [ + { + id: "google", + type: "shared", + }, + { + id: "github", + type: "shared", + }, + ], + allow_localhost: true, + credential_enabled: true + }, + } + }); + + await prismaClient.neonProvisionedProject.create({ + data: { + projectId: createdProject.id, + neonClientId: clientId, + }, + }); + + const set = await createApiKeySet({ + projectId: createdProject.id, + description: `Auto-generated for Neon (${req.body.display_name})`, + expires_at_millis: new Date(Date.now() + 1000 * 60 * 60 * 24 * 365 * 100).getTime(), + has_publishable_client_key: false, + has_secret_server_key: false, + has_super_secret_admin_key: true, + }); + + return { + statusCode: 200, + bodyType: "json", + body: { + project_id: createdProject.id, + super_secret_admin_key: set.super_secret_admin_key!, + }, + }; + }, +}); diff --git a/apps/backend/src/app/api/latest/integrations/projects/transfer/confirm/check/route.tsx b/apps/backend/src/app/api/latest/integrations/projects/transfer/confirm/check/route.tsx new file mode 100644 index 000000000..d5d21bc8e --- /dev/null +++ b/apps/backend/src/app/api/latest/integrations/projects/transfer/confirm/check/route.tsx @@ -0,0 +1,3 @@ +import { neonIntegrationProjectTransferCodeHandler } from "../verification-code-handler"; + +export const POST = neonIntegrationProjectTransferCodeHandler.checkHandler; diff --git a/apps/backend/src/app/api/latest/integrations/projects/transfer/confirm/route.tsx b/apps/backend/src/app/api/latest/integrations/projects/transfer/confirm/route.tsx new file mode 100644 index 000000000..09c5c0ca4 --- /dev/null +++ b/apps/backend/src/app/api/latest/integrations/projects/transfer/confirm/route.tsx @@ -0,0 +1,3 @@ +import { neonIntegrationProjectTransferCodeHandler } from "./verification-code-handler"; + +export const POST = neonIntegrationProjectTransferCodeHandler.postHandler; diff --git a/apps/backend/src/app/api/latest/integrations/projects/transfer/confirm/verification-code-handler.tsx b/apps/backend/src/app/api/latest/integrations/projects/transfer/confirm/verification-code-handler.tsx new file mode 100644 index 000000000..8bfa4fe98 --- /dev/null +++ b/apps/backend/src/app/api/latest/integrations/projects/transfer/confirm/verification-code-handler.tsx @@ -0,0 +1,94 @@ +import { prismaClient } from "@/prisma-client"; +import { createVerificationCodeHandler } from "@/route-handlers/verification-code-handler"; +import { VerificationCodeType } from "@prisma/client"; +import { KnownErrors } from "@stackframe/stack-shared"; +import { yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; +import { StatusError, throwErr } from "@stackframe/stack-shared/dist/utils/errors"; + +export const neonIntegrationProjectTransferCodeHandler = createVerificationCodeHandler({ + metadata: { + post: { + hidden: true, + }, + check: { + hidden: true, + }, + }, + type: VerificationCodeType.NEON_INTEGRATION_PROJECT_TRANSFER, + data: yupObject({ + neon_client_id: yupString().defined(), + project_id: yupString().defined(), + }).defined(), + method: yupObject({}), + requestBody: yupObject({}), + response: yupObject({ + statusCode: yupNumber().oneOf([200]).defined(), + bodyType: yupString().oneOf(["json"]).defined(), + body: yupObject({ + project_id: yupString().defined(), + }).defined(), + }), + async validate(tenancy, method, data) { + const project = tenancy.project; + if (project.id !== "internal") throw new StatusError(400, "This endpoint is only available for internal projects."); + const neonProvisionedProjects = await prismaClient.neonProvisionedProject.findMany({ + where: { + projectId: data.project_id, + neonClientId: data.neon_client_id, + }, + }); + if (neonProvisionedProjects.length === 0) throw new StatusError(400, "The project to transfer was not provisioned by Neon or has already been transferred."); + }, + + async handler(tenancy, method, data, body, user) { + const project = tenancy.project; + if (!user) throw new KnownErrors.UserAuthenticationRequired; + + await prismaClient.$transaction(async (tx) => { + const neonProvisionedProject = await tx.neonProvisionedProject.deleteMany({ + where: { + projectId: data.project_id, + neonClientId: data.neon_client_id, + }, + }); + + if (neonProvisionedProject.count === 0) throw new StatusError(400, "The project to transfer was not provisioned by Neon or has already been transferred."); + + const recentDbUser = await tx.projectUser.findUnique({ + where: { + tenancyId_projectUserId: { + tenancyId: tenancy.id, + projectUserId: user.id, + }, + }, + }) ?? throwErr("Authenticated user not found in transaction. Something went wrong. Did the user delete their account at the wrong time? (Very unlikely.)"); + const rduServerMetadata: any = recentDbUser.serverMetadata; + + await tx.projectUser.update({ + where: { + tenancyId_projectUserId: { + tenancyId: tenancy.id, + projectUserId: user.id, + }, + }, + data: { + serverMetadata: { + ...typeof rduServerMetadata === "object" ? rduServerMetadata : {}, + managedProjectIds: [ + ...(Array.isArray(rduServerMetadata?.managedProjectIds) ? rduServerMetadata.managedProjectIds : []), + data.project_id, + ], + }, + }, + }); + }); + + return { + statusCode: 200, + bodyType: "json", + body: { + project_id: data.project_id, + }, + }; + } +}); diff --git a/apps/backend/src/app/api/latest/integrations/projects/transfer/initiate/route.tsx b/apps/backend/src/app/api/latest/integrations/projects/transfer/initiate/route.tsx new file mode 100644 index 000000000..5428a1f19 --- /dev/null +++ b/apps/backend/src/app/api/latest/integrations/projects/transfer/initiate/route.tsx @@ -0,0 +1,3 @@ +import { POST as initiateTransfer } from "../route"; + +export const POST = initiateTransfer; diff --git a/apps/backend/src/app/api/latest/integrations/projects/transfer/route.tsx b/apps/backend/src/app/api/latest/integrations/projects/transfer/route.tsx new file mode 100644 index 000000000..ffe8c3aed --- /dev/null +++ b/apps/backend/src/app/api/latest/integrations/projects/transfer/route.tsx @@ -0,0 +1,106 @@ +import { getProject } from "@/lib/projects"; +import { DEFAULT_BRANCH_ID, getSoleTenancyFromProjectBranch } from "@/lib/tenancies"; +import { prismaClient } from "@/prisma-client"; +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { neonAuthorizationHeaderSchema, urlSchema, yupNumber, yupObject, yupString, yupTuple } from "@stackframe/stack-shared/dist/schema-fields"; +import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env"; +import { StatusError, throwErr } from "@stackframe/stack-shared/dist/utils/errors"; +import { decodeBasicAuthorizationHeader } from "@stackframe/stack-shared/dist/utils/http"; +import { neonIntegrationProjectTransferCodeHandler } from "./confirm/verification-code-handler"; + +async function validateAndGetTransferInfo(authorizationHeader: string, projectId: string) { + const [clientId, clientSecret] = decodeBasicAuthorizationHeader(authorizationHeader)!; + const internalProject = await getProject("internal") ?? throwErr("Internal project not found"); + + const neonProvisionedProject = await prismaClient.neonProvisionedProject.findUnique({ + where: { + projectId, + neonClientId: clientId, + }, + }); + if (!neonProvisionedProject) { + // note: Neon relies on this exact status code and error message, so don't change it without consulting them first + throw new StatusError(400, "This project either doesn't exist or the current Neon client is not authorized to transfer it. Note that projects can only be transferred once."); + } + + return { + neonProvisionedProject, + internalProject, + }; +} + + +export const GET = createSmartRouteHandler({ + metadata: { + hidden: true, + }, + request: yupObject({ + query: yupObject({ + project_id: yupString().defined(), + }).defined(), + headers: yupObject({ + authorization: yupTuple([neonAuthorizationHeaderSchema.defined()]).defined(), + }).defined(), + }), + response: yupObject({ + statusCode: yupNumber().oneOf([200]).defined(), + bodyType: yupString().oneOf(["json"]).defined(), + body: yupObject({ + message: yupString().defined(), + }).defined(), + }), + handler: async (req) => { + await validateAndGetTransferInfo(req.headers.authorization[0], req.query.project_id); + + return { + statusCode: 200, + bodyType: "json", + body: { + message: "Ready to transfer project; please use the POST method to initiate it.", + }, + }; + }, +}); + +export const POST = createSmartRouteHandler({ + metadata: { + hidden: true, + }, + request: yupObject({ + body: yupObject({ + project_id: yupString().defined(), + }).defined(), + headers: yupObject({ + authorization: yupTuple([neonAuthorizationHeaderSchema.defined()]).defined(), + }).defined(), + }), + response: yupObject({ + statusCode: yupNumber().oneOf([200]).defined(), + bodyType: yupString().oneOf(["json"]).defined(), + body: yupObject({ + confirmation_url: urlSchema.defined(), + }).defined(), + }), + handler: async (req) => { + const { neonProvisionedProject } = await validateAndGetTransferInfo(req.headers.authorization[0], req.body.project_id); + + const transferCodeObj = await neonIntegrationProjectTransferCodeHandler.createCode({ + tenancy: await getSoleTenancyFromProjectBranch("internal", DEFAULT_BRANCH_ID), + method: {}, + data: { + project_id: neonProvisionedProject.projectId, + neon_client_id: neonProvisionedProject.neonClientId, + }, + callbackUrl: new URL("/integrations/neon/projects/transfer/confirm", getEnvVariable("NEXT_PUBLIC_STACK_DASHBOARD_URL")), + expiresInMs: 1000 * 60 * 60, + }); + + return { + statusCode: 200, + bodyType: "json", + body: { + confirmation_url: transferCodeObj.link.toString(), + }, + }; + }, +}); diff --git a/apps/e2e/tests/backend/endpoints/api/v1/integrations/domain.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/integrations/domain.test.ts new file mode 100644 index 000000000..c2e5c9fa1 --- /dev/null +++ b/apps/e2e/tests/backend/endpoints/api/v1/integrations/domain.test.ts @@ -0,0 +1,161 @@ +import { it } from "../../../../../helpers"; +import { Auth, Project, niceBackendFetch } from "../../../../backend-helpers"; + +it("list domains", async ({ expect }) => { + await Auth.Otp.signIn(); + const { adminAccessToken } = await Project.createAndGetAdminToken(); + const response = await niceBackendFetch("/api/v1/integrations/domains", { + accessType: "admin", + headers: { + 'x-stack-admin-access-token': adminAccessToken, + }, + }); + expect(response).toMatchInlineSnapshot(` + NiceResponse { + "status": 200, + "body": { + "is_paginated": false, + "items": [], + }, + "headers": Headers {