diff --git a/.env.development.example b/.env.development.example new file mode 100644 index 0000000..8b9bb8d --- /dev/null +++ b/.env.development.example @@ -0,0 +1,4 @@ +# Loaded by bun dev and the default database commands. +APP_ORIGIN=http://127.0.0.1:3000 +# Optional overrides: PORT, DATABASE_PATH, DISCORD_CLIENT_ID, +# DISCORD_CLIENT_SECRET, AUTH_COOKIE_SECRET. diff --git a/.env.example b/.env.example index c84a676..60c6db2 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,12 @@ +# Optional shared defaults. Values in the selected environment file override these. DATABASE_PATH=./data/minabot.sqlite PORT=3000 + +# Discord Developer Portal > OAuth2. Register both redirect URIs: +# http://127.0.0.1:3000/api/auth/discord/callback +# https://YOUR_PRODUCTION_HOST/api/auth/discord/callback +# Credentials may be shared here or set separately in each environment file. +DISCORD_CLIENT_ID= +DISCORD_CLIENT_SECRET= +# Generate once: openssl rand -hex 32 (at least 32 characters required) +AUTH_COOKIE_SECRET= diff --git a/.env.production.example b/.env.production.example new file mode 100644 index 0000000..4ed871e --- /dev/null +++ b/.env.production.example @@ -0,0 +1,5 @@ +# Loaded by bun start and bun run db:migrate:production. +# Set your public origin, e.g. https://habits.example.com +APP_ORIGIN= +# Optional overrides: PORT, DATABASE_PATH, DISCORD_CLIENT_ID, +# DISCORD_CLIENT_SECRET, AUTH_COOKIE_SECRET. diff --git a/.gitignore b/.gitignore index 6968e92..6f35873 100644 --- a/.gitignore +++ b/.gitignore @@ -26,12 +26,11 @@ logs _.log report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json -# dotenv environment variable files +# dotenv files may contain credentials; commit only example templates. .env -.env.development.local -.env.test.local -.env.production.local -.env.local +.env.* +!.env.example +!.env.*.example # caches .eslintcache diff --git a/README.md b/README.md index 31b47a0..5f92386 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Open http://127.0.0.1:3000. Set `PORT` to use a different port. - Hono endpoint: `GET /api/health`. - Unknown API routes return JSON with a 404 status. -Pages contain only a heading and native navigation buttons. Tailwind utilities +Pages contain a heading, native navigation buttons, and account controls. Tailwind utilities and shadcn configuration are available; Tailwind Preflight is omitted to preserve native browser styling. @@ -34,7 +34,8 @@ created at `data/minabot.sqlite`, with WAL mode and foreign keys enabled. Databa files are ignored by Git. Development and production use the same file. Optionally copy `.env.example` to `.env` and set `DATABASE_PATH` to another local -file. Relative paths are resolved from the project root by the package scripts. +file. Environment-specific database paths can be set in `.env.development` and +`.env.production`. Relative paths are resolved from the project root by the package scripts. Define tables in `src/db/schema.ts`, then generate and apply migrations: @@ -45,7 +46,135 @@ bun run db:migrate Commit the generated `drizzle/` files alongside schema changes. `bun dev` and `bun start` apply pending migrations before starting the server. The initial -schema contains no application tables. +migration creates the users and sessions tables. Import `db` from `src/db/index.ts` in backend code to query the database. `GET /api/health` checks the database connection before returning success. + +## Discord sign-in + +1. Copy `.env.example` to `.env` for shared defaults, and copy + `.env.development.example` / `.env.production.example` to `.env.development` / + `.env.production` if those files do not exist. These local files are Git-ignored. +2. Create an application in the [Discord Developer Portal](https://discord.com/developers/applications). + Under OAuth2, copy the client ID and client secret into `DISCORD_CLIENT_ID` + and `DISCORD_CLIENT_SECRET` in `.env`. +3. Set `AUTH_COOKIE_SECRET` to a random secret of at least 32 characters, generated + with `openssl rand -hex 32`. Keep it server-side along with the client secret. +4. Set `APP_ORIGIN` in `.env.development` to the development browser origin, + initially `http://127.0.0.1:3000`, and set the same variable in `.env.production` + to the production origin, such as `https://habits.example.com`. Register **both** + origins with `/api/auth/discord/callback` appended as redirect URIs in Discord. + Use the matching hostname throughout each sign-in; `localhost` and `127.0.0.1` + have different cookie storage. +5. Run `bun dev`, click **Sign in with Discord**, and authorize the `identify` scope. + No bot token or email scope is needed. After returning, **View my profile** opens + `/api/me`. **Sign out** revokes the current browser's session. + +Without the OAuth settings, public pages and `/api/health` remain usable, `/api/me` +returns 401, and sign-in displays a configuration message. Restart after editing +any env file. Use HTTPS for non-local deployments; HTTP is only allowed on loopback +origins. When using a reverse proxy, preserve the public request host and serve +both React and `/api` from the selected origin. + +The package scripts explicitly select the environment before migrations or server +startup: + +| Command | Environment file | Mode | +| --- | --- | --- | +| `bun dev` | `.env.development` | development | +| `bun start` | `.env.production` | production | +| `bun run db:generate` / `bun run db:migrate` | `.env.development` | development | +| `bun run db:migrate:production` | `.env.production` | production | + +Each command loads optional `.env` shared defaults, then the selected file. +Variables exported by the shell or deployment platform take precedence over files. +The selected `APP_ORIGIN` controls OAuth callbacks, cookie security, and logout +Origin validation. Production requires an explicit `APP_ORIGIN`; development can +fall back to `http://127.0.0.1:$PORT` (port 3000 if unset). To test `bun start` +locally, set `APP_ORIGIN=http://127.0.0.1:3000` in `.env.production`. + +Automatic env-file loading is disabled in `bunfig.toml` so the outer `bun start` +process cannot preload development settings into production. Package scripts load +the files explicitly using Bun's `--env-file` option; `.env.local` files are not +loaded by these commands. Build, test, and typecheck do not load application env +files. For custom Bun commands, pass the desired `--env-file` flags explicitly. +See [Bun environment variables](https://bun.sh/docs/runtime/environment-variables). + +Use `APP_ORIGIN` in each file; `APP_ORIGIN_DEV` and `APP_ORIGIN_PROD` are no longer +used. Credentials can stay in `.env` or be overridden separately in each file. +For deployments sharing a machine, set distinct `DATABASE_PATH` values to keep +production habit data separate from development data. + +### Schema and session behavior + +- `users`: app UUID, unique Discord ID stored as text, username, nullable display + name and avatar hash, IANA timezone, and creation/update timestamps. +- `sessions`: SHA-256 token hash, user foreign key with cascading deletion, + creation timestamp, and expiry timestamp. User and expiry indexes support + session management and cleanup. +- Timestamps are Unix milliseconds. Each session expires 30 days after sign-in; + activity does not extend it. A new sign-in replaces the current browser's session. + Other browsers retain independent sessions. Expired sessions are removed during + sign-in or when presented to an authenticated route. +- Cookies are HttpOnly, SameSite=Lax, and host-only. HTTPS enables Secure and + `__Host-` cookie names. OAuth state uses a signed cookie with a ten-minute + lifetime and is cleared on callback. Token exchange and profile retrieval happen + server-side; Discord access/refresh tokens are never persisted or sent to React. +- React detects the browser timezone during initial registration. The server + validates it using `Intl.DateTimeFormat`, falling back to UTC. Later sign-ins + update the Discord profile without changing the saved timezone. A timezone + settings editor and habit reset logic are future work. Habit timestamps should + remain UTC; daily boundaries should be calculated using the user's timezone. + +### API + +| Method | Route | Behavior | +| --- | --- | --- | +| GET | `/api/health` | Public database health check | +| GET | `/api/auth/discord?timezone=Europe%2FBelgrade` | Start sign-in | +| GET | `/api/auth/discord/callback` | Validate OAuth response and create a session | +| POST | `/api/auth/logout` | Revoke current session; requires matching Origin | +| GET | `/api/me` | Public user fields for the current session, or 401 | + +`/api/me` returns the object directly: + +```json +{ + "id": "app-generated-uuid", + "discordId": "123456789012345678", + "username": "mina", + "displayName": "Mina", + "avatarUrl": null, + "timezone": "Europe/Belgrade" +} +``` + +Only the fields declared in `src/shared/user.ts` are returned. Missing/invalid or +expired sessions receive `401 {"error":"Unauthorized"}`. Authentication endpoints +and `/api/me` use `Cache-Control: no-store`. + +In `src/api.ts`, protect another route with the same middleware: + +```ts +app.get("/api/example", auth.requireAuth, c => c.json({ userId: c.get("user").id })); +app.post("/api/example", auth.requireSameOrigin, auth.requireAuth, c => { + // Validate input and enforce ownership using c.get("user").id. + return c.json({ ok: true }); +}); +``` + +Use `requireSameOrigin` for all cookie-authenticated mutations. Same-origin browser +fetches send the cookie automatically; tokens should not be placed in localStorage. + +```sh +bun test +bun run typecheck +bun run build +``` + +Tests use an isolated in-memory SQLite database with the real migrations and a +stubbed Discord HTTP client. They cover registration, profile refresh, timezone +preservation, cookie security, invalid/expired state, provider failures, session +expiry/rotation, logout CSRF checks, and the exact `/api/me` response. A real Discord +consent flow additionally requires your application credentials and browser login. diff --git a/bunfig.toml b/bunfig.toml index 0175d44..0d0612c 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -1,3 +1,7 @@ +# Package scripts load .env plus the selected environment file explicitly. +# Prevent the outer bun process from preloading development values for bun start. +env = false + [serve.static] plugins = ["bun-plugin-tailwind"] env = "BUN_PUBLIC_*" diff --git a/drizzle/0000_discord_auth.sql b/drizzle/0000_discord_auth.sql new file mode 100644 index 0000000..2077267 --- /dev/null +++ b/drizzle/0000_discord_auth.sql @@ -0,0 +1,22 @@ +CREATE TABLE `sessions` ( + `token_hash` text PRIMARY KEY NOT NULL, + `user_id` text NOT NULL, + `created_at` integer NOT NULL, + `expires_at` integer NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX `sessions_user_id_idx` ON `sessions` (`user_id`);--> statement-breakpoint +CREATE INDEX `sessions_expires_at_idx` ON `sessions` (`expires_at`);--> statement-breakpoint +CREATE TABLE `users` ( + `id` text PRIMARY KEY NOT NULL, + `discord_id` text NOT NULL, + `username` text NOT NULL, + `global_name` text, + `avatar_hash` text, + `timezone` text DEFAULT 'UTC' NOT NULL, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `users_discord_id_unique` ON `users` (`discord_id`); \ No newline at end of file diff --git a/drizzle/meta/0000_snapshot.json b/drizzle/meta/0000_snapshot.json new file mode 100644 index 0000000..ed7ad2c --- /dev/null +++ b/drizzle/meta/0000_snapshot.json @@ -0,0 +1,160 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "1d46cf45-94a2-434f-b5d7-bc0346cc1fdd", + "prevId": "00000000-0000-0000-0000-000000000000", + "tables": { + "sessions": { + "name": "sessions", + "columns": { + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "sessions_user_id_idx": { + "name": "sessions_user_id_idx", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "sessions_expires_at_idx": { + "name": "sessions_expires_at_idx", + "columns": [ + "expires_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "discord_id": { + "name": "discord_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "global_name": { + "name": "global_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "avatar_hash": { + "name": "avatar_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'UTC'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "users_discord_id_unique": { + "name": "users_discord_id_unique", + "columns": [ + "discord_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index b9b8421..13e3ed6 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -1 +1,13 @@ -{"version":"7","dialect":"sqlite","entries":[]} \ No newline at end of file +{ + "version": "7", + "dialect": "sqlite", + "entries": [ + { + "idx": 0, + "version": "6", + "when": 1788501609255, + "tag": "0000_discord_auth", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/package.json b/package.json index fb93292..201812f 100644 --- a/package.json +++ b/package.json @@ -4,12 +4,14 @@ "private": true, "type": "module", "scripts": { - "dev": "bun run db:migrate && bun --hot src/index.ts", - "start": "bun run db:migrate && bun scripts/start.ts", + "dev": "NODE_ENV=development bun --env-file=.env --env-file=.env.development scripts/migrate.ts && NODE_ENV=development bun --env-file=.env --env-file=.env.development --hot src/index.ts", + "start": "NODE_ENV=production bun --env-file=.env --env-file=.env.production scripts/migrate.ts && NODE_ENV=production bun --env-file=.env --env-file=.env.production scripts/start.ts", "build": "bun run build.ts", "typecheck": "tsc --noEmit", - "db:generate": "bunx --bun drizzle-kit generate", - "db:migrate": "bun scripts/migrate.ts" + "db:generate": "NODE_ENV=development bun --env-file=.env --env-file=.env.development x --bun drizzle-kit generate", + "db:migrate": "NODE_ENV=development bun --env-file=.env --env-file=.env.development scripts/migrate.ts", + "test": "bun test", + "db:migrate:production": "NODE_ENV=production bun --env-file=.env --env-file=.env.production scripts/migrate.ts" }, "dependencies": { "bun-plugin-tailwind": "^0.1.2", diff --git a/scripts/environment.test.ts b/scripts/environment.test.ts new file mode 100644 index 0000000..58ae5a3 --- /dev/null +++ b/scripts/environment.test.ts @@ -0,0 +1,58 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +let directory: string; +const project = new URL("../", import.meta.url); +beforeEach(() => { + directory = mkdtempSync(join(tmpdir(), "minabot-env-test-")); + // Run the real package commands with disposable entrypoints that report only fixture values. + writeFileSync(join(directory, "package.json"), readFileSync(new URL("package.json", project))); + writeFileSync(join(directory, "bunfig.toml"), readFileSync(new URL("bunfig.toml", project))); + mkdirSync(join(directory, "scripts")); + mkdirSync(join(directory, "src")); + for (const [file, stage] of [["scripts/migrate.ts", "migration"], ["scripts/start.ts", "server"], ["src/index.ts", "server"]]) { + writeFileSync(join(directory, file!), `console.log(JSON.stringify({ stage: ${JSON.stringify(stage)}, mode: process.env.NODE_ENV, origin: process.env.APP_ORIGIN, database: process.env.DATABASE_PATH, shared: process.env.MINABOT_ENV_TEST_SHARED })); process.exit(0);`); + } + writeFileSync(join(directory, ".env"), "APP_ORIGIN=https://shared.example\nDATABASE_PATH=shared.sqlite\nMINABOT_ENV_TEST_SHARED=shared\n"); + writeFileSync(join(directory, ".env.development"), "APP_ORIGIN=http://127.0.0.1:4000\nDATABASE_PATH=development.sqlite\n"); + writeFileSync(join(directory, ".env.production"), "APP_ORIGIN=https://production.example\nDATABASE_PATH=production.sqlite\n"); +}); +afterEach(() => rmSync(directory, { recursive: true, force: true })); + +function run(command: string, overrides: Record = {}) { + const env = { ...process.env }; + for (const key of ["NODE_ENV", "APP_ORIGIN", "DATABASE_PATH", "MINABOT_ENV_TEST_SHARED", "BUN_OPTIONS"]) delete env[key]; + const result = Bun.spawnSync([process.execPath, "run", command], { + cwd: directory, env: { ...env, ...overrides }, timeout: 10_000, + stdout: "pipe", stderr: "pipe", + }); + expect(result.exitCode).toBe(0); + return result.stdout.toString().trim().split("\n").map(line => JSON.parse(line)); +} + +test.each(["development", "production"])("%s loads the same environment before migration and server startup", mode => { + const records = run(mode === "production" ? "start" : "dev"); + expect(records).toEqual(["migration", "server"].map(stage => ({ + stage, mode, + origin: mode === "production" ? "https://production.example" : "http://127.0.0.1:4000", + database: `${mode}.sqlite`, shared: "shared", + }))); +}); + +test("database commands select their corresponding environment", () => { + expect(run("db:migrate")[0].database).toBe("development.sqlite"); + expect(run("db:migrate:production")[0].database).toBe("production.sqlite"); +}); + +test("exported deployment variables override environment files", () => { + const records = run("start", { APP_ORIGIN: "https://deployed.example", DATABASE_PATH: "deployed.sqlite" }); + expect(records.every(record => record.origin === "https://deployed.example" && record.database === "deployed.sqlite")).toBe(true); +}); + +test("production never preloads development values when its file is absent", () => { + rmSync(join(directory, ".env.production")); + const records = run("start"); + expect(records.every(record => record.origin === "https://shared.example" && record.database === "shared.sqlite")).toBe(true); +}); diff --git a/src/App.tsx b/src/App.tsx index 8938e30..5059900 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,5 @@ import { Route, Routes, useLocation, useNavigate } from "react-router"; +import { AuthControls } from "./components/AuthControls"; import { Home } from "./pages/Home"; import { About } from "./pages/About"; import { Settings } from "./pages/Settings"; @@ -14,6 +15,7 @@ export function App() { {" "} +
} /> diff --git a/src/api.ts b/src/api.ts new file mode 100644 index 0000000..165e95f --- /dev/null +++ b/src/api.ts @@ -0,0 +1,22 @@ +import { Hono } from "hono"; +import { sql } from "drizzle-orm"; +import { createAuth, type AppDatabase, type AuthEnv } from "./auth"; +import type { AuthConfig } from "./auth/config"; +import type { FetchDiscord } from "./auth/discord"; + +export function createApi(db: AppDatabase, config: AuthConfig, request?: FetchDiscord, now?: () => number) { + const app = new Hono(); + const auth = createAuth(db, config, request, now); + app.get("/api/health", c => { + db.get(sql`SELECT 1`); + return c.json({ status: "ok" }); + }); + app.route("/api/auth", auth.routes); + app.get("/api/me", auth.requireAuth, c => c.json(c.get("user"))); + app.notFound(c => c.json({ error: "Not found" }, 404)); + app.onError((_error, c) => { + c.header("Cache-Control", "no-store"); + return c.json({ error: "Internal server error" }, 500); + }); + return app; +} diff --git a/src/auth/auth.test.ts b/src/auth/auth.test.ts new file mode 100644 index 0000000..cf80cd2 --- /dev/null +++ b/src/auth/auth.test.ts @@ -0,0 +1,206 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { Database } from "bun:sqlite"; +import { drizzle } from "drizzle-orm/bun-sqlite"; +import { migrate } from "drizzle-orm/bun-sqlite/migrator"; +import { eq } from "drizzle-orm"; +import { createApi } from "../api"; +import * as schema from "../db/schema"; +import { hashToken, normalizeTimezone } from "./index"; +import { readAuthConfig, type AuthConfig } from "./config"; +import type { DiscordProfile, FetchDiscord } from "./discord"; + +const origin = "http://127.0.0.1:3000"; +const config: AuthConfig = { origin, clientId: "test-client", clientSecret: "test-secret", cookieSecret: "test-only-signing-secret-at-least-32-characters" }; +let sqlite: Database; +let db: ReturnType>; +let app: ReturnType; +let timestamp: number; +let calls: { url: string; init: RequestInit }[]; +let profile: DiscordProfile; +let failure: "token" | "profile" | "malformed" | "throw" | undefined; +const request: FetchDiscord = async (url, init) => { + calls.push({ url, init }); + if (failure === "throw") throw new Error("provider secret response"); + if (url.endsWith("/token")) { + if (failure === "token") return new Response("provider secret response", { status: 400 }); + return Response.json({ access_token: "secret-access", refresh_token: "secret-refresh", token_type: "Bearer", scope: "identify" }); + } + if (failure === "profile") return new Response("provider secret response", { status: 503 }); + return Response.json(failure === "malformed" ? { id: 123, username: "bad" } : profile); +}; +const cookie = (response: Response, name: string) => response.headers.getSetCookie().find(value => value.startsWith(`${name}=`))?.split(";")[0] ?? ""; +async function begin(timezone = "Europe/Belgrade", target = app, base = origin) { + const response = await target.request(`${base}/api/auth/discord?${new URLSearchParams({ timezone })}`); + const location = new URL(response.headers.get("Location")!); + return { response, state: location.searchParams.get("state")!, cookie: cookie(response, base.startsWith("https:") ? "__Host-minabot_oauth" : "minabot_oauth") }; +} +async function login(timezone = "Europe/Belgrade", previous = "") { + const flow = await begin(timezone); + const response = await app.request(`${origin}/api/auth/discord/callback?${new URLSearchParams({ state: flow.state, code: "test-code" })}`, { + headers: { Cookie: [flow.cookie, previous].filter(Boolean).join("; ") }, + }); + return { response, cookie: cookie(response, "minabot_session") }; +} + +beforeEach(() => { + sqlite = new Database(":memory:"); + sqlite.exec("PRAGMA foreign_keys = ON"); + db = drizzle(sqlite, { schema }); + migrate(db, { migrationsFolder: "./drizzle" }); + timestamp = Date.now(); calls = []; failure = undefined; + profile = { id: "123456789012345678", username: "mina", global_name: "Mina", avatar: null }; + app = createApi(db, config, request, () => timestamp); +}); +afterEach(() => sqlite.close()); + +describe("Discord sign-in and sessions", () => { + test("me requires a valid session and health stays public", async () => { + for (const value of ["", "minabot_session=malformed", `minabot_session=${"a".repeat(43)}`]) { + const response = await app.request(`${origin}/api/me`, { headers: { Cookie: value } }); + expect(response.status).toBe(401); + expect(await response.json()).toEqual({ error: "Unauthorized" }); + expect(response.headers.get("Cache-Control")).toBe("no-store"); + } + expect((await app.request(`${origin}/api/health`)).status).toBe(200); + expect((await app.request(`${origin}/api/missing`)).status).toBe(404); + }); + + test("authorization uses identify, random state, and a signed expiring HttpOnly cookie", async () => { + const first = await begin(); const second = await begin(); + const url = new URL(first.response.headers.get("Location")!); + expect(url.origin).toBe("https://discord.com"); + expect(url.searchParams.get("scope")).toBe("identify"); + expect(url.searchParams.get("redirect_uri")).toBe(`${origin}/api/auth/discord/callback`); + expect(first.state).not.toBe(second.state); + const header = first.response.headers.get("Set-Cookie")!; + expect(header).toContain("HttpOnly"); expect(header).toContain("SameSite=Lax"); expect(header).toContain("Max-Age=600"); + }); + + test("callback creates a user and hashed session, and me returns only public fields", async () => { + const result = await login(); + expect(result.response.headers.get("Location")).toBe("/"); + const user = db.select().from(schema.users).get()!; + const session = db.select().from(schema.sessions).get()!; + expect(user.timezone).toBe("Europe/Belgrade"); + expect(session.tokenHash).toBe(hashToken(result.cookie.split("=")[1]!)); + expect(session.expiresAt - session.createdAt).toBe(30 * 24 * 60 * 60 * 1000); + expect(result.response.headers.getSetCookie().join(";")).toContain("Max-Age=2592000"); + expect(result.response.headers.getSetCookie().join(";")).toContain("Max-Age=0"); + const response = await app.request(`${origin}/api/me`, { headers: { Cookie: result.cookie } }); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ id: user.id, discordId: profile.id, username: "mina", displayName: "Mina", avatarUrl: null, timezone: "Europe/Belgrade" }); + const body = calls[0]!.init.body as URLSearchParams; + expect(body.get("client_secret")).toBe(config.clientSecret); + expect(body.get("code")).toBe("test-code"); + expect(calls[1]!.init.headers).toEqual({ Authorization: "Bearer secret-access" }); + expect(JSON.stringify({ user, session })).not.toContain("secret-access"); + }); + + test("repeat login updates profile but preserves ID, creation time and timezone; rotates session", async () => { + const first = await login(); + const oldUser = db.select().from(schema.users).get()!; + timestamp += 1000; + profile = { ...profile, username: "new-name", global_name: null, avatar: "a_abcdef" }; + const second = await login("America/New_York", first.cookie); + expect(db.select().from(schema.users).all()).toHaveLength(1); + const user = db.select().from(schema.users).get()!; + expect(user.id).toBe(oldUser.id); expect(user.createdAt).toBe(oldUser.createdAt); + expect(user.updatedAt).toBe(timestamp); expect(user.timezone).toBe("Europe/Belgrade"); + expect(db.select().from(schema.sessions).all()).toHaveLength(1); + expect((await app.request(`${origin}/api/me`, { headers: { Cookie: first.cookie } })).status).toBe(401); + const response = await app.request(`${origin}/api/me`, { headers: { Cookie: second.cookie } }); + const data = await response.json(); + expect(data.displayName).toBe("new-name"); + expect(data.avatarUrl).toBe(`https://cdn.discordapp.com/avatars/${profile.id}/a_abcdef.gif`); + }); + + test("invalid or missing timezone falls back to UTC", async () => { + expect(normalizeTimezone(undefined)).toBe("UTC"); + expect(normalizeTimezone("+02:00")).toBe("UTC"); + expect(normalizeTimezone("Europe/Belgrade")).toBe("Europe/Belgrade"); + await login("invalid/timezone"); + expect(db.select().from(schema.users).get()!.timezone).toBe("UTC"); + }); + + test("rejects missing, mismatched, tampered and expired OAuth state before contacting Discord", async () => { + const flow = await begin(); + const cases = [ + { state: flow.state, cookie: "" }, + { state: "b".repeat(43), cookie: flow.cookie }, + { state: flow.state, cookie: `${flow.cookie}tampered` }, + { state: "", cookie: flow.cookie }, + ]; + for (const value of cases) { + const response = await app.request(`${origin}/api/auth/discord/callback?${new URLSearchParams({ state: value.state, code: "test-code" })}`, { headers: { Cookie: value.cookie } }); + expect(response.headers.get("Location")).toBe("/?auth_error=invalid_state"); + } + timestamp += 600_000; + const response = await app.request(`${origin}/api/auth/discord/callback?state=${flow.state}&code=test-code`, { headers: { Cookie: flow.cookie } }); + expect(response.headers.get("Location")).toBe("/?auth_error=invalid_state"); + expect(calls).toHaveLength(0); expect(db.select().from(schema.users).all()).toHaveLength(0); + }); + + test("handles consent denial and missing code without creating a session", async () => { + for (const [query, error] of [["error=access_denied", "denied"], ["", "invalid_code"]]) { + const flow = await begin(); + const response = await app.request(`${origin}/api/auth/discord/callback?state=${flow.state}&${query}`, { headers: { Cookie: flow.cookie } }); + expect(response.headers.get("Location")).toBe(`/?auth_error=${error}`); + } + expect(calls).toHaveLength(0); expect(db.select().from(schema.sessions).all()).toHaveLength(0); + }); + + test.each(["token", "profile", "malformed", "throw"] as const)("provider failure (%s) leaves no account or session", async value => { + failure = value; + const result = await login(); + expect(result.response.headers.get("Location")).toBe("/?auth_error=discord_unavailable"); + expect(result.cookie).toBe(""); + expect(db.select().from(schema.users).all()).toHaveLength(0); + expect(db.select().from(schema.sessions).all()).toHaveLength(0); + }); + + test("expired sessions are rejected at their exact expiry", async () => { + const result = await login(); + timestamp = db.select().from(schema.sessions).get()!.expiresAt; + expect((await app.request(`${origin}/api/me`, { headers: { Cookie: result.cookie } })).status).toBe(401); + expect(db.select().from(schema.sessions).all()).toHaveLength(0); + }); + + test("logout rejects cross-origin or missing origins and revokes only the current session", async () => { + const first = await login(); const second = await login(); + for (const headers of [new Headers({ Cookie: first.cookie }), new Headers({ Cookie: first.cookie, Origin: "https://evil.example" })]) { + expect((await app.request(`${origin}/api/auth/logout`, { method: "POST", headers })).status).toBe(403); + } + expect(db.select().from(schema.sessions).all()).toHaveLength(2); + const response = await app.request(`${origin}/api/auth/logout`, { method: "POST", headers: { Cookie: first.cookie, Origin: origin } }); + expect(response.status).toBe(204); expect(response.headers.get("Set-Cookie")).toContain("Max-Age=0"); + expect((await app.request(`${origin}/api/me`, { headers: { Cookie: first.cookie } })).status).toBe(401); + expect((await app.request(`${origin}/api/me`, { headers: { Cookie: second.cookie } })).status).toBe(200); + expect((await app.request(`${origin}/api/auth/logout`)).status).toBe(404); + }); + + test("foreign keys cascade session deletion and duplicate Discord IDs are rejected", async () => { + await login(); + const user = db.select().from(schema.users).get()!; + expect(() => db.insert(schema.users).values({ ...user, id: "different" }).run()).toThrow(); + db.delete(schema.users).where(eq(schema.users.id, user.id)).run(); + expect(db.select().from(schema.sessions).all()).toHaveLength(0); + }); + + test("HTTPS cookies are Secure and use host-only names", async () => { + const secureConfig = { ...config, origin: "https://habits.example" }; + const secureApp = createApi(db, secureConfig, request, () => timestamp); + const flow = await begin("UTC", secureApp, secureConfig.origin); + const response = await secureApp.request(`${secureConfig.origin}/api/auth/discord/callback?state=${flow.state}&code=test-code`, { headers: { Cookie: flow.cookie } }); + expect(response.headers.getSetCookie().find(value => value.startsWith("__Host-minabot_session="))).toContain("Secure"); + expect(flow.response.headers.get("Set-Cookie")).toContain("Secure"); + }); + + test("missing credentials fail gracefully and unsafe configured origins fail fast", async () => { + const unconfigured = createApi(db, { ...config, clientSecret: "" }, request); + const response = await unconfigured.request(`${origin}/api/auth/discord`); + expect(response.headers.get("Location")).toBe("/?auth_error=not_configured"); + expect(calls).toHaveLength(0); + expect(() => readAuthConfig({ APP_ORIGIN: "http://habits.example" })).toThrow(); + expect(() => readAuthConfig({ APP_ORIGIN: "https://habits.example/path" })).toThrow(); + }); +}); diff --git a/src/auth/config.test.ts b/src/auth/config.test.ts new file mode 100644 index 0000000..348150a --- /dev/null +++ b/src/auth/config.test.ts @@ -0,0 +1,20 @@ +import { expect, test } from "bun:test"; +import { readAuthConfig } from "./config"; + +test("reads the active environment's APP_ORIGIN and normalizes it", () => { + expect(readAuthConfig({ APP_ORIGIN: " http://127.0.0.1:4000/ " }).origin).toBe("http://127.0.0.1:4000"); + expect(readAuthConfig({ NODE_ENV: "production", APP_ORIGIN: "https://habits.example.com" }).origin).toBe("https://habits.example.com"); +}); + +test("only development defaults to the local port", () => { + expect(readAuthConfig({}).origin).toBe("http://127.0.0.1:3000"); + expect(readAuthConfig({ PORT: "4000" }).origin).toBe("http://127.0.0.1:4000"); + expect(() => readAuthConfig({ NODE_ENV: "production", APP_ORIGIN: " " })).toThrow("Set APP_ORIGIN"); +}); + +test("rejects invalid origins and allows explicitly configured local production testing", () => { + for (const value of ["not a URL", "http://habits.example.com", "https://habits.example.com/path"]) { + expect(() => readAuthConfig({ NODE_ENV: "production", APP_ORIGIN: value })).toThrow("APP_ORIGIN"); + } + expect(readAuthConfig({ NODE_ENV: "production", APP_ORIGIN: "http://127.0.0.1:3000" }).origin).toBe("http://127.0.0.1:3000"); +}); diff --git a/src/auth/config.ts b/src/auth/config.ts new file mode 100644 index 0000000..c5f89c4 --- /dev/null +++ b/src/auth/config.ts @@ -0,0 +1,36 @@ +export type AuthConfig = { + origin: string; + clientId: string; + clientSecret: string; + cookieSecret: string; +}; + +export function readAuthConfig(env = process.env): AuthConfig { + const production = env.NODE_ENV === "production"; + const configuredOrigin = env.APP_ORIGIN?.trim(); + if (production && !configuredOrigin) { + throw new Error("Set APP_ORIGIN in .env.production or the process environment before starting in production."); + } + const origin = configuredOrigin || `http://127.0.0.1:${env.PORT ?? 3000}`; + let url: URL; + try { + url = new URL(origin); + } catch { + throw new Error("APP_ORIGIN must be a valid origin."); + } + const local = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname); + if (url.username || url.password || url.pathname !== "/" || url.search || url.hash || + (url.protocol !== "https:" && !(local && url.protocol === "http:"))) { + throw new Error("APP_ORIGIN must be an HTTPS origin (HTTP is allowed for localhost)."); + } + return { + origin: url.origin, + clientId: env.DISCORD_CLIENT_ID ?? "", + clientSecret: env.DISCORD_CLIENT_SECRET ?? "", + cookieSecret: env.AUTH_COOKIE_SECRET ?? "", + }; +} + +export function isAuthConfigured(config: AuthConfig) { + return Boolean(config.clientId && config.clientSecret && config.cookieSecret.length >= 32); +} diff --git a/src/auth/discord.ts b/src/auth/discord.ts new file mode 100644 index 0000000..24800f7 --- /dev/null +++ b/src/auth/discord.ts @@ -0,0 +1,45 @@ +import type { AuthConfig } from "./config"; + +export type DiscordProfile = { + id: string; + username: string; + global_name: string | null; + avatar: string | null; +}; + +export type FetchDiscord = (url: string, init: RequestInit) => Promise; + +export async function fetchDiscordProfile(code: string, config: AuthConfig, request: FetchDiscord): Promise { + const tokenResponse = await request("https://discord.com/api/oauth2/token", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "authorization_code", + client_id: config.clientId, + client_secret: config.clientSecret, + redirect_uri: `${config.origin}/api/auth/discord/callback`, + code, + }), + signal: AbortSignal.timeout(10_000), + }); + if (!tokenResponse.ok) throw new Error("Discord token exchange failed"); + const token = await tokenResponse.json(); + if (typeof token.access_token !== "string" || !token.access_token || + typeof token.token_type !== "string" || token.token_type.toLowerCase() !== "bearer" || + typeof token.scope !== "string" || !token.scope.split(" ").includes("identify")) { + throw new Error("Invalid Discord token response"); + } + const profileResponse = await request("https://discord.com/api/v10/users/@me", { + headers: { Authorization: `Bearer ${token.access_token}` }, + signal: AbortSignal.timeout(10_000), + }); + if (!profileResponse.ok) throw new Error("Discord profile request failed"); + const profile = await profileResponse.json(); + if (!profile || typeof profile.id !== "string" || !/^\d{1,20}$/.test(profile.id) || + typeof profile.username !== "string" || !profile.username || + !(profile.global_name === null || typeof profile.global_name === "string") || + !(profile.avatar === null || (typeof profile.avatar === "string" && /^(a_)?[a-f0-9]+$/.test(profile.avatar)))) { + throw new Error("Invalid Discord profile"); + } + return profile; +} diff --git a/src/auth/index.ts b/src/auth/index.ts new file mode 100644 index 0000000..d91a8f2 --- /dev/null +++ b/src/auth/index.ts @@ -0,0 +1,146 @@ +import { createHash, randomBytes, timingSafeEqual } from "node:crypto"; +import { and, eq, gt, lte } from "drizzle-orm"; +import type { BunSQLiteDatabase } from "drizzle-orm/bun-sqlite"; +import { Hono } from "hono"; +import { createMiddleware } from "hono/factory"; +import { deleteCookie, getCookie, getSignedCookie, setCookie, setSignedCookie } from "hono/cookie"; +import * as schema from "../db/schema"; +import type { PublicUser } from "../shared/user"; +import { isAuthConfigured, type AuthConfig } from "./config"; +import { fetchDiscordProfile, type FetchDiscord } from "./discord"; + +const { users, sessions } = schema; +const SESSION_SECONDS = 30 * 24 * 60 * 60; +const STATE_SECONDS = 10 * 60; +export type AuthEnv = { Variables: { user: PublicUser } }; +export type AppDatabase = BunSQLiteDatabase; +export const hashToken = (token: string) => createHash("sha256").update(token).digest("hex"); +const randomToken = () => randomBytes(32).toString("base64url"); + +export function normalizeTimezone(value: unknown): string { + if (typeof value !== "string" || !value || value.length > 100 || /^[+-]/.test(value)) return "UTC"; + try { + return new Intl.DateTimeFormat("en-US", { timeZone: value }).resolvedOptions().timeZone; + } catch { + return "UTC"; + } +} + +function publicUser(user: typeof users.$inferSelect): PublicUser { + return { + id: user.id, + discordId: user.discordId, + username: user.username, + displayName: user.globalName ?? user.username, + avatarUrl: user.avatarHash + ? `https://cdn.discordapp.com/avatars/${user.discordId}/${user.avatarHash}.${user.avatarHash.startsWith("a_") ? "gif" : "png"}` + : null, + timezone: user.timezone, + }; +} + +export function createAuth(db: AppDatabase, config: AuthConfig, request: FetchDiscord = fetch, now = Date.now) { + const secure = config.origin.startsWith("https:"); + const sessionName = secure ? "__Host-minabot_session" : "minabot_session"; + const stateName = secure ? "__Host-minabot_oauth" : "minabot_oauth"; + const cookieOptions = { path: "/", httpOnly: true, sameSite: "Lax" as const, secure }; + const readToken = (value: string | undefined) => value && /^[A-Za-z0-9_-]{43}$/.test(value) ? value : undefined; + const routes = new Hono(); + + // Apply to all future cookie-authenticated mutations, including JSON requests. + const requireSameOrigin = createMiddleware(async (c, next) => { + if (c.req.header("Origin") !== config.origin) return c.json({ error: "Forbidden origin" }, 403); + await next(); + }); + const requireAuth = createMiddleware(async (c, next) => { + c.header("Cache-Control", "no-store"); + const token = readToken(getCookie(c, sessionName)); + const session = token ? db.select({ user: users }).from(sessions) + .innerJoin(users, eq(users.id, sessions.userId)) + .where(and(eq(sessions.tokenHash, hashToken(token)), gt(sessions.expiresAt, now()))).get() : undefined; + if (!session) { + if (token) db.delete(sessions).where(and(eq(sessions.tokenHash, hashToken(token)), lte(sessions.expiresAt, now()))).run(); + deleteCookie(c, sessionName, cookieOptions); + return c.json({ error: "Unauthorized" }, 401); + } + c.set("user", publicUser(session.user)); + await next(); + }); + + routes.use("*", async (c, next) => { + c.header("Cache-Control", "no-store"); + c.header("Referrer-Policy", "no-referrer"); + await next(); + }); + routes.get("/discord", async c => { + if (!isAuthConfigured(config)) return c.redirect("/?auth_error=not_configured"); + // Use one configured origin for redirects and cookie ownership. + if (new URL(c.req.url).origin !== config.origin) return c.redirect(`${config.origin}/api/auth/discord?${new URLSearchParams({ timezone: normalizeTimezone(c.req.query("timezone")) })}`); + const state = randomToken(); + await setSignedCookie(c, stateName, JSON.stringify({ + state, + timezone: normalizeTimezone(c.req.query("timezone")), + expiresAt: now() + STATE_SECONDS * 1000, + }), config.cookieSecret, { ...cookieOptions, maxAge: STATE_SECONDS }); + const url = new URL("https://discord.com/oauth2/authorize"); + url.search = new URLSearchParams({ + client_id: config.clientId, + response_type: "code", + scope: "identify", + redirect_uri: `${config.origin}/api/auth/discord/callback`, + state, + }).toString(); + return c.redirect(url.toString()); + }); + routes.get("/discord/callback", async c => { + if (!isAuthConfigured(config)) return c.redirect("/?auth_error=not_configured"); + const signedState = await getSignedCookie(c, config.cookieSecret, stateName); + deleteCookie(c, stateName, cookieOptions); + let state: { state?: unknown; timezone?: unknown; expiresAt?: unknown } | null = null; + try { state = signedState ? JSON.parse(signedState) : null; } catch { /* Invalid cookie. */ } + const returnedState = c.req.query("state"); + if (!state || typeof state.state !== "string" || typeof returnedState !== "string" || + !/^[A-Za-z0-9_-]{43}$/.test(state.state) || !/^[A-Za-z0-9_-]{43}$/.test(returnedState) || + !timingSafeEqual(Buffer.from(state.state), Buffer.from(returnedState)) || + typeof state.expiresAt !== "number" || state.expiresAt <= now()) { + return c.redirect("/?auth_error=invalid_state"); + } + if (c.req.query("error")) return c.redirect("/?auth_error=denied"); + const code = c.req.query("code"); + if (!code || code.length > 2048) return c.redirect("/?auth_error=invalid_code"); + let profile; + try { + profile = await fetchDiscordProfile(code, config, request); + } catch { + // Never log authorization codes, provider responses, or credentials. + return c.redirect("/?auth_error=discord_unavailable"); + } + const timestamp = now(); + const token = randomToken(); + const previousToken = readToken(getCookie(c, sessionName)); + db.transaction(tx => { + const user = tx.insert(users).values({ + id: crypto.randomUUID(), discordId: profile.id, username: profile.username, + globalName: profile.global_name, avatarHash: profile.avatar, + timezone: normalizeTimezone(state.timezone), createdAt: timestamp, updatedAt: timestamp, + }).onConflictDoUpdate({ target: users.discordId, set: { + username: profile.username, globalName: profile.global_name, + avatarHash: profile.avatar, updatedAt: timestamp, + // A browser or travel timezone must not overwrite an existing preference. + } }).returning().get(); + if (previousToken) tx.delete(sessions).where(eq(sessions.tokenHash, hashToken(previousToken))).run(); + tx.delete(sessions).where(lte(sessions.expiresAt, timestamp)).run(); + tx.insert(sessions).values({ tokenHash: hashToken(token), userId: user.id, + createdAt: timestamp, expiresAt: timestamp + SESSION_SECONDS * 1000 }).run(); + }); + setCookie(c, sessionName, token, { ...cookieOptions, maxAge: SESSION_SECONDS }); + return c.redirect("/"); + }); + routes.post("/logout", requireSameOrigin, c => { + const token = readToken(getCookie(c, sessionName)); + if (token) db.delete(sessions).where(eq(sessions.tokenHash, hashToken(token))).run(); + deleteCookie(c, sessionName, cookieOptions); + return c.body(null, 204); + }); + return { routes, requireAuth, requireSameOrigin }; +} diff --git a/src/components/AuthControls.tsx b/src/components/AuthControls.tsx new file mode 100644 index 0000000..dda4f64 --- /dev/null +++ b/src/components/AuthControls.tsx @@ -0,0 +1,69 @@ +import { useEffect, useState } from "react"; +import { useLocation } from "react-router"; +import type { PublicUser } from "../shared/user"; + +const authErrors: Record = { + not_configured: "Discord sign-in is not configured yet.", + invalid_state: "Your sign-in attempt expired or could not be verified. Please try again.", + denied: "Discord sign-in was cancelled. You can try again when ready.", + invalid_code: "Discord did not return a sign-in code. Please try again.", + discord_unavailable: "Could not complete Discord sign-in. Please try again.", +}; + +export function AuthControls() { + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const { search } = useLocation(); + const signInError = authErrors[new URLSearchParams(search).get("auth_error") ?? ""]; + + useEffect(() => { + const controller = new AbortController(); + async function loadUser() { + try { + const response = await fetch("/api/me", { signal: controller.signal }); + if (response.status === 401) return; + if (!response.ok) throw new Error("Could not load your account. Please reload to try again."); + setUser(await response.json()); + } catch (error) { + if (!controller.signal.aborted) setError(error instanceof Error ? error.message : "Could not load your account."); + } finally { + if (!controller.signal.aborted) setLoading(false); + } + } + void loadUser(); + return () => controller.abort(); + }, []); + + function signIn() { + let timezone = "UTC"; + try { timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; } catch { /* Use UTC fallback. */ } + window.location.assign(`/api/auth/discord?${new URLSearchParams({ timezone })}`); + } + + async function signOut() { + setBusy(true); + setError(""); + try { + const response = await fetch("/api/auth/logout", { method: "POST" }); + if (!response.ok) throw new Error("Could not sign out. Please try again."); + setUser(null); + } catch (error) { + setError(error instanceof Error ? error.message : "Could not sign out."); + } finally { setBusy(false); } + } + + return ( +
+ {loading ?

Loading account…

: user ? ( +

+ Signed in as {user.displayName} · {user.timezone}{" "} + {" "} + View my profile +

+ ) :

} + {(error || signInError) &&

{error || signInError}

} +
+ ); +} diff --git a/src/db/schema.ts b/src/db/schema.ts index 2ef78b9..4686a68 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -1,2 +1,22 @@ -// Define application tables here with drizzle-orm/sqlite-core. -export {}; +import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; + +export const users = sqliteTable("users", { + id: text("id").primaryKey(), + discordId: text("discord_id").notNull().unique(), + username: text("username").notNull(), + globalName: text("global_name"), + avatarHash: text("avatar_hash"), + timezone: text("timezone").notNull().default("UTC"), + createdAt: integer("created_at").notNull(), + updatedAt: integer("updated_at").notNull(), +}); + +export const sessions = sqliteTable("sessions", { + tokenHash: text("token_hash").primaryKey(), + userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }), + createdAt: integer("created_at").notNull(), + expiresAt: integer("expires_at").notNull(), +}, table => [ + index("sessions_user_id_idx").on(table.userId), + index("sessions_expires_at_idx").on(table.expiresAt), +]); diff --git a/src/index.ts b/src/index.ts index 138b1b3..6663a50 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,15 +1,9 @@ -import { Hono } from "hono"; -import { sql } from "drizzle-orm"; import { db } from "./db"; +import { createApi } from "./api"; +import { readAuthConfig } from "./auth/config"; import index from "./index.html"; -const app = new Hono(); - -app.get("/api/health", c => { - db.get(sql`SELECT 1`); - return c.json({ status: "ok" }); -}); -app.notFound(c => c.json({ error: "Not found" }, 404)); +const app = createApi(db, readAuthConfig()); const server = Bun.serve({ hostname: "127.0.0.1", diff --git a/src/shared/user.ts b/src/shared/user.ts new file mode 100644 index 0000000..71d5677 --- /dev/null +++ b/src/shared/user.ts @@ -0,0 +1,8 @@ +export type PublicUser = { + id: string; + discordId: string; + username: string; + displayName: string; + avatarUrl: string | null; + timezone: string; +};