diff --git a/apps/api/drizzle.config.ts b/apps/api/drizzle.config.ts index 285ce6f7..52b74a82 100644 --- a/apps/api/drizzle.config.ts +++ b/apps/api/drizzle.config.ts @@ -1,14 +1,13 @@ +/* eslint-disable no-process-env */ import 'dotenv/config' import { defineConfig } from 'drizzle-kit' -import { env } from './src/lib/env' - export default defineConfig({ out: './drizzle', - schema: './src/db/schema', + schema: './src/db/schema/index.ts', dialect: 'postgresql', dbCredentials: { - url: env.DATABASE_URL + url: process.env.DATABASE_URL! } }) diff --git a/apps/api/package.json b/apps/api/package.json index 074846cf..78537bfc 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -62,6 +62,7 @@ }, "devDependencies": { "@types/jsonwebtoken": "^9.0.9", - "drizzle-kit": "^0.31.0" + "drizzle-kit": "^0.31.0", + "drizzle-orm": "^0.43.1" } } diff --git a/apps/api/src/api-v1/index.ts b/apps/api/src/api-v1/index.ts index e1fca0b9..af1b564b 100644 --- a/apps/api/src/api-v1/index.ts +++ b/apps/api/src/api-v1/index.ts @@ -4,6 +4,7 @@ import type { AuthenticatedEnv } from '@/lib/types' import * as middleware from '@/lib/middleware' import { registerHealthCheck } from './health-check' +import { registerV1ProjectsGetProject } from './projects/get-project' import { registerV1TeamsCreateTeam } from './teams/create-team' import { registerV1TeamsDeleteTeam } from './teams/delete-team' import { registerV1TeamsGetTeam } from './teams/get-team' @@ -41,6 +42,9 @@ registerV1TeamsMembersCreateTeamMember(pri) registerV1TeamsMembersUpdateTeamMember(pri) registerV1TeamsMembersDeleteTeamMember(pri) +// Projects crud +registerV1ProjectsGetProject(pri) + // Setup routes and middleware apiV1.route('/', pub) apiV1.use(middleware.authenticate) diff --git a/apps/api/src/api-v1/projects/get-project.ts b/apps/api/src/api-v1/projects/get-project.ts new file mode 100644 index 00000000..25f5cb64 --- /dev/null +++ b/apps/api/src/api-v1/projects/get-project.ts @@ -0,0 +1,56 @@ +import { createRoute, type OpenAPIHono } from '@hono/zod-openapi' + +import type { AuthenticatedEnv } from '@/lib/types' +import { + db, + eq, + // populateProjectSchema, + schema +} from '@/db' +import { acl } from '@/lib/acl' +import { assert, parseZodSchema } from '@/lib/utils' + +import { ProjectIdParamsSchema } from './schemas' + +const route = createRoute({ + description: 'Gets a project', + tags: ['projects'], + operationId: 'getProject', + method: 'get', + path: 'projects/{projectId}', + security: [{ bearerAuth: [] }], + request: { + params: ProjectIdParamsSchema + // query: populateProjectSchema + }, + responses: { + 200: { + description: 'A project', + content: { + 'application/json': { + schema: schema.projectSelectSchema + } + } + } + // TODO + // ...openApiErrorResponses + } +}) + +export function registerV1ProjectsGetProject( + app: OpenAPIHono +) { + return app.openapi(route, async (c) => { + const { projectId } = c.req.valid('param') + // const { populate = [] } = c.req.valid('query') + + const project = await db.query.projects.findFirst({ + where: eq(schema.projects.id, projectId) + // with: Object.fromEntries(populate.map((field) => [field, true])) + }) + assert(project, 404, `Project not found "${projectId}"`) + await acl(c, project, { label: 'Project' }) + + return c.json(parseZodSchema(schema.projectSelectSchema, project)) + }) +} diff --git a/apps/api/src/api-v1/projects/schemas.ts b/apps/api/src/api-v1/projects/schemas.ts new file mode 100644 index 00000000..f06362c0 --- /dev/null +++ b/apps/api/src/api-v1/projects/schemas.ts @@ -0,0 +1,13 @@ +import { z } from '@hono/zod-openapi' + +import { projectIdSchema } from '@/db' + +export const ProjectIdParamsSchema = z.object({ + projectId: projectIdSchema.openapi({ + param: { + description: 'Project ID', + name: 'projectId', + in: 'path' + } + }) +}) diff --git a/apps/api/src/db/schema/index.ts b/apps/api/src/db/schema/index.ts index 01a1e1b5..1120a3f0 100644 --- a/apps/api/src/db/schema/index.ts +++ b/apps/api/src/db/schema/index.ts @@ -6,3 +6,4 @@ export * from './team' export * from './team-member' export type * from './types' export * from './user' +export * from './utils' diff --git a/apps/api/src/db/schema/project.ts b/apps/api/src/db/schema/project.ts index 0c723307..9315bfb6 100644 --- a/apps/api/src/db/schema/project.ts +++ b/apps/api/src/db/schema/project.ts @@ -103,13 +103,12 @@ export const projects = pgTable( (table) => [ index('project_userId_idx').on(table.userId), index('project_teamId_idx').on(table.teamId), - index('project_teamId_idx').on(table.teamId), index('project_createdAt_idx').on(table.createdAt), index('project_updatedAt_idx').on(table.updatedAt) ] ) -export const projectsRelations = relations(projects, ({ one, many }) => ({ +export const projectsRelations = relations(projects, ({ one }) => ({ user: one(users, { fields: [projects.userId], references: [users.id] @@ -127,13 +126,28 @@ export const projectsRelations = relations(projects, ({ one, many }) => ({ fields: [projects.lastDeploymentId], references: [deployments.id], relationName: 'lastDeployment' - }), - deployments: many(deployments, { relationName: 'deployments' }), - publishedDeployments: many(deployments, { - relationName: 'publishedDeployments' }) + // deployments: many(deployments, { + // relationName: 'deployments' + // }), + // publishedDeployments: many(deployments, { + // relationName: 'publishedDeployments' + // }) })) +export type ProjectRelationFields = keyof ReturnType< + (typeof projectsRelations)['config'] +> + +export const projectRelationsSchema: z.ZodType = z.enum([ + 'user', + 'team', + 'lastPublishedDeployment', + 'lastDeployment' + // 'deployments', + // 'publishedDeployments' +]) + export const projectSelectSchema = createSelectSchema(projects, { stripeMetricProductIds: z.record(z.string(), z.string()).optional() // _webhooks: z.array(webhookSchema), diff --git a/apps/api/src/db/schema/user.ts b/apps/api/src/db/schema/user.ts index c91ca980..4584c457 100644 --- a/apps/api/src/db/schema/user.ts +++ b/apps/api/src/db/schema/user.ts @@ -45,7 +45,7 @@ export const users = pgTable( emailConfirmed: boolean().default(false).notNull(), emailConfirmedAt: timestamp(), - emailConfirmToken: text().unique().default(sha256()).notNull(), + emailConfirmToken: text().unique().notNull(), passwordResetToken: text().unique(), isStripeConnectEnabledByDefault: boolean().default(true).notNull(), @@ -71,13 +71,6 @@ export const usersRelations = relations(users, ({ many }) => ({ export const userSelectSchema = createSelectSchema(users).openapi('User') -function userRefinementHook(user: Partial) { - return { - ...user, - password: user.password ? hashSync(user.password) : undefined - } -} - export const userInsertSchema = createInsertSchema(users, { username: (schema) => schema.refine((username) => validators.username(username), { @@ -96,7 +89,13 @@ export const userInsertSchema = createInsertSchema(users, { lastName: true, image: true }) - .refine(userRefinementHook) + .refine((user) => { + return { + ...user, + emailConfirmToken: sha256(), + password: user.password ? hashSync(user.password) : undefined + } + }) export const userUpdateSchema = createUpdateSchema(users) .pick({ @@ -106,4 +105,9 @@ export const userUpdateSchema = createUpdateSchema(users) password: true, isStripeConnectEnabledByDefault: true }) - .refine(userRefinementHook) + .refine((user) => { + return { + ...user, + password: user.password ? hashSync(user.password) : undefined + } + }) diff --git a/apps/api/src/db/schemas.ts b/apps/api/src/db/schemas.ts index d81db892..5a9b1aa1 100644 --- a/apps/api/src/db/schemas.ts +++ b/apps/api/src/db/schemas.ts @@ -1,6 +1,8 @@ import { validators } from '@agentic/validators' import { z } from '@hono/zod-openapi' +import { projectRelationsSchema } from './schema/project' + function getCuidSchema(idLabel: string) { return z.string().refine((id) => validators.cuid(id), { message: `Invalid ${idLabel}` @@ -41,6 +43,10 @@ export const paginationSchema = z.object({ sortBy: z.enum(['createdAt', 'updatedAt']).default('createdAt').optional() }) +export const populateProjectSchema = z.object({ + populate: z.array(projectRelationsSchema).default([]).optional() +}) + // import type { PgTable, TableConfig } from '@fisch0920/drizzle-orm/pg-core' // import type { AnyZodObject } from 'zod' // diff --git a/apps/api/src/lib/acl.ts b/apps/api/src/lib/acl.ts index deeb3aeb..69b90b94 100644 --- a/apps/api/src/lib/acl.ts +++ b/apps/api/src/lib/acl.ts @@ -3,8 +3,8 @@ import { assert } from './utils' export async function acl< TModel extends Record, - TUserField extends keyof TModel = 'user', - TTeamField extends keyof TModel = 'team' + TUserField extends keyof TModel = 'userId', + TTeamField extends keyof TModel = 'teamId' >( ctx: AuthenticatedContext, model: TModel, diff --git a/apps/api/src/lib/middleware/authenticate.ts b/apps/api/src/lib/middleware/authenticate.ts index 22edf61b..2faf5783 100644 --- a/apps/api/src/lib/middleware/authenticate.ts +++ b/apps/api/src/lib/middleware/authenticate.ts @@ -1,5 +1,5 @@ import { createMiddleware } from 'hono/factory' -import { jwt } from 'hono/jwt' +import * as jwt from 'hono/jwt' import type { AuthenticatedEnv } from '@/lib/types' import { db, eq, schema } from '@/db' @@ -7,24 +7,40 @@ import { env } from '@/lib/env' import { assert } from '../utils' -const jwtMiddleware = jwt({ - secret: env.JWT_SECRET -}) +const token = await jwt.sign({ userId: 'test', type: 'user' }, env.JWT_SECRET) +console.log({ token }) export const authenticate = createMiddleware( async function authenticateMiddleware(ctx, next) { - await jwtMiddleware(ctx, async () => { - const payload = ctx.get('jwtPayload') - assert(payload, 401, 'Unauthorized') - assert(payload.type === 'user', 401, 'Unauthorized') + const credentials = ctx.req.raw.headers.get('Authorization') + assert(credentials, 401, 'Unauthorized') - const user = await db.query.users.findFirst({ - where: eq(schema.users.id, payload.userId) - }) - assert(user, 401, 'Unauthorized') - ctx.set('user', user) + const parts = credentials.split(/\s+/) + assert( + parts.length === 1 || + (parts.length === 2 && parts[0]?.toLowerCase() === 'bearer'), + 401, + 'Unauthorized' + ) + const token = parts.at(-1) + assert(token, 401, 'Unauthorized') - await next() + const payload = await jwt.verify(token, env.JWT_SECRET) + console.log({ payload }) + assert(payload, 401, 'Unauthorized') + assert(payload.type === 'user', 401, 'Unauthorized') + assert( + payload.userId && typeof payload.userId === 'string', + 401, + 'Unauthorized' + ) + + const user = await db.query.users.findFirst({ + where: eq(schema.users.id, payload.userId) }) + assert(user, 401, 'Unauthorized') + ctx.set('user', user as any) + + await next() } ) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 11203653..b8152794 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -201,6 +201,9 @@ importers: drizzle-kit: specifier: ^0.31.0 version: 0.31.0 + drizzle-orm: + specifier: ^0.43.1 + version: 0.43.1(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(postgres@3.4.5) packages/validators: dependencies: @@ -1730,6 +1733,95 @@ packages: resolution: {integrity: sha512-pcKVT+GbfPA+bUovPIilgVOoq+onNBo/YQBG86sf3/GFHkN6lRJPm1l7dKN0IMAk57RQoIm4GUllRrasLlcaSg==} hasBin: true + drizzle-orm@0.43.1: + resolution: {integrity: sha512-dUcDaZtE/zN4RV/xqGrVSMpnEczxd5cIaoDeor7Zst9wOe/HzC/7eAaulywWGYXdDEc9oBPMjayVEDg0ziTLJA==} + peerDependencies: + '@aws-sdk/client-rds-data': '>=3' + '@cloudflare/workers-types': '>=4' + '@electric-sql/pglite': '>=0.2.0' + '@libsql/client': '>=0.10.0' + '@libsql/client-wasm': '>=0.10.0' + '@neondatabase/serverless': '>=0.10.0' + '@op-engineering/op-sqlite': '>=2' + '@opentelemetry/api': ^1.4.1 + '@planetscale/database': '>=1.13' + '@prisma/client': '*' + '@tidbcloud/serverless': '*' + '@types/better-sqlite3': '*' + '@types/pg': '*' + '@types/sql.js': '*' + '@vercel/postgres': '>=0.8.0' + '@xata.io/client': '*' + better-sqlite3: '>=7' + bun-types: '*' + expo-sqlite: '>=14.0.0' + gel: '>=2' + knex: '*' + kysely: '*' + mysql2: '>=2' + pg: '>=8' + postgres: '>=3' + prisma: '*' + sql.js: '>=1' + sqlite3: '>=5' + peerDependenciesMeta: + '@aws-sdk/client-rds-data': + optional: true + '@cloudflare/workers-types': + optional: true + '@electric-sql/pglite': + optional: true + '@libsql/client': + optional: true + '@libsql/client-wasm': + optional: true + '@neondatabase/serverless': + optional: true + '@op-engineering/op-sqlite': + optional: true + '@opentelemetry/api': + optional: true + '@planetscale/database': + optional: true + '@prisma/client': + optional: true + '@tidbcloud/serverless': + optional: true + '@types/better-sqlite3': + optional: true + '@types/pg': + optional: true + '@types/sql.js': + optional: true + '@vercel/postgres': + optional: true + '@xata.io/client': + optional: true + better-sqlite3: + optional: true + bun-types: + optional: true + expo-sqlite: + optional: true + gel: + optional: true + knex: + optional: true + kysely: + optional: true + mysql2: + optional: true + pg: + optional: true + postgres: + optional: true + prisma: + optional: true + sql.js: + optional: true + sqlite3: + optional: true + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -5124,6 +5216,12 @@ snapshots: transitivePeerDependencies: - supports-color + drizzle-orm@0.43.1(@opentelemetry/api@1.9.0)(@types/pg@8.6.1)(postgres@3.4.5): + optionalDependencies: + '@opentelemetry/api': 1.9.0 + '@types/pg': 8.6.1 + postgres: 3.4.5 + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2