pull/715/head
Travis Fischer 2025-05-02 12:25:51 +07:00
rodzic 9d0a388ead
commit 74c84d9c07
15 zmienionych plików z 351 dodań i 25 usunięć

Wyświetl plik

@ -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=

Wyświetl plik

@ -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"

Wyświetl plik

@ -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')
}
})
}

Wyświetl plik

@ -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)
})
}

Wyświetl plik

@ -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<AuthenticatedEnv>
) {
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))
})
}

Wyświetl plik

@ -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'
}
})
})

Wyświetl plik

@ -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)

Wyświetl plik

@ -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<Stripe.Event.Type>([
'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' })
})
}

Wyświetl plik

@ -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))
}
})

Wyświetl plik

@ -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()

Wyświetl plik

@ -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<typeof envSchema>
@ -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_')

Wyświetl plik

@ -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'
})

Wyświetl plik

@ -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
})

Wyświetl plik

@ -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:

Wyświetl plik

@ -5,7 +5,9 @@
# Agentic <!-- omit from toc -->
**TODO**
## TODO
- simplify logger
## License