diff --git a/apps/api/.env.example b/apps/api/.env.example index 0c5ff796..43d6ee2a 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -13,3 +13,9 @@ SENTRY_DSN= GCP_PROJECT_ID= GCP_LOG_NAME='local-dev' METADATA_SERVER_DETECTION='none' + +STRIPE_SECRET_KEY= + +WORKOS_CLIENT_ID= +WORKOS_API_KEY= +WORKOS_SESSION_SECRET= diff --git a/apps/api/package.json b/apps/api/package.json index 78537bfc..ba6b05e6 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -56,6 +56,7 @@ "pino-abstract-transport": "^2.0.0", "postgres": "^3.4.5", "restore-cursor": "catalog:", + "stripe": "^18.1.0", "type-fest": "catalog:", "zod": "catalog:", "zod-validation-error": "^3.4.0" diff --git a/apps/api/src/api-v1/auth/callback.ts b/apps/api/src/api-v1/auth/callback.ts new file mode 100644 index 00000000..860c6e21 --- /dev/null +++ b/apps/api/src/api-v1/auth/callback.ts @@ -0,0 +1,60 @@ +import { createRoute, type OpenAPIHono, z } from '@hono/zod-openapi' +import { setCookie } from 'hono/cookie' + +import { env } from '@/lib/env' +import { assert } from '@/lib/utils' +import { workos } from '@/lib/workos' + +const route = createRoute({ + method: 'get', + path: 'auth/callback', + hide: true, + request: { + query: z.object({ + code: z.string() + }) + }, + responses: { + 302: { + description: 'Redirect' + } + } +}) + +export function registerAuthCallback(app: OpenAPIHono) { + return app.openapi(route, async (c) => { + const { code } = c.req.valid('query') + assert(code, 400, '"code" is required') + + try { + const authenticateResponse = + await workos.userManagement.authenticateWithCode({ + clientId: env.WORKOS_CLIENT_ID, + code, + session: { + sealSession: true, + cookiePassword: env.WORKOS_SESSION_SECRET + } + }) + + const { user: _user, sealedSession } = authenticateResponse + assert(sealedSession, 500, 'Sealed session is required') + + // Store session in a cookie + setCookie(c, 'wos-session', sealedSession!, { + path: '/', + httpOnly: true, + secure: true, + sameSite: 'lax', + maxAge: 60 * 60 * 24 * 30 // 30 days + }) + + // TODO: `user` + + // Redirect the user to the homepage + return c.redirect('/') + } catch { + return c.redirect('/auth/login') + } + }) +} diff --git a/apps/api/src/api-v1/auth/login.ts b/apps/api/src/api-v1/auth/login.ts new file mode 100644 index 00000000..571b6a11 --- /dev/null +++ b/apps/api/src/api-v1/auth/login.ts @@ -0,0 +1,39 @@ +import { createRoute, type OpenAPIHono, z } from '@hono/zod-openapi' + +import { env } from '@/lib/env' +import { workos } from '@/lib/workos' + +const route = createRoute({ + method: 'get', + path: 'auth/login', + hide: true, + request: { + query: z.object({ + redirectUri: z.string().url().optional() + }) + }, + responses: { + 302: { + description: 'Redirect to WorkOS login page' + } + } +}) + +export function registerAuthLogin(app: OpenAPIHono) { + return app.openapi(route, async (c) => { + const { redirectUri = 'http://localhost:3000/auth/callback' } = + c.req.valid('query') + + const authorizationUrl = workos.userManagement.getAuthorizationUrl({ + clientId: env.WORKOS_CLIENT_ID, + + // Specify that we'd like AuthKit to handle the authentication flow + provider: 'authkit', + + // The callback endpoint that WorkOS will redirect to after a user authenticates + redirectUri + }) + + return c.redirect(authorizationUrl) + }) +} diff --git a/apps/api/src/api-v1/consumers/get-consumer.ts b/apps/api/src/api-v1/consumers/get-consumer.ts new file mode 100644 index 00000000..47504c06 --- /dev/null +++ b/apps/api/src/api-v1/consumers/get-consumer.ts @@ -0,0 +1,48 @@ +import { createRoute, type OpenAPIHono } from '@hono/zod-openapi' + +import type { AuthenticatedEnv } from '@/lib/types' +import { db, eq, schema } from '@/db' +import { acl } from '@/lib/acl' +import { assert, parseZodSchema } from '@/lib/utils' + +import { consumerIdParamsSchema } from './schemas' + +const route = createRoute({ + description: 'Gets a consumer', + tags: ['consumers'], + operationId: 'getConsumer', + method: 'get', + path: 'consumers/{consumersId}', + security: [{ bearerAuth: [] }], + request: { + params: consumerIdParamsSchema + }, + responses: { + 200: { + description: 'A consumer object', + content: { + 'application/json': { + schema: schema.consumerSelectSchema + } + } + } + // TODO + // ...openApiErrorResponses + } +}) + +export function registerV1ConsumersGetConsumer( + app: OpenAPIHono +) { + return app.openapi(route, async (c) => { + const { consumerId } = c.req.valid('param') + + const consumer = await db.query.consumers.findFirst({ + where: eq(schema.consumers.id, consumerId) + }) + assert(consumer, 404, `Consumer not found "${consumerId}"`) + await acl(c, consumer, { label: 'Consumer' }) + + return c.json(parseZodSchema(schema.consumerSelectSchema, consumer)) + }) +} diff --git a/apps/api/src/api-v1/consumers/schemas.ts b/apps/api/src/api-v1/consumers/schemas.ts new file mode 100644 index 00000000..ab4a01b5 --- /dev/null +++ b/apps/api/src/api-v1/consumers/schemas.ts @@ -0,0 +1,13 @@ +import { z } from '@hono/zod-openapi' + +import { consumerIdSchema } from '@/db' + +export const consumerIdParamsSchema = z.object({ + consumerId: consumerIdSchema.openapi({ + param: { + description: 'Consumer ID', + name: 'consumerId', + in: 'path' + } + }) +}) diff --git a/apps/api/src/api-v1/index.ts b/apps/api/src/api-v1/index.ts index be60021c..91d3c32c 100644 --- a/apps/api/src/api-v1/index.ts +++ b/apps/api/src/api-v1/index.ts @@ -3,6 +3,7 @@ import { OpenAPIHono } from '@hono/zod-openapi' import type { AuthenticatedEnv } from '@/lib/types' import * as middleware from '@/lib/middleware' +import { registerV1ConsumersGetConsumer } from './consumers/get-consumer' import { registerHealthCheck } from './health-check' import { registerV1ProjectsCreateProject } from './projects/create-project' import { registerV1ProjectsGetProject } from './projects/get-project' @@ -18,6 +19,7 @@ import { registerV1TeamsMembersUpdateTeamMember } from './teams/members/update-t import { registerV1TeamsUpdateTeam } from './teams/update-team' import { registerV1UsersGetUser } from './users/get-user' import { registerV1UsersUpdateUser } from './users/update-user' +import { registerV1StripeWebhook } from './webhooks/stripe-webhook' export const apiV1 = new OpenAPIHono() @@ -62,6 +64,12 @@ registerV1ProjectsUpdateProject(pri) // require('./projects').read // ) +// Consumers crud +registerV1ConsumersGetConsumer(pri) + +// webhook events +registerV1StripeWebhook(pub) + // Setup routes and middleware apiV1.route('/', pub) apiV1.use(middleware.authenticate) diff --git a/apps/api/src/api-v1/webhooks/stripe-webhook.ts b/apps/api/src/api-v1/webhooks/stripe-webhook.ts new file mode 100644 index 00000000..50f32df0 --- /dev/null +++ b/apps/api/src/api-v1/webhooks/stripe-webhook.ts @@ -0,0 +1,117 @@ +import type { OpenAPIHono } from '@hono/zod-openapi' +import type Stripe from 'stripe' + +import { and, db, eq, schema } from '@/db' +import { env, isStripeLive } from '@/lib/env' +import { HttpError } from '@/lib/errors' +import { stripe } from '@/lib/stripe' +import { assert } from '@/lib/utils' + +const relevantStripeEvents = new Set([ + 'customer.subscription.updated' +]) + +const stripeValidSubscriptionStatuses = new Set([ + 'active', + 'trialing', + 'incomplete', + 'past_due' +]) + +export function registerV1StripeWebhook(app: OpenAPIHono) { + return app.get('/webhooks/stripe', async (ctx) => { + const body = await ctx.req.text() + const signature = ctx.req.header('Stripe-Signature') + assert(signature, 400, 'missing signature') + + let event: Stripe.Event + + try { + event = stripe.webhooks.constructEvent( + body, + signature, + env.STRIPE_WEBHOOK_SECRET + ) + } catch (err) { + throw new HttpError({ + message: 'invalid stripe event', + cause: err, + statusCode: 400 + }) + } + + // Shouldn't ever happen because the signatures should be different, but it's + // a useful sanity check just in case. + assert( + event.livemode === isStripeLive, + 400, + 'invalid stripe event: livemode mismatch' + ) + + if (!relevantStripeEvents.has(event.type)) { + // TODO + return ctx.json({ status: 'ok' }) + } + + try { + switch (event.type) { + case 'customer.subscription.updated': { + // https://docs.stripe.com/billing/subscriptions/overview#subscription-statuses + const subscription = event.data.object + const { userId, projectId } = subscription.metadata + assert(userId, 400, 'missing metadata userId') + assert(projectId, 400, 'missing metadata projectId') + + // logger.info(event.type, { + // userId, + // projectId, + // status: subscription.status + // }) + + const consumer = await db.query.consumers.findFirst({ + where: and( + eq(schema.consumers.userId, userId), + eq(schema.consumers.projectId, projectId) + ), + with: { + user: true, + project: true + } + }) + assert(consumer, 404, 'consumer not found') + + if (consumer.stripeStatus !== subscription.status) { + consumer.stripeStatus = subscription.status + consumer.enabled = + consumer.plan === 'free' || + stripeValidSubscriptionStatuses.has(consumer.stripeStatus) + + await db + .update(schema.consumers) + .set({ + stripeStatus: consumer.stripeStatus + }) + .where(eq(schema.consumers.id, consumer.id)) + + // TODO: invoke provider webhooks + // event.data.customer = consumer.getPublicDocument() + // await invokeWebhooks(consumer.project, event) + } + + break + } + + default: + throw new Error(`unexpected unhandled event "${event.type}"`) + } + } catch (err) { + throw new HttpError({ + message: `error processing stripe webhook type "${event.type}"`, + cause: err, + statusCode: 500 + }) + } + + return ctx.json({ status: 'ok' }) + }) +} diff --git a/apps/api/src/db/schema/consumer.ts b/apps/api/src/db/schema/consumer.ts index 8001a2d6..89f692b0 100644 --- a/apps/api/src/db/schema/consumer.ts +++ b/apps/api/src/db/schema/consumer.ts @@ -14,7 +14,6 @@ import { users, userSelectSchema } from './user' import { createInsertSchema, createSelectSchema, - createUpdateSchema, cuid, deploymentId, id, @@ -33,6 +32,7 @@ export const consumers = pgTable( id, ...timestamps, + // API token for this consumer token: text().notNull(), plan: text(), @@ -42,9 +42,6 @@ export const consumers = pgTable( env: text().default('dev').notNull(), coupon: text(), - // stripe subscription status (synced via webhooks) - status: text(), - // only used during initial creation source: text(), @@ -67,6 +64,9 @@ export const consumers = pgTable( onDelete: 'cascade' }), + // stripe subscription status (synced via webhooks) + stripeStatus: text(), + stripeSubscriptionId: stripeId().notNull(), stripeSubscriptionBaseItemId: stripeId(), stripeSubscriptionRequestItemId: stripeId(), @@ -107,13 +107,6 @@ export const consumersRelations = relations(consumers, ({ one }) => ({ }) })) -const stripeValidSubscriptionStatuses = new Set([ - 'active', - 'trialing', - 'incomplete', - 'past_due' -]) - export const consumerSelectSchema = createSelectSchema(consumers) .omit({ _stripeCustomerId: true @@ -148,15 +141,3 @@ export const consumerInsertSchema = createInsertSchema(consumers) deploymentId: true }) .strict() - -export const consumerUpdateSchema = createUpdateSchema(consumers) - .strict() - .refine((consumer) => { - return { - ...consumer, - enabled: - consumer.plan === 'free' || - (consumer.status && - stripeValidSubscriptionStatuses.has(consumer.status)) - } - }) diff --git a/apps/api/src/db/schemas.ts b/apps/api/src/db/schemas.ts index d81db892..a346f2b9 100644 --- a/apps/api/src/db/schemas.ts +++ b/apps/api/src/db/schemas.ts @@ -9,6 +9,7 @@ function getCuidSchema(idLabel: string) { export const cuidSchema = getCuidSchema('id') export const userIdSchema = getCuidSchema('user id') +export const consumerIdSchema = getCuidSchema('consumer id') export const projectIdSchema = z .string() diff --git a/apps/api/src/lib/env.ts b/apps/api/src/lib/env.ts index 0c544f93..6806becc 100644 --- a/apps/api/src/lib/env.ts +++ b/apps/api/src/lib/env.ts @@ -11,7 +11,15 @@ export const envSchema = z.object({ DATABASE_URL: z.string().url(), JWT_SECRET: z.string(), PORT: z.number().default(3000), - SENTRY_DSN: z.string().url() + SENTRY_DSN: z.string().url(), + + WORKOS_CLIENT_ID: z.string(), + WORKOS_API_KEY: z.string(), + WORKOS_SESSION_SECRET: z.string(), + + STRIPE_SECRET_KEY: z.string(), + STRIPE_PUBLISHABLE_KEY: z.string(), + STRIPE_WEBHOOK_SECRET: z.string() }) export type Env = z.infer @@ -23,3 +31,5 @@ export const env = parseZodSchema(envSchema, process.env, { export const isDev = env.NODE_ENV === 'development' export const isProd = env.NODE_ENV === 'production' export const isBrowser = (globalThis as any).window !== undefined + +export const isStripeLive = env.STRIPE_PUBLISHABLE_KEY.startsWith('pk_live_') diff --git a/apps/api/src/lib/stripe.ts b/apps/api/src/lib/stripe.ts new file mode 100644 index 00000000..746205c3 --- /dev/null +++ b/apps/api/src/lib/stripe.ts @@ -0,0 +1,7 @@ +import Stripe from 'stripe' + +import { env } from './env' + +export const stripe = new Stripe(env.STRIPE_SECRET_KEY, { + apiVersion: '2025-02-24.acacia' +}) diff --git a/apps/api/src/lib/workos.ts b/apps/api/src/lib/workos.ts new file mode 100644 index 00000000..cd071082 --- /dev/null +++ b/apps/api/src/lib/workos.ts @@ -0,0 +1,7 @@ +import { WorkOS } from '@workos-inc/node' + +import { env } from './env' + +export const workos = new WorkOS(env.WORKOS_API_KEY, { + clientId: env.WORKOS_CLIENT_ID +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b8152794..f6a2aad5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -185,6 +185,9 @@ importers: restore-cursor: specifier: 'catalog:' version: 5.1.0 + stripe: + specifier: ^18.1.0 + version: 18.1.0(@types/node@22.15.2) type-fest: specifier: 'catalog:' version: 4.40.1 @@ -3036,6 +3039,10 @@ packages: resolution: {integrity: sha512-pMpnA0qRdFp32b1sJl1wOJNxZLQ2cbQx+k6tjNtZ8CpvVhNqEPRgivZ2WOUev2YMajecdH7ctUPDvEe87nariQ==} engines: {node: '>=6.0.0'} + qs@6.14.0: + resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==} + engines: {node: '>=0.6'} + queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} @@ -3352,6 +3359,15 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + stripe@18.1.0: + resolution: {integrity: sha512-MLDiniPTHqcfIT3anyBPmOEcaiDhYa7/jRaNypQ3Rt2SJnayQZBvVbFghIziUCZdltGAndm/ZxVOSw6uuSCDig==} + engines: {node: '>=12.*'} + peerDependencies: + '@types/node': '>=12.x.x' + peerDependenciesMeta: + '@types/node': + optional: true + stubs@3.0.0: resolution: {integrity: sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw==} @@ -6631,6 +6647,10 @@ snapshots: pvutils@1.1.3: {} + qs@6.14.0: + dependencies: + side-channel: 1.1.0 + queue-microtask@1.2.3: {} quick-format-unescaped@4.0.4: {} @@ -7016,6 +7036,12 @@ snapshots: strip-json-comments@3.1.1: {} + stripe@18.1.0(@types/node@22.15.2): + dependencies: + qs: 6.14.0 + optionalDependencies: + '@types/node': 22.15.2 + stubs@3.0.0: {} sucrase@3.35.0: diff --git a/readme.md b/readme.md index 06abca4c..60854e5c 100644 --- a/readme.md +++ b/readme.md @@ -5,7 +5,9 @@ # Agentic -**TODO** +## TODO + +- simplify logger ## License