Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"hono": "catalog:",
"injeca": "catalog:",
"postgres": "^3.4.8",
"stripe": "catalog:",
"tsx": "^4.21.0",
"valibot": "catalog:"
},
Expand Down
34 changes: 32 additions & 2 deletions apps/server/src/app.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { Env } from './services/env'
import type { HonoEnv } from './types/hono'

import process, { exit } from 'node:process'
Expand All @@ -12,12 +13,16 @@ import { createLoggLogger, injeca } from 'injeca'
import { sessionMiddleware } from './middlewares/auth'
import { createCharacterRoutes } from './routes/characters'
import { createChatRoutes } from './routes/chats'
import { createFluxRoutes } from './routes/flux'
import { createProviderRoutes } from './routes/providers'
import { createStripeRoutes } from './routes/stripe'
import { createV1Routes } from './routes/v1'
import { createAuth } from './services/auth'
import { createCharacterService } from './services/characters'
import { createChatService } from './services/chats'
import { createDrizzle } from './services/db'
import { parsedEnv } from './services/env'
import { createFluxService } from './services/flux'
import { createProviderService } from './services/providers'
import { ApiError, createInternalError } from './utils/error'
import { getTrustedOrigin } from './utils/origin'
Expand All @@ -28,15 +33,18 @@ type AuthService = ReturnType<typeof createAuth>
type CharacterService = ReturnType<typeof createCharacterService>
type ChatService = ReturnType<typeof createChatService>
type ProviderService = ReturnType<typeof createProviderService>
type FluxService = ReturnType<typeof createFluxService>

interface AppDeps {
auth: AuthService
characterService: CharacterService
chatService: ChatService
providerService: ProviderService
fluxService: FluxService
env: Env
}

function buildApp({ auth, characterService, chatService, providerService }: AppDeps) {
function buildApp({ auth, characterService, chatService, providerService, fluxService, env }: AppDeps) {
const logger = useLogger('app').useGlobalConfig()

return new Hono<HonoEnv>()
Expand Down Expand Up @@ -86,6 +94,21 @@ function buildApp({ auth, characterService, chatService, providerService }: AppD
* Chat routes are handled by the chat service.
*/
.route('/api/chats', createChatRoutes(chatService))

/**
* V1 routes for official provider.
*/
.route('/v1', createV1Routes(fluxService, env))

/**
* Flux routes.
*/
.route('/api/flux', createFluxRoutes(fluxService))

/**
* Stripe routes.
*/
.route('/api/stripe', createStripeRoutes(fluxService, env))
}

export type AppType = ReturnType<typeof buildApp>
Expand Down Expand Up @@ -128,13 +151,20 @@ async function createApp() {
build: ({ dependsOn }) => createChatService(dependsOn.db),
})

const fluxService = injeca.provide('services:flux', {
dependsOn: { db },
build: ({ dependsOn }) => createFluxService(dependsOn.db),
})

await injeca.start()
const resolved = await injeca.resolve({ auth, characterService, chatService, providerService })
const resolved = await injeca.resolve({ auth, characterService, chatService, providerService, fluxService, env: parsedEnv })
const app = buildApp({
auth: resolved.auth,
characterService: resolved.characterService,
chatService: resolved.chatService,
providerService: resolved.providerService,
fluxService: resolved.fluxService,
env: resolved.env,
})

useLogger('app').useGlobalConfig().withFields({ port: 3000 }).log('Server started')
Expand Down
20 changes: 20 additions & 0 deletions apps/server/src/routes/flux.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type { FluxService } from '../services/flux'
import type { HonoEnv } from '../types/hono'

import { Hono } from 'hono'

import { authGuard } from '../middlewares/auth'

export function createFluxRoutes(fluxService: FluxService) {
const routes = new Hono<HonoEnv>()

routes.use('*', authGuard)

routes.get('/', async (c) => {
const user = c.get('user')!
const flux = await fluxService.getFlux(user.id)
return c.json(flux)
})

return routes
}
73 changes: 73 additions & 0 deletions apps/server/src/routes/stripe.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import type { FluxService } from '../services/flux'
import type { HonoEnv } from '../types/hono'

import Stripe from 'stripe'

import { Hono } from 'hono'

import { authGuard } from '../middlewares/auth'

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The env parameter is typed as any. For better type safety, it should be typed with the Env type from ../services/env. You'll need to import it:

import type { Env } from '../services/env'
Suggested change
export function createStripeRoutes(creditsService: CreditsService, env: Env) {

export function createStripeRoutes(fluxService: FluxService, env: any) {
const stripe = new Stripe(env.STRIPE_SECRET_KEY)
const routes = new Hono<HonoEnv>()

routes.post('/checkout', authGuard, async (c) => {
const user = c.get('user')!
const { amount } = await c.req.json()

const session = await stripe.checkout.sessions.create({
payment_method_types: ['card'],
line_items: [
{
price_data: {
currency: 'usd',
product_data: {
name: 'Flux Top-up',
},
unit_amount: amount,
},
quantity: 1,
},
],
mode: 'payment',
success_url: `${env.CLIENT_URL}/settings/flux?success=true`,
cancel_url: `${env.CLIENT_URL}/settings/flux?canceled=true`,
customer_email: user.email,
metadata: {
userId: user.id,
},
})

return c.json({ url: session.url })
})

routes.post('/webhook', async (c) => {
const sig = c.req.header('stripe-signature')
if (!sig)
return c.json({ error: 'No signature' }, 400)

let event: Stripe.Event
try {
const body = await c.req.text()
event = stripe.webhooks.constructEvent(body, sig, env.STRIPE_WEBHOOK_SECRET)
}
catch (err: any) {
return c.json({ error: `Webhook Error: ${err.message}` }, 400)
}

if (event.type === 'checkout.session.completed') {
const session = event.data.object as Stripe.Checkout.Session
const userId = session.metadata?.userId
const amount = session.amount_total

if (userId && amount) {
// Example: 1 flux per cent?
await fluxService.addFlux(userId, amount)
}
}

return c.json({ received: true })
})

return routes
}
44 changes: 44 additions & 0 deletions apps/server/src/routes/v1.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import type { FluxService } from '../services/flux'
import type { HonoEnv } from '../types/hono'

import { Hono } from 'hono'

import { authGuard } from '../middlewares/auth'

export function createV1Routes(fluxService: FluxService, env: any) {
const v1 = new Hono<HonoEnv>()

v1.use('*', authGuard)

async function handleCompletion(c: any) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The context object c is typed as any. It should be properly typed as Context<HonoEnv> for type safety and autocompletion. You'll need to import Context from hono.

import type { Context } from 'hono'
Suggested change
async function handleCompletion(c: any) {
async function handleCompletion(c: Context<HonoEnv>) {

const user = c.get('user')!
const flux = await fluxService.getFlux(user.id)
if (flux.flux <= 0) {
return c.json({ error: 'Insufficient flux' }, 402)
}

const body = await c.req.json()

// Consume flux (simplified: 1 per request)
await fluxService.consumeFlux(user.id, 1)

const response = await fetch(`${env.BACKEND_LLM_BASE_URL}chat/completions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${env.BACKEND_LLM_API_KEY}`,
},
body: JSON.stringify(body),
})

return new Response(response.body, {
status: response.status,
headers: response.headers,
})
}

v1.post('/chat/completions', handleCompletion)
v1.post('/chat/completion', handleCompletion)

return v1
}
10 changes: 10 additions & 0 deletions apps/server/src/schemas/flux.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { integer, pgTable, text, timestamp } from 'drizzle-orm/pg-core'

import { user } from './accounts'

export const userFlux = pgTable('user_credits', {
userId: text('user_id').primaryKey().references(() => user.id, { onDelete: 'cascade' }),
flux: integer('credits').notNull().default(0),
stripeCustomerId: text('stripe_customer_id'),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
})
1 change: 1 addition & 0 deletions apps/server/src/schemas/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export * from './accounts'
export * from './characters'
export * from './chats'
export * from './flux'
export * from './providers'
export * from './user-character'
5 changes: 5 additions & 0 deletions apps/server/src/services/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ const EnvSchema = object({
AUTH_GOOGLE_CLIENT_SECRET: pipe(string(), nonEmpty('AUTH_GOOGLE_CLIENT_SECRET is required')),
AUTH_GITHUB_CLIENT_ID: pipe(string(), nonEmpty('AUTH_GITHUB_CLIENT_ID is required')),
AUTH_GITHUB_CLIENT_SECRET: pipe(string(), nonEmpty('AUTH_GITHUB_CLIENT_SECRET is required')),
STRIPE_SECRET_KEY: pipe(string(), nonEmpty('STRIPE_SECRET_KEY is required')),
STRIPE_WEBHOOK_SECRET: pipe(string(), nonEmpty('STRIPE_WEBHOOK_SECRET is required')),
BACKEND_LLM_API_KEY: pipe(string(), nonEmpty('BACKEND_LLM_API_KEY is required')),
BACKEND_LLM_BASE_URL: pipe(string(), nonEmpty('BACKEND_LLM_BASE_URL is required')),
CLIENT_URL: pipe(string(), nonEmpty('CLIENT_URL is required')),
})

export type Env = InferOutput<typeof EnvSchema>
Expand Down
69 changes: 69 additions & 0 deletions apps/server/src/services/flux.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import type * as fullSchema from '../schemas'
import type { Database } from './db'

import { eq } from 'drizzle-orm'

import * as schema from '../schemas/flux'

export function createFluxService(db: Database<typeof fullSchema>) {
return {
async getFlux(userId: string) {
let record = await db.query.userFlux.findFirst({
where: eq(schema.userFlux.userId, userId),
})

if (!record) {
[record] = await db.insert(schema.userFlux).values({
userId,
flux: 100, // Default initial flux
}).returning()
}

return record
},

async consumeFlux(userId: string, amount: number) {
const record = await this.getFlux(userId)
if (record.flux < amount) {
throw new Error('Insufficient flux')
}

const [updated] = await db.update(schema.userFlux)
.set({
flux: record.flux - amount,
updatedAt: new Date(),
})
.where(eq(schema.userFlux.userId, userId))
.returning()

return updated
},

async addFlux(userId: string, amount: number) {
const record = await this.getFlux(userId)
const [updated] = await db.update(schema.userFlux)
.set({
flux: record.flux + amount,
updatedAt: new Date(),
})
.where(eq(schema.userFlux.userId, userId))
.returning()

return updated
},

async updateStripeCustomerId(userId: string, stripeCustomerId: string) {
const [updated] = await db.update(schema.userFlux)
.set({
stripeCustomerId,
updatedAt: new Date(),
})
.where(eq(schema.userFlux.userId, userId))
.returning()

return updated
},
}
}

export type FluxService = ReturnType<typeof createFluxService>
Loading