Rebuild the hub on Bun: Elysia API, Better Auth, Drizzle, Next.js website
CI / test (push) Successful in 30s
CI / test (push) Successful in 30s
The hub becomes a Bun monorepo: apps/api (Elysia, Better Auth for accounts, roles, bans, proxy device sign-in and JWT keys; tagline linking, presence and the console as before), packages/db (Drizzle; Postgres in production, PGlite in development and tests), and the Next.js website from apps/web. Self-hostable with docker compose; the website deploys to Vercel-style hosts, the API to any that runs Bun or containers. The first version, in Go, is removed. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
64c552330b
commit
7a06ba49c3
59 files changed
+2640
-3523
No files matched your search
@@ -0,0 +1,37 @@
|
||||
// The hub's database: Postgres in production (DATABASE_URL), or PGlite (Postgres compiled to WebAssembly, in
|
||||
// process) for development and tests, so nothing needs installing.
|
||||
import { drizzle as drizzleBun } from "drizzle-orm/bun-sql";
|
||||
import { migrate as migrateBun } from "drizzle-orm/bun-sql/migrator";
|
||||
import { drizzle as drizzlePglite } from "drizzle-orm/pglite";
|
||||
import { migrate as migratePglite } from "drizzle-orm/pglite/migrator";
|
||||
import { PGlite } from "@electric-sql/pglite";
|
||||
import { SQL } from "bun";
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import * as schema from "./schema";
|
||||
|
||||
export { schema };
|
||||
export * from "drizzle-orm";
|
||||
|
||||
const migrationsFolder = fileURLToPath(new URL("../drizzle", import.meta.url));
|
||||
|
||||
export type Database = ReturnType<typeof drizzlePglite<typeof schema>>;
|
||||
|
||||
/**
|
||||
* Opens the database and brings its schema up to date.
|
||||
* @param url a postgres:// URL, "memory" (a fresh in-process database), or a directory for PGlite's files.
|
||||
*/
|
||||
export async function openDatabase(url: string): Promise<{ db: Database; close: () => Promise<void> }> {
|
||||
if (url.startsWith("postgres://") || url.startsWith("postgresql://")) {
|
||||
const client = new SQL(url);
|
||||
const db = drizzleBun({ client, schema });
|
||||
await migrateBun(db, { migrationsFolder });
|
||||
// Both drivers speak the same query builder; the PGlite type stands for both.
|
||||
return { db: db as unknown as Database, close: async () => client.close() };
|
||||
}
|
||||
if (url !== "memory") mkdirSync(url, { recursive: true });
|
||||
const client = url === "memory" ? new PGlite() : new PGlite(url);
|
||||
const db = drizzlePglite({ client, schema });
|
||||
await migratePglite(db, { migrationsFolder });
|
||||
return { db, close: async () => client.close() };
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
// The hub's tables: Better Auth's (people, sessions, passwords, signing keys, device sign-ins) and the hub's own
|
||||
// (linked IMVU accounts, link codes, the audit log). Better Auth's are as its CLI generates them for the plugins
|
||||
// used (username, admin, device-authorization, bearer, jwt), with the hub's linkCap added to user.
|
||||
import { sql } from "drizzle-orm";
|
||||
import {
|
||||
bigint,
|
||||
boolean,
|
||||
index,
|
||||
integer,
|
||||
jsonb,
|
||||
pgTable,
|
||||
primaryKey,
|
||||
serial,
|
||||
text,
|
||||
timestamp,
|
||||
uniqueIndex,
|
||||
} from "drizzle-orm/pg-core";
|
||||
|
||||
const createdAt = () => timestamp("created_at", { withTimezone: true }).notNull().defaultNow();
|
||||
const updatedAt = () =>
|
||||
timestamp("updated_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow()
|
||||
.$onUpdate(() => new Date());
|
||||
|
||||
// A person: one T5DE account.
|
||||
export const user = pgTable(
|
||||
"user",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
email: text("email").notNull().unique(),
|
||||
emailVerified: boolean("email_verified").notNull().default(false),
|
||||
image: text("image"),
|
||||
createdAt: createdAt(),
|
||||
updatedAt: updatedAt(),
|
||||
username: text("username").unique(),
|
||||
displayUsername: text("display_username"),
|
||||
role: text("role").notNull().default("user"),
|
||||
banned: boolean("banned").default(false),
|
||||
banReason: text("ban_reason"),
|
||||
banExpires: timestamp("ban_expires", { withTimezone: true }),
|
||||
// How many IMVU accounts the person may link; moderators raise it on request.
|
||||
linkCap: integer("link_cap").notNull().default(10),
|
||||
},
|
||||
(t) => [uniqueIndex("user_username_lower").on(sql`lower(${t.username})`)],
|
||||
);
|
||||
|
||||
export const session = pgTable(
|
||||
"session",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
|
||||
token: text("token").notNull().unique(),
|
||||
createdAt: createdAt(),
|
||||
updatedAt: updatedAt(),
|
||||
ipAddress: text("ip_address"),
|
||||
userAgent: text("user_agent"),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
impersonatedBy: text("impersonated_by"),
|
||||
},
|
||||
(t) => [index("session_user").on(t.userId)],
|
||||
);
|
||||
|
||||
export const account = pgTable(
|
||||
"account",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
accountId: text("account_id").notNull(),
|
||||
providerId: text("provider_id").notNull(),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
accessToken: text("access_token"),
|
||||
refreshToken: text("refresh_token"),
|
||||
idToken: text("id_token"),
|
||||
accessTokenExpiresAt: timestamp("access_token_expires_at", { withTimezone: true }),
|
||||
refreshTokenExpiresAt: timestamp("refresh_token_expires_at", { withTimezone: true }),
|
||||
scope: text("scope"),
|
||||
password: text("password"),
|
||||
createdAt: createdAt(),
|
||||
updatedAt: updatedAt(),
|
||||
},
|
||||
(t) => [index("account_user").on(t.userId)],
|
||||
);
|
||||
|
||||
export const verification = pgTable(
|
||||
"verification",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
identifier: text("identifier").notNull(),
|
||||
value: text("value").notNull(),
|
||||
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
|
||||
createdAt: createdAt(),
|
||||
updatedAt: updatedAt(),
|
||||
},
|
||||
(t) => [index("verification_identifier").on(t.identifier)],
|
||||
);
|
||||
|
||||
export const jwks = pgTable("jwks", {
|
||||
id: text("id").primaryKey(),
|
||||
publicKey: text("public_key").notNull(),
|
||||
privateKey: text("private_key").notNull(),
|
||||
createdAt: createdAt(),
|
||||
expiresAt: timestamp("expires_at", { withTimezone: true }),
|
||||
alg: text("alg"),
|
||||
crv: text("crv"),
|
||||
});
|
||||
|
||||
// A T5DE proxy signing in: it shows userCode, the person approves it on the website.
|
||||
export const deviceCode = pgTable("device_code", {
|
||||
id: text("id").primaryKey(),
|
||||
deviceCode: text("device_code").notNull(),
|
||||
userCode: text("user_code").notNull(),
|
||||
userId: text("user_id").references(() => user.id, { onDelete: "cascade" }),
|
||||
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
|
||||
status: text("status").notNull(),
|
||||
lastPolledAt: timestamp("last_polled_at", { withTimezone: true }),
|
||||
pollingInterval: integer("polling_interval"),
|
||||
clientId: text("client_id"),
|
||||
scope: text("scope"),
|
||||
createdAt: createdAt(),
|
||||
updatedAt: updatedAt(),
|
||||
});
|
||||
|
||||
// A linked IMVU account. Linking it again from another person moves it (the most recent proof wins).
|
||||
export const imvuLink = pgTable(
|
||||
"imvu_link",
|
||||
{
|
||||
imvuId: bigint("imvu_id", { mode: "number" }).primaryKey(),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
// What catalogs and rooms see instead of the person, so a person's accounts can't be connected.
|
||||
pairwiseId: text("pairwise_id").notNull().unique(),
|
||||
username: text("username").notNull(),
|
||||
linkedAt: timestamp("linked_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
ageVerified: boolean("age_verified").notNull().default(false),
|
||||
accessPass: boolean("access_pass").notNull().default(false),
|
||||
checkedAt: timestamp("checked_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
showBadge: boolean("show_badge").notNull().default(false),
|
||||
showPresence: boolean("show_presence").notNull().default(false),
|
||||
},
|
||||
(t) => [index("imvu_link_user").on(t.userId)],
|
||||
);
|
||||
|
||||
// A code the person is to put in an IMVU account's tagline, proving it's theirs.
|
||||
export const linkChallenge = pgTable(
|
||||
"link_challenge",
|
||||
{
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
imvuId: bigint("imvu_id", { mode: "number" }).notNull(),
|
||||
username: text("username").notNull(),
|
||||
code: text("code").notNull(),
|
||||
createdAt: createdAt(),
|
||||
},
|
||||
(t) => [primaryKey({ columns: [t.userId, t.imvuId] })],
|
||||
);
|
||||
|
||||
export const audit = pgTable(
|
||||
"audit",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
at: timestamp("at", { withTimezone: true }).notNull().defaultNow(),
|
||||
actorId: text("actor_id").references(() => user.id, { onDelete: "set null" }),
|
||||
action: text("action").notNull(),
|
||||
target: text("target").notNull().default(""),
|
||||
detail: jsonb("detail").$type<Record<string, unknown>>().notNull().default({}),
|
||||
},
|
||||
(t) => [index("audit_at").on(t.at)],
|
||||
);
|
||||
Reference in new issue
Block a user