diff --git a/apps/dashboard/src/app/api/integrations/source-control/github/route.ts b/apps/dashboard/src/app/api/integrations/source-control/github/route.ts new file mode 100644 index 000000000..e69de29bb diff --git a/apps/dashboard/src/app/api/integrations/source-control/gitlab/route.ts b/apps/dashboard/src/app/api/integrations/source-control/gitlab/route.ts new file mode 100644 index 000000000..e69de29bb diff --git a/apps/dashboard/src/app/page.tsx b/apps/dashboard/src/app/page.tsx index d9633ff5c..d1b8a860f 100644 --- a/apps/dashboard/src/app/page.tsx +++ b/apps/dashboard/src/app/page.tsx @@ -1,5 +1,6 @@ import { UserButton, OrganizationSwitcher } from "@clerk/nextjs"; import { MainNav } from "~/components/ui/main-nav"; +import { GenerateToken } from "~/components/generate-token"; export default function Page() { return ( @@ -15,6 +16,7 @@ export default function Page() { + ); } diff --git a/apps/dashboard/src/components/generate-token.tsx b/apps/dashboard/src/components/generate-token.tsx new file mode 100644 index 000000000..e49fcfb80 --- /dev/null +++ b/apps/dashboard/src/components/generate-token.tsx @@ -0,0 +1,19 @@ +"use client" +import { useAuth } from '@clerk/nextjs'; + + +export function GenerateToken() { + const { getToken } = useAuth(); + const handleClick = async () => { + const token = await getToken({ template: 'dashboard' }); + console.log(token); + }; + + + return ( + + ); + +} diff --git a/apps/dashboard/src/components/user-test.tsx b/apps/dashboard/src/components/user-test.tsx new file mode 100644 index 000000000..3ed20d426 --- /dev/null +++ b/apps/dashboard/src/components/user-test.tsx @@ -0,0 +1,18 @@ +"use client"; +import { useUser } from "@clerk/clerk-react"; + +export default function Home() { + const { isSignedIn, user, isLoaded } = useUser(); + + if (!isLoaded) { + return null; + } + + if (isSignedIn) { + return
{JSON.stringify(user, null, 2)}
; + } + + return
Not signed in
; +} + + diff --git a/apps/extract-stack/.sst/types/index.ts b/apps/extract-stack/.sst/types/index.ts index 1d0e6abf6..ba7a76a42 100644 --- a/apps/extract-stack/.sst/types/index.ts +++ b/apps/extract-stack/.sst/types/index.ts @@ -39,6 +39,13 @@ declare module "sst/node/config" { value: string; } } +}import "sst/node/config"; +declare module "sst/node/config" { + export interface SecretResources { + "CLERK_SECRET_KEY": { + value: string; + } + } }import "sst/node/api"; declare module "sst/node/api" { export interface ApiResources { diff --git a/apps/extract-stack/package.json b/apps/extract-stack/package.json index 90e6bff6c..34944d644 100644 --- a/apps/extract-stack/package.json +++ b/apps/extract-stack/package.json @@ -7,11 +7,12 @@ "type-check": "tsc --noEmit && echo \"✔ No TypeScript warnings or errors\"", "test": "echo \"Warning: no test specified\"", "lint": "eslint . && echo \"✔ No ESLint warnings or errors\"", - "dev": "sst dev", + "dev": "npm run with-env sst dev", "build": "sst build", "deploy": "sst deploy", "remove": "sst remove", - "console": "sst console" + "console": "sst console", + "with-env": "dotenv -e ../../.env --" }, "author": "", "license": "ISC", @@ -19,6 +20,7 @@ "@acme/extract-functions": "^1.0.0", "@acme/extract-schema": "^1.0.0", "@acme/source-control": "^1.0.0", + "@clerk/clerk-sdk-node": "^4.12.2", "@libsql/client": "^0.3.1", "@tsconfig/node16": "^16.1.0", "aws-cdk-lib": "2.84.0", @@ -29,6 +31,7 @@ "zod": "^3.21.4" }, "devDependencies": { - "@types/aws-lambda": "^8.10.119" + "@types/aws-lambda": "^8.10.119", + "dotenv-cli": "^7.2.1" } } diff --git a/apps/extract-stack/src/events.ts b/apps/extract-stack/src/events.ts index 5a79c2ec8..960c34dc9 100644 --- a/apps/extract-stack/src/events.ts +++ b/apps/extract-stack/src/events.ts @@ -9,6 +9,8 @@ const eventBuilder = createEventBuilder({ version: z.number(), timestamp: z.number(), caller: z.string(), + sourceControl: z.literal("github").or(z.literal("gitlab")), + userId: z.string(), }).shape, }); diff --git a/apps/extract-stack/src/extract-repository.ts b/apps/extract-stack/src/extract-repository.ts index d0e5fb90e..b1ad5ffb1 100644 --- a/apps/extract-stack/src/extract-repository.ts +++ b/apps/extract-stack/src/extract-repository.ts @@ -1,20 +1,29 @@ import { extractRepositoryEvent, defineEvent } from "./events"; import { getRepository } from "@acme/extract-functions"; import type { Context, GetRepositorySourceControl, GetRepositoryEntities } from "@acme/extract-functions"; -import { GitlabSourceControl } from "@acme/source-control"; +import { GitlabSourceControl, GitHubSourceControl } from "@acme/source-control"; import { repositories, namespaces } from "@acme/extract-schema"; import { createClient } from '@libsql/client'; import { drizzle } from 'drizzle-orm/libsql'; -import type { APIGatewayProxyHandlerV2 } from "aws-lambda"; import { z } from "zod"; import { Config } from "sst/node/config"; +import { Clerk } from "@clerk/clerk-sdk-node"; +import { ApiHandler, useJsonBody } from 'sst/node/api'; +const clerkClient = Clerk({ secretKey: Config.CLERK_SECRET_KEY }); const client = createClient({ url: Config.DATABASE_URL, authToken: Config.DATABASE_AUTH_TOKEN }); const db = drizzle(client); const event = defineEvent(extractRepositoryEvent); +const fetchSourceControlAccessToken = async (userId: string, forgeryIdProvider: 'oauth_github' | 'oauth_gitlab') => { + const [userOauthAccessTokenPayload, ...rest] = await clerkClient.users.getUserOauthAccessToken(userId, forgeryIdProvider); + if (!userOauthAccessTokenPayload) throw new Error("Failed to get token"); + if (rest.length !== 0) throw new Error("wtf ?"); + + return userOauthAccessTokenPayload.token; +} const context: Context = { entities: { @@ -22,25 +31,53 @@ const context: Context = { namespaces, }, integrations: { - sourceControl: new GitlabSourceControl(Config.GITLAB_TOKEN), + sourceControl: null, }, db, }; +const contextSchema = z.object({ + authorizer: z.object({ + jwt: z.object({ + claims: z.object({ + sub: z.string(), + }), + }), + }), +}); + +type CTX = z.infer; + const inputSchema = z.object({ repositoryId: z.number(), repositoryName: z.string(), namespaceName: z.string(), + sourceControl: z.literal("gitlab").or(z.literal("github")), }); type Input = z.infer; -export const handler: APIGatewayProxyHandlerV2 = async (apiGatewayEvent) => { +export const handler = ApiHandler(async (ev) => { + + const body = useJsonBody() as unknown; + + let lambdaContext: CTX; + + try { + lambdaContext = contextSchema.parse(ev.requestContext); + } catch (error) { + return { + statusCode: 401, + body: JSON.stringify({ error: (error as Error).message }), + }; + } let input: Input; + let sourceControlAccessToken: string; try { - input = inputSchema.parse(apiGatewayEvent); + input = inputSchema.parse(body); + } catch (error) { return { statusCode: 400, @@ -48,14 +85,32 @@ export const handler: APIGatewayProxyHandlerV2 = async (apiGatewayEvent) => { }; } - const { repositoryId, repositoryName, namespaceName } = input; + const { sub } = lambdaContext.authorizer.jwt.claims; + + + const { repositoryId, repositoryName, namespaceName, sourceControl } = input; + + try { + sourceControlAccessToken = await fetchSourceControlAccessToken(sub, `oauth_${sourceControl}`); + } catch (error) { + return { + statusCode: 500, + body: JSON.stringify({ error: (error as Error).message }), + } + } + + if (sourceControl === "gitlab") { + context.integrations.sourceControl = new GitlabSourceControl(sourceControlAccessToken); + } else if (sourceControl === "github") { + context.integrations.sourceControl = new GitHubSourceControl(sourceControlAccessToken); + } const { repository, namespace } = await getRepository({ externalRepositoryId: repositoryId, repositoryName, namespaceName }, context); - await event.publish({ repository, namespace }, { caller: 'extract-repository', timestamp: new Date().getTime(), version: 1 }); + await event.publish({ repository, namespace }, { caller: 'extract-repository', timestamp: new Date().getTime(), version: 1, sourceControl, userId: sub }); return { statusCode: 200, body: JSON.stringify({}) }; -} +}); diff --git a/apps/extract-stack/src/stack.ts b/apps/extract-stack/src/stack.ts index a00e2cd27..fedd69f0d 100644 --- a/apps/extract-stack/src/stack.ts +++ b/apps/extract-stack/src/stack.ts @@ -1,6 +1,7 @@ import { Api, EventBus, Queue } from "sst/constructs"; import type { StackContext } from "sst/constructs"; import { Config } from "sst/constructs"; +import { z } from "zod"; export function ExtractStack({ stack }: StackContext) { const bus = new EventBus(stack, "ExtractBus", { @@ -16,15 +17,34 @@ export function ExtractStack({ stack }: StackContext) { const DATABASE_URL = new Config.Secret(stack, "DATABASE_URL"); const DATABASE_AUTH_TOKEN = new Config.Secret(stack, "DATABASE_AUTH_TOKEN"); const GITLAB_TOKEN = new Config.Secret(stack, "GITLAB_TOKEN"); + const CLERK_SECRET_KEY = new Config.Secret(stack, "CLERK_SECRET_KEY"); + + const ENVSchema = z.object({ + CLERK_JWT_ISSUER: z.string(), + CLERK_JWT_AUDIENCE: z.string(), + }); + + const ENV = ENVSchema.parse(process.env); const api = new Api(stack, "ExtractApi", { defaults: { + authorizer: 'JwtAuthorizer', function: { - bind: [bus, DATABASE_URL, DATABASE_AUTH_TOKEN, GITLAB_TOKEN, queue], + bind: [bus, DATABASE_URL, DATABASE_AUTH_TOKEN, GITLAB_TOKEN, CLERK_SECRET_KEY, queue], + }, + }, + authorizers: { + JwtAuthorizer: { + type: "jwt", + identitySource: ["$request.header.Authorization"], + jwt: { + issuer: ENV.CLERK_JWT_ISSUER, + audience: [ENV.CLERK_JWT_AUDIENCE], + }, }, }, routes: { - "POST /gitlab": "src/extract-repository.handler", + "POST /start": "src/extract-repository.handler", }, }); diff --git a/apps/test-stack/package.json b/apps/test-stack/package.json deleted file mode 100644 index b678fe4db..000000000 --- a/apps/test-stack/package.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "name": "@acme/test-stack", - "version": "1.0.0", - "description": "", - "main": "index.js", - "scripts": {}, - "keywords": [], - "author": "", - "license": "ISC" -} diff --git a/package-lock.json b/package-lock.json index 6256e990b..5e705c221 100644 --- a/package-lock.json +++ b/package-lock.json @@ -71,6 +71,7 @@ "@acme/extract-functions": "^1.0.0", "@acme/extract-schema": "^1.0.0", "@acme/source-control": "^1.0.0", + "@clerk/clerk-sdk-node": "^4.12.2", "@libsql/client": "^0.3.1", "@tsconfig/node16": "^16.1.0", "aws-cdk-lib": "2.84.0", @@ -81,7 +82,8 @@ "zod": "^3.21.4" }, "devDependencies": { - "@types/aws-lambda": "^8.10.119" + "@types/aws-lambda": "^8.10.119", + "dotenv-cli": "^7.2.1" } }, "apps/extract-stack/node_modules/@tsconfig/node16": { @@ -92,6 +94,7 @@ "apps/test-stack": { "name": "@acme/test-stack", "version": "1.0.0", + "extraneous": true, "license": "ISC" }, "node_modules/@acme/eslint-config": { @@ -122,10 +125,6 @@ "resolved": "packages/config/tailwind", "link": true }, - "node_modules/@acme/test-stack": { - "resolved": "apps/test-stack", - "link": true - }, "node_modules/@alcalzone/ansi-tokenize": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.1.1.tgz", @@ -2576,17 +2575,16 @@ "integrity": "sha512-tGyy4dAjRIEwI7BzsB0lynWgOpfqjUdq91XXAlIWD2OwKBH7oCl/GZG/HT4BOHrTlPMOASlMQ7veyTqpmRcrNA==" }, "node_modules/@clerk/clerk-sdk-node": { - "version": "4.10.15", - "resolved": "https://registry.npmjs.org/@clerk/clerk-sdk-node/-/clerk-sdk-node-4.10.15.tgz", - "integrity": "sha512-fif8iwedLDFgllPLx8xwxA66ICbp7KjsSULqb8mcKXHPnZ/xL17eZSx4puHg4CEGBmOxY6IL5nbRj1+s+TfgqQ==", + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@clerk/clerk-sdk-node/-/clerk-sdk-node-4.12.2.tgz", + "integrity": "sha512-7xYPsLSeGO5XoP0No/9m2dsCMezwtmiYGKOwWzt41ZzJNFlU0rfqYF3VOZEsbtQlc3ZXeU+67ItjoJYrf3kT6A==", "dependencies": { - "@clerk/backend": "^0.24.0", - "@clerk/types": "^3.46.1", + "@clerk/backend": "^0.27.0", + "@clerk/types": "^3.49.0", "@types/cookies": "0.7.7", "@types/express": "4.17.14", "@types/node-fetch": "2.6.2", "camelcase-keys": "6.2.2", - "cookie": "0.5.0", "snakecase-keys": "3.2.1", "tslib": "2.4.1" }, @@ -2594,6 +2592,42 @@ "node": ">=14" } }, + "node_modules/@clerk/clerk-sdk-node/node_modules/@clerk/backend": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@clerk/backend/-/backend-0.27.0.tgz", + "integrity": "sha512-Sj541JrpqAn1A/UwdyDBxFV3stq2A/Pe/8HdPTG3Cct6briPyavfi46O5s1+L3BSvUcKUY+UbM0+8VsoCNFi4w==", + "dependencies": { + "@clerk/types": "^3.49.0", + "@peculiar/webcrypto": "1.4.1", + "@types/node": "16.18.6", + "cookie": "0.5.0", + "deepmerge": "4.2.2", + "node-fetch-native": "1.0.1", + "snakecase-keys": "5.4.4", + "tslib": "2.4.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@clerk/clerk-sdk-node/node_modules/@clerk/backend/node_modules/snakecase-keys": { + "version": "5.4.4", + "resolved": "https://registry.npmjs.org/snakecase-keys/-/snakecase-keys-5.4.4.tgz", + "integrity": "sha512-YTywJG93yxwHLgrYLZjlC75moVEX04LZM4FHfihjHe1FCXm+QaLOFfSf535aXOAd0ArVQMWUAe8ZPm4VtWyXaA==", + "dependencies": { + "map-obj": "^4.1.0", + "snake-case": "^3.0.4", + "type-fest": "^2.5.2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@clerk/clerk-sdk-node/node_modules/@types/node": { + "version": "16.18.6", + "resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.6.tgz", + "integrity": "sha512-vmYJF0REqDyyU0gviezF/KHq/fYaUbFhkcNbQCuPGFQj6VTbXuHZoxs/Y7mutWe73C8AC6l9fFu8mSYiBAqkGA==" + }, "node_modules/@clerk/clerk-sdk-node/node_modules/snakecase-keys": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/snakecase-keys/-/snakecase-keys-3.2.1.tgz", @@ -2611,6 +2645,17 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.1.tgz", "integrity": "sha512-tGyy4dAjRIEwI7BzsB0lynWgOpfqjUdq91XXAlIWD2OwKBH7oCl/GZG/HT4BOHrTlPMOASlMQ7veyTqpmRcrNA==" }, + "node_modules/@clerk/clerk-sdk-node/node_modules/type-fest": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", + "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@clerk/nextjs": { "version": "4.21.15", "resolved": "https://registry.npmjs.org/@clerk/nextjs/-/nextjs-4.21.15.tgz", @@ -2651,9 +2696,9 @@ } }, "node_modules/@clerk/types": { - "version": "3.46.1", - "resolved": "https://registry.npmjs.org/@clerk/types/-/types-3.46.1.tgz", - "integrity": "sha512-IA/iSXJJZym4Z6f9OcT9OJayK2opVhxUVJky15aY4iVsJCPScgApBuuzC7N4vfXwA6RI/wGebFbvmsEQ3oI/UA==", + "version": "3.49.0", + "resolved": "https://registry.npmjs.org/@clerk/types/-/types-3.49.0.tgz", + "integrity": "sha512-vAx5R/iYfsgIaIDMiDr6ZKQnAneAmRrUVYz6KCtPG6/hnEAnRYhwXpEUi89e5G0BFmuUfSxe/N/Anfc1PNteXQ==", "dependencies": { "csstype": "3.1.1" }, @@ -10504,8 +10549,9 @@ }, "node_modules/dotenv-cli": { "version": "7.2.1", + "resolved": "https://registry.npmjs.org/dotenv-cli/-/dotenv-cli-7.2.1.tgz", + "integrity": "sha512-ODHbGTskqRtXAzZapDPvgNuDVQApu4oKX8lZW7Y0+9hKA6le1ZJlyRS687oU9FXjOVEDU/VFV6zI125HzhM1UQ==", "dev": true, - "license": "MIT", "dependencies": { "cross-spawn": "^7.0.3", "dotenv": "^16.0.0", diff --git a/packages/functions/extract/src/config.ts b/packages/functions/extract/src/config.ts index 5cce5b3a7..638f8ae92 100644 --- a/packages/functions/extract/src/config.ts +++ b/packages/functions/extract/src/config.ts @@ -13,7 +13,7 @@ export type Entities = { export type Context, E extends Partial> = { integrations: { - sourceControl: SC; + sourceControl: SC | null; }; db: Database; entities: E; diff --git a/packages/functions/extract/src/get-merge-requests.ts b/packages/functions/extract/src/get-merge-requests.ts index 11e4b9ae5..6de930897 100644 --- a/packages/functions/extract/src/get-merge-requests.ts +++ b/packages/functions/extract/src/get-merge-requests.ts @@ -25,6 +25,11 @@ export const getMergeRequests: GetMergeRequestsFunction = async ( { externalRepositoryId, namespaceName, repositoryName, repositoryId }, { integrations, db, entities } ) => { + + if(!integrations.sourceControl) { + throw new Error("Source control integration not configured"); + } + const { mergeRequests, pagination } = await integrations.sourceControl.fetchMergeRequests(externalRepositoryId, namespaceName, repositoryName, repositoryId); const insertedMergeRequests = await db.insert(entities.mergeRequests).values(mergeRequests) diff --git a/packages/functions/extract/src/get-repository.ts b/packages/functions/extract/src/get-repository.ts index 2b2c22aff..7e9ba61f9 100644 --- a/packages/functions/extract/src/get-repository.ts +++ b/packages/functions/extract/src/get-repository.ts @@ -23,6 +23,10 @@ export const getRepository: GetRepositoryFunction = async ( { integrations, db, entities } ) => { + if(!integrations.sourceControl) { + throw new Error("Source control integration not configured"); + } + const { repository, namespace } = await integrations.sourceControl.fetchRepository(externalRepositoryId, namespaceName, repositoryName); const insertedRepository = await db.insert(entities.repositories).values(repository) diff --git a/packages/integrations/source-control/src/gitlab/index.ts b/packages/integrations/source-control/src/gitlab/index.ts index d623369f8..a2e217d08 100644 --- a/packages/integrations/source-control/src/gitlab/index.ts +++ b/packages/integrations/source-control/src/gitlab/index.ts @@ -8,7 +8,7 @@ export class GitlabSourceControl implements SourceControl { constructor(token: string) { this.api = new Gitlab({ - token, + oauthToken: token, // camelize: true }); }