diff --git a/apps/aelis-backend/.env.example b/apps/aelis-backend/.env.example index 416efc1..50dc5ea 100644 --- a/apps/aelis-backend/.env.example +++ b/apps/aelis-backend/.env.example @@ -4,6 +4,9 @@ DATABASE_URL=postgresql://user:password@localhost:5432/aris # BetterAuth secret (min 32 chars, generate with: openssl rand -base64 32) BETTER_AUTH_SECRET= +# Encryption key for source credentials at rest (32 bytes, generate with: openssl rand -base64 32) +CREDENTIALS_ENCRYPTION_KEY= + # Base URL of the backend BETTER_AUTH_URL=http://localhost:3000 diff --git a/apps/aelis-backend/auth.ts b/apps/aelis-backend/auth.ts new file mode 100644 index 0000000..e9a5259 --- /dev/null +++ b/apps/aelis-backend/auth.ts @@ -0,0 +1,18 @@ +// Used by Better Auth CLI for schema generation. +// Run: bunx --bun auth@latest generate --config auth.ts --output src/db/auth-schema.ts +import { betterAuth } from "better-auth" +import { drizzleAdapter } from "better-auth/adapters/drizzle" +import { SQL } from "bun" +import { drizzle } from "drizzle-orm/bun-sql" + +const client = new SQL({ url: process.env.DATABASE_URL }) +const db = drizzle({ client }) + +export const auth = betterAuth({ + database: drizzleAdapter(db, { provider: "pg" }), + emailAndPassword: { + enabled: true, + }, +}) + +export default auth diff --git a/apps/aelis-backend/drizzle.config.ts b/apps/aelis-backend/drizzle.config.ts new file mode 100644 index 0000000..4fdff5e --- /dev/null +++ b/apps/aelis-backend/drizzle.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "drizzle-kit" + +export default defineConfig({ + out: "./drizzle", + schema: "./src/db/schema.ts", + dialect: "postgresql", + dbCredentials: { + url: process.env.DATABASE_URL!, + }, +}) diff --git a/apps/aelis-backend/drizzle/0000_wakeful_scorpion.sql b/apps/aelis-backend/drizzle/0000_wakeful_scorpion.sql new file mode 100644 index 0000000..758a0df --- /dev/null +++ b/apps/aelis-backend/drizzle/0000_wakeful_scorpion.sql @@ -0,0 +1,66 @@ +CREATE TABLE "account" ( + "id" text PRIMARY KEY NOT NULL, + "account_id" text NOT NULL, + "provider_id" text NOT NULL, + "user_id" text NOT NULL, + "access_token" text, + "refresh_token" text, + "id_token" text, + "access_token_expires_at" timestamp, + "refresh_token_expires_at" timestamp, + "scope" text, + "password" text, + "created_at" timestamp NOT NULL, + "updated_at" timestamp NOT NULL +); +--> statement-breakpoint +CREATE TABLE "session" ( + "id" text PRIMARY KEY NOT NULL, + "expires_at" timestamp NOT NULL, + "token" text NOT NULL, + "created_at" timestamp NOT NULL, + "updated_at" timestamp NOT NULL, + "ip_address" text, + "user_agent" text, + "user_id" text NOT NULL, + CONSTRAINT "session_token_unique" UNIQUE("token") +); +--> statement-breakpoint +CREATE TABLE "user" ( + "id" text PRIMARY KEY NOT NULL, + "name" text NOT NULL, + "email" text NOT NULL, + "email_verified" boolean DEFAULT false NOT NULL, + "image" text, + "created_at" timestamp NOT NULL, + "updated_at" timestamp NOT NULL, + CONSTRAINT "user_email_unique" UNIQUE("email") +); +--> statement-breakpoint +CREATE TABLE "user_sources" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" text NOT NULL, + "source_id" text NOT NULL, + "enabled" boolean DEFAULT true NOT NULL, + "config" jsonb DEFAULT '{}'::jsonb, + "credentials" "bytea", + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "user_sources_user_id_source_id_unique" UNIQUE("user_id","source_id") +); +--> statement-breakpoint +CREATE TABLE "verification" ( + "id" text PRIMARY KEY NOT NULL, + "identifier" text NOT NULL, + "value" text NOT NULL, + "expires_at" timestamp NOT NULL, + "created_at" timestamp NOT NULL, + "updated_at" timestamp NOT NULL +); +--> statement-breakpoint +ALTER TABLE "account" ADD CONSTRAINT "account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "session" ADD CONSTRAINT "session_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "user_sources" ADD CONSTRAINT "user_sources_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "account_userId_idx" ON "account" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "session_userId_idx" ON "session" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "verification_identifier_idx" ON "verification" USING btree ("identifier"); \ No newline at end of file diff --git a/apps/aelis-backend/drizzle/meta/0000_snapshot.json b/apps/aelis-backend/drizzle/meta/0000_snapshot.json new file mode 100644 index 0000000..53c5dcf --- /dev/null +++ b/apps/aelis-backend/drizzle/meta/0000_snapshot.json @@ -0,0 +1,457 @@ +{ + "id": "d8c59ec7-b686-41a7-a472-da29f3ab6727", + "prevId": "00000000-0000-0000-0000-000000000000", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_sources": { + "name": "user_sources", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "credentials": { + "name": "credentials", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_sources_user_id_user_id_fk": { + "name": "user_sources_user_id_user_id_fk", + "tableFrom": "user_sources", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_sources_user_id_source_id_unique": { + "name": "user_sources_user_id_source_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "source_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/aelis-backend/drizzle/meta/_journal.json b/apps/aelis-backend/drizzle/meta/_journal.json new file mode 100644 index 0000000..cd51d17 --- /dev/null +++ b/apps/aelis-backend/drizzle/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1773620066366, + "tag": "0000_wakeful_scorpion", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/apps/aelis-backend/package.json b/apps/aelis-backend/package.json index ac5b313..5486125 100644 --- a/apps/aelis-backend/package.json +++ b/apps/aelis-backend/package.json @@ -6,7 +6,11 @@ "scripts": { "dev": "bun run --watch src/server.ts", "start": "bun run src/server.ts", - "test": "bun test src/" + "test": "bun test src/", + "db:generate": "bunx drizzle-kit generate", + "db:generate-auth": "bunx --bun auth@latest generate --config auth.ts --output src/db/auth-schema.ts -y", + "db:migrate": "bunx drizzle-kit migrate", + "db:studio": "bunx drizzle-kit studio" }, "dependencies": { "@aelis/core": "workspace:*", @@ -18,10 +22,10 @@ "@openrouter/sdk": "^0.9.11", "arktype": "^2.1.29", "better-auth": "^1", - "hono": "^4", - "pg": "^8" + "drizzle-orm": "^0.45.1", + "hono": "^4" }, "devDependencies": { - "@types/pg": "^8" + "drizzle-kit": "^0.31.9" } } diff --git a/apps/aelis-backend/src/auth/http.ts b/apps/aelis-backend/src/auth/http.ts index 028e2cb..2e7b6b4 100644 --- a/apps/aelis-backend/src/auth/http.ts +++ b/apps/aelis-backend/src/auth/http.ts @@ -1,7 +1,7 @@ import type { Hono } from "hono" -import { auth } from "./index.ts" +import type { Auth } from "./index.ts" -export function registerAuthHandlers(app: Hono): void { +export function registerAuthHandlers(app: Hono, auth: Auth): void { app.on(["POST", "GET"], "/api/auth/*", (c) => auth.handler(c.req.raw)) } diff --git a/apps/aelis-backend/src/auth/index.ts b/apps/aelis-backend/src/auth/index.ts index 098d841..6fe8048 100644 --- a/apps/aelis-backend/src/auth/index.ts +++ b/apps/aelis-backend/src/auth/index.ts @@ -1,10 +1,19 @@ import { betterAuth } from "better-auth" +import { drizzleAdapter } from "better-auth/adapters/drizzle" -import { pool } from "../db.ts" +import type { Database } from "../db/index.ts" +import * as schema from "../db/schema.ts" -export const auth = betterAuth({ - database: pool, - emailAndPassword: { - enabled: true, - }, -}) +export function createAuth(db: Database) { + return betterAuth({ + database: drizzleAdapter(db, { + provider: "pg", + schema, + }), + emailAndPassword: { + enabled: true, + }, + }) +} + +export type Auth = ReturnType diff --git a/apps/aelis-backend/src/auth/session-middleware.ts b/apps/aelis-backend/src/auth/session-middleware.ts index a16e6c2..1ac1455 100644 --- a/apps/aelis-backend/src/auth/session-middleware.ts +++ b/apps/aelis-backend/src/auth/session-middleware.ts @@ -1,9 +1,8 @@ import type { Context, MiddlewareHandler, Next } from "hono" +import type { Auth } from "./index.ts" import type { AuthSession, AuthUser } from "./session.ts" -import { auth } from "./index.ts" - export interface SessionVariables { user: AuthUser | null session: AuthSession | null @@ -18,46 +17,52 @@ declare module "hono" { } /** - * Middleware that attaches session and user to the context. - * Does not reject unauthenticated requests - use requireSession for that. + * Creates a middleware that attaches session and user to the context. + * Does not reject unauthenticated requests - use createRequireSession for that. */ -export async function sessionMiddleware(c: Context, next: Next): Promise { - const session = await auth.api.getSession({ headers: c.req.raw.headers }) +export function createSessionMiddleware(auth: Auth): AuthSessionMiddleware { + return async (c: Context, next: Next): Promise => { + const session = await auth.api.getSession({ headers: c.req.raw.headers }) + + if (session) { + c.set("user", session.user) + c.set("session", session.session) + } else { + c.set("user", null) + c.set("session", null) + } + + await next() + } +} + +/** + * Creates a middleware that requires a valid session. Returns 401 if not authenticated. + */ +export function createRequireSession(auth: Auth): AuthSessionMiddleware { + return async (c: Context, next: Next): Promise => { + const session = await auth.api.getSession({ headers: c.req.raw.headers }) + + if (!session) { + return c.json({ error: "Unauthorized" }, 401) + } - if (session) { c.set("user", session.user) c.set("session", session.session) - } else { - c.set("user", null) - c.set("session", null) + await next() } - - await next() } /** - * Middleware that requires a valid session. Returns 401 if not authenticated. + * Creates a function to get session from headers. Useful for WebSocket upgrade validation. */ -export async function requireSession(c: Context, next: Next): Promise { - const session = await auth.api.getSession({ headers: c.req.raw.headers }) - - if (!session) { - return c.json({ error: "Unauthorized" }, 401) +export function createGetSessionFromHeaders(auth: Auth) { + return async ( + headers: Headers, + ): Promise<{ user: AuthUser; session: AuthSession } | null> => { + const session = await auth.api.getSession({ headers }) + return session } - - c.set("user", session.user) - c.set("session", session.session) - await next() -} - -/** - * Get session from headers. Useful for WebSocket upgrade validation. - */ -export async function getSessionFromHeaders( - headers: Headers, -): Promise<{ user: AuthUser; session: AuthSession } | null> { - const session = await auth.api.getSession({ headers }) - return session } /** diff --git a/apps/aelis-backend/src/auth/session.ts b/apps/aelis-backend/src/auth/session.ts index 187cf48..2084487 100644 --- a/apps/aelis-backend/src/auth/session.ts +++ b/apps/aelis-backend/src/auth/session.ts @@ -1,4 +1,4 @@ -import type { auth } from "./index.ts" +import type { Auth } from "./index.ts" -export type AuthUser = typeof auth.$Infer.Session.user -export type AuthSession = typeof auth.$Infer.Session.session +export type AuthUser = Auth["$Infer"]["Session"]["user"] +export type AuthSession = Auth["$Infer"]["Session"]["session"] diff --git a/apps/aelis-backend/src/db.ts b/apps/aelis-backend/src/db.ts deleted file mode 100644 index b6ec04a..0000000 --- a/apps/aelis-backend/src/db.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { Pool } from "pg" - -export const pool = new Pool({ - connectionString: process.env.DATABASE_URL, -}) diff --git a/apps/aelis-backend/src/db/auth-schema.ts b/apps/aelis-backend/src/db/auth-schema.ts new file mode 100644 index 0000000..7e7a4d9 --- /dev/null +++ b/apps/aelis-backend/src/db/auth-schema.ts @@ -0,0 +1,91 @@ +import { relations } from "drizzle-orm"; +import { pgTable, text, timestamp, boolean, index } from "drizzle-orm/pg-core"; + +export const user = pgTable("user", { + id: text("id").primaryKey(), + name: text("name").notNull(), + email: text("email").notNull().unique(), + emailVerified: boolean("email_verified").default(false).notNull(), + image: text("image"), + createdAt: timestamp("created_at").notNull(), + updatedAt: timestamp("updated_at") + .$onUpdate(() => new Date()) + .notNull(), +}); + +export const session = pgTable( + "session", + { + id: text("id").primaryKey(), + expiresAt: timestamp("expires_at").notNull(), + token: text("token").notNull().unique(), + createdAt: timestamp("created_at").notNull(), + updatedAt: timestamp("updated_at") + .$onUpdate(() => new Date()) + .notNull(), + ipAddress: text("ip_address"), + userAgent: text("user_agent"), + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + }, + (table) => [index("session_userId_idx").on(table.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"), + refreshTokenExpiresAt: timestamp("refresh_token_expires_at"), + scope: text("scope"), + password: text("password"), + createdAt: timestamp("created_at").notNull(), + updatedAt: timestamp("updated_at") + .$onUpdate(() => new Date()) + .notNull(), + }, + (table) => [index("account_userId_idx").on(table.userId)], +); + +export const verification = pgTable( + "verification", + { + id: text("id").primaryKey(), + identifier: text("identifier").notNull(), + value: text("value").notNull(), + expiresAt: timestamp("expires_at").notNull(), + createdAt: timestamp("created_at").notNull(), + updatedAt: timestamp("updated_at") + .$onUpdate(() => new Date()) + .notNull(), + }, + (table) => [index("verification_identifier_idx").on(table.identifier)], +); + +export const userRelations = relations(user, ({ many }) => ({ + sessions: many(session), + accounts: many(account), +})); + +export const sessionRelations = relations(session, ({ one }) => ({ + user: one(user, { + fields: [session.userId], + references: [user.id], + }), +})); + +export const accountRelations = relations(account, ({ one }) => ({ + user: one(user, { + fields: [account.userId], + references: [user.id], + }), +})); diff --git a/apps/aelis-backend/src/db/index.ts b/apps/aelis-backend/src/db/index.ts new file mode 100644 index 0000000..c45e7f9 --- /dev/null +++ b/apps/aelis-backend/src/db/index.ts @@ -0,0 +1,23 @@ +import { SQL } from "bun" +import { drizzle, type BunSQLDatabase } from "drizzle-orm/bun-sql" + +import * as schema from "./schema.ts" + +export type Database = BunSQLDatabase + +export interface DatabaseConnection { + db: Database + close: () => Promise +} + +export function createDatabase(url: string): DatabaseConnection { + if (!url) { + throw new Error("DATABASE_URL is required") + } + + const client = new SQL({ url }) + return { + db: drizzle({ client, schema }), + close: () => client.close(), + } +} diff --git a/apps/aelis-backend/src/db/schema.ts b/apps/aelis-backend/src/db/schema.ts new file mode 100644 index 0000000..6bd903b --- /dev/null +++ b/apps/aelis-backend/src/db/schema.ts @@ -0,0 +1,58 @@ +import { + boolean, + customType, + jsonb, + pgTable, + text, + timestamp, + unique, + uuid, +} from "drizzle-orm/pg-core" + +// --------------------------------------------------------------------------- +// Better Auth core tables +// Re-exported from CLI-generated schema. +// Regenerate with: bunx --bun auth@latest generate --config auth.ts --output src/db/auth-schema.ts +// --------------------------------------------------------------------------- + +export { + user, + session, + account, + verification, + userRelations, + sessionRelations, + accountRelations, +} from "./auth-schema.ts" + +import { user } from "./auth-schema.ts" + +// --------------------------------------------------------------------------- +// AELIS — per-user source configuration +// --------------------------------------------------------------------------- + +const bytea = customType<{ data: Buffer }>({ + dataType() { + return "bytea" + }, +}) + +export const userSources = pgTable( + "user_sources", + { + id: uuid("id").primaryKey().defaultRandom(), + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + sourceId: text("source_id").notNull(), + enabled: boolean("enabled").notNull().default(true), + config: jsonb("config").default({}), + credentials: bytea("credentials"), + createdAt: timestamp("created_at").notNull().defaultNow(), + updatedAt: timestamp("updated_at") + .notNull() + .defaultNow() + .$onUpdate(() => new Date()), + }, + (t) => [unique("user_sources_user_id_source_id_unique").on(t.userId, t.sourceId)], +) diff --git a/apps/aelis-backend/src/lib/crypto.test.ts b/apps/aelis-backend/src/lib/crypto.test.ts new file mode 100644 index 0000000..8b2dd00 --- /dev/null +++ b/apps/aelis-backend/src/lib/crypto.test.ts @@ -0,0 +1,62 @@ +import { randomBytes } from "node:crypto" +import { describe, expect, test } from "bun:test" + +import { CredentialEncryptor } from "./crypto.ts" + +const TEST_KEY = randomBytes(32).toString("base64") + +describe("CredentialEncryptor", () => { + const encryptor = new CredentialEncryptor(TEST_KEY) + + test("round-trip with simple string", () => { + const plaintext = "hello world" + const encrypted = encryptor.encrypt(plaintext) + expect(encryptor.decrypt(encrypted)).toBe(plaintext) + }) + + test("round-trip with JSON credentials", () => { + const credentials = JSON.stringify({ + accessToken: "ya29.a0AfH6SMB...", + refreshToken: "1//0dx...", + expiresAt: "2025-12-01T00:00:00Z", + }) + const encrypted = encryptor.encrypt(credentials) + expect(encryptor.decrypt(encrypted)).toBe(credentials) + }) + + test("round-trip with empty string", () => { + const encrypted = encryptor.encrypt("") + expect(encryptor.decrypt(encrypted)).toBe("") + }) + + test("round-trip with unicode", () => { + const plaintext = "日本語テスト 🔐" + const encrypted = encryptor.encrypt(plaintext) + expect(encryptor.decrypt(encrypted)).toBe(plaintext) + }) + + test("each encryption produces different ciphertext (unique IV)", () => { + const plaintext = "same input" + const a = encryptor.encrypt(plaintext) + const b = encryptor.encrypt(plaintext) + expect(a).not.toEqual(b) + expect(encryptor.decrypt(a)).toBe(plaintext) + expect(encryptor.decrypt(b)).toBe(plaintext) + }) + + test("tampered ciphertext throws", () => { + const encrypted = encryptor.encrypt("secret") + encrypted[13]! ^= 0xff + expect(() => encryptor.decrypt(encrypted)).toThrow() + }) + + test("truncated data throws", () => { + expect(() => encryptor.decrypt(Buffer.alloc(10))).toThrow("Encrypted data is too short") + }) + + test("throws when key is wrong length", () => { + expect(() => new CredentialEncryptor(Buffer.from("too-short").toString("base64"))).toThrow( + "must be 32 bytes", + ) + }) +}) diff --git a/apps/aelis-backend/src/lib/crypto.ts b/apps/aelis-backend/src/lib/crypto.ts new file mode 100644 index 0000000..8479b35 --- /dev/null +++ b/apps/aelis-backend/src/lib/crypto.ts @@ -0,0 +1,60 @@ +import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto" + +const ALGORITHM = "aes-256-gcm" +const IV_LENGTH = 12 +const AUTH_TAG_LENGTH = 16 + +/** + * AES-256-GCM encryption for credential storage. + * + * Caches the parsed key on construction to avoid repeated + * env reads and Buffer allocations. + */ +export class CredentialEncryptor { + private readonly key: Buffer + + constructor(base64Key: string) { + const key = Buffer.from(base64Key, "base64") + if (key.length !== 32) { + throw new Error( + `Encryption key must be 32 bytes (got ${key.length}). Generate with: openssl rand -base64 32`, + ) + } + this.key = key + } + + /** + * Encrypts plaintext using AES-256-GCM. + * + * Output format: [12-byte IV][ciphertext][16-byte auth tag] + */ + encrypt(plaintext: string): Buffer { + const iv = randomBytes(IV_LENGTH) + const cipher = createCipheriv(ALGORITHM, this.key, iv) + + const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]) + const authTag = cipher.getAuthTag() + + return Buffer.concat([iv, encrypted, authTag]) + } + + /** + * Decrypts a buffer produced by `encrypt`. + * + * Expects format: [12-byte IV][ciphertext][16-byte auth tag] + */ + decrypt(data: Buffer): string { + if (data.length < IV_LENGTH + AUTH_TAG_LENGTH) { + throw new Error("Encrypted data is too short") + } + + const iv = data.subarray(0, IV_LENGTH) + const authTag = data.subarray(data.length - AUTH_TAG_LENGTH) + const ciphertext = data.subarray(IV_LENGTH, data.length - AUTH_TAG_LENGTH) + + const decipher = createDecipheriv(ALGORITHM, this.key, iv) + decipher.setAuthTag(authTag) + + return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8") + } +} diff --git a/apps/aelis-backend/src/location/http.ts b/apps/aelis-backend/src/location/http.ts index 2f37dfc..6223031 100644 --- a/apps/aelis-backend/src/location/http.ts +++ b/apps/aelis-backend/src/location/http.ts @@ -3,10 +3,9 @@ import type { Context, Hono } from "hono" import { type } from "arktype" import { createMiddleware } from "hono/factory" +import type { AuthSessionMiddleware } from "../auth/session-middleware.ts" import type { UserSessionManager } from "../session/index.ts" -import { requireSession } from "../auth/session-middleware.ts" - type Env = { Variables: { sessionManager: UserSessionManager } } const locationInput = type({ @@ -16,16 +15,21 @@ const locationInput = type({ timestamp: "string.date.iso", }) +interface LocationHttpHandlersDeps { + sessionManager: UserSessionManager + authSessionMiddleware: AuthSessionMiddleware +} + export function registerLocationHttpHandlers( app: Hono, - { sessionManager }: { sessionManager: UserSessionManager }, + { sessionManager, authSessionMiddleware }: LocationHttpHandlersDeps, ) { const inject = createMiddleware(async (c, next) => { c.set("sessionManager", sessionManager) await next() }) - app.post("/api/location", inject, requireSession, handleUpdateLocation) + app.post("/api/location", inject, authSessionMiddleware, handleUpdateLocation) } async function handleUpdateLocation(c: Context) { diff --git a/apps/aelis-backend/src/location/provider.ts b/apps/aelis-backend/src/location/provider.ts new file mode 100644 index 0000000..ed9bfd3 --- /dev/null +++ b/apps/aelis-backend/src/location/provider.ts @@ -0,0 +1,25 @@ +import { LocationSource } from "@aelis/source-location" + +import type { Database } from "../db/index.ts" +import type { FeedSourceProvider } from "../session/feed-source-provider.ts" + +import { SourceDisabledError } from "../sources/errors.ts" +import { sources } from "../sources/user-sources.ts" + +export class LocationSourceProvider implements FeedSourceProvider { + private readonly db: Database + + constructor(db: Database) { + this.db = db + } + + async feedSourceForUser(userId: string): Promise { + const row = await sources(this.db, userId).find("aelis.location") + + if (!row || !row.enabled) { + throw new SourceDisabledError("aelis.location", userId) + } + + return new LocationSource() + } +} diff --git a/apps/aelis-backend/src/server.ts b/apps/aelis-backend/src/server.ts index 68eef07..b1a05d5 100644 --- a/apps/aelis-backend/src/server.ts +++ b/apps/aelis-backend/src/server.ts @@ -1,16 +1,21 @@ -import { LocationSource } from "@aelis/source-location" import { Hono } from "hono" import { registerAuthHandlers } from "./auth/http.ts" -import { requireSession } from "./auth/session-middleware.ts" +import { createAuth } from "./auth/index.ts" +import { createRequireSession } from "./auth/session-middleware.ts" +import { createDatabase } from "./db/index.ts" import { registerFeedHttpHandlers } from "./engine/http.ts" import { createFeedEnhancer } from "./enhancement/enhance-feed.ts" import { createLlmClient } from "./enhancement/llm-client.ts" import { registerLocationHttpHandlers } from "./location/http.ts" +import { LocationSourceProvider } from "./location/provider.ts" import { UserSessionManager } from "./session/index.ts" import { WeatherSourceProvider } from "./weather/provider.ts" function main() { + const { db, close: closeDb } = createDatabase(process.env.DATABASE_URL!) + const auth = createAuth(db) + const openrouterApiKey = process.env.OPENROUTER_API_KEY const feedEnhancer = openrouterApiKey ? createFeedEnhancer({ @@ -26,8 +31,9 @@ function main() { const sessionManager = new UserSessionManager({ providers: [ - async () => new LocationSource(), + new LocationSourceProvider(db), new WeatherSourceProvider({ + db, credentials: { privateKey: process.env.WEATHERKIT_PRIVATE_KEY!, keyId: process.env.WEATHERKIT_KEY_ID!, @@ -43,13 +49,20 @@ function main() { app.get("/health", (c) => c.json({ status: "ok" })) - registerAuthHandlers(app) + const authSessionMiddleware = createRequireSession(auth) + + registerAuthHandlers(app, auth) registerFeedHttpHandlers(app, { sessionManager, - authSessionMiddleware: requireSession, + authSessionMiddleware, + }) + registerLocationHttpHandlers(app, { sessionManager, authSessionMiddleware }) + + process.on("SIGTERM", async () => { + await closeDb() + process.exit(0) }) - registerLocationHttpHandlers(app, { sessionManager }) return app } diff --git a/apps/aelis-backend/src/session/user-session-manager.test.ts b/apps/aelis-backend/src/session/user-session-manager.test.ts index ca9078e..e49bdbc 100644 --- a/apps/aelis-backend/src/session/user-session-manager.test.ts +++ b/apps/aelis-backend/src/session/user-session-manager.test.ts @@ -1,14 +1,11 @@ -import type { WeatherKitClient, WeatherKitResponse } from "@aelis/source-weatherkit" - import { LocationSource } from "@aelis/source-location" +import { WeatherSource } from "@aelis/source-weatherkit" import { describe, expect, mock, spyOn, test } from "bun:test" -import { WeatherSourceProvider } from "../weather/provider.ts" import { UserSessionManager } from "./user-session-manager.ts" -const mockWeatherClient: WeatherKitClient = { - fetch: async () => ({}) as WeatherKitResponse, -} +const mockWeatherProvider = async () => + new WeatherSource({ client: { fetch: async () => ({}) as never } }) describe("UserSessionManager", () => { test("getOrCreate creates session on first call", async () => { @@ -76,9 +73,8 @@ describe("UserSessionManager", () => { }) test("accepts object providers", async () => { - const provider = new WeatherSourceProvider({ client: mockWeatherClient }) const manager = new UserSessionManager({ - providers: [async () => new LocationSource(), provider], + providers: [async () => new LocationSource(), mockWeatherProvider], }) const session = await manager.getOrCreate("user-1") @@ -87,9 +83,8 @@ describe("UserSessionManager", () => { }) test("accepts mixed providers", async () => { - const provider = new WeatherSourceProvider({ client: mockWeatherClient }) const manager = new UserSessionManager({ - providers: [async () => new LocationSource(), provider], + providers: [async () => new LocationSource(), mockWeatherProvider], }) const session = await manager.getOrCreate("user-1") diff --git a/apps/aelis-backend/src/sources/errors.ts b/apps/aelis-backend/src/sources/errors.ts new file mode 100644 index 0000000..9ceab77 --- /dev/null +++ b/apps/aelis-backend/src/sources/errors.ts @@ -0,0 +1,32 @@ +/** + * Thrown by a FeedSourceProvider when the source is not enabled for a user. + * + * UserSessionManager's Promise.allSettled handles this gracefully — + * the source is excluded from the session without crashing. + */ +export class SourceDisabledError extends Error { + readonly sourceId: string + readonly userId: string + + constructor(sourceId: string, userId: string) { + super(`Source "${sourceId}" is not enabled for user "${userId}"`) + this.name = "SourceDisabledError" + this.sourceId = sourceId + this.userId = userId + } +} + +/** + * Thrown when an operation targets a user source that doesn't exist. + */ +export class SourceNotFoundError extends Error { + readonly sourceId: string + readonly userId: string + + constructor(sourceId: string, userId: string) { + super(`Source "${sourceId}" not found for user "${userId}"`) + this.name = "SourceNotFoundError" + this.sourceId = sourceId + this.userId = userId + } +} diff --git a/apps/aelis-backend/src/sources/user-sources.ts b/apps/aelis-backend/src/sources/user-sources.ts new file mode 100644 index 0000000..5bbc810 --- /dev/null +++ b/apps/aelis-backend/src/sources/user-sources.ts @@ -0,0 +1,79 @@ +import { and, eq } from "drizzle-orm" + +import type { Database } from "../db/index.ts" + +import { userSources } from "../db/schema.ts" +import { SourceNotFoundError } from "./errors.ts" + +export function sources(db: Database, userId: string) { + return { + /** Returns all enabled sources for the user. */ + async enabled() { + return db + .select() + .from(userSources) + .where(and(eq(userSources.userId, userId), eq(userSources.enabled, true))) + }, + + /** Returns a specific source by ID, or undefined. */ + async find(sourceId: string) { + const rows = await db + .select() + .from(userSources) + .where(and(eq(userSources.userId, userId), eq(userSources.sourceId, sourceId))) + .limit(1) + + return rows[0] + }, + + /** Enables a source for the user. Throws if the source row doesn't exist. */ + async enableSource(sourceId: string) { + const rows = await db + .update(userSources) + .set({ enabled: true }) + .where(and(eq(userSources.userId, userId), eq(userSources.sourceId, sourceId))) + .returning({ id: userSources.id }) + + if (rows.length === 0) { + throw new SourceNotFoundError(sourceId, userId) + } + }, + + /** Disables a source for the user. Throws if the source row doesn't exist. */ + async disableSource(sourceId: string) { + const rows = await db + .update(userSources) + .set({ enabled: false }) + .where(and(eq(userSources.userId, userId), eq(userSources.sourceId, sourceId))) + .returning({ id: userSources.id }) + + if (rows.length === 0) { + throw new SourceNotFoundError(sourceId, userId) + } + }, + + /** Creates or updates the config for a source. */ + async upsertConfig(sourceId: string, config: Record) { + await db + .insert(userSources) + .values({ userId, sourceId, config }) + .onConflictDoUpdate({ + target: [userSources.userId, userSources.sourceId], + set: { config }, + }) + }, + + /** Updates the encrypted credentials for a source. Throws if the source row doesn't exist. */ + async updateCredentials(sourceId: string, credentials: Buffer) { + const rows = await db + .update(userSources) + .set({ credentials }) + .where(and(eq(userSources.userId, userId), eq(userSources.sourceId, sourceId))) + .returning({ id: userSources.id }) + + if (rows.length === 0) { + throw new SourceNotFoundError(sourceId, userId) + } + }, + } +} diff --git a/apps/aelis-backend/src/tfl/provider.ts b/apps/aelis-backend/src/tfl/provider.ts index 4e84ced..d69ca96 100644 --- a/apps/aelis-backend/src/tfl/provider.ts +++ b/apps/aelis-backend/src/tfl/provider.ts @@ -1,19 +1,47 @@ -import { TflSource, type ITflApi } from "@aelis/source-tfl" +import { TflSource, type ITflApi, type TflLineId } from "@aelis/source-tfl" +import { type } from "arktype" +import type { Database } from "../db/index.ts" import type { FeedSourceProvider } from "../session/feed-source-provider.ts" +import { SourceDisabledError } from "../sources/errors.ts" +import { sources } from "../sources/user-sources.ts" + export type TflSourceProviderOptions = - | { apiKey: string; client?: never } - | { apiKey?: never; client: ITflApi } + | { db: Database; apiKey: string; client?: never } + | { db: Database; apiKey?: never; client: ITflApi } + +const tflConfig = type({ + "lines?": "string[]", +}) export class TflSourceProvider implements FeedSourceProvider { - private readonly options: TflSourceProviderOptions + private readonly db: Database + private readonly apiKey: string | undefined + private readonly client: ITflApi | undefined constructor(options: TflSourceProviderOptions) { - this.options = options + this.db = options.db + this.apiKey = "apiKey" in options ? options.apiKey : undefined + this.client = "client" in options ? options.client : undefined } - async feedSourceForUser(_userId: string): Promise { - return new TflSource(this.options) + async feedSourceForUser(userId: string): Promise { + const row = await sources(this.db, userId).find("aelis.tfl") + + if (!row || !row.enabled) { + throw new SourceDisabledError("aelis.tfl", userId) + } + + const parsed = tflConfig(row.config ?? {}) + if (parsed instanceof type.errors) { + throw new Error(`Invalid TFL config for user ${userId}: ${parsed.summary}`) + } + + return new TflSource({ + apiKey: this.apiKey, + client: this.client, + lines: parsed.lines as TflLineId[] | undefined, + }) } } diff --git a/apps/aelis-backend/src/weather/provider.ts b/apps/aelis-backend/src/weather/provider.ts index 626ec87..61d6468 100644 --- a/apps/aelis-backend/src/weather/provider.ts +++ b/apps/aelis-backend/src/weather/provider.ts @@ -1,15 +1,53 @@ import { WeatherSource, type WeatherSourceOptions } from "@aelis/source-weatherkit" +import { type } from "arktype" +import type { Database } from "../db/index.ts" import type { FeedSourceProvider } from "../session/feed-source-provider.ts" -export class WeatherSourceProvider implements FeedSourceProvider { - private readonly options: WeatherSourceOptions +import { SourceDisabledError } from "../sources/errors.ts" +import { sources } from "../sources/user-sources.ts" - constructor(options: WeatherSourceOptions) { - this.options = options +export interface WeatherSourceProviderOptions { + db: Database + credentials: WeatherSourceOptions["credentials"] + client?: WeatherSourceOptions["client"] +} + +const weatherConfig = type({ + "units?": "'metric' | 'imperial'", + "hourlyLimit?": "number", + "dailyLimit?": "number", +}) + +export class WeatherSourceProvider implements FeedSourceProvider { + private readonly db: Database + private readonly credentials: WeatherSourceOptions["credentials"] + private readonly client: WeatherSourceOptions["client"] + + constructor(options: WeatherSourceProviderOptions) { + this.db = options.db + this.credentials = options.credentials + this.client = options.client } - async feedSourceForUser(_userId: string): Promise { - return new WeatherSource(this.options) + async feedSourceForUser(userId: string): Promise { + const row = await sources(this.db, userId).find("aelis.weather") + + if (!row || !row.enabled) { + throw new SourceDisabledError("aelis.weather", userId) + } + + const parsed = weatherConfig(row.config ?? {}) + if (parsed instanceof type.errors) { + throw new Error(`Invalid weather config for user ${userId}: ${parsed.summary}`) + } + + return new WeatherSource({ + credentials: this.credentials, + client: this.client, + units: parsed.units, + hourlyLimit: parsed.hourlyLimit, + dailyLimit: parsed.dailyLimit, + }) } } diff --git a/bun.lock b/bun.lock index 4954148..90bdadd 100644 --- a/bun.lock +++ b/bun.lock @@ -28,11 +28,11 @@ "@openrouter/sdk": "^0.9.11", "arktype": "^2.1.29", "better-auth": "^1", + "drizzle-orm": "^0.45.1", "hono": "^4", - "pg": "^8", }, "devDependencies": { - "@types/pg": "^8", + "drizzle-kit": "^0.31.9", }, }, "apps/aelis-client": { @@ -444,6 +444,8 @@ "@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="], + "@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="], + "@egjs/hammerjs": ["@egjs/hammerjs@2.0.17", "", { "dependencies": { "@types/hammerjs": "^2.0.36" } }, "sha512-XQsZgjm2EcVUiZQf11UBJQfmZeEmOW8DpI1gsFeln6w0ae0ii4dMQEQ0kjl6DspdWX1aGY1/loyXnP0JS06e/A=="], "@electric-sql/pglite": ["@electric-sql/pglite@0.3.15", "", {}, "sha512-Cj++n1Mekf9ETfdc16TlDi+cDDQF0W7EcbyRHYOAeZdsAe8M/FJg18itDTSwyHfar2WIezawM9o0EKaRGVKygQ=="], @@ -458,57 +460,61 @@ "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="], - "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="], + "@esbuild-kit/core-utils": ["@esbuild-kit/core-utils@3.3.2", "", { "dependencies": { "esbuild": "~0.18.20", "source-map-support": "^0.5.21" } }, "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ=="], - "@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="], + "@esbuild-kit/esm-loader": ["@esbuild-kit/esm-loader@2.6.5", "", { "dependencies": { "@esbuild-kit/core-utils": "^3.3.2", "get-tsconfig": "^4.7.0" } }, "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA=="], - "@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.3", "", { "os": "android", "cpu": "arm64" }, "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg=="], + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], - "@esbuild/android-x64": ["@esbuild/android-x64@0.27.3", "", { "os": "android", "cpu": "x64" }, "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ=="], + "@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], - "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg=="], + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="], - "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg=="], + "@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="], - "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w=="], + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="], - "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA=="], + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="], - "@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.3", "", { "os": "linux", "cpu": "arm" }, "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw=="], + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="], - "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg=="], + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="], - "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.3", "", { "os": "linux", "cpu": "ia32" }, "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg=="], + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="], - "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA=="], + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="], - "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw=="], + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="], - "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA=="], + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="], - "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ=="], + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="], - "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw=="], + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="], - "@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA=="], + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="], - "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA=="], + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="], - "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.3", "", { "os": "none", "cpu": "x64" }, "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA=="], + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="], - "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.3", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw=="], + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="], - "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ=="], + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="], - "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g=="], + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="], - "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.3", "", { "os": "sunos", "cpu": "x64" }, "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA=="], + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="], - "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA=="], + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="], - "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q=="], + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="], - "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="], + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="], @@ -1700,6 +1706,8 @@ "dotenv-expand": ["dotenv-expand@11.0.7", "", { "dependencies": { "dotenv": "^16.4.5" } }, "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA=="], + "drizzle-kit": ["drizzle-kit@0.31.9", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "esbuild-register": "^3.5.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-GViD3IgsXn7trFyBUUHyTFBpH/FsHTxYJ66qdbVggxef4UBPHRYxQaRzYLTuekYnk9i5FIEL9pbBIwMqX/Uwrg=="], + "drizzle-orm": ["drizzle-orm@0.45.1", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-Te0FOdKIistGNPMq2jscdqngBRfBpC8uMFVwqjf6gtTVJHIQ/dosgV/CLBU2N4ZJBsXL5savCba9b0YJskKdcA=="], "dtrace-provider": ["dtrace-provider@0.8.8", "", { "dependencies": { "nan": "^2.14.0" } }, "sha512-b7Z7cNtHPhH9EJhNNbbeqTcXB8LGFFZhq1PGgEvpeHlzd36bhbdTWoE/Ba/YguqpBSlAPKnARWhVlhunCMwfxg=="], @@ -1760,7 +1768,9 @@ "es-to-primitive": ["es-to-primitive@1.3.0", "", { "dependencies": { "is-callable": "^1.2.7", "is-date-object": "^1.0.5", "is-symbol": "^1.0.4" } }, "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g=="], - "esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="], + "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], + + "esbuild-register": ["esbuild-register@3.6.0", "", { "dependencies": { "debug": "^4.3.4" }, "peerDependencies": { "esbuild": ">=0.12 <1" } }, "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg=="], "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], @@ -3312,6 +3322,8 @@ "@cspotcode/source-map-support/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="], + "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], + "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], "@eslint/config-array/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], @@ -3800,6 +3812,8 @@ "twrnc/tailwindcss": ["tailwindcss@3.4.19", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.6.0", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.21.7", "lilconfig": "^3.1.3", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", "postcss-nested": "^6.2.0", "postcss-selector-parser": "^6.1.2", "resolve": "^1.22.8", "sucrase": "^3.35.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ=="], + "vite/esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="], + "vite-node/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], "waitlist-website/@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="], @@ -3822,6 +3836,50 @@ "@babel/highlight/chalk/supports-color": ["supports-color@5.5.0", "", { "dependencies": { "has-flag": "^3.0.0" } }, "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow=="], + "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.18.20", "", { "os": "android", "cpu": "x64" }, "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.18.20", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.18.20", "", { "os": "darwin", "cpu": "x64" }, "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.18.20", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.18.20", "", { "os": "freebsd", "cpu": "x64" }, "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.18.20", "", { "os": "linux", "cpu": "arm" }, "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.18.20", "", { "os": "linux", "cpu": "arm64" }, "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.18.20", "", { "os": "linux", "cpu": "ia32" }, "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.18.20", "", { "os": "linux", "cpu": "ppc64" }, "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.18.20", "", { "os": "linux", "cpu": "s390x" }, "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.18.20", "", { "os": "linux", "cpu": "x64" }, "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.18.20", "", { "os": "none", "cpu": "x64" }, "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.18.20", "", { "os": "openbsd", "cpu": "x64" }, "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.18.20", "", { "os": "sunos", "cpu": "x64" }, "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.18.20", "", { "os": "win32", "cpu": "arm64" }, "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.18.20", "", { "os": "win32", "cpu": "ia32" }, "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="], + "@eslint/config-array/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], "@eslint/eslintrc/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], @@ -4088,6 +4146,58 @@ "twrnc/tailwindcss/sucrase": ["sucrase@3.35.1", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "tinyglobby": "^0.2.11", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw=="], + "vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="], + + "vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="], + + "vite/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.3", "", { "os": "android", "cpu": "arm64" }, "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg=="], + + "vite/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.27.3", "", { "os": "android", "cpu": "x64" }, "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ=="], + + "vite/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg=="], + + "vite/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg=="], + + "vite/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w=="], + + "vite/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA=="], + + "vite/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.3", "", { "os": "linux", "cpu": "arm" }, "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw=="], + + "vite/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg=="], + + "vite/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.3", "", { "os": "linux", "cpu": "ia32" }, "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg=="], + + "vite/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA=="], + + "vite/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw=="], + + "vite/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA=="], + + "vite/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ=="], + + "vite/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw=="], + + "vite/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA=="], + + "vite/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA=="], + + "vite/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.3", "", { "os": "none", "cpu": "x64" }, "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA=="], + + "vite/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.3", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw=="], + + "vite/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ=="], + + "vite/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g=="], + + "vite/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.3", "", { "os": "sunos", "cpu": "x64" }, "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA=="], + + "vite/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA=="], + + "vite/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q=="], + + "vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="], + "waitlist-website/react-dom/scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], "@babel/highlight/chalk/ansi-styles/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="], diff --git a/docs/db-persistence-layer-spec.md b/docs/db-persistence-layer-spec.md new file mode 100644 index 0000000..4df67ee --- /dev/null +++ b/docs/db-persistence-layer-spec.md @@ -0,0 +1,230 @@ +# DB Persistence Layer Spec + +## Problem Statement + +AELIS currently hardcodes the same set of feed sources for every user. Source configuration (TFL lines, weather units, calendar IDs, etc.) and credentials (OAuth tokens) are not persisted. Users cannot customize which sources appear in their feed or configure source-specific settings. + +The backend uses a raw `pg` Pool for Better Auth and has no ORM. We need a persistence layer that stores per-user source configuration and credentials, using Drizzle ORM with Bun.sql as the Postgres driver. + +## Requirements + +### 1. Replace `pg` with `Bun.sql` + +- Remove `pg` and `@types/pg` dependencies +- Replace `db.ts` with a Drizzle instance backed by `Bun.sql` (`drizzle-orm/bun-sql`) +- All DB access goes through Drizzle — no raw Pool usage + +### 2. Migrate Better Auth to Drizzle adapter + +- Use `better-auth/adapters/drizzle` so auth tables are managed through the same Drizzle instance +- Define Better Auth tables (user, session, account, verification) in the Drizzle schema +- Better Auth's `database` option switches from `Pool` to the Drizzle adapter + +### 3. User source configuration table + +A `user_sources` table stores per-user source state: + +| Column | Type | Description | +| ------------ | ------------------------ | ------------------------------------------------------------ | +| `id` | `uuid` PK | Row ID | +| `user_id` | `text` FK → `user.id` | Owner | +| `source_id` | `text` | Source identifier (e.g., `aelis.tfl`, `aelis.weather`) | +| `enabled` | `boolean` | Whether this source is active in the user's feed | +| `config` | `jsonb` | Source-specific configuration (validated by source at runtime)| +| `credentials`| `bytea` | Encrypted OAuth tokens / secrets (AES-256-GCM) | +| `created_at` | `timestamp with tz` | Row creation time | +| `updated_at` | `timestamp with tz` | Last modification time | + +- Unique constraint on `(user_id, source_id)` — one config row per source per user. +- `config` is a generic `jsonb` column. Each source package exports an arktype schema; the backend provider validates the JSON at source construction time. +- `credentials` is stored as encrypted bytes. Only OAuth tokens and secrets go here — non-sensitive config stays in `config`. + +### 4. Credential encryption + +- AES-256-GCM encryption for the `credentials` column +- Encryption key sourced from an environment variable (`CREDENTIALS_ENCRYPTION_KEY`) +- A `crypto` utility module in the backend provides `encrypt(plaintext)` → `Buffer` and `decrypt(ciphertext)` → `string` +- IV is generated per-encryption and stored as a prefix to the ciphertext + +### 5. Default sources on signup + +When a new user is created, seed `user_sources` rows for default sources: + +| Source | Default config | +| ------------------ | --------------------------------------------------------------- | +| `aelis.location` | `{}` | +| `aelis.weather` | `{ "units": "metric", "hourlyLimit": 12, "dailyLimit": 7 }` | +| `aelis.tfl` | `{ "lines": }` | + +- Seeding happens via a Better Auth `after` hook on user creation, or via application-level logic after signup. +- Sources requiring credentials (Google Calendar, CalDAV) are **not** enabled by default — they require the user to connect an account first. + +### 6. Source providers query DB + +`FeedSourceProvider.feedSourceForUser` is already async (returns `Promise`). `UserSessionManager.getOrCreate` is already async with in-flight deduplication and `Promise.allSettled`-based graceful degradation — if a provider throws, the source is skipped and the error is logged. + +Each provider receives the Drizzle DB instance and queries `user_sources` internally. If the source is disabled or the row is missing, the provider throws a `SourceDisabledError`. If config validation fails, it throws with a descriptive message. Both cases are handled by `createSession`'s `Promise.allSettled` — the source is excluded from the session and the error is logged. + +```typescript +class TflSourceProvider implements FeedSourceProvider { + constructor(private db: DrizzleDb, private apiKey: string) {} + + async feedSourceForUser(userId: string): Promise { + const row = await this.db.select() + .from(userSources) + .where(and( + eq(userSources.userId, userId), + eq(userSources.sourceId, "aelis.tfl"), + eq(userSources.enabled, true), + )) + .limit(1) + + if (!row[0]) { + throw new SourceDisabledError("aelis.tfl", userId) + } + + const config = tflSourceConfig(row[0].config ?? {}) + if (config instanceof type.errors) { + throw new Error(`Invalid TFL config for user ${userId}: ${config.summary}`) + } + + return new TflSource({ ...config, apiKey: this.apiKey }) + } +} +``` + +No interface changes are needed — the existing async `FeedSourceProvider` and `UserSessionManager` signatures are sufficient. + +### 7. Drizzle Kit migrations + +- Use `drizzle-kit` for schema migrations +- `drizzle.config.ts` at `apps/aelis-backend/drizzle.config.ts` +- Migration files stored in `apps/aelis-backend/drizzle/` +- Scripts in `package.json`: `db:generate`, `db:migrate`, `db:studio` + +## Acceptance Criteria + +1. **Bun.sql driver** + - [ ] `pg` and `@types/pg` are removed from `package.json` + - [ ] `db.ts` exports a Drizzle instance using `Bun.sql` + - [ ] All existing DB usage (Better Auth) works with the new driver + +2. **Better Auth on Drizzle** + - [ ] Better Auth uses `drizzle-adapter` with the shared Drizzle instance + - [ ] Auth tables (user, session, account, verification) are defined in the Drizzle schema + - [ ] Signup, signin, and session validation work as before + +3. **User sources table** + - [ ] `user_sources` table exists with the schema described above + - [ ] Unique constraint on `(user_id, source_id)` is enforced + - [ ] `config` column accepts arbitrary JSON + - [ ] `credentials` column stores encrypted bytes + +4. **Credential encryption** + - [ ] Encrypt/decrypt utility works with AES-256-GCM + - [ ] IV is unique per encryption + - [ ] Missing `CREDENTIALS_ENCRYPTION_KEY` fails fast at startup + - [ ] Unit tests cover round-trip encrypt → decrypt + +5. **Default source seeding** + - [ ] New user signup creates `user_sources` rows for location, weather, and TFL + - [ ] Default config values match the table above + - [ ] Sources requiring credentials are not auto-enabled + +6. **Provider DB integration** + - [ ] Each provider queries `user_sources` for the user's config and credentials + - [ ] Disabled sources (enabled=false or missing row) throw `SourceDisabledError`, excluded via `Promise.allSettled` + - [ ] Invalid config logs an error and skips the source (graceful degradation) + - [ ] `SourceDisabledError` class is created in `src/session/` + + _Note: `FeedSourceProvider` is already async, `UserSessionManager.getOrCreate` is already async with in-flight deduplication and `Promise.allSettled` graceful degradation. No interface changes needed._ + +7. **Migrations** + - [ ] `drizzle.config.ts` is configured + - [ ] Initial migration creates all tables (auth + user_sources) + - [ ] `bun run db:generate` and `bun run db:migrate` work + +## Implementation Approach + +### Phase 1: Drizzle + Bun.sql setup + +1. Install `drizzle-orm` and `drizzle-kit`; remove `pg` and `@types/pg` +2. Create `src/db/index.ts` — Drizzle instance with `Bun.sql` +3. Create `src/db/schema.ts` — Better Auth tables + `user_sources` table +4. Create `drizzle.config.ts` +5. Add `db:generate`, `db:migrate`, `db:studio` scripts to `package.json` + +### Phase 2: Better Auth migration + +6. Update `src/auth/index.ts` to use `drizzle-adapter` with the Drizzle instance +7. Verify signup/signin/session validation still work +8. Remove old `src/db.ts` (raw Pool) + +### Phase 3: Credential encryption + +9. Create `src/lib/crypto.ts` with `encrypt` and `decrypt` functions (AES-256-GCM) +10. Add `CREDENTIALS_ENCRYPTION_KEY` to `.env.example` +11. Write unit tests for encrypt/decrypt round-trip + +### Phase 4: User source config + +12. Create `src/db/user-sources.ts` — query helpers (get sources for user, upsert config, etc.) +13. Create `src/session/source-disabled-error.ts` — `SourceDisabledError` class +14. Implement default source seeding on user creation +15. Update each provider (Weather, TFL, Location) to accept Drizzle DB instance and query `user_sources` for config/credentials + +_`FeedSourceProvider` is already async and `UserSessionManager.getOrCreate` already handles provider failures via `Promise.allSettled`. No interface or caller changes needed._ + +### Phase 5: Verification + +16. Generate and run initial migration +17. Run existing tests, fix any breakage +18. Manual test: signup → default sources created → feed returns data + +## File Structure (new/modified) + +``` +apps/aelis-backend/ +├── drizzle.config.ts # NEW +├── drizzle/ # NEW — migration files +├── src/ +│ ├── db.ts # REPLACE — Drizzle + Bun.sql +│ ├── db/ +│ │ ├── schema.ts # NEW — all table definitions +│ │ └── user-sources.ts # NEW — query helpers +│ ├── auth/ +│ │ └── index.ts # MODIFY — drizzle adapter +│ ├── lib/ +│ │ ├── crypto.ts # NEW — encrypt/decrypt +│ │ └── crypto.test.ts # NEW +│ ├── session/ +│ │ └── source-disabled-error.ts # NEW — SourceDisabledError +│ ├── weather/ +│ │ └── provider.ts # MODIFY — query DB +│ └── tfl/ +│ └── provider.ts # MODIFY — query DB +``` + +_`feed-source-provider.ts`, `user-session-manager.ts`, `engine/http.ts`, and `location/http.ts` are already async-ready on master and do not need changes._ + +## Dependencies + +**Add:** +- `drizzle-orm` +- `drizzle-kit` (dev) + +**Remove:** +- `pg` +- `@types/pg` (dev) + +## Environment Variables + +**Add to `.env.example`:** +- `CREDENTIALS_ENCRYPTION_KEY` — 32-byte hex or base64 key for AES-256-GCM + +## Open Questions (Deferred) + +- HTTP endpoints for CRUD on user source config (settings UI) +- OAuth flow for connecting Google Calendar / CalDAV accounts +- Source config validation schemas exported from each source package (currently only TFL has one) +- Whether to cache DB-loaded config in the UserSession to avoid repeated queries on reconnect