summaryrefslogtreecommitdiffhomepage
path: root/packages/console/core/src
diff options
context:
space:
mode:
authorFrank <[email protected]>2025-09-18 10:59:01 -0400
committerFrank <[email protected]>2025-09-18 10:59:01 -0400
commit4ceabdffa07b1af8d99eb73622a4d549d99ec6d2 (patch)
tree72e2ae62084a9e24cc76caffbd1f30dafc69ea56 /packages/console/core/src
parentc87480cf931a6f8f8b55552558ef521f1918b578 (diff)
downloadopencode-4ceabdffa07b1af8d99eb73622a4d549d99ec6d2.tar.gz
opencode-4ceabdffa07b1af8d99eb73622a4d549d99ec6d2.zip
wip: zen
Diffstat (limited to 'packages/console/core/src')
-rw-r--r--packages/console/core/src/account.ts67
-rw-r--r--packages/console/core/src/actor.ts74
-rw-r--r--packages/console/core/src/billing.ts244
-rw-r--r--packages/console/core/src/context.ts21
-rw-r--r--packages/console/core/src/drizzle/index.ts86
-rw-r--r--packages/console/core/src/drizzle/types.ts33
-rw-r--r--packages/console/core/src/identifier.ts26
-rw-r--r--packages/console/core/src/key.ts75
-rw-r--r--packages/console/core/src/schema/account.sql.ts12
-rw-r--r--packages/console/core/src/schema/billing.sql.ts53
-rw-r--r--packages/console/core/src/schema/key.sql.ts22
-rw-r--r--packages/console/core/src/schema/user.sql.ts16
-rw-r--r--packages/console/core/src/schema/workspace.sql.ts21
-rw-r--r--packages/console/core/src/user.ts18
-rw-r--r--packages/console/core/src/util/env.cloudflare.ts0
-rw-r--r--packages/console/core/src/util/fn.ts11
-rw-r--r--packages/console/core/src/util/log.ts55
-rw-r--r--packages/console/core/src/util/memo.ts18
-rw-r--r--packages/console/core/src/util/price.ts3
-rw-r--r--packages/console/core/src/workspace.ts58
20 files changed, 913 insertions, 0 deletions
diff --git a/packages/console/core/src/account.ts b/packages/console/core/src/account.ts
new file mode 100644
index 000000000..cb123e048
--- /dev/null
+++ b/packages/console/core/src/account.ts
@@ -0,0 +1,67 @@
+import { z } from "zod"
+import { and, eq, getTableColumns, isNull } from "drizzle-orm"
+import { fn } from "./util/fn"
+import { Database } from "./drizzle"
+import { Identifier } from "./identifier"
+import { AccountTable } from "./schema/account.sql"
+import { Actor } from "./actor"
+import { WorkspaceTable } from "./schema/workspace.sql"
+import { UserTable } from "./schema/user.sql"
+
+export namespace Account {
+ export const create = fn(
+ z.object({
+ email: z.string().email(),
+ id: z.string().optional(),
+ }),
+ async (input) =>
+ Database.transaction(async (tx) => {
+ const id = input.id ?? Identifier.create("account")
+ await tx.insert(AccountTable).values({
+ id,
+ email: input.email,
+ })
+ return id
+ }),
+ )
+
+ export const fromID = fn(z.string(), async (id) =>
+ Database.transaction(async (tx) => {
+ return tx
+ .select()
+ .from(AccountTable)
+ .where(eq(AccountTable.id, id))
+ .execute()
+ .then((rows) => rows[0])
+ }),
+ )
+
+ export const fromEmail = fn(z.string().email(), async (email) =>
+ Database.transaction(async (tx) => {
+ return tx
+ .select()
+ .from(AccountTable)
+ .where(eq(AccountTable.email, email))
+ .execute()
+ .then((rows) => rows[0])
+ }),
+ )
+
+ export const workspaces = async () => {
+ const actor = Actor.assert("account")
+ return Database.transaction(async (tx) =>
+ tx
+ .select(getTableColumns(WorkspaceTable))
+ .from(WorkspaceTable)
+ .innerJoin(UserTable, eq(UserTable.workspaceID, WorkspaceTable.id))
+ .where(
+ and(
+ eq(UserTable.email, actor.properties.email),
+ isNull(UserTable.timeDeleted),
+ isNull(WorkspaceTable.timeDeleted),
+ ),
+ )
+ .execute(),
+ )
+ }
+}
diff --git a/packages/console/core/src/actor.ts b/packages/console/core/src/actor.ts
new file mode 100644
index 000000000..0d13f7216
--- /dev/null
+++ b/packages/console/core/src/actor.ts
@@ -0,0 +1,74 @@
+import { Context } from "./context"
+import { Log } from "./util/log"
+
+export namespace Actor {
+ interface Account {
+ type: "account"
+ properties: {
+ accountID: string
+ email: string
+ }
+ }
+
+ interface Public {
+ type: "public"
+ properties: {}
+ }
+
+ interface User {
+ type: "user"
+ properties: {
+ userID: string
+ workspaceID: string
+ }
+ }
+
+ interface System {
+ type: "system"
+ properties: {
+ workspaceID: string
+ }
+ }
+
+ export type Info = Account | Public | User | System
+
+ const ctx = Context.create<Info>()
+ export const use = ctx.use
+
+ const log = Log.create().tag("namespace", "actor")
+
+ export function provide<R, T extends Info["type"]>(
+ type: T,
+ properties: Extract<Info, { type: T }>["properties"],
+ cb: () => R,
+ ) {
+ return ctx.provide(
+ {
+ type,
+ properties,
+ } as any,
+ () => {
+ return Log.provide({ ...properties }, () => {
+ log.info("provided")
+ return cb()
+ })
+ },
+ )
+ }
+
+ export function assert<T extends Info["type"]>(type: T) {
+ const actor = use()
+ if (actor.type !== type) {
+ throw new Error(`Expected actor type ${type}, got ${actor.type}`)
+ }
+ return actor as Extract<Info, { type: T }>
+ }
+
+ export function workspace() {
+ const actor = use()
+ if ("workspaceID" in actor.properties) {
+ return actor.properties.workspaceID
+ }
+ throw new Error(`actor of type "${actor.type}" is not associated with a workspace`)
+ }
+}
diff --git a/packages/console/core/src/billing.ts b/packages/console/core/src/billing.ts
new file mode 100644
index 000000000..dda0c539f
--- /dev/null
+++ b/packages/console/core/src/billing.ts
@@ -0,0 +1,244 @@
+import { Stripe } from "stripe"
+import { Database, eq, sql } from "./drizzle"
+import { BillingTable, PaymentTable, UsageTable } from "./schema/billing.sql"
+import { Actor } from "./actor"
+import { fn } from "./util/fn"
+import { z } from "zod"
+import { User } from "./user"
+import { Resource } from "@opencode/console-resource"
+import { Identifier } from "./identifier"
+import { centsToMicroCents } from "./util/price"
+
+export namespace Billing {
+ export const CHARGE_AMOUNT = 2000 // $20
+ export const CHARGE_FEE = 123 // Stripe fee 4.4% + $0.30
+ export const CHARGE_THRESHOLD = 500 // $5
+ export const stripe = () =>
+ new Stripe(Resource.STRIPE_SECRET_KEY.value, {
+ apiVersion: "2025-03-31.basil",
+ })
+
+ export const get = async () => {
+ return Database.use(async (tx) =>
+ tx
+ .select({
+ customerID: BillingTable.customerID,
+ paymentMethodID: BillingTable.paymentMethodID,
+ paymentMethodLast4: BillingTable.paymentMethodLast4,
+ balance: BillingTable.balance,
+ reload: BillingTable.reload,
+ monthlyLimit: BillingTable.monthlyLimit,
+ monthlyUsage: BillingTable.monthlyUsage,
+ timeMonthlyUsageUpdated: BillingTable.timeMonthlyUsageUpdated,
+ reloadError: BillingTable.reloadError,
+ timeReloadError: BillingTable.timeReloadError,
+ })
+ .from(BillingTable)
+ .where(eq(BillingTable.workspaceID, Actor.workspace()))
+ .then((r) => r[0]),
+ )
+ }
+
+ export const payments = async () => {
+ return await Database.use((tx) =>
+ tx
+ .select()
+ .from(PaymentTable)
+ .where(eq(PaymentTable.workspaceID, Actor.workspace()))
+ .orderBy(sql`${PaymentTable.timeCreated} DESC`)
+ .limit(100),
+ )
+ }
+
+ export const usages = async () => {
+ return await Database.use((tx) =>
+ tx
+ .select()
+ .from(UsageTable)
+ .where(eq(UsageTable.workspaceID, Actor.workspace()))
+ .orderBy(sql`${UsageTable.timeCreated} DESC`)
+ .limit(100),
+ )
+ }
+
+ export const reload = async () => {
+ const { customerID, paymentMethodID } = await Database.use((tx) =>
+ tx
+ .select({
+ customerID: BillingTable.customerID,
+ paymentMethodID: BillingTable.paymentMethodID,
+ })
+ .from(BillingTable)
+ .where(eq(BillingTable.workspaceID, Actor.workspace()))
+ .then((rows) => rows[0]),
+ )
+ const paymentID = Identifier.create("payment")
+ let charge
+ try {
+ charge = await Billing.stripe().paymentIntents.create(
+ {
+ amount: Billing.CHARGE_AMOUNT + Billing.CHARGE_FEE,
+ currency: "usd",
+ customer: customerID!,
+ payment_method: paymentMethodID!,
+ off_session: true,
+ confirm: true,
+ },
+ { idempotencyKey: paymentID },
+ )
+
+ if (charge.status !== "succeeded") throw new Error(charge.last_payment_error?.message)
+ } catch (e: any) {
+ await Database.use((tx) =>
+ tx
+ .update(BillingTable)
+ .set({
+ reloadError: e.message ?? "Payment failed.",
+ timeReloadError: sql`now()`,
+ })
+ .where(eq(BillingTable.workspaceID, Actor.workspace())),
+ )
+ return
+ }
+
+ await Database.transaction(async (tx) => {
+ await tx
+ .update(BillingTable)
+ .set({
+ balance: sql`${BillingTable.balance} + ${centsToMicroCents(CHARGE_AMOUNT)}`,
+ reloadError: null,
+ timeReloadError: null,
+ })
+ .where(eq(BillingTable.workspaceID, Actor.workspace()))
+ await tx.insert(PaymentTable).values({
+ workspaceID: Actor.workspace(),
+ id: paymentID,
+ amount: centsToMicroCents(CHARGE_AMOUNT),
+ paymentID: charge.id,
+ customerID,
+ })
+ })
+ }
+
+ export const disableReload = async () => {
+ return await Database.use((tx) =>
+ tx
+ .update(BillingTable)
+ .set({
+ reload: false,
+ })
+ .where(eq(BillingTable.workspaceID, Actor.workspace())),
+ )
+ }
+
+ export const setMonthlyLimit = fn(z.number(), async (input) => {
+ return await Database.use((tx) =>
+ tx
+ .update(BillingTable)
+ .set({
+ monthlyLimit: input,
+ })
+ .where(eq(BillingTable.workspaceID, Actor.workspace())),
+ )
+ })
+
+ export const generateCheckoutUrl = fn(
+ z.object({
+ successUrl: z.string(),
+ cancelUrl: z.string(),
+ }),
+ async (input) => {
+ const account = Actor.assert("user")
+ const { successUrl, cancelUrl } = input
+
+ const user = await User.fromID(account.properties.userID)
+ const customer = await Billing.get()
+ const session = await Billing.stripe().checkout.sessions.create({
+ mode: "payment",
+ line_items: [
+ {
+ price_data: {
+ currency: "usd",
+ product_data: {
+ name: "opencode credits",
+ },
+ unit_amount: CHARGE_AMOUNT,
+ },
+ quantity: 1,
+ },
+ {
+ price_data: {
+ currency: "usd",
+ product_data: {
+ name: "processing fee",
+ },
+ unit_amount: CHARGE_FEE,
+ },
+ quantity: 1,
+ },
+ ],
+ payment_intent_data: {
+ setup_future_usage: "on_session",
+ },
+ ...(customer.customerID
+ ? {
+ customer: customer.customerID,
+ }
+ : {
+ customer_email: user.email,
+ customer_creation: "always",
+ }),
+ metadata: {
+ workspaceID: Actor.workspace(),
+ },
+ currency: "usd",
+ payment_method_types: ["card"],
+ payment_method_data: {
+ allow_redisplay: "always",
+ },
+ success_url: successUrl,
+ cancel_url: cancelUrl,
+ })
+
+ return session.url
+ },
+ )
+
+ export const generateSessionUrl = fn(
+ z.object({
+ returnUrl: z.string(),
+ }),
+ async (input) => {
+ const { returnUrl } = input
+
+ const customer = await Billing.get()
+ if (!customer?.customerID) {
+ throw new Error("No stripe customer ID")
+ }
+
+ const session = await Billing.stripe().billingPortal.sessions.create({
+ customer: customer.customerID,
+ return_url: returnUrl,
+ })
+
+ return session.url
+ },
+ )
+
+ export const generateReceiptUrl = fn(
+ z.object({
+ paymentID: z.string(),
+ }),
+ async (input) => {
+ const { paymentID } = input
+
+ const intent = await Billing.stripe().paymentIntents.retrieve(paymentID)
+ if (!intent.latest_charge) throw new Error("No charge found")
+
+ const charge = await Billing.stripe().charges.retrieve(intent.latest_charge as string)
+ if (!charge.receipt_url) throw new Error("No receipt URL found")
+
+ return charge.receipt_url
+ },
+ )
+}
diff --git a/packages/console/core/src/context.ts b/packages/console/core/src/context.ts
new file mode 100644
index 000000000..c2ca6a313
--- /dev/null
+++ b/packages/console/core/src/context.ts
@@ -0,0 +1,21 @@
+import { AsyncLocalStorage } from "node:async_hooks"
+
+export namespace Context {
+ export class NotFound extends Error {}
+
+ export function create<T>() {
+ const storage = new AsyncLocalStorage<T>()
+ return {
+ use() {
+ const result = storage.getStore()
+ if (!result) {
+ throw new NotFound()
+ }
+ return result
+ },
+ provide<R>(value: T, fn: () => R) {
+ return storage.run<R>(value, fn)
+ },
+ }
+ }
+}
diff --git a/packages/console/core/src/drizzle/index.ts b/packages/console/core/src/drizzle/index.ts
new file mode 100644
index 000000000..899f69148
--- /dev/null
+++ b/packages/console/core/src/drizzle/index.ts
@@ -0,0 +1,86 @@
+import { drizzle } from "drizzle-orm/planetscale-serverless"
+import { Resource } from "@opencode/console-resource"
+export * from "drizzle-orm"
+import { Client } from "@planetscale/database"
+
+import { MySqlTransaction, type MySqlTransactionConfig } from "drizzle-orm/mysql-core"
+import type { ExtractTablesWithRelations } from "drizzle-orm"
+import type { PlanetScalePreparedQueryHKT, PlanetscaleQueryResultHKT } from "drizzle-orm/planetscale-serverless"
+import { Context } from "../context"
+import { memo } from "../util/memo"
+
+export namespace Database {
+ export type Transaction = MySqlTransaction<
+ PlanetscaleQueryResultHKT,
+ PlanetScalePreparedQueryHKT,
+ Record<string, never>,
+ ExtractTablesWithRelations<Record<string, never>>
+ >
+
+ const client = memo(() => {
+ const result = new Client({
+ host: Resource.Database.host,
+ username: Resource.Database.username,
+ password: Resource.Database.password,
+ })
+ const db = drizzle(result, {})
+ return db
+ })
+
+ export type TxOrDb = Transaction | ReturnType<typeof client>
+
+ const TransactionContext = Context.create<{
+ tx: TxOrDb
+ effects: (() => void | Promise<void>)[]
+ }>()
+
+ export async function use<T>(callback: (trx: TxOrDb) => Promise<T>) {
+ try {
+ const { tx } = TransactionContext.use()
+ return tx.transaction(callback)
+ } catch (err) {
+ if (err instanceof Context.NotFound) {
+ const effects: (() => void | Promise<void>)[] = []
+ const result = await TransactionContext.provide(
+ {
+ effects,
+ tx: client(),
+ },
+ () => callback(client()),
+ )
+ await Promise.all(effects.map((x) => x()))
+ return result
+ }
+ throw err
+ }
+ }
+ export async function fn<Input, T>(callback: (input: Input, trx: TxOrDb) => Promise<T>) {
+ return (input: Input) => use(async (tx) => callback(input, tx))
+ }
+
+ export async function effect(effect: () => any | Promise<any>) {
+ try {
+ const { effects } = TransactionContext.use()
+ effects.push(effect)
+ } catch {
+ await effect()
+ }
+ }
+
+ export async function transaction<T>(callback: (tx: TxOrDb) => Promise<T>, config?: MySqlTransactionConfig) {
+ try {
+ const { tx } = TransactionContext.use()
+ return callback(tx)
+ } catch (err) {
+ if (err instanceof Context.NotFound) {
+ const effects: (() => void | Promise<void>)[] = []
+ const result = await client().transaction(async (tx) => {
+ return TransactionContext.provide({ tx, effects }, () => callback(tx))
+ }, config)
+ await Promise.all(effects.map((x) => x()))
+ return result
+ }
+ throw err
+ }
+ }
+}
diff --git a/packages/console/core/src/drizzle/types.ts b/packages/console/core/src/drizzle/types.ts
new file mode 100644
index 000000000..f16ad5a8a
--- /dev/null
+++ b/packages/console/core/src/drizzle/types.ts
@@ -0,0 +1,33 @@
+import { sql } from "drizzle-orm"
+import { bigint, timestamp, varchar } from "drizzle-orm/mysql-core"
+
+export const ulid = (name: string) => varchar(name, { length: 30 })
+
+export const workspaceColumns = {
+ get id() {
+ return ulid("id").notNull()
+ },
+ get workspaceID() {
+ return ulid("workspace_id").notNull()
+ },
+}
+
+export const id = () => ulid("id").notNull()
+
+export const utc = (name: string) =>
+ timestamp(name, {
+ fsp: 3,
+ })
+
+export const currency = (name: string) =>
+ bigint(name, {
+ mode: "number",
+ })
+
+export const timestamps = {
+ timeCreated: utc("time_created").notNull().defaultNow(),
+ timeUpdated: utc("time_updated")
+ .notNull()
+ .default(sql`CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)`),
+ timeDeleted: utc("time_deleted"),
+}
diff --git a/packages/console/core/src/identifier.ts b/packages/console/core/src/identifier.ts
new file mode 100644
index 000000000..f8e73852e
--- /dev/null
+++ b/packages/console/core/src/identifier.ts
@@ -0,0 +1,26 @@
+import { ulid } from "ulid"
+import { z } from "zod"
+
+export namespace Identifier {
+ const prefixes = {
+ account: "acc",
+ billing: "bil",
+ key: "key",
+ payment: "pay",
+ usage: "usg",
+ user: "usr",
+ workspace: "wrk",
+ } as const
+
+ export function create(prefix: keyof typeof prefixes, given?: string): string {
+ if (given) {
+ if (given.startsWith(prefixes[prefix])) return given
+ throw new Error(`ID ${given} does not start with ${prefixes[prefix]}`)
+ }
+ return [prefixes[prefix], ulid()].join("_")
+ }
+
+ export function schema(prefix: keyof typeof prefixes) {
+ return z.string().startsWith(prefixes[prefix])
+ }
+}
diff --git a/packages/console/core/src/key.ts b/packages/console/core/src/key.ts
new file mode 100644
index 000000000..28643a521
--- /dev/null
+++ b/packages/console/core/src/key.ts
@@ -0,0 +1,75 @@
+import { z } from "zod"
+import { fn } from "./util/fn"
+import { Actor } from "./actor"
+import { and, Database, eq, isNull, sql } from "./drizzle"
+import { Identifier } from "./identifier"
+import { KeyTable } from "./schema/key.sql"
+
+export namespace Key {
+ export const list = async () => {
+ const workspace = Actor.workspace()
+ const keys = await Database.use((tx) =>
+ tx
+ .select()
+ .from(KeyTable)
+ .where(and(eq(KeyTable.workspaceID, workspace), isNull(KeyTable.timeDeleted)))
+ .orderBy(sql`${KeyTable.timeCreated} DESC`),
+ )
+ return keys
+ }
+
+ export const create = fn(z.object({ name: z.string().min(1).max(255) }), async (input) => {
+ const workspaceID = Actor.workspace()
+ const { name } = input
+
+ // Generate secret key: sk- + 64 random characters (upper, lower, numbers)
+ const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
+ let secretKey = "sk-"
+ const array = new Uint32Array(64)
+ crypto.getRandomValues(array)
+ for (let i = 0, l = array.length; i < l; i++) {
+ secretKey += chars[array[i] % chars.length]
+ }
+ const keyID = Identifier.create("key")
+
+ await Database.use((tx) =>
+ tx.insert(KeyTable).values({
+ id: keyID,
+ workspaceID,
+ actor: Actor.use(),
+ name,
+ key: secretKey,
+ timeUsed: null,
+ }),
+ ).catch((e: any) => {
+ if (e.message.match(/Duplicate entry '.*' for key 'key.name'/))
+ throw new Error("A key with this name already exists. Please choose a different name.")
+ throw e
+ })
+
+ return keyID
+ })
+
+ export const remove = fn(z.object({ id: z.string() }), async (input) => {
+ const workspace = Actor.workspace()
+ await Database.transaction(async (tx) => {
+ const row = await tx
+ .select({
+ name: KeyTable.name,
+ })
+ .from(KeyTable)
+ .where(and(eq(KeyTable.id, input.id), eq(KeyTable.workspaceID, workspace)))
+ .then((rows) => rows[0])
+ if (!row) return
+
+ await tx
+ .update(KeyTable)
+ .set({
+ timeDeleted: sql`now()`,
+ oldName: row.name,
+ name: input.id, // Use the key ID as the name
+ })
+ .where(and(eq(KeyTable.id, input.id), eq(KeyTable.workspaceID, workspace)))
+ })
+ })
+}
diff --git a/packages/console/core/src/schema/account.sql.ts b/packages/console/core/src/schema/account.sql.ts
new file mode 100644
index 000000000..4d9937114
--- /dev/null
+++ b/packages/console/core/src/schema/account.sql.ts
@@ -0,0 +1,12 @@
+import { mysqlTable, uniqueIndex, varchar } from "drizzle-orm/mysql-core"
+import { id, timestamps } from "../drizzle/types"
+
+export const AccountTable = mysqlTable(
+ "account",
+ {
+ id: id(),
+ ...timestamps,
+ email: varchar("email", { length: 255 }).notNull(),
+ },
+ (table) => [uniqueIndex("email").on(table.email)],
+)
diff --git a/packages/console/core/src/schema/billing.sql.ts b/packages/console/core/src/schema/billing.sql.ts
new file mode 100644
index 000000000..5bec4e900
--- /dev/null
+++ b/packages/console/core/src/schema/billing.sql.ts
@@ -0,0 +1,53 @@
+import { bigint, boolean, int, mysqlTable, uniqueIndex, varchar } from "drizzle-orm/mysql-core"
+import { timestamps, utc, workspaceColumns } from "../drizzle/types"
+import { workspaceIndexes } from "./workspace.sql"
+
+export const BillingTable = mysqlTable(
+ "billing",
+ {
+ ...workspaceColumns,
+ ...timestamps,
+ customerID: varchar("customer_id", { length: 255 }),
+ paymentMethodID: varchar("payment_method_id", { length: 255 }),
+ paymentMethodLast4: varchar("payment_method_last4", { length: 4 }),
+ balance: bigint("balance", { mode: "number" }).notNull(),
+ monthlyLimit: int("monthly_limit"),
+ monthlyUsage: bigint("monthly_usage", { mode: "number" }),
+ timeMonthlyUsageUpdated: utc("time_monthly_usage_updated"),
+ reload: boolean("reload"),
+ reloadError: varchar("reload_error", { length: 255 }),
+ timeReloadError: utc("time_reload_error"),
+ timeReloadLockedTill: utc("time_reload_locked_till"),
+ },
+ (table) => [...workspaceIndexes(table), uniqueIndex("global_customer_id").on(table.customerID)],
+)
+
+export const PaymentTable = mysqlTable(
+ "payment",
+ {
+ ...workspaceColumns,
+ ...timestamps,
+ customerID: varchar("customer_id", { length: 255 }),
+ paymentID: varchar("payment_id", { length: 255 }),
+ amount: bigint("amount", { mode: "number" }).notNull(),
+ },
+ (table) => [...workspaceIndexes(table)],
+)
+
+export const UsageTable = mysqlTable(
+ "usage",
+ {
+ ...workspaceColumns,
+ ...timestamps,
+ model: varchar("model", { length: 255 }).notNull(),
+ provider: varchar("provider", { length: 255 }).notNull(),
+ inputTokens: int("input_tokens").notNull(),
+ outputTokens: int("output_tokens").notNull(),
+ reasoningTokens: int("reasoning_tokens"),
+ cacheReadTokens: int("cache_read_tokens"),
+ cacheWrite5mTokens: int("cache_write_5m_tokens"),
+ cacheWrite1hTokens: int("cache_write_1h_tokens"),
+ cost: bigint("cost", { mode: "number" }).notNull(),
+ },
+ (table) => [...workspaceIndexes(table)],
+)
diff --git a/packages/console/core/src/schema/key.sql.ts b/packages/console/core/src/schema/key.sql.ts
new file mode 100644
index 000000000..98b99c788
--- /dev/null
+++ b/packages/console/core/src/schema/key.sql.ts
@@ -0,0 +1,22 @@
+import { mysqlTable, varchar, uniqueIndex, json } from "drizzle-orm/mysql-core"
+import { timestamps, utc, workspaceColumns } from "../drizzle/types"
+import { workspaceIndexes } from "./workspace.sql"
+import { Actor } from "../actor"
+
+export const KeyTable = mysqlTable(
+ "key",
+ {
+ ...workspaceColumns,
+ ...timestamps,
+ actor: json("actor").$type<Actor.Info>(),
+ name: varchar("name", { length: 255 }).notNull(),
+ oldName: varchar("old_name", { length: 255 }),
+ key: varchar("key", { length: 255 }).notNull(),
+ timeUsed: utc("time_used"),
+ },
+ (table) => [
+ ...workspaceIndexes(table),
+ uniqueIndex("global_key").on(table.key),
+ uniqueIndex("name").on(table.workspaceID, table.name),
+ ],
+)
diff --git a/packages/console/core/src/schema/user.sql.ts b/packages/console/core/src/schema/user.sql.ts
new file mode 100644
index 000000000..00c372d1a
--- /dev/null
+++ b/packages/console/core/src/schema/user.sql.ts
@@ -0,0 +1,16 @@
+import { text, mysqlTable, uniqueIndex, varchar, int } from "drizzle-orm/mysql-core"
+import { timestamps, utc, workspaceColumns } from "../drizzle/types"
+import { workspaceIndexes } from "./workspace.sql"
+
+export const UserTable = mysqlTable(
+ "user",
+ {
+ ...workspaceColumns,
+ ...timestamps,
+ email: varchar("email", { length: 255 }).notNull(),
+ name: varchar("name", { length: 255 }).notNull(),
+ timeSeen: utc("time_seen"),
+ color: int("color"),
+ },
+ (table) => [...workspaceIndexes(table), uniqueIndex("user_email").on(table.workspaceID, table.email)],
+)
diff --git a/packages/console/core/src/schema/workspace.sql.ts b/packages/console/core/src/schema/workspace.sql.ts
new file mode 100644
index 000000000..979255428
--- /dev/null
+++ b/packages/console/core/src/schema/workspace.sql.ts
@@ -0,0 +1,21 @@
+import { primaryKey, mysqlTable, uniqueIndex, varchar } from "drizzle-orm/mysql-core"
+import { timestamps, ulid } from "../drizzle/types"
+
+export const WorkspaceTable = mysqlTable(
+ "workspace",
+ {
+ id: ulid("id").notNull().primaryKey(),
+ slug: varchar("slug", { length: 255 }),
+ name: varchar("name", { length: 255 }),
+ ...timestamps,
+ },
+ (table) => [uniqueIndex("slug").on(table.slug)],
+)
+
+export function workspaceIndexes(table: any) {
+ return [
+ primaryKey({
+ columns: [table.workspaceID, table.id],
+ }),
+ ]
+}
diff --git a/packages/console/core/src/user.ts b/packages/console/core/src/user.ts
new file mode 100644
index 000000000..7914926ff
--- /dev/null
+++ b/packages/console/core/src/user.ts
@@ -0,0 +1,18 @@
+import { z } from "zod"
+import { eq } from "drizzle-orm"
+import { fn } from "./util/fn"
+import { Database } from "./drizzle"
+import { UserTable } from "./schema/user.sql"
+
+export namespace User {
+ export const fromID = fn(z.string(), async (id) =>
+ Database.transaction(async (tx) => {
+ return tx
+ .select()
+ .from(UserTable)
+ .where(eq(UserTable.id, id))
+ .execute()
+ .then((rows) => rows[0])
+ }),
+ )
+}
diff --git a/packages/console/core/src/util/env.cloudflare.ts b/packages/console/core/src/util/env.cloudflare.ts
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/packages/console/core/src/util/env.cloudflare.ts
diff --git a/packages/console/core/src/util/fn.ts b/packages/console/core/src/util/fn.ts
new file mode 100644
index 000000000..9efe4622f
--- /dev/null
+++ b/packages/console/core/src/util/fn.ts
@@ -0,0 +1,11 @@
+import { z } from "zod"
+
+export function fn<T extends z.ZodType, Result>(schema: T, cb: (input: z.infer<T>) => Result) {
+ const result = (input: z.infer<T>) => {
+ const parsed = schema.parse(input)
+ return cb(parsed)
+ }
+ result.force = (input: z.infer<T>) => cb(input)
+ result.schema = schema
+ return result
+}
diff --git a/packages/console/core/src/util/log.ts b/packages/console/core/src/util/log.ts
new file mode 100644
index 000000000..4f2d25c13
--- /dev/null
+++ b/packages/console/core/src/util/log.ts
@@ -0,0 +1,55 @@
+import { Context } from "../context"
+
+export namespace Log {
+ const ctx = Context.create<{
+ tags: Record<string, any>
+ }>()
+
+ export function create(tags?: Record<string, any>) {
+ tags = tags || {}
+
+ const result = {
+ info(message?: any, extra?: Record<string, any>) {
+ const prefix = Object.entries({
+ ...use().tags,
+ ...tags,
+ ...extra,
+ })
+ .map(([key, value]) => `${key}=${value}`)
+ .join(" ")
+ console.log(prefix, message)
+ return result
+ },
+ tag(key: string, value: string) {
+ if (tags) tags[key] = value
+ return result
+ },
+ clone() {
+ return Log.create({ ...tags })
+ },
+ }
+
+ return result
+ }
+
+ export function provide<R>(tags: Record<string, any>, cb: () => R) {
+ const existing = use()
+ return ctx.provide(
+ {
+ tags: {
+ ...existing.tags,
+ ...tags,
+ },
+ },
+ cb,
+ )
+ }
+
+ function use() {
+ try {
+ return ctx.use()
+ } catch (e) {
+ return { tags: {} }
+ }
+ }
+}
diff --git a/packages/console/core/src/util/memo.ts b/packages/console/core/src/util/memo.ts
new file mode 100644
index 000000000..49043010f
--- /dev/null
+++ b/packages/console/core/src/util/memo.ts
@@ -0,0 +1,18 @@
+export function memo<T>(fn: () => T, cleanup?: (input: T) => Promise<void>) {
+ let value: T | undefined
+ let loaded = false
+
+ const result = (): T => {
+ if (loaded) return value as T
+ loaded = true
+ value = fn()
+ return value as T
+ }
+ result.reset = async () => {
+ if (cleanup && value) await cleanup(value)
+ loaded = false
+ value = undefined
+ }
+
+ return result
+}
diff --git a/packages/console/core/src/util/price.ts b/packages/console/core/src/util/price.ts
new file mode 100644
index 000000000..abdbca032
--- /dev/null
+++ b/packages/console/core/src/util/price.ts
@@ -0,0 +1,3 @@
+export function centsToMicroCents(amount: number) {
+ return Math.round(amount * 1000000)
+}
diff --git a/packages/console/core/src/workspace.ts b/packages/console/core/src/workspace.ts
new file mode 100644
index 000000000..a9fb923d6
--- /dev/null
+++ b/packages/console/core/src/workspace.ts
@@ -0,0 +1,58 @@
+import { z } from "zod"
+import { fn } from "./util/fn"
+import { centsToMicroCents } from "./util/price"
+import { Actor } from "./actor"
+import { Database, eq } from "./drizzle"
+import { Identifier } from "./identifier"
+import { UserTable } from "./schema/user.sql"
+import { BillingTable } from "./schema/billing.sql"
+import { WorkspaceTable } from "./schema/workspace.sql"
+import { Key } from "./key"
+
+export namespace Workspace {
+ export const create = fn(z.void(), async () => {
+ const account = Actor.assert("account")
+ const workspaceID = Identifier.create("workspace")
+ await Database.transaction(async (tx) => {
+ await tx.insert(WorkspaceTable).values({
+ id: workspaceID,
+ })
+ await tx.insert(UserTable).values({
+ workspaceID,
+ id: Identifier.create("user"),
+ email: account.properties.email,
+ name: "",
+ })
+ await tx.insert(BillingTable).values({
+ workspaceID,
+ id: Identifier.create("billing"),
+ balance: 0,
+ })
+ })
+ await Actor.provide(
+ "system",
+ {
+ workspaceID,
+ },
+ async () => {
+ await Key.create({ name: "Default API Key" })
+ },
+ )
+ return workspaceID
+ })
+
+ export async function list() {
+ const account = Actor.assert("account")
+ return Database.use(async (tx) => {
+ return tx
+ .select({
+ id: WorkspaceTable.id,
+ slug: WorkspaceTable.slug,
+ name: WorkspaceTable.name,
+ })
+ .from(UserTable)
+ .innerJoin(WorkspaceTable, eq(UserTable.workspaceID, WorkspaceTable.id))
+ .where(eq(UserTable.email, account.properties.email))
+ })
+ }
+}