feat(auth): add Discord OAuth sign-in and user timezones

Add persistent user and session storage, protected profile requests, and React sign-in controls. Capture and preserve each user's timezone, and load development and production configuration before migrations and server startup.
This commit is contained in:
syntaxbullet
2026-09-04 08:29:48 +02:00
parent 29bf75760b
commit d3cbf2f7dc
22 changed files with 997 additions and 24 deletions

4
.env.development.example Normal file
View File

@@ -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.

View File

@@ -1,2 +1,12 @@
# Optional shared defaults. Values in the selected environment file override these.
DATABASE_PATH=./data/minabot.sqlite DATABASE_PATH=./data/minabot.sqlite
PORT=3000 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=

5
.env.production.example Normal file
View File

@@ -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.

9
.gitignore vendored
View File

@@ -26,12 +26,11 @@ logs
_.log _.log
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json 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
.env.development.local .env.*
.env.test.local !.env.example
.env.production.local !.env.*.example
.env.local
# caches # caches
.eslintcache .eslintcache

135
README.md
View File

@@ -14,7 +14,7 @@ Open http://127.0.0.1:3000. Set `PORT` to use a different port.
- Hono endpoint: `GET /api/health`. - Hono endpoint: `GET /api/health`.
- Unknown API routes return JSON with a 404 status. - 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 and shadcn configuration are available; Tailwind Preflight is omitted to preserve
native browser styling. 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. 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 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: 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 Commit the generated `drizzle/` files alongside schema changes. `bun dev` and
`bun start` apply pending migrations before starting the server. The initial `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. Import `db` from `src/db/index.ts` in backend code to query the database.
`GET /api/health` checks the database connection before returning success. `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.

View File

@@ -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] [serve.static]
plugins = ["bun-plugin-tailwind"] plugins = ["bun-plugin-tailwind"]
env = "BUN_PUBLIC_*" env = "BUN_PUBLIC_*"

View File

@@ -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`);

View File

@@ -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": {}
}
}

View File

@@ -1 +1,13 @@
{"version":"7","dialect":"sqlite","entries":[]} {
"version": "7",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1788501609255,
"tag": "0000_discord_auth",
"breakpoints": true
}
]
}

View File

@@ -4,12 +4,14 @@
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "bun run db:migrate && bun --hot src/index.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": "bun run db:migrate && bun scripts/start.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", "build": "bun run build.ts",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"db:generate": "bunx --bun drizzle-kit generate", "db:generate": "NODE_ENV=development bun --env-file=.env --env-file=.env.development x --bun drizzle-kit generate",
"db:migrate": "bun scripts/migrate.ts" "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": { "dependencies": {
"bun-plugin-tailwind": "^0.1.2", "bun-plugin-tailwind": "^0.1.2",

View File

@@ -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<string, string> = {}) {
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);
});

View File

@@ -1,4 +1,5 @@
import { Route, Routes, useLocation, useNavigate } from "react-router"; import { Route, Routes, useLocation, useNavigate } from "react-router";
import { AuthControls } from "./components/AuthControls";
import { Home } from "./pages/Home"; import { Home } from "./pages/Home";
import { About } from "./pages/About"; import { About } from "./pages/About";
import { Settings } from "./pages/Settings"; import { Settings } from "./pages/Settings";
@@ -14,6 +15,7 @@ export function App() {
<button type="button" disabled={pathname === "/about"} onClick={() => navigate("/about")}>About</button>{" "} <button type="button" disabled={pathname === "/about"} onClick={() => navigate("/about")}>About</button>{" "}
<button type="button" disabled={pathname === "/settings"} onClick={() => navigate("/settings")}>Settings</button> <button type="button" disabled={pathname === "/settings"} onClick={() => navigate("/settings")}>Settings</button>
</nav> </nav>
<AuthControls />
<main> <main>
<Routes> <Routes>
<Route path="/" element={<Home />} /> <Route path="/" element={<Home />} />

22
src/api.ts Normal file
View File

@@ -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<AuthEnv>();
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;
}

206
src/auth/auth.test.ts Normal file
View File

@@ -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<typeof drizzle<typeof schema>>;
let app: ReturnType<typeof createApi>;
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();
});
});

20
src/auth/config.test.ts Normal file
View File

@@ -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");
});

36
src/auth/config.ts Normal file
View File

@@ -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);
}

45
src/auth/discord.ts Normal file
View File

@@ -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<Response>;
export async function fetchDiscordProfile(code: string, config: AuthConfig, request: FetchDiscord): Promise<DiscordProfile> {
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;
}

146
src/auth/index.ts Normal file
View File

@@ -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<typeof schema>;
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<AuthEnv>();
// Apply to all future cookie-authenticated mutations, including JSON requests.
const requireSameOrigin = createMiddleware<AuthEnv>(async (c, next) => {
if (c.req.header("Origin") !== config.origin) return c.json({ error: "Forbidden origin" }, 403);
await next();
});
const requireAuth = createMiddleware<AuthEnv>(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 };
}

View File

@@ -0,0 +1,69 @@
import { useEffect, useState } from "react";
import { useLocation } from "react-router";
import type { PublicUser } from "../shared/user";
const authErrors: Record<string, string> = {
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<PublicUser | null>(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 (
<section aria-label="Account">
{loading ? <p role="status">Loading account</p> : user ? (
<p>
Signed in as <strong>{user.displayName}</strong> · {user.timezone}{" "}
<button type="button" disabled={busy} onClick={signOut}>{busy ? "Signing out…" : "Sign out"}</button>{" "}
<a href="/api/me">View my profile</a>
</p>
) : <p><button type="button" onClick={signIn}>Sign in with Discord</button></p>}
{(error || signInError) && <p role="alert">{error || signInError}</p>}
</section>
);
}

View File

@@ -1,2 +1,22 @@
// Define application tables here with drizzle-orm/sqlite-core. import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
export {};
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),
]);

View File

@@ -1,15 +1,9 @@
import { Hono } from "hono";
import { sql } from "drizzle-orm";
import { db } from "./db"; import { db } from "./db";
import { createApi } from "./api";
import { readAuthConfig } from "./auth/config";
import index from "./index.html"; import index from "./index.html";
const app = new Hono(); const app = createApi(db, readAuthConfig());
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 server = Bun.serve({ const server = Bun.serve({
hostname: "127.0.0.1", hostname: "127.0.0.1",

8
src/shared/user.ts Normal file
View File

@@ -0,0 +1,8 @@
export type PublicUser = {
id: string;
discordId: string;
username: string;
displayName: string;
avatarUrl: string | null;
timezone: string;
};