From 811458427593a4172a2cd535cc768db375350dca Mon Sep 17 00:00:00 2001 From: nsfisis Date: Sat, 6 Dec 2025 17:05:21 +0900 Subject: feat(dev): change architecture and directory structure --- README.md | 19 +- docs/dev/architecture.md | 63 ++-- docs/dev/roadmap.md | 4 +- drizzle.config.ts | 15 + drizzle/0000_cynical_zeigeist.sql | 58 +++ drizzle/0001_spotty_jane_foster.sql | 9 + drizzle/meta/0000_snapshot.json | 403 ++++++++++++++++++++ drizzle/meta/0001_snapshot.json | 462 +++++++++++++++++++++++ drizzle/meta/_journal.json | 20 + package.json | 36 +- pkgs/client/package.json | 13 - pkgs/client/src/index.ts | 1 - pkgs/client/tsconfig.json | 10 - pkgs/server/drizzle.config.ts | 15 - pkgs/server/drizzle/0000_cynical_zeigeist.sql | 58 --- pkgs/server/drizzle/0001_spotty_jane_foster.sql | 9 - pkgs/server/drizzle/meta/0000_snapshot.json | 403 -------------------- pkgs/server/drizzle/meta/0001_snapshot.json | 462 ----------------------- pkgs/server/drizzle/meta/_journal.json | 20 - pkgs/server/package.json | 35 -- pkgs/server/src/db/index.ts | 12 - pkgs/server/src/db/schema.ts | 116 ------ pkgs/server/src/index.test.ts | 12 - pkgs/server/src/index.ts | 30 -- pkgs/server/src/middleware/auth.test.ts | 190 ---------- pkgs/server/src/middleware/auth.ts | 65 ---- pkgs/server/src/middleware/error-handler.test.ts | 173 --------- pkgs/server/src/middleware/error-handler.ts | 95 ----- pkgs/server/src/middleware/index.ts | 2 - pkgs/server/src/repositories/index.ts | 3 - pkgs/server/src/repositories/refresh-token.ts | 35 -- pkgs/server/src/repositories/types.ts | 46 --- pkgs/server/src/repositories/user.ts | 55 --- pkgs/server/src/routes/auth.test.ts | 428 --------------------- pkgs/server/src/routes/auth.ts | 199 ---------- pkgs/server/src/routes/index.ts | 1 - pkgs/server/tsconfig.json | 9 - pkgs/server/vitest.config.ts | 10 - pkgs/shared/package.json | 23 -- pkgs/shared/src/index.ts | 2 - pkgs/shared/src/schemas/index.ts | 140 ------- pkgs/shared/src/types/index.ts | 79 ---- pkgs/shared/tsconfig.json | 9 - pnpm-lock.yaml | 29 +- pnpm-workspace.yaml | 2 - src/client/index.tsx | 1 + src/server/db/index.ts | 12 + src/server/db/schema.ts | 116 ++++++ src/server/index.test.ts | 12 + src/server/index.ts | 30 ++ src/server/middleware/auth.test.ts | 190 ++++++++++ src/server/middleware/auth.ts | 65 ++++ src/server/middleware/error-handler.test.ts | 173 +++++++++ src/server/middleware/error-handler.ts | 95 +++++ src/server/middleware/index.ts | 2 + src/server/repositories/index.ts | 3 + src/server/repositories/refresh-token.ts | 35 ++ src/server/repositories/types.ts | 46 +++ src/server/repositories/user.ts | 57 +++ src/server/routes/auth.test.ts | 428 +++++++++++++++++++++ src/server/routes/auth.ts | 199 ++++++++++ src/server/routes/index.ts | 1 + src/server/schemas/index.ts | 140 +++++++ src/server/types/index.ts | 79 ++++ tsconfig.json | 5 +- vitest.config.ts | 10 + 66 files changed, 2747 insertions(+), 2832 deletions(-) create mode 100644 drizzle.config.ts create mode 100644 drizzle/0000_cynical_zeigeist.sql create mode 100644 drizzle/0001_spotty_jane_foster.sql create mode 100644 drizzle/meta/0000_snapshot.json create mode 100644 drizzle/meta/0001_snapshot.json create mode 100644 drizzle/meta/_journal.json delete mode 100644 pkgs/client/package.json delete mode 100644 pkgs/client/src/index.ts delete mode 100644 pkgs/client/tsconfig.json delete mode 100644 pkgs/server/drizzle.config.ts delete mode 100644 pkgs/server/drizzle/0000_cynical_zeigeist.sql delete mode 100644 pkgs/server/drizzle/0001_spotty_jane_foster.sql delete mode 100644 pkgs/server/drizzle/meta/0000_snapshot.json delete mode 100644 pkgs/server/drizzle/meta/0001_snapshot.json delete mode 100644 pkgs/server/drizzle/meta/_journal.json delete mode 100644 pkgs/server/package.json delete mode 100644 pkgs/server/src/db/index.ts delete mode 100644 pkgs/server/src/db/schema.ts delete mode 100644 pkgs/server/src/index.test.ts delete mode 100644 pkgs/server/src/index.ts delete mode 100644 pkgs/server/src/middleware/auth.test.ts delete mode 100644 pkgs/server/src/middleware/auth.ts delete mode 100644 pkgs/server/src/middleware/error-handler.test.ts delete mode 100644 pkgs/server/src/middleware/error-handler.ts delete mode 100644 pkgs/server/src/middleware/index.ts delete mode 100644 pkgs/server/src/repositories/index.ts delete mode 100644 pkgs/server/src/repositories/refresh-token.ts delete mode 100644 pkgs/server/src/repositories/types.ts delete mode 100644 pkgs/server/src/repositories/user.ts delete mode 100644 pkgs/server/src/routes/auth.test.ts delete mode 100644 pkgs/server/src/routes/auth.ts delete mode 100644 pkgs/server/src/routes/index.ts delete mode 100644 pkgs/server/tsconfig.json delete mode 100644 pkgs/server/vitest.config.ts delete mode 100644 pkgs/shared/package.json delete mode 100644 pkgs/shared/src/index.ts delete mode 100644 pkgs/shared/src/schemas/index.ts delete mode 100644 pkgs/shared/src/types/index.ts delete mode 100644 pkgs/shared/tsconfig.json delete mode 100644 pnpm-workspace.yaml create mode 100644 src/client/index.tsx create mode 100644 src/server/db/index.ts create mode 100644 src/server/db/schema.ts create mode 100644 src/server/index.test.ts create mode 100644 src/server/index.ts create mode 100644 src/server/middleware/auth.test.ts create mode 100644 src/server/middleware/auth.ts create mode 100644 src/server/middleware/error-handler.test.ts create mode 100644 src/server/middleware/error-handler.ts create mode 100644 src/server/middleware/index.ts create mode 100644 src/server/repositories/index.ts create mode 100644 src/server/repositories/refresh-token.ts create mode 100644 src/server/repositories/types.ts create mode 100644 src/server/repositories/user.ts create mode 100644 src/server/routes/auth.test.ts create mode 100644 src/server/routes/auth.ts create mode 100644 src/server/routes/index.ts create mode 100644 src/server/schemas/index.ts create mode 100644 src/server/types/index.ts create mode 100644 vitest.config.ts diff --git a/README.md b/README.md index 4d90b2f..eab12fc 100644 --- a/README.md +++ b/README.md @@ -29,32 +29,31 @@ pnpm install docker compose up # Run database migrations -pnpm --filter server db:migrate +pnpm db:migrate -# Start development servers +# Start development server pnpm dev ``` ### Environment Variables -Create `.env` files in each app directory: +Create `.env` file in the root directory: -```pkgs/server/.env +``` DATABASE_URL=postgresql://user:password@localhost:5432/kioku JWT_SECRET=your-secret-key ``` -```pkgs/web/.env -PUBLIC_API_URL=http://localhost:3000 -``` - ## Scripts ```bash -pnpm dev # Start all apps in development -pnpm build # Build all apps +pnpm dev # Start server in development +pnpm dev:client # Start client in development +pnpm build # Build all pnpm test # Run tests pnpm lint # Lint code +pnpm db:migrate # Run database migrations +pnpm db:studio # Open Drizzle Studio ``` ## Documentation diff --git a/docs/dev/architecture.md b/docs/dev/architecture.md index 2ace388..6b6e21d 100644 --- a/docs/dev/architecture.md +++ b/docs/dev/architecture.md @@ -4,25 +4,24 @@ | Layer | Technology | |-------|------------| -| Frontend | SvelteKit | +| Frontend | React + Vite | | Backend | Hono + TypeScript | | Database | PostgreSQL | | ORM | Drizzle | | Client DB | Dexie.js (IndexedDB) | -| PWA | @vite-pwa/sveltekit | +| PWA | vite-plugin-pwa | | Algorithm | FSRS (ts-fsrs) | | Auth | username/password + JWT | | Test | Vitest | -| Monorepo | pnpm workspace | | Deploy | Docker + VPS | ## Architecture Diagram ``` +--------------------------------------------------+ -| Client (PWA) | +| Client (PWA) | | +-------------+ +------------+ +------------+ | -| | SvelteKit | | Dexie.js | | Service | | +| | React | | Dexie.js | | Service | | | | UI |<>| (IndexedDB)|<>| Worker | | | +-------------+ +------------+ +------------+ | | | | | @@ -35,7 +34,7 @@ | v HTTPS (REST API) +--------------------------------------------------+ -| Server | +| Server | | +----------------------------------------------+| | | Hono (TypeScript) || | | +--------+ +--------+ +--------+ +--------+ || @@ -54,32 +53,32 @@ ``` kioku/ -├── package.json # Workspace root -├── pnpm-workspace.yaml -├── docker-compose.yml -└── pkgs/ - ├── web/ # SvelteKit frontend - │ ├── src/ - │ │ ├── lib/ - │ │ │ ├── components/ - │ │ │ ├── stores/ - │ │ │ ├── db/ # Dexie IndexedDB - │ │ │ ├── sync/ # Sync engine - │ │ │ └── api/ - │ │ └── routes/ - │ └── static/ - ├── server/ # Hono backend - │ └── src/ - │ ├── routes/ - │ ├── services/ - │ ├── db/ # Drizzle schema - │ ├── middleware/ - │ └── lib/ - │ └── apkg/ # Anki import - └── shared/ # Shared types - └── src/ - ├── types/ - └── schemas/ # Zod validation +├── src/ +│ ├── server/ # Hono backend +│ │ ├── index.ts +│ │ ├── db/ # Drizzle schema +│ │ ├── middleware/ +│ │ ├── repositories/ +│ │ ├── routes/ +│ │ ├── types/ # Server types +│ │ ├── schemas/ # Zod validation +│ │ └── lib/ +│ │ └── apkg/ # Anki import +│ └── client/ # React frontend +│ ├── index.tsx +│ ├── components/ +│ ├── stores/ +│ ├── db/ # Dexie IndexedDB +│ ├── sync/ # Sync engine +│ ├── types/ # Client types +│ └── api/ +├── drizzle/ # Drizzle migrations +├── public/ # Static files (PWA manifest) +├── package.json +├── tsconfig.json +├── vite.config.ts +├── drizzle.config.ts +└── compose.yaml ``` ## Data Models diff --git a/docs/dev/roadmap.md b/docs/dev/roadmap.md index 1d6fcc4..c452a51 100644 --- a/docs/dev/roadmap.md +++ b/docs/dev/roadmap.md @@ -46,7 +46,7 @@ Smaller features first to enable early MVP validation. **Goal**: Minimal UI for user login and registration ### Frontend Foundation -- [ ] Initialize SvelteKit +- [ ] Initialize React + Vite - [ ] Setup routing - [ ] API client (fetch wrapper with auth headers) - [ ] Auth store (token management) @@ -126,7 +126,7 @@ Smaller features first to enable early MVP validation. **Goal**: Study offline ### PWA Setup -- [ ] @vite-pwa/sveltekit configuration +- [ ] vite-plugin-pwa configuration - [ ] Web manifest - [ ] Service Worker - [ ] Offline fallback page diff --git a/drizzle.config.ts b/drizzle.config.ts new file mode 100644 index 0000000..6fe73ec --- /dev/null +++ b/drizzle.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from "drizzle-kit"; + +const databaseUrl = process.env.DATABASE_URL; +if (!databaseUrl) { + throw new Error("DATABASE_URL environment variable is not set"); +} + +export default defineConfig({ + out: "./drizzle", + schema: "./src/server/db/schema.ts", + dialect: "postgresql", + dbCredentials: { + url: databaseUrl, + }, +}); diff --git a/drizzle/0000_cynical_zeigeist.sql b/drizzle/0000_cynical_zeigeist.sql new file mode 100644 index 0000000..3034102 --- /dev/null +++ b/drizzle/0000_cynical_zeigeist.sql @@ -0,0 +1,58 @@ +CREATE TABLE "cards" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "deck_id" uuid NOT NULL, + "front" text NOT NULL, + "back" text NOT NULL, + "state" smallint DEFAULT 0 NOT NULL, + "due" timestamp with time zone DEFAULT now() NOT NULL, + "stability" real DEFAULT 0 NOT NULL, + "difficulty" real DEFAULT 0 NOT NULL, + "elapsed_days" integer DEFAULT 0 NOT NULL, + "scheduled_days" integer DEFAULT 0 NOT NULL, + "reps" integer DEFAULT 0 NOT NULL, + "lapses" integer DEFAULT 0 NOT NULL, + "last_review" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + "deleted_at" timestamp with time zone, + "sync_version" integer DEFAULT 0 NOT NULL +); +--> statement-breakpoint +CREATE TABLE "decks" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL, + "name" varchar(255) NOT NULL, + "description" text, + "new_cards_per_day" integer DEFAULT 20 NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + "deleted_at" timestamp with time zone, + "sync_version" integer DEFAULT 0 NOT NULL +); +--> statement-breakpoint +CREATE TABLE "review_logs" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "card_id" uuid NOT NULL, + "user_id" uuid NOT NULL, + "rating" smallint NOT NULL, + "state" smallint NOT NULL, + "scheduled_days" integer NOT NULL, + "elapsed_days" integer NOT NULL, + "reviewed_at" timestamp with time zone DEFAULT now() NOT NULL, + "duration_ms" integer, + "sync_version" integer DEFAULT 0 NOT NULL +); +--> statement-breakpoint +CREATE TABLE "users" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "username" varchar(255) NOT NULL, + "password_hash" varchar(255) NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "users_username_unique" UNIQUE("username") +); +--> statement-breakpoint +ALTER TABLE "cards" ADD CONSTRAINT "cards_deck_id_decks_id_fk" FOREIGN KEY ("deck_id") REFERENCES "public"."decks"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "decks" ADD CONSTRAINT "decks_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "review_logs" ADD CONSTRAINT "review_logs_card_id_cards_id_fk" FOREIGN KEY ("card_id") REFERENCES "public"."cards"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "review_logs" ADD CONSTRAINT "review_logs_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action; \ No newline at end of file diff --git a/drizzle/0001_spotty_jane_foster.sql b/drizzle/0001_spotty_jane_foster.sql new file mode 100644 index 0000000..417408f --- /dev/null +++ b/drizzle/0001_spotty_jane_foster.sql @@ -0,0 +1,9 @@ +CREATE TABLE "refresh_tokens" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL, + "token_hash" varchar(255) NOT NULL, + "expires_at" timestamp with time zone NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "refresh_tokens" ADD CONSTRAINT "refresh_tokens_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action; \ 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..16a3f68 --- /dev/null +++ b/drizzle/meta/0000_snapshot.json @@ -0,0 +1,403 @@ +{ + "id": "d2f779a2-d302-4fe3-91bb-a541025dbe4a", + "prevId": "00000000-0000-0000-0000-000000000000", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.cards": { + "name": "cards", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "deck_id": { + "name": "deck_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "front": { + "name": "front", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "back": { + "name": "back", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "smallint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "due": { + "name": "due", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "stability": { + "name": "stability", + "type": "real", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "difficulty": { + "name": "difficulty", + "type": "real", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "elapsed_days": { + "name": "elapsed_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "scheduled_days": { + "name": "scheduled_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "reps": { + "name": "reps", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "lapses": { + "name": "lapses", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_review": { + "name": "last_review", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "sync_version": { + "name": "sync_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "cards_deck_id_decks_id_fk": { + "name": "cards_deck_id_decks_id_fk", + "tableFrom": "cards", + "tableTo": "decks", + "columnsFrom": [ + "deck_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.decks": { + "name": "decks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "new_cards_per_day": { + "name": "new_cards_per_day", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 20 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "sync_version": { + "name": "sync_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "decks_user_id_users_id_fk": { + "name": "decks_user_id_users_id_fk", + "tableFrom": "decks", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.review_logs": { + "name": "review_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "card_id": { + "name": "card_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "rating": { + "name": "rating", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "scheduled_days": { + "name": "scheduled_days", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "elapsed_days": { + "name": "elapsed_days", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "reviewed_at": { + "name": "reviewed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "sync_version": { + "name": "sync_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "review_logs_card_id_cards_id_fk": { + "name": "review_logs_card_id_cards_id_fk", + "tableFrom": "review_logs", + "tableTo": "cards", + "columnsFrom": [ + "card_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "review_logs_user_id_users_id_fk": { + "name": "review_logs_user_id_users_id_fk", + "tableFrom": "review_logs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "username": { + "name": "username", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_username_unique": { + "name": "users_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/0001_snapshot.json b/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..55c3999 --- /dev/null +++ b/drizzle/meta/0001_snapshot.json @@ -0,0 +1,462 @@ +{ + "id": "d3d0fb16-7e44-4217-916e-a5edc9ab7a16", + "prevId": "d2f779a2-d302-4fe3-91bb-a541025dbe4a", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.cards": { + "name": "cards", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "deck_id": { + "name": "deck_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "front": { + "name": "front", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "back": { + "name": "back", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "smallint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "due": { + "name": "due", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "stability": { + "name": "stability", + "type": "real", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "difficulty": { + "name": "difficulty", + "type": "real", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "elapsed_days": { + "name": "elapsed_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "scheduled_days": { + "name": "scheduled_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "reps": { + "name": "reps", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "lapses": { + "name": "lapses", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_review": { + "name": "last_review", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "sync_version": { + "name": "sync_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "cards_deck_id_decks_id_fk": { + "name": "cards_deck_id_decks_id_fk", + "tableFrom": "cards", + "tableTo": "decks", + "columnsFrom": [ + "deck_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.decks": { + "name": "decks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "new_cards_per_day": { + "name": "new_cards_per_day", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 20 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "sync_version": { + "name": "sync_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "decks_user_id_users_id_fk": { + "name": "decks_user_id_users_id_fk", + "tableFrom": "decks", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.refresh_tokens": { + "name": "refresh_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "refresh_tokens_user_id_users_id_fk": { + "name": "refresh_tokens_user_id_users_id_fk", + "tableFrom": "refresh_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.review_logs": { + "name": "review_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "card_id": { + "name": "card_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "rating": { + "name": "rating", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "scheduled_days": { + "name": "scheduled_days", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "elapsed_days": { + "name": "elapsed_days", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "reviewed_at": { + "name": "reviewed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "sync_version": { + "name": "sync_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "review_logs_card_id_cards_id_fk": { + "name": "review_logs_card_id_cards_id_fk", + "tableFrom": "review_logs", + "tableTo": "cards", + "columnsFrom": [ + "card_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "review_logs_user_id_users_id_fk": { + "name": "review_logs_user_id_users_id_fk", + "tableFrom": "review_logs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "username": { + "name": "username", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_username_unique": { + "name": "users_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json new file mode 100644 index 0000000..f245fa6 --- /dev/null +++ b/drizzle/meta/_journal.json @@ -0,0 +1,20 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1764706245996, + "tag": "0000_cynical_zeigeist", + "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1764708169736, + "tag": "0001_spotty_jane_foster", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/package.json b/package.json index 55487af..0bb6bdd 100644 --- a/package.json +++ b/package.json @@ -2,21 +2,47 @@ "name": "kioku", "version": "0.1.0", "description": "An Anki clone", - "main": "index.js", + "main": "src/server/index.ts", "scripts": { - "test": "pnpm -r run test", - "typecheck": "tsc --build pkgs/*/tsconfig.json", + "dev": "node --watch src/server/index.ts", + "build": "tsc", + "start": "node dist/server/index.js", + "test": "vitest run", + "test:watch": "vitest", + "typecheck": "tsc --noEmit", "lint": "biome check .", "lint:fix": "biome check --write .", - "format": "biome format --write ." + "format": "biome format --write .", + "db:generate": "drizzle-kit generate", + "db:migrate": "drizzle-kit migrate", + "db:push": "drizzle-kit push", + "db:studio": "drizzle-kit studio" }, "keywords": [], "author": "nsfisis", "license": "MIT", "packageManager": "pnpm@10.23.0", "type": "module", + "dependencies": { + "@hono/node-server": "^1.19.6", + "argon2": "^0.44.0", + "drizzle-orm": "^0.44.7", + "hono": "^4.10.7", + "pg": "^8.16.3", + "zod": "^4.1.13" + }, "devDependencies": { "@biomejs/biome": "^2.3.8", - "typescript": "^5.9.3" + "@types/node": "^24.10.1", + "@types/pg": "^8.15.6", + "drizzle-kit": "^0.31.7", + "typescript": "^5.9.3", + "vitest": "^4.0.14" + }, + "pnpm": { + "onlyBuiltDependencies": [ + "argon2", + "esbuild" + ] } } diff --git a/pkgs/client/package.json b/pkgs/client/package.json deleted file mode 100644 index ec5a7ee..0000000 --- a/pkgs/client/package.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "name": "@kioku/client", - "version": "0.1.0", - "private": true, - "main": "index.js", - "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" - }, - "author": "nsfisis", - "license": "MIT", - "packageManager": "pnpm@10.23.0", - "type": "module" -} diff --git a/pkgs/client/src/index.ts b/pkgs/client/src/index.ts deleted file mode 100644 index cb0ff5c..0000000 --- a/pkgs/client/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export {}; diff --git a/pkgs/client/tsconfig.json b/pkgs/client/tsconfig.json deleted file mode 100644 index 21d77ad..0000000 --- a/pkgs/client/tsconfig.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "extends": "../../tsconfig.json", - "compilerOptions": { - "lib": ["ES2022", "DOM", "DOM.Iterable"], - "outDir": "./dist", - "rootDir": "./src" - }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist"] -} diff --git a/pkgs/server/drizzle.config.ts b/pkgs/server/drizzle.config.ts deleted file mode 100644 index 0e4596b..0000000 --- a/pkgs/server/drizzle.config.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { defineConfig } from "drizzle-kit"; - -const databaseUrl = process.env.DATABASE_URL; -if (!databaseUrl) { - throw new Error("DATABASE_URL environment variable is not set"); -} - -export default defineConfig({ - out: "./drizzle", - schema: "./src/db/schema.ts", - dialect: "postgresql", - dbCredentials: { - url: databaseUrl, - }, -}); diff --git a/pkgs/server/drizzle/0000_cynical_zeigeist.sql b/pkgs/server/drizzle/0000_cynical_zeigeist.sql deleted file mode 100644 index 3034102..0000000 --- a/pkgs/server/drizzle/0000_cynical_zeigeist.sql +++ /dev/null @@ -1,58 +0,0 @@ -CREATE TABLE "cards" ( - "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "deck_id" uuid NOT NULL, - "front" text NOT NULL, - "back" text NOT NULL, - "state" smallint DEFAULT 0 NOT NULL, - "due" timestamp with time zone DEFAULT now() NOT NULL, - "stability" real DEFAULT 0 NOT NULL, - "difficulty" real DEFAULT 0 NOT NULL, - "elapsed_days" integer DEFAULT 0 NOT NULL, - "scheduled_days" integer DEFAULT 0 NOT NULL, - "reps" integer DEFAULT 0 NOT NULL, - "lapses" integer DEFAULT 0 NOT NULL, - "last_review" timestamp with time zone, - "created_at" timestamp with time zone DEFAULT now() NOT NULL, - "updated_at" timestamp with time zone DEFAULT now() NOT NULL, - "deleted_at" timestamp with time zone, - "sync_version" integer DEFAULT 0 NOT NULL -); ---> statement-breakpoint -CREATE TABLE "decks" ( - "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "user_id" uuid NOT NULL, - "name" varchar(255) NOT NULL, - "description" text, - "new_cards_per_day" integer DEFAULT 20 NOT NULL, - "created_at" timestamp with time zone DEFAULT now() NOT NULL, - "updated_at" timestamp with time zone DEFAULT now() NOT NULL, - "deleted_at" timestamp with time zone, - "sync_version" integer DEFAULT 0 NOT NULL -); ---> statement-breakpoint -CREATE TABLE "review_logs" ( - "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "card_id" uuid NOT NULL, - "user_id" uuid NOT NULL, - "rating" smallint NOT NULL, - "state" smallint NOT NULL, - "scheduled_days" integer NOT NULL, - "elapsed_days" integer NOT NULL, - "reviewed_at" timestamp with time zone DEFAULT now() NOT NULL, - "duration_ms" integer, - "sync_version" integer DEFAULT 0 NOT NULL -); ---> statement-breakpoint -CREATE TABLE "users" ( - "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "username" varchar(255) NOT NULL, - "password_hash" varchar(255) NOT NULL, - "created_at" timestamp with time zone DEFAULT now() NOT NULL, - "updated_at" timestamp with time zone DEFAULT now() NOT NULL, - CONSTRAINT "users_username_unique" UNIQUE("username") -); ---> statement-breakpoint -ALTER TABLE "cards" ADD CONSTRAINT "cards_deck_id_decks_id_fk" FOREIGN KEY ("deck_id") REFERENCES "public"."decks"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "decks" ADD CONSTRAINT "decks_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "review_logs" ADD CONSTRAINT "review_logs_card_id_cards_id_fk" FOREIGN KEY ("card_id") REFERENCES "public"."cards"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "review_logs" ADD CONSTRAINT "review_logs_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action; \ No newline at end of file diff --git a/pkgs/server/drizzle/0001_spotty_jane_foster.sql b/pkgs/server/drizzle/0001_spotty_jane_foster.sql deleted file mode 100644 index 417408f..0000000 --- a/pkgs/server/drizzle/0001_spotty_jane_foster.sql +++ /dev/null @@ -1,9 +0,0 @@ -CREATE TABLE "refresh_tokens" ( - "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "user_id" uuid NOT NULL, - "token_hash" varchar(255) NOT NULL, - "expires_at" timestamp with time zone NOT NULL, - "created_at" timestamp with time zone DEFAULT now() NOT NULL -); ---> statement-breakpoint -ALTER TABLE "refresh_tokens" ADD CONSTRAINT "refresh_tokens_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action; \ No newline at end of file diff --git a/pkgs/server/drizzle/meta/0000_snapshot.json b/pkgs/server/drizzle/meta/0000_snapshot.json deleted file mode 100644 index 16a3f68..0000000 --- a/pkgs/server/drizzle/meta/0000_snapshot.json +++ /dev/null @@ -1,403 +0,0 @@ -{ - "id": "d2f779a2-d302-4fe3-91bb-a541025dbe4a", - "prevId": "00000000-0000-0000-0000-000000000000", - "version": "7", - "dialect": "postgresql", - "tables": { - "public.cards": { - "name": "cards", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "deck_id": { - "name": "deck_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "front": { - "name": "front", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "back": { - "name": "back", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "state": { - "name": "state", - "type": "smallint", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "due": { - "name": "due", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "stability": { - "name": "stability", - "type": "real", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "difficulty": { - "name": "difficulty", - "type": "real", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "elapsed_days": { - "name": "elapsed_days", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "scheduled_days": { - "name": "scheduled_days", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "reps": { - "name": "reps", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "lapses": { - "name": "lapses", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "last_review": { - "name": "last_review", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "deleted_at": { - "name": "deleted_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "sync_version": { - "name": "sync_version", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - } - }, - "indexes": {}, - "foreignKeys": { - "cards_deck_id_decks_id_fk": { - "name": "cards_deck_id_decks_id_fk", - "tableFrom": "cards", - "tableTo": "decks", - "columnsFrom": [ - "deck_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.decks": { - "name": "decks", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "user_id": { - "name": "user_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "name": { - "name": "name", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true - }, - "description": { - "name": "description", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "new_cards_per_day": { - "name": "new_cards_per_day", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 20 - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "deleted_at": { - "name": "deleted_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "sync_version": { - "name": "sync_version", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - } - }, - "indexes": {}, - "foreignKeys": { - "decks_user_id_users_id_fk": { - "name": "decks_user_id_users_id_fk", - "tableFrom": "decks", - "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.review_logs": { - "name": "review_logs", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "card_id": { - "name": "card_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "user_id": { - "name": "user_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "rating": { - "name": "rating", - "type": "smallint", - "primaryKey": false, - "notNull": true - }, - "state": { - "name": "state", - "type": "smallint", - "primaryKey": false, - "notNull": true - }, - "scheduled_days": { - "name": "scheduled_days", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "elapsed_days": { - "name": "elapsed_days", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "reviewed_at": { - "name": "reviewed_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "duration_ms": { - "name": "duration_ms", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "sync_version": { - "name": "sync_version", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - } - }, - "indexes": {}, - "foreignKeys": { - "review_logs_card_id_cards_id_fk": { - "name": "review_logs_card_id_cards_id_fk", - "tableFrom": "review_logs", - "tableTo": "cards", - "columnsFrom": [ - "card_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - }, - "review_logs_user_id_users_id_fk": { - "name": "review_logs_user_id_users_id_fk", - "tableFrom": "review_logs", - "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.users": { - "name": "users", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "username": { - "name": "username", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true - }, - "password_hash": { - "name": "password_hash", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "users_username_unique": { - "name": "users_username_unique", - "nullsNotDistinct": false, - "columns": [ - "username" - ] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - } - }, - "enums": {}, - "schemas": {}, - "sequences": {}, - "roles": {}, - "policies": {}, - "views": {}, - "_meta": { - "columns": {}, - "schemas": {}, - "tables": {} - } -} \ No newline at end of file diff --git a/pkgs/server/drizzle/meta/0001_snapshot.json b/pkgs/server/drizzle/meta/0001_snapshot.json deleted file mode 100644 index 55c3999..0000000 --- a/pkgs/server/drizzle/meta/0001_snapshot.json +++ /dev/null @@ -1,462 +0,0 @@ -{ - "id": "d3d0fb16-7e44-4217-916e-a5edc9ab7a16", - "prevId": "d2f779a2-d302-4fe3-91bb-a541025dbe4a", - "version": "7", - "dialect": "postgresql", - "tables": { - "public.cards": { - "name": "cards", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "deck_id": { - "name": "deck_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "front": { - "name": "front", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "back": { - "name": "back", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "state": { - "name": "state", - "type": "smallint", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "due": { - "name": "due", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "stability": { - "name": "stability", - "type": "real", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "difficulty": { - "name": "difficulty", - "type": "real", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "elapsed_days": { - "name": "elapsed_days", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "scheduled_days": { - "name": "scheduled_days", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "reps": { - "name": "reps", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "lapses": { - "name": "lapses", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "last_review": { - "name": "last_review", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "deleted_at": { - "name": "deleted_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "sync_version": { - "name": "sync_version", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - } - }, - "indexes": {}, - "foreignKeys": { - "cards_deck_id_decks_id_fk": { - "name": "cards_deck_id_decks_id_fk", - "tableFrom": "cards", - "tableTo": "decks", - "columnsFrom": [ - "deck_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.decks": { - "name": "decks", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "user_id": { - "name": "user_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "name": { - "name": "name", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true - }, - "description": { - "name": "description", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "new_cards_per_day": { - "name": "new_cards_per_day", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 20 - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "deleted_at": { - "name": "deleted_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "sync_version": { - "name": "sync_version", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - } - }, - "indexes": {}, - "foreignKeys": { - "decks_user_id_users_id_fk": { - "name": "decks_user_id_users_id_fk", - "tableFrom": "decks", - "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.refresh_tokens": { - "name": "refresh_tokens", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "user_id": { - "name": "user_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "token_hash": { - "name": "token_hash", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true - }, - "expires_at": { - "name": "expires_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": { - "refresh_tokens_user_id_users_id_fk": { - "name": "refresh_tokens_user_id_users_id_fk", - "tableFrom": "refresh_tokens", - "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.review_logs": { - "name": "review_logs", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "card_id": { - "name": "card_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "user_id": { - "name": "user_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "rating": { - "name": "rating", - "type": "smallint", - "primaryKey": false, - "notNull": true - }, - "state": { - "name": "state", - "type": "smallint", - "primaryKey": false, - "notNull": true - }, - "scheduled_days": { - "name": "scheduled_days", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "elapsed_days": { - "name": "elapsed_days", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "reviewed_at": { - "name": "reviewed_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "duration_ms": { - "name": "duration_ms", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "sync_version": { - "name": "sync_version", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - } - }, - "indexes": {}, - "foreignKeys": { - "review_logs_card_id_cards_id_fk": { - "name": "review_logs_card_id_cards_id_fk", - "tableFrom": "review_logs", - "tableTo": "cards", - "columnsFrom": [ - "card_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - }, - "review_logs_user_id_users_id_fk": { - "name": "review_logs_user_id_users_id_fk", - "tableFrom": "review_logs", - "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.users": { - "name": "users", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "username": { - "name": "username", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true - }, - "password_hash": { - "name": "password_hash", - "type": "varchar(255)", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "users_username_unique": { - "name": "users_username_unique", - "nullsNotDistinct": false, - "columns": [ - "username" - ] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - } - }, - "enums": {}, - "schemas": {}, - "sequences": {}, - "roles": {}, - "policies": {}, - "views": {}, - "_meta": { - "columns": {}, - "schemas": {}, - "tables": {} - } -} \ No newline at end of file diff --git a/pkgs/server/drizzle/meta/_journal.json b/pkgs/server/drizzle/meta/_journal.json deleted file mode 100644 index f245fa6..0000000 --- a/pkgs/server/drizzle/meta/_journal.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "version": "7", - "dialect": "postgresql", - "entries": [ - { - "idx": 0, - "version": "7", - "when": 1764706245996, - "tag": "0000_cynical_zeigeist", - "breakpoints": true - }, - { - "idx": 1, - "version": "7", - "when": 1764708169736, - "tag": "0001_spotty_jane_foster", - "breakpoints": true - } - ] -} \ No newline at end of file diff --git a/pkgs/server/package.json b/pkgs/server/package.json deleted file mode 100644 index 4159084..0000000 --- a/pkgs/server/package.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "name": "@kioku/server", - "version": "0.1.0", - "private": true, - "main": "dist/index.js", - "scripts": { - "dev": "node --watch src/index.ts", - "build": "tsc", - "start": "node dist/index.js", - "test": "vitest run", - "test:watch": "vitest", - "db:generate": "drizzle-kit generate", - "db:migrate": "drizzle-kit migrate", - "db:push": "drizzle-kit push", - "db:studio": "drizzle-kit studio" - }, - "author": "nsfisis", - "license": "MIT", - "packageManager": "pnpm@10.23.0", - "type": "module", - "dependencies": { - "@hono/node-server": "^1.19.6", - "@kioku/shared": "workspace:*", - "argon2": "^0.44.0", - "drizzle-orm": "^0.44.7", - "hono": "^4.10.7", - "pg": "^8.16.3" - }, - "devDependencies": { - "@types/node": "^24.10.1", - "@types/pg": "^8.15.6", - "drizzle-kit": "^0.31.7", - "vitest": "^4.0.14" - } -} diff --git a/pkgs/server/src/db/index.ts b/pkgs/server/src/db/index.ts deleted file mode 100644 index 6730947..0000000 --- a/pkgs/server/src/db/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { drizzle } from "drizzle-orm/node-postgres"; -import * as schema from "./schema"; - -const databaseUrl = process.env.DATABASE_URL; - -if (!databaseUrl) { - throw new Error("DATABASE_URL environment variable is not set"); -} - -export const db = drizzle(databaseUrl, { schema }); - -export * from "./schema"; diff --git a/pkgs/server/src/db/schema.ts b/pkgs/server/src/db/schema.ts deleted file mode 100644 index 4b9631f..0000000 --- a/pkgs/server/src/db/schema.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { - integer, - pgTable, - real, - smallint, - text, - timestamp, - uuid, - varchar, -} from "drizzle-orm/pg-core"; - -// Card states for FSRS algorithm -export const CardState = { - New: 0, - Learning: 1, - Review: 2, - Relearning: 3, -} as const; - -// Rating values for reviews -export const Rating = { - Again: 1, - Hard: 2, - Good: 3, - Easy: 4, -} as const; - -export const users = pgTable("users", { - id: uuid("id").primaryKey().defaultRandom(), - username: varchar("username", { length: 255 }).notNull().unique(), - passwordHash: varchar("password_hash", { length: 255 }).notNull(), - createdAt: timestamp("created_at", { withTimezone: true }) - .notNull() - .defaultNow(), - updatedAt: timestamp("updated_at", { withTimezone: true }) - .notNull() - .defaultNow(), -}); - -export const refreshTokens = pgTable("refresh_tokens", { - id: uuid("id").primaryKey().defaultRandom(), - userId: uuid("user_id") - .notNull() - .references(() => users.id, { onDelete: "cascade" }), - tokenHash: varchar("token_hash", { length: 255 }).notNull(), - expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), - createdAt: timestamp("created_at", { withTimezone: true }) - .notNull() - .defaultNow(), -}); - -export const decks = pgTable("decks", { - id: uuid("id").primaryKey().defaultRandom(), - userId: uuid("user_id") - .notNull() - .references(() => users.id), - name: varchar("name", { length: 255 }).notNull(), - description: text("description"), - newCardsPerDay: integer("new_cards_per_day").notNull().default(20), - createdAt: timestamp("created_at", { withTimezone: true }) - .notNull() - .defaultNow(), - updatedAt: timestamp("updated_at", { withTimezone: true }) - .notNull() - .defaultNow(), - deletedAt: timestamp("deleted_at", { withTimezone: true }), - syncVersion: integer("sync_version").notNull().default(0), -}); - -export const cards = pgTable("cards", { - id: uuid("id").primaryKey().defaultRandom(), - deckId: uuid("deck_id") - .notNull() - .references(() => decks.id), - front: text("front").notNull(), - back: text("back").notNull(), - - // FSRS fields - state: smallint("state").notNull().default(CardState.New), - due: timestamp("due", { withTimezone: true }).notNull().defaultNow(), - stability: real("stability").notNull().default(0), - difficulty: real("difficulty").notNull().default(0), - elapsedDays: integer("elapsed_days").notNull().default(0), - scheduledDays: integer("scheduled_days").notNull().default(0), - reps: integer("reps").notNull().default(0), - lapses: integer("lapses").notNull().default(0), - lastReview: timestamp("last_review", { withTimezone: true }), - - createdAt: timestamp("created_at", { withTimezone: true }) - .notNull() - .defaultNow(), - updatedAt: timestamp("updated_at", { withTimezone: true }) - .notNull() - .defaultNow(), - deletedAt: timestamp("deleted_at", { withTimezone: true }), - syncVersion: integer("sync_version").notNull().default(0), -}); - -export const reviewLogs = pgTable("review_logs", { - id: uuid("id").primaryKey().defaultRandom(), - cardId: uuid("card_id") - .notNull() - .references(() => cards.id), - userId: uuid("user_id") - .notNull() - .references(() => users.id), - rating: smallint("rating").notNull(), - state: smallint("state").notNull(), - scheduledDays: integer("scheduled_days").notNull(), - elapsedDays: integer("elapsed_days").notNull(), - reviewedAt: timestamp("reviewed_at", { withTimezone: true }) - .notNull() - .defaultNow(), - durationMs: integer("duration_ms"), - syncVersion: integer("sync_version").notNull().default(0), -}); diff --git a/pkgs/server/src/index.test.ts b/pkgs/server/src/index.test.ts deleted file mode 100644 index 216e965..0000000 --- a/pkgs/server/src/index.test.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { app } from "./index"; - -describe("Hono app", () => { - describe("GET /api/health", () => { - it("returns ok status", async () => { - const res = await app.request("/api/health"); - expect(res.status).toBe(200); - expect(await res.json()).toEqual({ status: "ok" }); - }); - }); -}); diff --git a/pkgs/server/src/index.ts b/pkgs/server/src/index.ts deleted file mode 100644 index a0ae0a4..0000000 --- a/pkgs/server/src/index.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { serve } from "@hono/node-server"; -import { Hono } from "hono"; -import { logger } from "hono/logger"; -import { errorHandler } from "./middleware"; -import { auth } from "./routes"; - -const app = new Hono(); - -app.use("*", logger()); -app.onError(errorHandler); - -app.get("/", (c) => { - return c.json({ message: "Kioku API" }); -}); - -app.get("/api/health", (c) => { - return c.json({ status: "ok" }); -}); - -app.route("/api/auth", auth); - -const port = Number(process.env.PORT) || 3000; -console.log(`Server is running on port ${port}`); - -serve({ - fetch: app.fetch, - port, -}); - -export { app }; diff --git a/pkgs/server/src/middleware/auth.test.ts b/pkgs/server/src/middleware/auth.test.ts deleted file mode 100644 index 8c4286b..0000000 --- a/pkgs/server/src/middleware/auth.test.ts +++ /dev/null @@ -1,190 +0,0 @@ -import { Hono } from "hono"; -import { sign } from "hono/jwt"; -import { beforeEach, describe, expect, it } from "vitest"; -import { authMiddleware, getAuthUser } from "./auth"; -import { errorHandler } from "./error-handler"; - -const JWT_SECRET = process.env.JWT_SECRET || "test-secret"; - -interface ErrorResponse { - error: { - code: string; - message: string; - }; -} - -interface SuccessResponse { - userId: string; -} - -describe("authMiddleware", () => { - let app: Hono; - - beforeEach(() => { - app = new Hono(); - app.onError(errorHandler); - app.use("/protected/*", authMiddleware); - app.get("/protected/resource", (c) => { - const user = getAuthUser(c); - return c.json({ userId: user.id }); - }); - }); - - it("allows access with valid token", async () => { - const now = Math.floor(Date.now() / 1000); - const token = await sign( - { - sub: "user-123", - iat: now, - exp: now + 3600, - }, - JWT_SECRET, - ); - - const res = await app.request("/protected/resource", { - headers: { - Authorization: `Bearer ${token}`, - }, - }); - - expect(res.status).toBe(200); - const body = (await res.json()) as SuccessResponse; - expect(body.userId).toBe("user-123"); - }); - - it("returns 401 when Authorization header is missing", async () => { - const res = await app.request("/protected/resource"); - - expect(res.status).toBe(401); - const body = (await res.json()) as ErrorResponse; - expect(body.error.code).toBe("MISSING_AUTH"); - }); - - it("returns 401 for invalid Authorization format", async () => { - const res = await app.request("/protected/resource", { - headers: { - Authorization: "Basic sometoken", - }, - }); - - expect(res.status).toBe(401); - const body = (await res.json()) as ErrorResponse; - expect(body.error.code).toBe("INVALID_AUTH_FORMAT"); - }); - - it("returns 401 for empty Bearer token", async () => { - const res = await app.request("/protected/resource", { - headers: { - Authorization: "Bearer", - }, - }); - - expect(res.status).toBe(401); - const body = (await res.json()) as ErrorResponse; - expect(body.error.code).toBe("INVALID_AUTH_FORMAT"); - }); - - it("returns 401 for expired token", async () => { - const now = Math.floor(Date.now() / 1000); - const token = await sign( - { - sub: "user-123", - iat: now - 7200, - exp: now - 3600, // expired 1 hour ago - }, - JWT_SECRET, - ); - - const res = await app.request("/protected/resource", { - headers: { - Authorization: `Bearer ${token}`, - }, - }); - - expect(res.status).toBe(401); - const body = (await res.json()) as ErrorResponse; - expect(body.error.code).toBe("INVALID_TOKEN"); - }); - - it("returns 401 for invalid token", async () => { - const res = await app.request("/protected/resource", { - headers: { - Authorization: "Bearer invalid.token.here", - }, - }); - - expect(res.status).toBe(401); - const body = (await res.json()) as ErrorResponse; - expect(body.error.code).toBe("INVALID_TOKEN"); - }); - - it("returns 401 for token signed with wrong secret", async () => { - const now = Math.floor(Date.now() / 1000); - const token = await sign( - { - sub: "user-123", - iat: now, - exp: now + 3600, - }, - "wrong-secret", - ); - - const res = await app.request("/protected/resource", { - headers: { - Authorization: `Bearer ${token}`, - }, - }); - - expect(res.status).toBe(401); - const body = (await res.json()) as ErrorResponse; - expect(body.error.code).toBe("INVALID_TOKEN"); - }); -}); - -describe("getAuthUser", () => { - it("returns user from context when authenticated", async () => { - const app = new Hono(); - app.onError(errorHandler); - app.use("/test", authMiddleware); - app.get("/test", (c) => { - const user = getAuthUser(c); - return c.json({ id: user.id }); - }); - - const now = Math.floor(Date.now() / 1000); - const token = await sign( - { - sub: "test-user-456", - iat: now, - exp: now + 3600, - }, - JWT_SECRET, - ); - - const res = await app.request("/test", { - headers: { - Authorization: `Bearer ${token}`, - }, - }); - - expect(res.status).toBe(200); - const body = (await res.json()) as { id: string }; - expect(body.id).toBe("test-user-456"); - }); - - it("throws when user is not in context", async () => { - const app = new Hono(); - app.onError(errorHandler); - // Note: no authMiddleware applied - app.get("/unprotected", (c) => { - const user = getAuthUser(c); - return c.json({ id: user.id }); - }); - - const res = await app.request("/unprotected"); - - expect(res.status).toBe(401); - const body = (await res.json()) as ErrorResponse; - expect(body.error.code).toBe("NOT_AUTHENTICATED"); - }); -}); diff --git a/pkgs/server/src/middleware/auth.ts b/pkgs/server/src/middleware/auth.ts deleted file mode 100644 index c295834..0000000 --- a/pkgs/server/src/middleware/auth.ts +++ /dev/null @@ -1,65 +0,0 @@ -import type { Context, Next } from "hono"; -import { verify } from "hono/jwt"; -import { Errors } from "./error-handler"; - -const JWT_SECRET = process.env.JWT_SECRET; -if (!JWT_SECRET) { - throw new Error("JWT_SECRET environment variable is required"); -} - -export interface AuthUser { - id: string; -} - -interface JWTPayload { - sub: string; - iat: number; - exp: number; -} - -/** - * Auth middleware that validates JWT tokens from Authorization header - * Sets the authenticated user in context variables - */ -export async function authMiddleware(c: Context, next: Next) { - const authHeader = c.req.header("Authorization"); - - if (!authHeader) { - throw Errors.unauthorized("Missing Authorization header", "MISSING_AUTH"); - } - - if (!authHeader.startsWith("Bearer ")) { - throw Errors.unauthorized( - "Invalid Authorization header format", - "INVALID_AUTH_FORMAT", - ); - } - - const token = authHeader.slice(7); - - try { - const payload = (await verify(token, JWT_SECRET)) as unknown as JWTPayload; - - const user: AuthUser = { - id: payload.sub, - }; - - c.set("user", user); - - await next(); - } catch { - throw Errors.unauthorized("Invalid or expired token", "INVALID_TOKEN"); - } -} - -/** - * Helper function to get the authenticated user from context - * Throws if user is not authenticated - */ -export function getAuthUser(c: Context): AuthUser { - const user = c.get("user") as AuthUser | undefined; - if (!user) { - throw Errors.unauthorized("Not authenticated", "NOT_AUTHENTICATED"); - } - return user; -} diff --git a/pkgs/server/src/middleware/error-handler.test.ts b/pkgs/server/src/middleware/error-handler.test.ts deleted file mode 100644 index 21d6fc1..0000000 --- a/pkgs/server/src/middleware/error-handler.test.ts +++ /dev/null @@ -1,173 +0,0 @@ -import { Hono } from "hono"; -import { describe, expect, it } from "vitest"; -import { AppError, Errors, errorHandler } from "./error-handler"; - -function createTestApp() { - const app = new Hono(); - app.onError(errorHandler); - return app; -} - -describe("errorHandler", () => { - describe("AppError handling", () => { - it("returns correct status and message for AppError", async () => { - const app = createTestApp(); - app.get("/test", () => { - throw new AppError("Custom error", 400, "CUSTOM_ERROR"); - }); - - const res = await app.request("/test"); - expect(res.status).toBe(400); - expect(await res.json()).toEqual({ - error: { - message: "Custom error", - code: "CUSTOM_ERROR", - }, - }); - }); - - it("uses default values for AppError", async () => { - const app = createTestApp(); - app.get("/test", () => { - throw new AppError("Something went wrong"); - }); - - const res = await app.request("/test"); - expect(res.status).toBe(500); - expect(await res.json()).toEqual({ - error: { - message: "Something went wrong", - code: "INTERNAL_ERROR", - }, - }); - }); - }); - - describe("Errors factory functions", () => { - it("handles badRequest error", async () => { - const app = createTestApp(); - app.get("/test", () => { - throw Errors.badRequest("Invalid input"); - }); - - const res = await app.request("/test"); - expect(res.status).toBe(400); - expect(await res.json()).toEqual({ - error: { - message: "Invalid input", - code: "BAD_REQUEST", - }, - }); - }); - - it("handles unauthorized error", async () => { - const app = createTestApp(); - app.get("/test", () => { - throw Errors.unauthorized(); - }); - - const res = await app.request("/test"); - expect(res.status).toBe(401); - expect(await res.json()).toEqual({ - error: { - message: "Unauthorized", - code: "UNAUTHORIZED", - }, - }); - }); - - it("handles forbidden error", async () => { - const app = createTestApp(); - app.get("/test", () => { - throw Errors.forbidden("Access denied"); - }); - - const res = await app.request("/test"); - expect(res.status).toBe(403); - expect(await res.json()).toEqual({ - error: { - message: "Access denied", - code: "FORBIDDEN", - }, - }); - }); - - it("handles notFound error", async () => { - const app = createTestApp(); - app.get("/test", () => { - throw Errors.notFound("Resource not found"); - }); - - const res = await app.request("/test"); - expect(res.status).toBe(404); - expect(await res.json()).toEqual({ - error: { - message: "Resource not found", - code: "NOT_FOUND", - }, - }); - }); - - it("handles conflict error", async () => { - const app = createTestApp(); - app.get("/test", () => { - throw Errors.conflict("Already exists"); - }); - - const res = await app.request("/test"); - expect(res.status).toBe(409); - expect(await res.json()).toEqual({ - error: { - message: "Already exists", - code: "CONFLICT", - }, - }); - }); - - it("handles validationError", async () => { - const app = createTestApp(); - app.get("/test", () => { - throw Errors.validationError("Invalid data"); - }); - - const res = await app.request("/test"); - expect(res.status).toBe(422); - expect(await res.json()).toEqual({ - error: { - message: "Invalid data", - code: "VALIDATION_ERROR", - }, - }); - }); - - it("handles internal error", async () => { - const app = createTestApp(); - app.get("/test", () => { - throw Errors.internal("Database connection failed"); - }); - - const res = await app.request("/test"); - expect(res.status).toBe(500); - expect(await res.json()).toEqual({ - error: { - message: "Database connection failed", - code: "INTERNAL_ERROR", - }, - }); - }); - }); - - describe("unknown error handling", () => { - it("handles generic Error with 500 status", async () => { - const app = createTestApp(); - app.get("/test", () => { - throw new Error("Unexpected error"); - }); - - const res = await app.request("/test"); - expect(res.status).toBe(500); - const body = (await res.json()) as { error: { code: string } }; - expect(body.error.code).toBe("INTERNAL_ERROR"); - }); - }); -}); diff --git a/pkgs/server/src/middleware/error-handler.ts b/pkgs/server/src/middleware/error-handler.ts deleted file mode 100644 index 7b92940..0000000 --- a/pkgs/server/src/middleware/error-handler.ts +++ /dev/null @@ -1,95 +0,0 @@ -import type { Context } from "hono"; -import { HTTPException } from "hono/http-exception"; -import type { ContentfulStatusCode } from "hono/utils/http-status"; - -/** - * Application-specific error with status code and optional details - */ -export class AppError extends Error { - readonly statusCode: ContentfulStatusCode; - readonly code: string; - - constructor( - message: string, - statusCode: ContentfulStatusCode = 500, - code = "INTERNAL_ERROR", - ) { - super(message); - this.name = "AppError"; - this.statusCode = statusCode; - this.code = code; - } -} - -/** - * Common error factory functions - */ -export const Errors = { - badRequest: (message = "Bad request", code = "BAD_REQUEST") => - new AppError(message, 400, code), - - unauthorized: (message = "Unauthorized", code = "UNAUTHORIZED") => - new AppError(message, 401, code), - - forbidden: (message = "Forbidden", code = "FORBIDDEN") => - new AppError(message, 403, code), - - notFound: (message = "Not found", code = "NOT_FOUND") => - new AppError(message, 404, code), - - conflict: (message = "Conflict", code = "CONFLICT") => - new AppError(message, 409, code), - - validationError: (message = "Validation failed", code = "VALIDATION_ERROR") => - new AppError(message, 422, code), - - internal: (message = "Internal server error", code = "INTERNAL_ERROR") => - new AppError(message, 500, code), -}; - -interface ErrorResponse { - error: { - message: string; - code: string; - }; -} - -/** - * Global error handler middleware for Hono - */ -export function errorHandler(err: Error, c: Context): Response { - // Handle AppError - if (err instanceof AppError) { - const response: ErrorResponse = { - error: { - message: err.message, - code: err.code, - }, - }; - return c.json(response, err.statusCode); - } - - // Handle Hono's HTTPException - if (err instanceof HTTPException) { - const response: ErrorResponse = { - error: { - message: err.message, - code: "HTTP_ERROR", - }, - }; - return c.json(response, err.status as ContentfulStatusCode); - } - - // Handle unknown errors - console.error("Unhandled error:", err); - const response: ErrorResponse = { - error: { - message: - process.env.NODE_ENV === "production" - ? "Internal server error" - : err.message, - code: "INTERNAL_ERROR", - }, - }; - return c.json(response, 500); -} diff --git a/pkgs/server/src/middleware/index.ts b/pkgs/server/src/middleware/index.ts deleted file mode 100644 index 57de4dd..0000000 --- a/pkgs/server/src/middleware/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { type AuthUser, authMiddleware, getAuthUser } from "./auth"; -export { AppError, Errors, errorHandler } from "./error-handler"; diff --git a/pkgs/server/src/repositories/index.ts b/pkgs/server/src/repositories/index.ts deleted file mode 100644 index f1bcfb1..0000000 --- a/pkgs/server/src/repositories/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { refreshTokenRepository } from "./refresh-token"; -export * from "./types"; -export { userRepository } from "./user"; diff --git a/pkgs/server/src/repositories/refresh-token.ts b/pkgs/server/src/repositories/refresh-token.ts deleted file mode 100644 index 82302df..0000000 --- a/pkgs/server/src/repositories/refresh-token.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { and, eq, gt } from "drizzle-orm"; -import { db, refreshTokens } from "../db"; -import type { RefreshTokenRepository } from "./types"; - -export const refreshTokenRepository: RefreshTokenRepository = { - async findValidToken(tokenHash) { - const [token] = await db - .select({ - id: refreshTokens.id, - userId: refreshTokens.userId, - expiresAt: refreshTokens.expiresAt, - }) - .from(refreshTokens) - .where( - and( - eq(refreshTokens.tokenHash, tokenHash), - gt(refreshTokens.expiresAt, new Date()), - ), - ) - .limit(1); - return token; - }, - - async create(data) { - await db.insert(refreshTokens).values({ - userId: data.userId, - tokenHash: data.tokenHash, - expiresAt: data.expiresAt, - }); - }, - - async deleteById(id) { - await db.delete(refreshTokens).where(eq(refreshTokens.id, id)); - }, -}; diff --git a/pkgs/server/src/repositories/types.ts b/pkgs/server/src/repositories/types.ts deleted file mode 100644 index 1ab4bdc..0000000 --- a/pkgs/server/src/repositories/types.ts +++ /dev/null @@ -1,46 +0,0 @@ -/** - * Repository types for abstracting database operations - */ - -export interface User { - id: string; - username: string; - passwordHash: string; - createdAt: Date; - updatedAt: Date; -} - -export interface UserPublic { - id: string; - username: string; - createdAt: Date; -} - -export interface RefreshToken { - id: string; - userId: string; - tokenHash: string; - expiresAt: Date; - createdAt: Date; -} - -export interface UserRepository { - findByUsername( - username: string, - ): Promise | undefined>; - existsByUsername(username: string): Promise; - create(data: { username: string; passwordHash: string }): Promise; - findById(id: string): Promise | undefined>; -} - -export interface RefreshTokenRepository { - findValidToken( - tokenHash: string, - ): Promise | undefined>; - create(data: { - userId: string; - tokenHash: string; - expiresAt: Date; - }): Promise; - deleteById(id: string): Promise; -} diff --git a/pkgs/server/src/repositories/user.ts b/pkgs/server/src/repositories/user.ts deleted file mode 100644 index 7917632..0000000 --- a/pkgs/server/src/repositories/user.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { eq } from "drizzle-orm"; -import { db, users } from "../db"; -import type { UserPublic, UserRepository } from "./types"; - -export const userRepository: UserRepository = { - async findByUsername(username) { - const [user] = await db - .select({ - id: users.id, - username: users.username, - passwordHash: users.passwordHash, - }) - .from(users) - .where(eq(users.username, username)) - .limit(1); - return user; - }, - - async existsByUsername(username) { - const [user] = await db - .select({ id: users.id }) - .from(users) - .where(eq(users.username, username)) - .limit(1); - return user !== undefined; - }, - - async create(data): Promise { - const [newUser] = await db - .insert(users) - .values({ - username: data.username, - passwordHash: data.passwordHash, - }) - .returning({ - id: users.id, - username: users.username, - createdAt: users.createdAt, - }); - // Insert with returning should always return the created row - return newUser!; - }, - - async findById(id) { - const [user] = await db - .select({ - id: users.id, - username: users.username, - }) - .from(users) - .where(eq(users.id, id)) - .limit(1); - return user; - }, -}; diff --git a/pkgs/server/src/routes/auth.test.ts b/pkgs/server/src/routes/auth.test.ts deleted file mode 100644 index 34eb2b6..0000000 --- a/pkgs/server/src/routes/auth.test.ts +++ /dev/null @@ -1,428 +0,0 @@ -import { Hono } from "hono"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { errorHandler } from "../middleware"; -import type { - RefreshTokenRepository, - UserPublic, - UserRepository, -} from "../repositories"; -import { createAuthRouter } from "./auth"; - -vi.mock("argon2", () => ({ - hash: vi.fn((password: string) => Promise.resolve(`hashed_${password}`)), - verify: vi.fn((hash: string, password: string) => - Promise.resolve(hash === `hashed_${password}`), - ), -})); - -function createMockUserRepo(): UserRepository { - return { - findByUsername: vi.fn(), - existsByUsername: vi.fn(), - create: vi.fn(), - findById: vi.fn(), - }; -} - -function createMockRefreshTokenRepo(): RefreshTokenRepository { - return { - findValidToken: vi.fn(), - create: vi.fn(), - deleteById: vi.fn(), - }; -} - -interface RegisterResponse { - user?: { - id: string; - username: string; - createdAt: string; - }; - error?: { - code: string; - message: string; - }; -} - -interface LoginResponse { - accessToken?: string; - refreshToken?: string; - user?: { - id: string; - username: string; - }; - error?: { - code: string; - message: string; - }; -} - -describe("POST /register", () => { - let app: Hono; - let mockUserRepo: ReturnType; - let mockRefreshTokenRepo: ReturnType; - - beforeEach(() => { - vi.clearAllMocks(); - mockUserRepo = createMockUserRepo(); - mockRefreshTokenRepo = createMockRefreshTokenRepo(); - const auth = createAuthRouter({ - userRepo: mockUserRepo, - refreshTokenRepo: mockRefreshTokenRepo, - }); - app = new Hono(); - app.onError(errorHandler); - app.route("/api/auth", auth); - }); - - it("creates a new user with valid credentials", async () => { - vi.mocked(mockUserRepo.existsByUsername).mockResolvedValue(false); - vi.mocked(mockUserRepo.create).mockResolvedValue({ - id: "test-uuid-123", - username: "testuser", - createdAt: new Date("2024-01-01T00:00:00Z"), - } as UserPublic); - - const res = await app.request("/api/auth/register", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - username: "testuser", - password: "securepassword12345", - }), - }); - - expect(res.status).toBe(201); - const body = (await res.json()) as RegisterResponse; - expect(body.user).toEqual({ - id: "test-uuid-123", - username: "testuser", - createdAt: "2024-01-01T00:00:00.000Z", - }); - expect(mockUserRepo.existsByUsername).toHaveBeenCalledWith("testuser"); - expect(mockUserRepo.create).toHaveBeenCalledWith({ - username: "testuser", - passwordHash: "hashed_securepassword12345", - }); - }); - - it("returns 422 for invalid username", async () => { - const res = await app.request("/api/auth/register", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - username: "", - password: "securepassword12345", - }), - }); - - expect(res.status).toBe(422); - const body = (await res.json()) as RegisterResponse; - expect(body.error?.code).toBe("VALIDATION_ERROR"); - }); - - it("returns 422 for password too short", async () => { - const res = await app.request("/api/auth/register", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - username: "testuser", - password: "tooshort123456", - }), - }); - - expect(res.status).toBe(422); - const body = (await res.json()) as RegisterResponse; - expect(body.error?.code).toBe("VALIDATION_ERROR"); - }); - - it("returns 409 for existing username", async () => { - vi.mocked(mockUserRepo.existsByUsername).mockResolvedValue(true); - - const res = await app.request("/api/auth/register", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - username: "existinguser", - password: "securepassword12345", - }), - }); - - expect(res.status).toBe(409); - const body = (await res.json()) as RegisterResponse; - expect(body.error?.code).toBe("USERNAME_EXISTS"); - }); -}); - -describe("POST /login", () => { - let app: Hono; - let mockUserRepo: ReturnType; - let mockRefreshTokenRepo: ReturnType; - - beforeEach(() => { - vi.clearAllMocks(); - mockUserRepo = createMockUserRepo(); - mockRefreshTokenRepo = createMockRefreshTokenRepo(); - const auth = createAuthRouter({ - userRepo: mockUserRepo, - refreshTokenRepo: mockRefreshTokenRepo, - }); - app = new Hono(); - app.onError(errorHandler); - app.route("/api/auth", auth); - }); - - it("returns access token for valid credentials", async () => { - vi.mocked(mockUserRepo.findByUsername).mockResolvedValue({ - id: "user-uuid-123", - username: "testuser", - passwordHash: "hashed_correctpassword", - }); - vi.mocked(mockRefreshTokenRepo.create).mockResolvedValue(undefined); - - const res = await app.request("/api/auth/login", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - username: "testuser", - password: "correctpassword", - }), - }); - - expect(res.status).toBe(200); - const body = (await res.json()) as LoginResponse; - expect(body.accessToken).toBeDefined(); - expect(typeof body.accessToken).toBe("string"); - expect(body.refreshToken).toBeDefined(); - expect(typeof body.refreshToken).toBe("string"); - expect(body.user).toEqual({ - id: "user-uuid-123", - username: "testuser", - }); - expect(mockRefreshTokenRepo.create).toHaveBeenCalledWith({ - userId: "user-uuid-123", - tokenHash: expect.any(String), - expiresAt: expect.any(Date), - }); - }); - - it("returns 401 for non-existent user", async () => { - vi.mocked(mockUserRepo.findByUsername).mockResolvedValue(undefined); - - const res = await app.request("/api/auth/login", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - username: "nonexistent", - password: "anypassword", - }), - }); - - expect(res.status).toBe(401); - const body = (await res.json()) as LoginResponse; - expect(body.error?.code).toBe("INVALID_CREDENTIALS"); - }); - - it("returns 401 for incorrect password", async () => { - vi.mocked(mockUserRepo.findByUsername).mockResolvedValue({ - id: "user-uuid-123", - username: "testuser", - passwordHash: "hashed_correctpassword", - }); - - const res = await app.request("/api/auth/login", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - username: "testuser", - password: "wrongpassword", - }), - }); - - expect(res.status).toBe(401); - const body = (await res.json()) as LoginResponse; - expect(body.error?.code).toBe("INVALID_CREDENTIALS"); - }); - - it("returns 422 for missing username", async () => { - const res = await app.request("/api/auth/login", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - username: "", - password: "somepassword", - }), - }); - - expect(res.status).toBe(422); - const body = (await res.json()) as LoginResponse; - expect(body.error?.code).toBe("VALIDATION_ERROR"); - }); - - it("returns 422 for missing password", async () => { - const res = await app.request("/api/auth/login", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - username: "testuser", - password: "", - }), - }); - - expect(res.status).toBe(422); - const body = (await res.json()) as LoginResponse; - expect(body.error?.code).toBe("VALIDATION_ERROR"); - }); -}); - -interface RefreshResponse { - accessToken?: string; - refreshToken?: string; - user?: { - id: string; - username: string; - }; - error?: { - code: string; - message: string; - }; -} - -describe("POST /refresh", () => { - let app: Hono; - let mockUserRepo: ReturnType; - let mockRefreshTokenRepo: ReturnType; - - beforeEach(() => { - vi.clearAllMocks(); - mockUserRepo = createMockUserRepo(); - mockRefreshTokenRepo = createMockRefreshTokenRepo(); - const auth = createAuthRouter({ - userRepo: mockUserRepo, - refreshTokenRepo: mockRefreshTokenRepo, - }); - app = new Hono(); - app.onError(errorHandler); - app.route("/api/auth", auth); - }); - - it("returns new tokens for valid refresh token", async () => { - vi.mocked(mockRefreshTokenRepo.findValidToken).mockResolvedValue({ - id: "token-id-123", - userId: "user-uuid-123", - expiresAt: new Date(Date.now() + 86400000), - }); - vi.mocked(mockUserRepo.findById).mockResolvedValue({ - id: "user-uuid-123", - username: "testuser", - }); - vi.mocked(mockRefreshTokenRepo.deleteById).mockResolvedValue(undefined); - vi.mocked(mockRefreshTokenRepo.create).mockResolvedValue(undefined); - - const res = await app.request("/api/auth/refresh", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - refreshToken: "valid-refresh-token-hex", - }), - }); - - expect(res.status).toBe(200); - const body = (await res.json()) as RefreshResponse; - expect(body.accessToken).toBeDefined(); - expect(typeof body.accessToken).toBe("string"); - expect(body.refreshToken).toBeDefined(); - expect(typeof body.refreshToken).toBe("string"); - expect(body.user).toEqual({ - id: "user-uuid-123", - username: "testuser", - }); - expect(mockRefreshTokenRepo.deleteById).toHaveBeenCalledWith( - "token-id-123", - ); - expect(mockRefreshTokenRepo.create).toHaveBeenCalledWith({ - userId: "user-uuid-123", - tokenHash: expect.any(String), - expiresAt: expect.any(Date), - }); - }); - - it("returns 401 for invalid refresh token", async () => { - vi.mocked(mockRefreshTokenRepo.findValidToken).mockResolvedValue(undefined); - - const res = await app.request("/api/auth/refresh", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - refreshToken: "invalid-refresh-token", - }), - }); - - expect(res.status).toBe(401); - const body = (await res.json()) as RefreshResponse; - expect(body.error?.code).toBe("INVALID_REFRESH_TOKEN"); - }); - - it("returns 401 for expired refresh token", async () => { - vi.mocked(mockRefreshTokenRepo.findValidToken).mockResolvedValue(undefined); - - const res = await app.request("/api/auth/refresh", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - refreshToken: "expired-refresh-token", - }), - }); - - expect(res.status).toBe(401); - const body = (await res.json()) as RefreshResponse; - expect(body.error?.code).toBe("INVALID_REFRESH_TOKEN"); - }); - - it("returns 401 when user not found", async () => { - vi.mocked(mockRefreshTokenRepo.findValidToken).mockResolvedValue({ - id: "token-id-123", - userId: "deleted-user-id", - expiresAt: new Date(Date.now() + 86400000), - }); - vi.mocked(mockUserRepo.findById).mockResolvedValue(undefined); - - const res = await app.request("/api/auth/refresh", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - refreshToken: "valid-refresh-token", - }), - }); - - expect(res.status).toBe(401); - const body = (await res.json()) as RefreshResponse; - expect(body.error?.code).toBe("USER_NOT_FOUND"); - }); - - it("returns 422 for missing refresh token", async () => { - const res = await app.request("/api/auth/refresh", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({}), - }); - - expect(res.status).toBe(422); - const body = (await res.json()) as RefreshResponse; - expect(body.error?.code).toBe("VALIDATION_ERROR"); - }); - - it("returns 422 for empty refresh token", async () => { - const res = await app.request("/api/auth/refresh", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - refreshToken: "", - }), - }); - - expect(res.status).toBe(422); - const body = (await res.json()) as RefreshResponse; - expect(body.error?.code).toBe("VALIDATION_ERROR"); - }); -}); diff --git a/pkgs/server/src/routes/auth.ts b/pkgs/server/src/routes/auth.ts deleted file mode 100644 index e1f7ebb..0000000 --- a/pkgs/server/src/routes/auth.ts +++ /dev/null @@ -1,199 +0,0 @@ -import { createHash, randomBytes } from "node:crypto"; -import { - createUserSchema, - loginSchema, - refreshTokenSchema, -} from "@kioku/shared"; -import * as argon2 from "argon2"; -import { Hono } from "hono"; -import { sign } from "hono/jwt"; -import { Errors } from "../middleware"; -import { - type RefreshTokenRepository, - refreshTokenRepository, - type UserRepository, - userRepository, -} from "../repositories"; - -const JWT_SECRET = process.env.JWT_SECRET; -if (!JWT_SECRET) { - throw new Error("JWT_SECRET environment variable is required"); -} -const ACCESS_TOKEN_EXPIRES_IN = 60 * 15; // 15 minutes -const REFRESH_TOKEN_EXPIRES_IN = 60 * 60 * 24 * 7; // 7 days - -function generateRefreshToken(): string { - return randomBytes(32).toString("hex"); -} - -function hashToken(token: string): string { - return createHash("sha256").update(token).digest("hex"); -} - -export interface AuthDependencies { - userRepo: UserRepository; - refreshTokenRepo: RefreshTokenRepository; -} - -export function createAuthRouter(deps: AuthDependencies) { - const { userRepo, refreshTokenRepo } = deps; - const auth = new Hono(); - - auth.post("/register", async (c) => { - const body = await c.req.json(); - - const parsed = createUserSchema.safeParse(body); - if (!parsed.success) { - throw Errors.validationError(parsed.error.issues[0]?.message); - } - - const { username, password } = parsed.data; - - // Check if username already exists - const exists = await userRepo.existsByUsername(username); - if (exists) { - throw Errors.conflict("Username already exists", "USERNAME_EXISTS"); - } - - // Hash password with Argon2 - const passwordHash = await argon2.hash(password); - - // Create user - const newUser = await userRepo.create({ username, passwordHash }); - - return c.json({ user: newUser }, 201); - }); - - auth.post("/login", async (c) => { - const body = await c.req.json(); - - const parsed = loginSchema.safeParse(body); - if (!parsed.success) { - throw Errors.validationError(parsed.error.issues[0]?.message); - } - - const { username, password } = parsed.data; - - // Find user by username - const user = await userRepo.findByUsername(username); - - if (!user) { - throw Errors.unauthorized( - "Invalid username or password", - "INVALID_CREDENTIALS", - ); - } - - // Verify password - const isPasswordValid = await argon2.verify(user.passwordHash, password); - if (!isPasswordValid) { - throw Errors.unauthorized( - "Invalid username or password", - "INVALID_CREDENTIALS", - ); - } - - // Generate JWT access token - const now = Math.floor(Date.now() / 1000); - const accessToken = await sign( - { - sub: user.id, - iat: now, - exp: now + ACCESS_TOKEN_EXPIRES_IN, - }, - JWT_SECRET, - ); - - // Generate refresh token - const refreshToken = generateRefreshToken(); - const tokenHash = hashToken(refreshToken); - const expiresAt = new Date(Date.now() + REFRESH_TOKEN_EXPIRES_IN * 1000); - - // Store refresh token in database - await refreshTokenRepo.create({ - userId: user.id, - tokenHash, - expiresAt, - }); - - return c.json({ - accessToken, - refreshToken, - user: { - id: user.id, - username: user.username, - }, - }); - }); - - auth.post("/refresh", async (c) => { - const body = await c.req.json(); - - const parsed = refreshTokenSchema.safeParse(body); - if (!parsed.success) { - throw Errors.validationError(parsed.error.issues[0]?.message); - } - - const { refreshToken } = parsed.data; - const tokenHash = hashToken(refreshToken); - - // Find valid refresh token - const storedToken = await refreshTokenRepo.findValidToken(tokenHash); - - if (!storedToken) { - throw Errors.unauthorized( - "Invalid or expired refresh token", - "INVALID_REFRESH_TOKEN", - ); - } - - // Get user info - const user = await userRepo.findById(storedToken.userId); - - if (!user) { - throw Errors.unauthorized("User not found", "USER_NOT_FOUND"); - } - - // Delete old refresh token (rotation) - await refreshTokenRepo.deleteById(storedToken.id); - - // Generate new access token - const now = Math.floor(Date.now() / 1000); - const accessToken = await sign( - { - sub: user.id, - iat: now, - exp: now + ACCESS_TOKEN_EXPIRES_IN, - }, - JWT_SECRET, - ); - - // Generate new refresh token (rotation) - const newRefreshToken = generateRefreshToken(); - const newTokenHash = hashToken(newRefreshToken); - const expiresAt = new Date(Date.now() + REFRESH_TOKEN_EXPIRES_IN * 1000); - - await refreshTokenRepo.create({ - userId: user.id, - tokenHash: newTokenHash, - expiresAt, - }); - - return c.json({ - accessToken, - refreshToken: newRefreshToken, - user: { - id: user.id, - username: user.username, - }, - }); - }); - - return auth; -} - -// Default auth router with real repositories for production use -export const auth = createAuthRouter({ - userRepo: userRepository, - refreshTokenRepo: refreshTokenRepository, -}); diff --git a/pkgs/server/src/routes/index.ts b/pkgs/server/src/routes/index.ts deleted file mode 100644 index 2925e6d..0000000 --- a/pkgs/server/src/routes/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { auth } from "./auth"; diff --git a/pkgs/server/tsconfig.json b/pkgs/server/tsconfig.json deleted file mode 100644 index 038af8a..0000000 --- a/pkgs/server/tsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "../../tsconfig.json", - "compilerOptions": { - "outDir": "./dist", - "rootDir": "./src" - }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist"] -} diff --git a/pkgs/server/vitest.config.ts b/pkgs/server/vitest.config.ts deleted file mode 100644 index af9649f..0000000 --- a/pkgs/server/vitest.config.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { defineConfig } from "vitest/config"; - -export default defineConfig({ - test: { - globals: true, - env: { - JWT_SECRET: "test-secret-key", - }, - }, -}); diff --git a/pkgs/shared/package.json b/pkgs/shared/package.json deleted file mode 100644 index 8c50a8b..0000000 --- a/pkgs/shared/package.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "name": "@kioku/shared", - "version": "0.1.0", - "private": true, - "main": "./src/index.ts", - "types": "./src/index.ts", - "exports": { - ".": { - "types": "./src/index.ts", - "import": "./src/index.ts" - } - }, - "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" - }, - "author": "nsfisis", - "license": "MIT", - "packageManager": "pnpm@10.23.0", - "type": "module", - "dependencies": { - "zod": "^4.1.13" - } -} diff --git a/pkgs/shared/src/index.ts b/pkgs/shared/src/index.ts deleted file mode 100644 index 0ca6112..0000000 --- a/pkgs/shared/src/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./schemas/index.js"; -export * from "./types/index.js"; diff --git a/pkgs/shared/src/schemas/index.ts b/pkgs/shared/src/schemas/index.ts deleted file mode 100644 index 05b926a..0000000 --- a/pkgs/shared/src/schemas/index.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { z } from "zod"; - -// Card states for FSRS algorithm -export const cardStateSchema = z.union([ - z.literal(0), // New - z.literal(1), // Learning - z.literal(2), // Review - z.literal(3), // Relearning -]); - -// Rating values for reviews -export const ratingSchema = z.union([ - z.literal(1), // Again - z.literal(2), // Hard - z.literal(3), // Good - z.literal(4), // Easy -]); - -// User schema -export const userSchema = z.object({ - id: z.string().uuid(), - username: z.string().min(1).max(255), - passwordHash: z.string(), - createdAt: z.coerce.date(), - updatedAt: z.coerce.date(), -}); - -// User creation input schema -export const createUserSchema = z.object({ - username: z.string().min(1).max(255), - password: z.string().min(15).max(255), -}); - -// Login input schema -export const loginSchema = z.object({ - username: z.string().min(1), - password: z.string().min(1), -}); - -// Refresh token input schema -export const refreshTokenSchema = z.object({ - refreshToken: z.string().min(1), -}); - -// Deck schema -export const deckSchema = z.object({ - id: z.string().uuid(), - userId: z.string().uuid(), - name: z.string().min(1).max(255), - description: z.string().max(1000).nullable(), - newCardsPerDay: z.number().int().min(0).default(20), - createdAt: z.coerce.date(), - updatedAt: z.coerce.date(), - deletedAt: z.coerce.date().nullable(), - syncVersion: z.number().int().min(0), -}); - -// Deck creation input schema -export const createDeckSchema = z.object({ - name: z.string().min(1).max(255), - description: z.string().max(1000).nullable().optional(), - newCardsPerDay: z.number().int().min(0).default(20), -}); - -// Deck update input schema -export const updateDeckSchema = z.object({ - name: z.string().min(1).max(255).optional(), - description: z.string().max(1000).nullable().optional(), - newCardsPerDay: z.number().int().min(0).optional(), -}); - -// Card schema -export const cardSchema = z.object({ - id: z.string().uuid(), - deckId: z.string().uuid(), - front: z.string().min(1), - back: z.string().min(1), - - // FSRS fields - state: cardStateSchema, - due: z.coerce.date(), - stability: z.number().min(0), - difficulty: z.number().min(0).max(10), - elapsedDays: z.number().int().min(0), - scheduledDays: z.number().int().min(0), - reps: z.number().int().min(0), - lapses: z.number().int().min(0), - lastReview: z.coerce.date().nullable(), - - createdAt: z.coerce.date(), - updatedAt: z.coerce.date(), - deletedAt: z.coerce.date().nullable(), - syncVersion: z.number().int().min(0), -}); - -// Card creation input schema -export const createCardSchema = z.object({ - front: z.string().min(1), - back: z.string().min(1), -}); - -// Card update input schema -export const updateCardSchema = z.object({ - front: z.string().min(1).optional(), - back: z.string().min(1).optional(), -}); - -// ReviewLog schema -export const reviewLogSchema = z.object({ - id: z.string().uuid(), - cardId: z.string().uuid(), - userId: z.string().uuid(), - rating: ratingSchema, - state: cardStateSchema, - scheduledDays: z.number().int().min(0), - elapsedDays: z.number().int().min(0), - reviewedAt: z.coerce.date(), - durationMs: z.number().int().min(0).nullable(), - syncVersion: z.number().int().min(0), -}); - -// Submit review input schema -export const submitReviewSchema = z.object({ - rating: ratingSchema, - durationMs: z.number().int().min(0).nullable().optional(), -}); - -// Inferred types from schemas -export type UserSchema = z.infer; -export type CreateUserSchema = z.infer; -export type LoginSchema = z.infer; -export type RefreshTokenSchema = z.infer; -export type DeckSchema = z.infer; -export type CreateDeckSchema = z.infer; -export type UpdateDeckSchema = z.infer; -export type CardSchema = z.infer; -export type CreateCardSchema = z.infer; -export type UpdateCardSchema = z.infer; -export type ReviewLogSchema = z.infer; -export type SubmitReviewSchema = z.infer; diff --git a/pkgs/shared/src/types/index.ts b/pkgs/shared/src/types/index.ts deleted file mode 100644 index bfba06f..0000000 --- a/pkgs/shared/src/types/index.ts +++ /dev/null @@ -1,79 +0,0 @@ -// Card states for FSRS algorithm -export const CardState = { - New: 0, - Learning: 1, - Review: 2, - Relearning: 3, -} as const; - -export type CardState = (typeof CardState)[keyof typeof CardState]; - -// Rating values for reviews -export const Rating = { - Again: 1, - Hard: 2, - Good: 3, - Easy: 4, -} as const; - -export type Rating = (typeof Rating)[keyof typeof Rating]; - -// User -export interface User { - id: string; - username: string; - passwordHash: string; - createdAt: Date; - updatedAt: Date; -} - -// Deck -export interface Deck { - id: string; - userId: string; - name: string; - description: string | null; - newCardsPerDay: number; - createdAt: Date; - updatedAt: Date; - deletedAt: Date | null; - syncVersion: number; -} - -// Card with FSRS fields -export interface Card { - id: string; - deckId: string; - front: string; - back: string; - - // FSRS fields - state: CardState; - due: Date; - stability: number; - difficulty: number; - elapsedDays: number; - scheduledDays: number; - reps: number; - lapses: number; - lastReview: Date | null; - - createdAt: Date; - updatedAt: Date; - deletedAt: Date | null; - syncVersion: number; -} - -// ReviewLog (append-only) -export interface ReviewLog { - id: string; - cardId: string; - userId: string; - rating: Rating; - state: CardState; - scheduledDays: number; - elapsedDays: number; - reviewedAt: Date; - durationMs: number | null; - syncVersion: number; -} diff --git a/pkgs/shared/tsconfig.json b/pkgs/shared/tsconfig.json deleted file mode 100644 index 038af8a..0000000 --- a/pkgs/shared/tsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "../../tsconfig.json", - "compilerOptions": { - "outDir": "./dist", - "rootDir": "./src" - }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist"] -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9ce2ee0..6f2e9e7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,24 +7,10 @@ settings: importers: .: - devDependencies: - '@biomejs/biome': - specifier: ^2.3.8 - version: 2.3.8 - typescript: - specifier: ^5.9.3 - version: 5.9.3 - - pkgs/client: {} - - pkgs/server: dependencies: '@hono/node-server': specifier: ^1.19.6 version: 1.19.6(hono@4.10.7) - '@kioku/shared': - specifier: workspace:* - version: link:../shared argon2: specifier: ^0.44.0 version: 0.44.0 @@ -37,7 +23,13 @@ importers: pg: specifier: ^8.16.3 version: 8.16.3 + zod: + specifier: ^4.1.13 + version: 4.1.13 devDependencies: + '@biomejs/biome': + specifier: ^2.3.8 + version: 2.3.8 '@types/node': specifier: ^24.10.1 version: 24.10.1 @@ -47,16 +39,13 @@ importers: drizzle-kit: specifier: ^0.31.7 version: 0.31.7 + typescript: + specifier: ^5.9.3 + version: 5.9.3 vitest: specifier: ^4.0.14 version: 4.0.14(@types/node@24.10.1) - pkgs/shared: - dependencies: - zod: - specifier: ^4.1.13 - version: 4.1.13 - packages: '@biomejs/biome@2.3.8': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml deleted file mode 100644 index 4276d04..0000000 --- a/pnpm-workspace.yaml +++ /dev/null @@ -1,2 +0,0 @@ -packages: - - 'pkgs/**' diff --git a/src/client/index.tsx b/src/client/index.tsx new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/src/client/index.tsx @@ -0,0 +1 @@ +export {}; diff --git a/src/server/db/index.ts b/src/server/db/index.ts new file mode 100644 index 0000000..22da621 --- /dev/null +++ b/src/server/db/index.ts @@ -0,0 +1,12 @@ +import { drizzle } from "drizzle-orm/node-postgres"; +import * as schema from "./schema.js"; + +const databaseUrl = process.env.DATABASE_URL; + +if (!databaseUrl) { + throw new Error("DATABASE_URL environment variable is not set"); +} + +export const db = drizzle(databaseUrl, { schema }); + +export * from "./schema.js"; diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts new file mode 100644 index 0000000..4b9631f --- /dev/null +++ b/src/server/db/schema.ts @@ -0,0 +1,116 @@ +import { + integer, + pgTable, + real, + smallint, + text, + timestamp, + uuid, + varchar, +} from "drizzle-orm/pg-core"; + +// Card states for FSRS algorithm +export const CardState = { + New: 0, + Learning: 1, + Review: 2, + Relearning: 3, +} as const; + +// Rating values for reviews +export const Rating = { + Again: 1, + Hard: 2, + Good: 3, + Easy: 4, +} as const; + +export const users = pgTable("users", { + id: uuid("id").primaryKey().defaultRandom(), + username: varchar("username", { length: 255 }).notNull().unique(), + passwordHash: varchar("password_hash", { length: 255 }).notNull(), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), +}); + +export const refreshTokens = pgTable("refresh_tokens", { + id: uuid("id").primaryKey().defaultRandom(), + userId: uuid("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + tokenHash: varchar("token_hash", { length: 255 }).notNull(), + expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), +}); + +export const decks = pgTable("decks", { + id: uuid("id").primaryKey().defaultRandom(), + userId: uuid("user_id") + .notNull() + .references(() => users.id), + name: varchar("name", { length: 255 }).notNull(), + description: text("description"), + newCardsPerDay: integer("new_cards_per_day").notNull().default(20), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + deletedAt: timestamp("deleted_at", { withTimezone: true }), + syncVersion: integer("sync_version").notNull().default(0), +}); + +export const cards = pgTable("cards", { + id: uuid("id").primaryKey().defaultRandom(), + deckId: uuid("deck_id") + .notNull() + .references(() => decks.id), + front: text("front").notNull(), + back: text("back").notNull(), + + // FSRS fields + state: smallint("state").notNull().default(CardState.New), + due: timestamp("due", { withTimezone: true }).notNull().defaultNow(), + stability: real("stability").notNull().default(0), + difficulty: real("difficulty").notNull().default(0), + elapsedDays: integer("elapsed_days").notNull().default(0), + scheduledDays: integer("scheduled_days").notNull().default(0), + reps: integer("reps").notNull().default(0), + lapses: integer("lapses").notNull().default(0), + lastReview: timestamp("last_review", { withTimezone: true }), + + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + deletedAt: timestamp("deleted_at", { withTimezone: true }), + syncVersion: integer("sync_version").notNull().default(0), +}); + +export const reviewLogs = pgTable("review_logs", { + id: uuid("id").primaryKey().defaultRandom(), + cardId: uuid("card_id") + .notNull() + .references(() => cards.id), + userId: uuid("user_id") + .notNull() + .references(() => users.id), + rating: smallint("rating").notNull(), + state: smallint("state").notNull(), + scheduledDays: integer("scheduled_days").notNull(), + elapsedDays: integer("elapsed_days").notNull(), + reviewedAt: timestamp("reviewed_at", { withTimezone: true }) + .notNull() + .defaultNow(), + durationMs: integer("duration_ms"), + syncVersion: integer("sync_version").notNull().default(0), +}); diff --git a/src/server/index.test.ts b/src/server/index.test.ts new file mode 100644 index 0000000..6d2dda9 --- /dev/null +++ b/src/server/index.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from "vitest"; +import { app } from "./index.js"; + +describe("Hono app", () => { + describe("GET /api/health", () => { + it("returns ok status", async () => { + const res = await app.request("/api/health"); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ status: "ok" }); + }); + }); +}); diff --git a/src/server/index.ts b/src/server/index.ts new file mode 100644 index 0000000..01a489f --- /dev/null +++ b/src/server/index.ts @@ -0,0 +1,30 @@ +import { serve } from "@hono/node-server"; +import { Hono } from "hono"; +import { logger } from "hono/logger"; +import { errorHandler } from "./middleware/index.js"; +import { auth } from "./routes/index.js"; + +const app = new Hono(); + +app.use("*", logger()); +app.onError(errorHandler); + +app.get("/", (c) => { + return c.json({ message: "Kioku API" }); +}); + +app.get("/api/health", (c) => { + return c.json({ status: "ok" }); +}); + +app.route("/api/auth", auth); + +const port = Number(process.env.PORT) || 3000; +console.log(`Server is running on port ${port}`); + +serve({ + fetch: app.fetch, + port, +}); + +export { app }; diff --git a/src/server/middleware/auth.test.ts b/src/server/middleware/auth.test.ts new file mode 100644 index 0000000..a8b7f3d --- /dev/null +++ b/src/server/middleware/auth.test.ts @@ -0,0 +1,190 @@ +import { Hono } from "hono"; +import { sign } from "hono/jwt"; +import { beforeEach, describe, expect, it } from "vitest"; +import { authMiddleware, getAuthUser } from "./auth.js"; +import { errorHandler } from "./error-handler.js"; + +const JWT_SECRET = process.env.JWT_SECRET || "test-secret"; + +interface ErrorResponse { + error: { + code: string; + message: string; + }; +} + +interface SuccessResponse { + userId: string; +} + +describe("authMiddleware", () => { + let app: Hono; + + beforeEach(() => { + app = new Hono(); + app.onError(errorHandler); + app.use("/protected/*", authMiddleware); + app.get("/protected/resource", (c) => { + const user = getAuthUser(c); + return c.json({ userId: user.id }); + }); + }); + + it("allows access with valid token", async () => { + const now = Math.floor(Date.now() / 1000); + const token = await sign( + { + sub: "user-123", + iat: now, + exp: now + 3600, + }, + JWT_SECRET, + ); + + const res = await app.request("/protected/resource", { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as SuccessResponse; + expect(body.userId).toBe("user-123"); + }); + + it("returns 401 when Authorization header is missing", async () => { + const res = await app.request("/protected/resource"); + + expect(res.status).toBe(401); + const body = (await res.json()) as ErrorResponse; + expect(body.error.code).toBe("MISSING_AUTH"); + }); + + it("returns 401 for invalid Authorization format", async () => { + const res = await app.request("/protected/resource", { + headers: { + Authorization: "Basic sometoken", + }, + }); + + expect(res.status).toBe(401); + const body = (await res.json()) as ErrorResponse; + expect(body.error.code).toBe("INVALID_AUTH_FORMAT"); + }); + + it("returns 401 for empty Bearer token", async () => { + const res = await app.request("/protected/resource", { + headers: { + Authorization: "Bearer", + }, + }); + + expect(res.status).toBe(401); + const body = (await res.json()) as ErrorResponse; + expect(body.error.code).toBe("INVALID_AUTH_FORMAT"); + }); + + it("returns 401 for expired token", async () => { + const now = Math.floor(Date.now() / 1000); + const token = await sign( + { + sub: "user-123", + iat: now - 7200, + exp: now - 3600, // expired 1 hour ago + }, + JWT_SECRET, + ); + + const res = await app.request("/protected/resource", { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + expect(res.status).toBe(401); + const body = (await res.json()) as ErrorResponse; + expect(body.error.code).toBe("INVALID_TOKEN"); + }); + + it("returns 401 for invalid token", async () => { + const res = await app.request("/protected/resource", { + headers: { + Authorization: "Bearer invalid.token.here", + }, + }); + + expect(res.status).toBe(401); + const body = (await res.json()) as ErrorResponse; + expect(body.error.code).toBe("INVALID_TOKEN"); + }); + + it("returns 401 for token signed with wrong secret", async () => { + const now = Math.floor(Date.now() / 1000); + const token = await sign( + { + sub: "user-123", + iat: now, + exp: now + 3600, + }, + "wrong-secret", + ); + + const res = await app.request("/protected/resource", { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + expect(res.status).toBe(401); + const body = (await res.json()) as ErrorResponse; + expect(body.error.code).toBe("INVALID_TOKEN"); + }); +}); + +describe("getAuthUser", () => { + it("returns user from context when authenticated", async () => { + const app = new Hono(); + app.onError(errorHandler); + app.use("/test", authMiddleware); + app.get("/test", (c) => { + const user = getAuthUser(c); + return c.json({ id: user.id }); + }); + + const now = Math.floor(Date.now() / 1000); + const token = await sign( + { + sub: "test-user-456", + iat: now, + exp: now + 3600, + }, + JWT_SECRET, + ); + + const res = await app.request("/test", { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as { id: string }; + expect(body.id).toBe("test-user-456"); + }); + + it("throws when user is not in context", async () => { + const app = new Hono(); + app.onError(errorHandler); + // Note: no authMiddleware applied + app.get("/unprotected", (c) => { + const user = getAuthUser(c); + return c.json({ id: user.id }); + }); + + const res = await app.request("/unprotected"); + + expect(res.status).toBe(401); + const body = (await res.json()) as ErrorResponse; + expect(body.error.code).toBe("NOT_AUTHENTICATED"); + }); +}); diff --git a/src/server/middleware/auth.ts b/src/server/middleware/auth.ts new file mode 100644 index 0000000..51b4d9d --- /dev/null +++ b/src/server/middleware/auth.ts @@ -0,0 +1,65 @@ +import type { Context, Next } from "hono"; +import { verify } from "hono/jwt"; +import { Errors } from "./error-handler.js"; + +const JWT_SECRET = process.env.JWT_SECRET; +if (!JWT_SECRET) { + throw new Error("JWT_SECRET environment variable is required"); +} + +export interface AuthUser { + id: string; +} + +interface JWTPayload { + sub: string; + iat: number; + exp: number; +} + +/** + * Auth middleware that validates JWT tokens from Authorization header + * Sets the authenticated user in context variables + */ +export async function authMiddleware(c: Context, next: Next) { + const authHeader = c.req.header("Authorization"); + + if (!authHeader) { + throw Errors.unauthorized("Missing Authorization header", "MISSING_AUTH"); + } + + if (!authHeader.startsWith("Bearer ")) { + throw Errors.unauthorized( + "Invalid Authorization header format", + "INVALID_AUTH_FORMAT", + ); + } + + const token = authHeader.slice(7); + + try { + const payload = (await verify(token, JWT_SECRET)) as unknown as JWTPayload; + + const user: AuthUser = { + id: payload.sub, + }; + + c.set("user", user); + + await next(); + } catch { + throw Errors.unauthorized("Invalid or expired token", "INVALID_TOKEN"); + } +} + +/** + * Helper function to get the authenticated user from context + * Throws if user is not authenticated + */ +export function getAuthUser(c: Context): AuthUser { + const user = c.get("user") as AuthUser | undefined; + if (!user) { + throw Errors.unauthorized("Not authenticated", "NOT_AUTHENTICATED"); + } + return user; +} diff --git a/src/server/middleware/error-handler.test.ts b/src/server/middleware/error-handler.test.ts new file mode 100644 index 0000000..d4be84f --- /dev/null +++ b/src/server/middleware/error-handler.test.ts @@ -0,0 +1,173 @@ +import { Hono } from "hono"; +import { describe, expect, it } from "vitest"; +import { AppError, Errors, errorHandler } from "./error-handler.js"; + +function createTestApp() { + const app = new Hono(); + app.onError(errorHandler); + return app; +} + +describe("errorHandler", () => { + describe("AppError handling", () => { + it("returns correct status and message for AppError", async () => { + const app = createTestApp(); + app.get("/test", () => { + throw new AppError("Custom error", 400, "CUSTOM_ERROR"); + }); + + const res = await app.request("/test"); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ + error: { + message: "Custom error", + code: "CUSTOM_ERROR", + }, + }); + }); + + it("uses default values for AppError", async () => { + const app = createTestApp(); + app.get("/test", () => { + throw new AppError("Something went wrong"); + }); + + const res = await app.request("/test"); + expect(res.status).toBe(500); + expect(await res.json()).toEqual({ + error: { + message: "Something went wrong", + code: "INTERNAL_ERROR", + }, + }); + }); + }); + + describe("Errors factory functions", () => { + it("handles badRequest error", async () => { + const app = createTestApp(); + app.get("/test", () => { + throw Errors.badRequest("Invalid input"); + }); + + const res = await app.request("/test"); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ + error: { + message: "Invalid input", + code: "BAD_REQUEST", + }, + }); + }); + + it("handles unauthorized error", async () => { + const app = createTestApp(); + app.get("/test", () => { + throw Errors.unauthorized(); + }); + + const res = await app.request("/test"); + expect(res.status).toBe(401); + expect(await res.json()).toEqual({ + error: { + message: "Unauthorized", + code: "UNAUTHORIZED", + }, + }); + }); + + it("handles forbidden error", async () => { + const app = createTestApp(); + app.get("/test", () => { + throw Errors.forbidden("Access denied"); + }); + + const res = await app.request("/test"); + expect(res.status).toBe(403); + expect(await res.json()).toEqual({ + error: { + message: "Access denied", + code: "FORBIDDEN", + }, + }); + }); + + it("handles notFound error", async () => { + const app = createTestApp(); + app.get("/test", () => { + throw Errors.notFound("Resource not found"); + }); + + const res = await app.request("/test"); + expect(res.status).toBe(404); + expect(await res.json()).toEqual({ + error: { + message: "Resource not found", + code: "NOT_FOUND", + }, + }); + }); + + it("handles conflict error", async () => { + const app = createTestApp(); + app.get("/test", () => { + throw Errors.conflict("Already exists"); + }); + + const res = await app.request("/test"); + expect(res.status).toBe(409); + expect(await res.json()).toEqual({ + error: { + message: "Already exists", + code: "CONFLICT", + }, + }); + }); + + it("handles validationError", async () => { + const app = createTestApp(); + app.get("/test", () => { + throw Errors.validationError("Invalid data"); + }); + + const res = await app.request("/test"); + expect(res.status).toBe(422); + expect(await res.json()).toEqual({ + error: { + message: "Invalid data", + code: "VALIDATION_ERROR", + }, + }); + }); + + it("handles internal error", async () => { + const app = createTestApp(); + app.get("/test", () => { + throw Errors.internal("Database connection failed"); + }); + + const res = await app.request("/test"); + expect(res.status).toBe(500); + expect(await res.json()).toEqual({ + error: { + message: "Database connection failed", + code: "INTERNAL_ERROR", + }, + }); + }); + }); + + describe("unknown error handling", () => { + it("handles generic Error with 500 status", async () => { + const app = createTestApp(); + app.get("/test", () => { + throw new Error("Unexpected error"); + }); + + const res = await app.request("/test"); + expect(res.status).toBe(500); + const body = (await res.json()) as { error: { code: string } }; + expect(body.error.code).toBe("INTERNAL_ERROR"); + }); + }); +}); diff --git a/src/server/middleware/error-handler.ts b/src/server/middleware/error-handler.ts new file mode 100644 index 0000000..7b92940 --- /dev/null +++ b/src/server/middleware/error-handler.ts @@ -0,0 +1,95 @@ +import type { Context } from "hono"; +import { HTTPException } from "hono/http-exception"; +import type { ContentfulStatusCode } from "hono/utils/http-status"; + +/** + * Application-specific error with status code and optional details + */ +export class AppError extends Error { + readonly statusCode: ContentfulStatusCode; + readonly code: string; + + constructor( + message: string, + statusCode: ContentfulStatusCode = 500, + code = "INTERNAL_ERROR", + ) { + super(message); + this.name = "AppError"; + this.statusCode = statusCode; + this.code = code; + } +} + +/** + * Common error factory functions + */ +export const Errors = { + badRequest: (message = "Bad request", code = "BAD_REQUEST") => + new AppError(message, 400, code), + + unauthorized: (message = "Unauthorized", code = "UNAUTHORIZED") => + new AppError(message, 401, code), + + forbidden: (message = "Forbidden", code = "FORBIDDEN") => + new AppError(message, 403, code), + + notFound: (message = "Not found", code = "NOT_FOUND") => + new AppError(message, 404, code), + + conflict: (message = "Conflict", code = "CONFLICT") => + new AppError(message, 409, code), + + validationError: (message = "Validation failed", code = "VALIDATION_ERROR") => + new AppError(message, 422, code), + + internal: (message = "Internal server error", code = "INTERNAL_ERROR") => + new AppError(message, 500, code), +}; + +interface ErrorResponse { + error: { + message: string; + code: string; + }; +} + +/** + * Global error handler middleware for Hono + */ +export function errorHandler(err: Error, c: Context): Response { + // Handle AppError + if (err instanceof AppError) { + const response: ErrorResponse = { + error: { + message: err.message, + code: err.code, + }, + }; + return c.json(response, err.statusCode); + } + + // Handle Hono's HTTPException + if (err instanceof HTTPException) { + const response: ErrorResponse = { + error: { + message: err.message, + code: "HTTP_ERROR", + }, + }; + return c.json(response, err.status as ContentfulStatusCode); + } + + // Handle unknown errors + console.error("Unhandled error:", err); + const response: ErrorResponse = { + error: { + message: + process.env.NODE_ENV === "production" + ? "Internal server error" + : err.message, + code: "INTERNAL_ERROR", + }, + }; + return c.json(response, 500); +} diff --git a/src/server/middleware/index.ts b/src/server/middleware/index.ts new file mode 100644 index 0000000..e894a42 --- /dev/null +++ b/src/server/middleware/index.ts @@ -0,0 +1,2 @@ +export { type AuthUser, authMiddleware, getAuthUser } from "./auth.js"; +export { AppError, Errors, errorHandler } from "./error-handler.js"; diff --git a/src/server/repositories/index.ts b/src/server/repositories/index.ts new file mode 100644 index 0000000..04b1f35 --- /dev/null +++ b/src/server/repositories/index.ts @@ -0,0 +1,3 @@ +export { refreshTokenRepository } from "./refresh-token.js"; +export * from "./types.js"; +export { userRepository } from "./user.js"; diff --git a/src/server/repositories/refresh-token.ts b/src/server/repositories/refresh-token.ts new file mode 100644 index 0000000..e92a744 --- /dev/null +++ b/src/server/repositories/refresh-token.ts @@ -0,0 +1,35 @@ +import { and, eq, gt } from "drizzle-orm"; +import { db, refreshTokens } from "../db/index.js"; +import type { RefreshTokenRepository } from "./types.js"; + +export const refreshTokenRepository: RefreshTokenRepository = { + async findValidToken(tokenHash) { + const [token] = await db + .select({ + id: refreshTokens.id, + userId: refreshTokens.userId, + expiresAt: refreshTokens.expiresAt, + }) + .from(refreshTokens) + .where( + and( + eq(refreshTokens.tokenHash, tokenHash), + gt(refreshTokens.expiresAt, new Date()), + ), + ) + .limit(1); + return token; + }, + + async create(data) { + await db.insert(refreshTokens).values({ + userId: data.userId, + tokenHash: data.tokenHash, + expiresAt: data.expiresAt, + }); + }, + + async deleteById(id) { + await db.delete(refreshTokens).where(eq(refreshTokens.id, id)); + }, +}; diff --git a/src/server/repositories/types.ts b/src/server/repositories/types.ts new file mode 100644 index 0000000..1ab4bdc --- /dev/null +++ b/src/server/repositories/types.ts @@ -0,0 +1,46 @@ +/** + * Repository types for abstracting database operations + */ + +export interface User { + id: string; + username: string; + passwordHash: string; + createdAt: Date; + updatedAt: Date; +} + +export interface UserPublic { + id: string; + username: string; + createdAt: Date; +} + +export interface RefreshToken { + id: string; + userId: string; + tokenHash: string; + expiresAt: Date; + createdAt: Date; +} + +export interface UserRepository { + findByUsername( + username: string, + ): Promise | undefined>; + existsByUsername(username: string): Promise; + create(data: { username: string; passwordHash: string }): Promise; + findById(id: string): Promise | undefined>; +} + +export interface RefreshTokenRepository { + findValidToken( + tokenHash: string, + ): Promise | undefined>; + create(data: { + userId: string; + tokenHash: string; + expiresAt: Date; + }): Promise; + deleteById(id: string): Promise; +} diff --git a/src/server/repositories/user.ts b/src/server/repositories/user.ts new file mode 100644 index 0000000..e571409 --- /dev/null +++ b/src/server/repositories/user.ts @@ -0,0 +1,57 @@ +import { eq } from "drizzle-orm"; +import { db, users } from "../db/index.js"; +import type { UserPublic, UserRepository } from "./types.js"; + +export const userRepository: UserRepository = { + async findByUsername(username) { + const [user] = await db + .select({ + id: users.id, + username: users.username, + passwordHash: users.passwordHash, + }) + .from(users) + .where(eq(users.username, username)) + .limit(1); + return user; + }, + + async existsByUsername(username) { + const [user] = await db + .select({ id: users.id }) + .from(users) + .where(eq(users.username, username)) + .limit(1); + return user !== undefined; + }, + + async create(data): Promise { + const [newUser] = await db + .insert(users) + .values({ + username: data.username, + passwordHash: data.passwordHash, + }) + .returning({ + id: users.id, + username: users.username, + createdAt: users.createdAt, + }); + if (!newUser) { + throw new Error("Failed to create user"); + } + return newUser; + }, + + async findById(id) { + const [user] = await db + .select({ + id: users.id, + username: users.username, + }) + .from(users) + .where(eq(users.id, id)) + .limit(1); + return user; + }, +}; diff --git a/src/server/routes/auth.test.ts b/src/server/routes/auth.test.ts new file mode 100644 index 0000000..95fd6e9 --- /dev/null +++ b/src/server/routes/auth.test.ts @@ -0,0 +1,428 @@ +import { Hono } from "hono"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { errorHandler } from "../middleware/index.js"; +import type { + RefreshTokenRepository, + UserPublic, + UserRepository, +} from "../repositories/index.js"; +import { createAuthRouter } from "./auth.js"; + +vi.mock("argon2", () => ({ + hash: vi.fn((password: string) => Promise.resolve(`hashed_${password}`)), + verify: vi.fn((hash: string, password: string) => + Promise.resolve(hash === `hashed_${password}`), + ), +})); + +function createMockUserRepo(): UserRepository { + return { + findByUsername: vi.fn(), + existsByUsername: vi.fn(), + create: vi.fn(), + findById: vi.fn(), + }; +} + +function createMockRefreshTokenRepo(): RefreshTokenRepository { + return { + findValidToken: vi.fn(), + create: vi.fn(), + deleteById: vi.fn(), + }; +} + +interface RegisterResponse { + user?: { + id: string; + username: string; + createdAt: string; + }; + error?: { + code: string; + message: string; + }; +} + +interface LoginResponse { + accessToken?: string; + refreshToken?: string; + user?: { + id: string; + username: string; + }; + error?: { + code: string; + message: string; + }; +} + +describe("POST /register", () => { + let app: Hono; + let mockUserRepo: ReturnType; + let mockRefreshTokenRepo: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + mockUserRepo = createMockUserRepo(); + mockRefreshTokenRepo = createMockRefreshTokenRepo(); + const auth = createAuthRouter({ + userRepo: mockUserRepo, + refreshTokenRepo: mockRefreshTokenRepo, + }); + app = new Hono(); + app.onError(errorHandler); + app.route("/api/auth", auth); + }); + + it("creates a new user with valid credentials", async () => { + vi.mocked(mockUserRepo.existsByUsername).mockResolvedValue(false); + vi.mocked(mockUserRepo.create).mockResolvedValue({ + id: "test-uuid-123", + username: "testuser", + createdAt: new Date("2024-01-01T00:00:00Z"), + } as UserPublic); + + const res = await app.request("/api/auth/register", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + username: "testuser", + password: "securepassword12345", + }), + }); + + expect(res.status).toBe(201); + const body = (await res.json()) as RegisterResponse; + expect(body.user).toEqual({ + id: "test-uuid-123", + username: "testuser", + createdAt: "2024-01-01T00:00:00.000Z", + }); + expect(mockUserRepo.existsByUsername).toHaveBeenCalledWith("testuser"); + expect(mockUserRepo.create).toHaveBeenCalledWith({ + username: "testuser", + passwordHash: "hashed_securepassword12345", + }); + }); + + it("returns 422 for invalid username", async () => { + const res = await app.request("/api/auth/register", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + username: "", + password: "securepassword12345", + }), + }); + + expect(res.status).toBe(422); + const body = (await res.json()) as RegisterResponse; + expect(body.error?.code).toBe("VALIDATION_ERROR"); + }); + + it("returns 422 for password too short", async () => { + const res = await app.request("/api/auth/register", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + username: "testuser", + password: "tooshort123456", + }), + }); + + expect(res.status).toBe(422); + const body = (await res.json()) as RegisterResponse; + expect(body.error?.code).toBe("VALIDATION_ERROR"); + }); + + it("returns 409 for existing username", async () => { + vi.mocked(mockUserRepo.existsByUsername).mockResolvedValue(true); + + const res = await app.request("/api/auth/register", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + username: "existinguser", + password: "securepassword12345", + }), + }); + + expect(res.status).toBe(409); + const body = (await res.json()) as RegisterResponse; + expect(body.error?.code).toBe("USERNAME_EXISTS"); + }); +}); + +describe("POST /login", () => { + let app: Hono; + let mockUserRepo: ReturnType; + let mockRefreshTokenRepo: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + mockUserRepo = createMockUserRepo(); + mockRefreshTokenRepo = createMockRefreshTokenRepo(); + const auth = createAuthRouter({ + userRepo: mockUserRepo, + refreshTokenRepo: mockRefreshTokenRepo, + }); + app = new Hono(); + app.onError(errorHandler); + app.route("/api/auth", auth); + }); + + it("returns access token for valid credentials", async () => { + vi.mocked(mockUserRepo.findByUsername).mockResolvedValue({ + id: "user-uuid-123", + username: "testuser", + passwordHash: "hashed_correctpassword", + }); + vi.mocked(mockRefreshTokenRepo.create).mockResolvedValue(undefined); + + const res = await app.request("/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + username: "testuser", + password: "correctpassword", + }), + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as LoginResponse; + expect(body.accessToken).toBeDefined(); + expect(typeof body.accessToken).toBe("string"); + expect(body.refreshToken).toBeDefined(); + expect(typeof body.refreshToken).toBe("string"); + expect(body.user).toEqual({ + id: "user-uuid-123", + username: "testuser", + }); + expect(mockRefreshTokenRepo.create).toHaveBeenCalledWith({ + userId: "user-uuid-123", + tokenHash: expect.any(String), + expiresAt: expect.any(Date), + }); + }); + + it("returns 401 for non-existent user", async () => { + vi.mocked(mockUserRepo.findByUsername).mockResolvedValue(undefined); + + const res = await app.request("/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + username: "nonexistent", + password: "anypassword", + }), + }); + + expect(res.status).toBe(401); + const body = (await res.json()) as LoginResponse; + expect(body.error?.code).toBe("INVALID_CREDENTIALS"); + }); + + it("returns 401 for incorrect password", async () => { + vi.mocked(mockUserRepo.findByUsername).mockResolvedValue({ + id: "user-uuid-123", + username: "testuser", + passwordHash: "hashed_correctpassword", + }); + + const res = await app.request("/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + username: "testuser", + password: "wrongpassword", + }), + }); + + expect(res.status).toBe(401); + const body = (await res.json()) as LoginResponse; + expect(body.error?.code).toBe("INVALID_CREDENTIALS"); + }); + + it("returns 422 for missing username", async () => { + const res = await app.request("/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + username: "", + password: "somepassword", + }), + }); + + expect(res.status).toBe(422); + const body = (await res.json()) as LoginResponse; + expect(body.error?.code).toBe("VALIDATION_ERROR"); + }); + + it("returns 422 for missing password", async () => { + const res = await app.request("/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + username: "testuser", + password: "", + }), + }); + + expect(res.status).toBe(422); + const body = (await res.json()) as LoginResponse; + expect(body.error?.code).toBe("VALIDATION_ERROR"); + }); +}); + +interface RefreshResponse { + accessToken?: string; + refreshToken?: string; + user?: { + id: string; + username: string; + }; + error?: { + code: string; + message: string; + }; +} + +describe("POST /refresh", () => { + let app: Hono; + let mockUserRepo: ReturnType; + let mockRefreshTokenRepo: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + mockUserRepo = createMockUserRepo(); + mockRefreshTokenRepo = createMockRefreshTokenRepo(); + const auth = createAuthRouter({ + userRepo: mockUserRepo, + refreshTokenRepo: mockRefreshTokenRepo, + }); + app = new Hono(); + app.onError(errorHandler); + app.route("/api/auth", auth); + }); + + it("returns new tokens for valid refresh token", async () => { + vi.mocked(mockRefreshTokenRepo.findValidToken).mockResolvedValue({ + id: "token-id-123", + userId: "user-uuid-123", + expiresAt: new Date(Date.now() + 86400000), + }); + vi.mocked(mockUserRepo.findById).mockResolvedValue({ + id: "user-uuid-123", + username: "testuser", + }); + vi.mocked(mockRefreshTokenRepo.deleteById).mockResolvedValue(undefined); + vi.mocked(mockRefreshTokenRepo.create).mockResolvedValue(undefined); + + const res = await app.request("/api/auth/refresh", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + refreshToken: "valid-refresh-token-hex", + }), + }); + + expect(res.status).toBe(200); + const body = (await res.json()) as RefreshResponse; + expect(body.accessToken).toBeDefined(); + expect(typeof body.accessToken).toBe("string"); + expect(body.refreshToken).toBeDefined(); + expect(typeof body.refreshToken).toBe("string"); + expect(body.user).toEqual({ + id: "user-uuid-123", + username: "testuser", + }); + expect(mockRefreshTokenRepo.deleteById).toHaveBeenCalledWith( + "token-id-123", + ); + expect(mockRefreshTokenRepo.create).toHaveBeenCalledWith({ + userId: "user-uuid-123", + tokenHash: expect.any(String), + expiresAt: expect.any(Date), + }); + }); + + it("returns 401 for invalid refresh token", async () => { + vi.mocked(mockRefreshTokenRepo.findValidToken).mockResolvedValue(undefined); + + const res = await app.request("/api/auth/refresh", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + refreshToken: "invalid-refresh-token", + }), + }); + + expect(res.status).toBe(401); + const body = (await res.json()) as RefreshResponse; + expect(body.error?.code).toBe("INVALID_REFRESH_TOKEN"); + }); + + it("returns 401 for expired refresh token", async () => { + vi.mocked(mockRefreshTokenRepo.findValidToken).mockResolvedValue(undefined); + + const res = await app.request("/api/auth/refresh", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + refreshToken: "expired-refresh-token", + }), + }); + + expect(res.status).toBe(401); + const body = (await res.json()) as RefreshResponse; + expect(body.error?.code).toBe("INVALID_REFRESH_TOKEN"); + }); + + it("returns 401 when user not found", async () => { + vi.mocked(mockRefreshTokenRepo.findValidToken).mockResolvedValue({ + id: "token-id-123", + userId: "deleted-user-id", + expiresAt: new Date(Date.now() + 86400000), + }); + vi.mocked(mockUserRepo.findById).mockResolvedValue(undefined); + + const res = await app.request("/api/auth/refresh", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + refreshToken: "valid-refresh-token", + }), + }); + + expect(res.status).toBe(401); + const body = (await res.json()) as RefreshResponse; + expect(body.error?.code).toBe("USER_NOT_FOUND"); + }); + + it("returns 422 for missing refresh token", async () => { + const res = await app.request("/api/auth/refresh", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + + expect(res.status).toBe(422); + const body = (await res.json()) as RefreshResponse; + expect(body.error?.code).toBe("VALIDATION_ERROR"); + }); + + it("returns 422 for empty refresh token", async () => { + const res = await app.request("/api/auth/refresh", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + refreshToken: "", + }), + }); + + expect(res.status).toBe(422); + const body = (await res.json()) as RefreshResponse; + expect(body.error?.code).toBe("VALIDATION_ERROR"); + }); +}); diff --git a/src/server/routes/auth.ts b/src/server/routes/auth.ts new file mode 100644 index 0000000..25c959b --- /dev/null +++ b/src/server/routes/auth.ts @@ -0,0 +1,199 @@ +import { createHash, randomBytes } from "node:crypto"; +import * as argon2 from "argon2"; +import { Hono } from "hono"; +import { sign } from "hono/jwt"; +import { Errors } from "../middleware/index.js"; +import { + type RefreshTokenRepository, + refreshTokenRepository, + type UserRepository, + userRepository, +} from "../repositories/index.js"; +import { + createUserSchema, + loginSchema, + refreshTokenSchema, +} from "../schemas/index.js"; + +const JWT_SECRET = process.env.JWT_SECRET; +if (!JWT_SECRET) { + throw new Error("JWT_SECRET environment variable is required"); +} +const ACCESS_TOKEN_EXPIRES_IN = 60 * 15; // 15 minutes +const REFRESH_TOKEN_EXPIRES_IN = 60 * 60 * 24 * 7; // 7 days + +function generateRefreshToken(): string { + return randomBytes(32).toString("hex"); +} + +function hashToken(token: string): string { + return createHash("sha256").update(token).digest("hex"); +} + +export interface AuthDependencies { + userRepo: UserRepository; + refreshTokenRepo: RefreshTokenRepository; +} + +export function createAuthRouter(deps: AuthDependencies) { + const { userRepo, refreshTokenRepo } = deps; + const auth = new Hono(); + + auth.post("/register", async (c) => { + const body = await c.req.json(); + + const parsed = createUserSchema.safeParse(body); + if (!parsed.success) { + throw Errors.validationError(parsed.error.issues[0]?.message); + } + + const { username, password } = parsed.data; + + // Check if username already exists + const exists = await userRepo.existsByUsername(username); + if (exists) { + throw Errors.conflict("Username already exists", "USERNAME_EXISTS"); + } + + // Hash password with Argon2 + const passwordHash = await argon2.hash(password); + + // Create user + const newUser = await userRepo.create({ username, passwordHash }); + + return c.json({ user: newUser }, 201); + }); + + auth.post("/login", async (c) => { + const body = await c.req.json(); + + const parsed = loginSchema.safeParse(body); + if (!parsed.success) { + throw Errors.validationError(parsed.error.issues[0]?.message); + } + + const { username, password } = parsed.data; + + // Find user by username + const user = await userRepo.findByUsername(username); + + if (!user) { + throw Errors.unauthorized( + "Invalid username or password", + "INVALID_CREDENTIALS", + ); + } + + // Verify password + const isPasswordValid = await argon2.verify(user.passwordHash, password); + if (!isPasswordValid) { + throw Errors.unauthorized( + "Invalid username or password", + "INVALID_CREDENTIALS", + ); + } + + // Generate JWT access token + const now = Math.floor(Date.now() / 1000); + const accessToken = await sign( + { + sub: user.id, + iat: now, + exp: now + ACCESS_TOKEN_EXPIRES_IN, + }, + JWT_SECRET, + ); + + // Generate refresh token + const refreshToken = generateRefreshToken(); + const tokenHash = hashToken(refreshToken); + const expiresAt = new Date(Date.now() + REFRESH_TOKEN_EXPIRES_IN * 1000); + + // Store refresh token in database + await refreshTokenRepo.create({ + userId: user.id, + tokenHash, + expiresAt, + }); + + return c.json({ + accessToken, + refreshToken, + user: { + id: user.id, + username: user.username, + }, + }); + }); + + auth.post("/refresh", async (c) => { + const body = await c.req.json(); + + const parsed = refreshTokenSchema.safeParse(body); + if (!parsed.success) { + throw Errors.validationError(parsed.error.issues[0]?.message); + } + + const { refreshToken } = parsed.data; + const tokenHash = hashToken(refreshToken); + + // Find valid refresh token + const storedToken = await refreshTokenRepo.findValidToken(tokenHash); + + if (!storedToken) { + throw Errors.unauthorized( + "Invalid or expired refresh token", + "INVALID_REFRESH_TOKEN", + ); + } + + // Get user info + const user = await userRepo.findById(storedToken.userId); + + if (!user) { + throw Errors.unauthorized("User not found", "USER_NOT_FOUND"); + } + + // Delete old refresh token (rotation) + await refreshTokenRepo.deleteById(storedToken.id); + + // Generate new access token + const now = Math.floor(Date.now() / 1000); + const accessToken = await sign( + { + sub: user.id, + iat: now, + exp: now + ACCESS_TOKEN_EXPIRES_IN, + }, + JWT_SECRET, + ); + + // Generate new refresh token (rotation) + const newRefreshToken = generateRefreshToken(); + const newTokenHash = hashToken(newRefreshToken); + const expiresAt = new Date(Date.now() + REFRESH_TOKEN_EXPIRES_IN * 1000); + + await refreshTokenRepo.create({ + userId: user.id, + tokenHash: newTokenHash, + expiresAt, + }); + + return c.json({ + accessToken, + refreshToken: newRefreshToken, + user: { + id: user.id, + username: user.username, + }, + }); + }); + + return auth; +} + +// Default auth router with real repositories for production use +export const auth = createAuthRouter({ + userRepo: userRepository, + refreshTokenRepo: refreshTokenRepository, +}); diff --git a/src/server/routes/index.ts b/src/server/routes/index.ts new file mode 100644 index 0000000..0b89782 --- /dev/null +++ b/src/server/routes/index.ts @@ -0,0 +1 @@ +export { auth } from "./auth.js"; diff --git a/src/server/schemas/index.ts b/src/server/schemas/index.ts new file mode 100644 index 0000000..05b926a --- /dev/null +++ b/src/server/schemas/index.ts @@ -0,0 +1,140 @@ +import { z } from "zod"; + +// Card states for FSRS algorithm +export const cardStateSchema = z.union([ + z.literal(0), // New + z.literal(1), // Learning + z.literal(2), // Review + z.literal(3), // Relearning +]); + +// Rating values for reviews +export const ratingSchema = z.union([ + z.literal(1), // Again + z.literal(2), // Hard + z.literal(3), // Good + z.literal(4), // Easy +]); + +// User schema +export const userSchema = z.object({ + id: z.string().uuid(), + username: z.string().min(1).max(255), + passwordHash: z.string(), + createdAt: z.coerce.date(), + updatedAt: z.coerce.date(), +}); + +// User creation input schema +export const createUserSchema = z.object({ + username: z.string().min(1).max(255), + password: z.string().min(15).max(255), +}); + +// Login input schema +export const loginSchema = z.object({ + username: z.string().min(1), + password: z.string().min(1), +}); + +// Refresh token input schema +export const refreshTokenSchema = z.object({ + refreshToken: z.string().min(1), +}); + +// Deck schema +export const deckSchema = z.object({ + id: z.string().uuid(), + userId: z.string().uuid(), + name: z.string().min(1).max(255), + description: z.string().max(1000).nullable(), + newCardsPerDay: z.number().int().min(0).default(20), + createdAt: z.coerce.date(), + updatedAt: z.coerce.date(), + deletedAt: z.coerce.date().nullable(), + syncVersion: z.number().int().min(0), +}); + +// Deck creation input schema +export const createDeckSchema = z.object({ + name: z.string().min(1).max(255), + description: z.string().max(1000).nullable().optional(), + newCardsPerDay: z.number().int().min(0).default(20), +}); + +// Deck update input schema +export const updateDeckSchema = z.object({ + name: z.string().min(1).max(255).optional(), + description: z.string().max(1000).nullable().optional(), + newCardsPerDay: z.number().int().min(0).optional(), +}); + +// Card schema +export const cardSchema = z.object({ + id: z.string().uuid(), + deckId: z.string().uuid(), + front: z.string().min(1), + back: z.string().min(1), + + // FSRS fields + state: cardStateSchema, + due: z.coerce.date(), + stability: z.number().min(0), + difficulty: z.number().min(0).max(10), + elapsedDays: z.number().int().min(0), + scheduledDays: z.number().int().min(0), + reps: z.number().int().min(0), + lapses: z.number().int().min(0), + lastReview: z.coerce.date().nullable(), + + createdAt: z.coerce.date(), + updatedAt: z.coerce.date(), + deletedAt: z.coerce.date().nullable(), + syncVersion: z.number().int().min(0), +}); + +// Card creation input schema +export const createCardSchema = z.object({ + front: z.string().min(1), + back: z.string().min(1), +}); + +// Card update input schema +export const updateCardSchema = z.object({ + front: z.string().min(1).optional(), + back: z.string().min(1).optional(), +}); + +// ReviewLog schema +export const reviewLogSchema = z.object({ + id: z.string().uuid(), + cardId: z.string().uuid(), + userId: z.string().uuid(), + rating: ratingSchema, + state: cardStateSchema, + scheduledDays: z.number().int().min(0), + elapsedDays: z.number().int().min(0), + reviewedAt: z.coerce.date(), + durationMs: z.number().int().min(0).nullable(), + syncVersion: z.number().int().min(0), +}); + +// Submit review input schema +export const submitReviewSchema = z.object({ + rating: ratingSchema, + durationMs: z.number().int().min(0).nullable().optional(), +}); + +// Inferred types from schemas +export type UserSchema = z.infer; +export type CreateUserSchema = z.infer; +export type LoginSchema = z.infer; +export type RefreshTokenSchema = z.infer; +export type DeckSchema = z.infer; +export type CreateDeckSchema = z.infer; +export type UpdateDeckSchema = z.infer; +export type CardSchema = z.infer; +export type CreateCardSchema = z.infer; +export type UpdateCardSchema = z.infer; +export type ReviewLogSchema = z.infer; +export type SubmitReviewSchema = z.infer; diff --git a/src/server/types/index.ts b/src/server/types/index.ts new file mode 100644 index 0000000..bfba06f --- /dev/null +++ b/src/server/types/index.ts @@ -0,0 +1,79 @@ +// Card states for FSRS algorithm +export const CardState = { + New: 0, + Learning: 1, + Review: 2, + Relearning: 3, +} as const; + +export type CardState = (typeof CardState)[keyof typeof CardState]; + +// Rating values for reviews +export const Rating = { + Again: 1, + Hard: 2, + Good: 3, + Easy: 4, +} as const; + +export type Rating = (typeof Rating)[keyof typeof Rating]; + +// User +export interface User { + id: string; + username: string; + passwordHash: string; + createdAt: Date; + updatedAt: Date; +} + +// Deck +export interface Deck { + id: string; + userId: string; + name: string; + description: string | null; + newCardsPerDay: number; + createdAt: Date; + updatedAt: Date; + deletedAt: Date | null; + syncVersion: number; +} + +// Card with FSRS fields +export interface Card { + id: string; + deckId: string; + front: string; + back: string; + + // FSRS fields + state: CardState; + due: Date; + stability: number; + difficulty: number; + elapsedDays: number; + scheduledDays: number; + reps: number; + lapses: number; + lastReview: Date | null; + + createdAt: Date; + updatedAt: Date; + deletedAt: Date | null; + syncVersion: number; +} + +// ReviewLog (append-only) +export interface ReviewLog { + id: string; + cardId: string; + userId: string; + rating: Rating; + state: CardState; + scheduledDays: number; + elapsedDays: number; + reviewedAt: Date; + durationMs: number | null; + syncVersion: number; +} diff --git a/tsconfig.json b/tsconfig.json index 564e2a2..bed26bd 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -16,7 +16,10 @@ "verbatimModuleSyntax": true, "noUncheckedIndexedAccess": true, "noImplicitOverride": true, - "forceConsistentCasingInFileNames": true + "forceConsistentCasingInFileNames": true, + "outDir": "./dist", + "rootDir": "./src" }, + "include": ["src/**/*"], "exclude": ["node_modules", "dist"] } diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..af9649f --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: true, + env: { + JWT_SECRET: "test-secret-key", + }, + }, +}); -- cgit v1.2.3-70-g09d2