kopia lustrzana https://github.com/transitive-bullshit/chatgpt-api
pull/715/head
rodzic
9d0a388ead
commit
74c84d9c07
|
@ -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=
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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')
|
||||
}
|
||||
})
|
||||
}
|
|
@ -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)
|
||||
})
|
||||
}
|
|
@ -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))
|
||||
})
|
||||
}
|
|
@ -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'
|
||||
}
|
||||
})
|
||||
})
|
|
@ -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)
|
||||
|
|
|
@ -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' })
|
||||
})
|
||||
}
|
|
@ -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))
|
||||
}
|
||||
})
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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_')
|
||||
|
|
|
@ -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'
|
||||
})
|
|
@ -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
|
||||
})
|
|
@ -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:
|
||||
|
|
|
@ -5,7 +5,9 @@
|
|||
|
||||
# Agentic <!-- omit from toc -->
|
||||
|
||||
**TODO**
|
||||
## TODO
|
||||
|
||||
- simplify logger
|
||||
|
||||
## License
|
||||
|
||||
|
|
Ładowanie…
Reference in New Issue