diff --git a/.cursor/rules/frontend/stack.mdc b/.cursor/rules/frontend/stack.mdc index cee95c94..68bce6e6 100644 --- a/.cursor/rules/frontend/stack.mdc +++ b/.cursor/rules/frontend/stack.mdc @@ -7,7 +7,7 @@ See @apps/docu/content/docs/architecture/frontend-stack.mdx for complete documen ## Core Libraries - `@tanstack/react-query`: Data fetching/async (use `@lukemorales/query-key-factory`) -- `@repo/react`: Generated React Query hooks from Hey API clients +- `@repo/react`: React Query hooks and helpers only—never UI components. Route-specific UI (e.g. login form) lives in apps, collocated by route - `nuqs`: URL-based state (search/filters/tabs/pagination) - `zod`: Schema validation - `@repo/lib`: Utility library diff --git a/.gitignore b/.gitignore index e0c05ebd..84b76ec8 100644 --- a/.gitignore +++ b/.gitignore @@ -60,6 +60,9 @@ __dev__dev # TypeScript build info *.tsbuildinfo +# tsup temp config (bundled during build) +tsup.config.bundled_*.mjs + # Package publish temp files .package-originals.json diff --git a/.gitleaks.toml b/.gitleaks.toml index 262cf651..2d3ac641 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -15,6 +15,7 @@ paths = [ '''\.env\.example$''', '''\.env\.test\.example$''', '''\.env-sample$''', + '''^apps/next/\.env\.local\.example$''', '''\.env\.sample$''', '''\.env\.schema$''', '''\.env\.test$''', diff --git a/apps/docu/app/(home)/layout.tsx b/apps/docu/app/(home)/layout.tsx index 5f8498f1..45ec0eb3 100644 --- a/apps/docu/app/(home)/layout.tsx +++ b/apps/docu/app/(home)/layout.tsx @@ -1,6 +1,7 @@ import { HomeLayout } from 'fumadocs-ui/layouts/home' +import type { ReactNode } from 'react' import { baseOptions } from '@/lib/layout.shared' -export default function Layout({ children }: LayoutProps<'/'>) { +export default function Layout({ children }: { children: ReactNode }) { return {children} } diff --git a/apps/docu/app/docs/[[...slug]]/page.tsx b/apps/docu/app/docs/[[...slug]]/page.tsx index cad88b1a..c6a331b6 100644 --- a/apps/docu/app/docs/[[...slug]]/page.tsx +++ b/apps/docu/app/docs/[[...slug]]/page.tsx @@ -6,7 +6,9 @@ import { notFound } from 'next/navigation' import { getPageImage, source } from '@/lib/source' import { getMDXComponents } from '@/mdx-components' -export default async function Page(props: PageProps<'/docs/[[...slug]]'>) { +type PageParams = { slug?: string[] } + +export default async function Page(props: { params: Promise }) { const params = await props.params const page = source.getPage(params.slug) if (!page) notFound() @@ -35,7 +37,7 @@ export async function generateStaticParams() { return source.generateParams() } -export async function generateMetadata(props: PageProps<'/docs/[[...slug]]'>): Promise { +export async function generateMetadata(props: { params: Promise }): Promise { const params = await props.params const page = source.getPage(params.slug) if (!page) notFound() diff --git a/apps/docu/app/docs/layout.tsx b/apps/docu/app/docs/layout.tsx index 03ea4ad9..ab0cb6e3 100644 --- a/apps/docu/app/docs/layout.tsx +++ b/apps/docu/app/docs/layout.tsx @@ -1,8 +1,9 @@ import { DocsLayout } from 'fumadocs-ui/layouts/docs' +import type { ReactNode } from 'react' import { baseOptions } from '@/lib/layout.shared' import { source } from '@/lib/source' -export default function Layout({ children }: LayoutProps<'/docs'>) { +export default function Layout({ children }: { children: ReactNode }) { return ( {children} diff --git a/apps/docu/app/layout.tsx b/apps/docu/app/layout.tsx index 3989adaa..3c62d5f8 100644 --- a/apps/docu/app/layout.tsx +++ b/apps/docu/app/layout.tsx @@ -1,4 +1,5 @@ import { RootProvider } from 'fumadocs-ui/provider/next' +import type { ReactNode } from 'react' import './global.css' import type { Metadata } from 'next' import { Inter } from 'next/font/google' @@ -12,7 +13,7 @@ export const metadata: Metadata = { metadataBase: new URL(env.NEXT_PUBLIC_SITE_URL), } -export default function Layout({ children }: LayoutProps<'/'>) { +export default function Layout({ children }: { children: ReactNode }) { return ( diff --git a/apps/docu/app/og/docs/[...slug]/route.tsx b/apps/docu/app/og/docs/[...slug]/route.tsx index d2c58e87..a1843f50 100644 --- a/apps/docu/app/og/docs/[...slug]/route.tsx +++ b/apps/docu/app/og/docs/[...slug]/route.tsx @@ -5,7 +5,7 @@ import { getPageImage, source } from '@/lib/source' export const revalidate = false -export async function GET(_req: Request, { params }: RouteContext<'/og/docs/[...slug]'>) { +export async function GET(_req: Request, { params }: { params: Promise<{ slug: string[] }> }) { const { slug } = await params const page = source.getPage(slug.slice(0, -1)) if (!page) notFound() diff --git a/apps/docu/content/docs/architecture/authentication.mdx b/apps/docu/content/docs/architecture/authentication.mdx index b1980118..d210b60e 100644 --- a/apps/docu/content/docs/architecture/authentication.mdx +++ b/apps/docu/content/docs/architecture/authentication.mdx @@ -85,7 +85,13 @@ Passkeys use WebAuthn for passwordless registration and sign-in. Registration cr ### Magic link (implemented) -Magic link sign-in issues a **single-use token** over email, then exchanges it for JWTs. +Magic link sign-in issues a **6-digit login code** over email, then exchanges it for JWTs. Users can either type the code into the login form or click the magic link button in the email. + +**Flow:** +1. User enters email → `POST /auth/magiclink/request` → email sent with subject `{code} - {APP_NAME} verification code` +2. Email contains the 6-digit code prominently and a "Sign in" button (magic link) +3. **Option A (manual):** User types the 6-digit code in the login form → `POST /auth/magiclink/verify` with `{ token: code }` → JWTs returned +4. **Option B (link):** User clicks the button in email → callback page receives `?token={code}` → verify → cookies set, redirect ### OAuth (GitHub) @@ -393,26 +399,35 @@ sequenceDiagram ## Magic link flow (web) -The email link points to the callback page, which exchanges the token for JWTs and sets cookies. +A 6-digit code is sent by email. Users can enter the code on the login page or click the magic link in the email. Both paths exchange the code for JWTs. ```mermaid sequenceDiagram participant Browser + participant LoginForm participant CallbackPage as /auth/callback/magiclink participant FastifyAPI as Fastify API participant DB as PostgreSQL participant Email as Email provider Browser->>FastifyAPI: POST /auth/magiclink/request (email, callbackUrl) - FastifyAPI->>DB: upsert user + store verification token - FastifyAPI->>Email: send email with callbackUrl?token=... - Email-->>Browser: magic link - - Browser->>CallbackPage: GET /auth/callback/magiclink?token=...&callbackURL=... - CallbackPage->>FastifyAPI: POST /auth/magiclink/verify (token) - FastifyAPI->>DB: consume token, create session - FastifyAPI-->>CallbackPage: { token, refreshToken } - CallbackPage->>CallbackPage: set cookies, redirect to callbackURL + FastifyAPI->>DB: upsert user + store hash(6-digit code) + FastifyAPI->>Email: send email (code + magic link button) + Email-->>Browser: email with code and link + + alt Manual code entry + Browser->>LoginForm: User enters 6-digit code + LoginForm->>FastifyAPI: POST /auth/magiclink/verify (token: code) + FastifyAPI->>DB: consume code, create session + FastifyAPI-->>LoginForm: { token, refreshToken } + LoginForm->>LoginForm: updateAuthTokens, redirect + else Link click + Browser->>CallbackPage: GET /auth/callback/magiclink?token={code}&callbackUrl=... + CallbackPage->>FastifyAPI: POST /auth/magiclink/verify (token) + FastifyAPI->>DB: consume code, create session + FastifyAPI-->>CallbackPage: { token, refreshToken } + CallbackPage->>CallbackPage: set cookies, redirect to callbackUrl + end Browser->>FastifyAPI: GET /auth/session/user (Bearer from cookie) FastifyAPI-->>Browser: { user } diff --git a/apps/docu/content/docs/architecture/frontend-stack.mdx b/apps/docu/content/docs/architecture/frontend-stack.mdx index c8fac8e3..8aa7a023 100644 --- a/apps/docu/content/docs/architecture/frontend-stack.mdx +++ b/apps/docu/content/docs/architecture/frontend-stack.mdx @@ -8,7 +8,7 @@ Core technology choices and patterns for frontend apps. See [Frontend Architectu ## Data fetching & async state - **`@tanstack/react-query`**: default choice for async data and server state (caching, dedupe, retries) -- **`@repo/react`**: generated React Query hooks and integration for the OpenAPI client surface +- **`@repo/react`**: React Query hooks and helpers only (no UI components). Route-specific UI (e.g. login form) lives in apps, collocated by route - **`@repo/core`**: generated, runtime-agnostic API client and types - **`@lukemorales/query-key-factory`**: centralized query key factories (for hand-written queries) diff --git a/apps/docu/content/docs/development/package-conventions.mdx b/apps/docu/content/docs/development/package-conventions.mdx index ec3000fd..f939c26f 100644 --- a/apps/docu/content/docs/development/package-conventions.mdx +++ b/apps/docu/content/docs/development/package-conventions.mdx @@ -28,7 +28,7 @@ The key idea: - **Fastify routes + TypeBox schemas** are the source of truth. - The **OpenAPI spec** is generated from routes. - `@repo/core` contains the **generated client + types** and a small wrapper API. -- `@repo/react` is a **React Query layer** on top of `@repo/core` (handwritten hooks/components). +- `@repo/react` is a **React Query layer** on top of `@repo/core` (handwritten hooks and helpers only—no UI components). ## Workspace layout @@ -122,10 +122,11 @@ React-only helpers using **TanStack Query**. - React Query hooks that call `@repo/core` (handwritten) - Shared provider/context to supply a core client instance to hooks -- Small React utilities/components that are reusable across apps +- Hooks and helpers only—no UI components (login forms, cards, etc.). UI lives in apps, collocated by route **Hard rules** +* ✅ Hooks and helpers only—never add UI components to `@repo/react` * ✅ React-only dependencies live here * ✅ `@tanstack/react-query` is a peer dependency (apps own the version) diff --git a/apps/docu/content/docs/development/packages.mdx b/apps/docu/content/docs/development/packages.mdx index faedf54a..02923ccb 100644 --- a/apps/docu/content/docs/development/packages.mdx +++ b/apps/docu/content/docs/development/packages.mdx @@ -101,7 +101,6 @@ React-only helpers built on top of `@repo/core`. - `useUser` - `useHealthCheck` - `useMagicLink` -- `LoginForm` - `createReactApiConfig` **Usage** (minimal): diff --git a/apps/fastify/.env-sample b/apps/fastify/.env-sample index 7302a889..4d37ea01 100644 --- a/apps/fastify/.env-sample +++ b/apps/fastify/.env-sample @@ -11,6 +11,9 @@ OLLAMA_BASE_URL=https://ollama.example.com # Optional: Override default model. Ollama: qwen2.5:3b. Open Router: openrouter/free # AI_DEFAULT_MODEL=qwen2.5:3b +# JWT signing secret. Must match Next.js. Min 32 chars. +JWT_SECRET=default-jwt-secret-min-32-chars-for-dev + # Database (PGLITE=true uses in-memory PGLite; when false, DATABASE_URL is required) PGLITE=false DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:54322/postgres diff --git a/apps/fastify/.gitignore b/apps/fastify/.gitignore index 676654da..ce27875a 100644 --- a/apps/fastify/.gitignore +++ b/apps/fastify/.gitignore @@ -52,8 +52,11 @@ profile-* .vscode *code-workspace -# clinic -profile* +# clinic (profile-*.json etc; exclude account profile route) +profile-*.json +profile-*.txt +!src/routes/account/profile/ +!src/routes/account/profile/** *clinic* *flamegraph* diff --git a/apps/fastify/eslint.config.mjs b/apps/fastify/eslint.config.mjs index 3b64cf47..644ad146 100644 --- a/apps/fastify/eslint.config.mjs +++ b/apps/fastify/eslint.config.mjs @@ -8,4 +8,11 @@ export default [ 'max-lines': ['error', { max: 400, skipBlankLines: true, skipComments: true }], }, }, + { + files: ['src/routes/reference/template.ts'], + rules: { + 'max-lines': ['error', { max: 350, skipBlankLines: true, skipComments: true }], + 'max-params': 'off', + }, + }, ] diff --git a/apps/fastify/openapi/openapi.json b/apps/fastify/openapi/openapi.json index aba84d03..29bc6f4b 100644 --- a/apps/fastify/openapi/openapi.json +++ b/apps/fastify/openapi/openapi.json @@ -1749,6 +1749,156 @@ } } }, + "/account/profile/": { + "patch": { + "operationId": "accountProfileUpdate", + "summary": "Update profile", + "tags": [ + "account" + ], + "description": "Update profile (name, username)", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "minLength": 1, + "maxLength": 32, + "type": "string" + }, + "username": { + "anyOf": [ + { + "minLength": 1, + "maxLength": 48, + "pattern": "^[a-zA-Z0-9_-]{1,48}$", + "type": "string" + }, + { + "type": "null" + } + ] + } + } + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "user" + ], + "properties": { + "user": { + "type": "object", + "required": [ + "id", + "email", + "name", + "username" + ], + "properties": { + "id": { + "type": "string" + }, + "email": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "username": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + } + } + } + } + } + } + }, + "401": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "code", + "message" + ], + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } + } + } + } + }, + "409": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "code", + "message" + ], + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } + } + } + } + } + } + } + }, "/ai/chat": { "post": { "operationId": "chat", @@ -2025,6 +2175,18 @@ ], "properties": { "token": { + "pattern": "^\\d{6}$", + "description": "6-digit code", + "type": "string" + }, + "verificationId": { + "format": "uuid", + "description": "Verification row id (from magic link URL)", + "type": "string" + }, + "email": { + "format": "email", + "description": "Email (for code entry on login page)", "type": "string" } } @@ -2056,6 +2218,28 @@ } } }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "code", + "message" + ], + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } + } + } + } + }, "401": { "description": "Default Response", "content": { @@ -2099,6 +2283,28 @@ } } } + }, + "429": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "code", + "message" + ], + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + } + } + } + } + } } } } @@ -3653,6 +3859,7 @@ "id", "email", "name", + "username", "emailVerified", "linkedWallets", "totpEnabled", @@ -3682,6 +3889,16 @@ } ] }, + "username": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, "emailVerified": { "anyOf": [ { diff --git a/apps/fastify/package.json b/apps/fastify/package.json index 0a3c2bb4..5c8fe311 100644 --- a/apps/fastify/package.json +++ b/apps/fastify/package.json @@ -79,7 +79,8 @@ "siwe": "^3.0.0", "tweetnacl": "^1.0.3", "viem": "^2.45.3", - "zod": "^4.3.6" + "zod": "^4.3.6", + "@faker-js/faker": "^9.4.0" }, "devDependencies": { "@electric-sql/pglite": "^0.3.15", diff --git a/apps/fastify/src/db/migrations/0012_aberrant_johnny_blaze.sql b/apps/fastify/src/db/migrations/0012_aberrant_johnny_blaze.sql new file mode 100644 index 00000000..a96faec8 --- /dev/null +++ b/apps/fastify/src/db/migrations/0012_aberrant_johnny_blaze.sql @@ -0,0 +1,2 @@ +ALTER TABLE "users" ADD COLUMN "username" varchar(48);--> statement-breakpoint +ALTER TABLE "users" ADD CONSTRAINT "users_username_unique" UNIQUE("username"); \ No newline at end of file diff --git a/apps/fastify/src/db/migrations/0013_familiar_master_chief.sql b/apps/fastify/src/db/migrations/0013_familiar_master_chief.sql new file mode 100644 index 00000000..dd0f9965 --- /dev/null +++ b/apps/fastify/src/db/migrations/0013_familiar_master_chief.sql @@ -0,0 +1,12 @@ +CREATE TABLE "auth_attempts" ( + "id" text PRIMARY KEY NOT NULL, + "key" text NOT NULL, + "type" text DEFAULT 'magic_link' NOT NULL, + "failed_attempts" integer DEFAULT 0 NOT NULL, + "first_failure_at" timestamp, + "locked_until" timestamp, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE INDEX "auth_attempts_key_type_idx" ON "auth_attempts" USING btree ("key","type"); \ No newline at end of file diff --git a/apps/fastify/src/db/migrations/0014_slippery_puff_adder.sql b/apps/fastify/src/db/migrations/0014_slippery_puff_adder.sql new file mode 100644 index 00000000..0c698a68 --- /dev/null +++ b/apps/fastify/src/db/migrations/0014_slippery_puff_adder.sql @@ -0,0 +1 @@ +CREATE UNIQUE INDEX "auth_attempts_key_type_unique" ON "auth_attempts" USING btree ("key","type"); \ No newline at end of file diff --git a/apps/fastify/src/db/migrations/0015_shallow_umar.sql b/apps/fastify/src/db/migrations/0015_shallow_umar.sql new file mode 100644 index 00000000..aa99b394 --- /dev/null +++ b/apps/fastify/src/db/migrations/0015_shallow_umar.sql @@ -0,0 +1 @@ +ALTER TABLE "account" ADD CONSTRAINT "account_provider_account_unique" UNIQUE("provider_id","account_id"); \ No newline at end of file diff --git a/apps/fastify/src/db/migrations/meta/0012_snapshot.json b/apps/fastify/src/db/migrations/meta/0012_snapshot.json new file mode 100644 index 00000000..3888e6fd --- /dev/null +++ b/apps/fastify/src/db/migrations/meta/0012_snapshot.json @@ -0,0 +1,1291 @@ +{ + "id": "e7372605-2343-4e5f-a72c-e4ef8f3d0e35", + "prevId": "f831f8ab-46c2-4f66-8fc3-d4139a786896", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "account_user_id_idx": { + "name": "account_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "account_account_id_idx": { + "name": "account_account_id_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_users_id_fk": { + "name": "account_user_id_users_id_fk", + "tableFrom": "account", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_keys": { + "name": "api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "api_keys_prefix_idx": { + "name": "api_keys_prefix_idx", + "columns": [ + { + "expression": "prefix", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_keys_user_id_idx": { + "name": "api_keys_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "api_keys_user_id_users_id_fk": { + "name": "api_keys_user_id_users_id_fk", + "tableFrom": "api_keys", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_keys_prefix_unique": { + "name": "api_keys_prefix_unique", + "nullsNotDistinct": false, + "columns": ["prefix"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.passkey_auth_challenges": { + "name": "passkey_auth_challenges", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "challenge": { + "name": "challenge", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "passkey_auth_challenges_session_id_idx": { + "name": "passkey_auth_challenges_session_id_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "passkey_auth_challenges_expires_at_idx": { + "name": "passkey_auth_challenges_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.passkey_callback": { + "name": "passkey_callback", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "code_hash": { + "name": "code_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "callback_origin": { + "name": "callback_origin", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "passkey_callback_code_hash_idx": { + "name": "passkey_callback_code_hash_idx", + "columns": [ + { + "expression": "code_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "passkey_callback_expires_at_idx": { + "name": "passkey_callback_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.passkey_challenges": { + "name": "passkey_challenges", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "challenge": { + "name": "challenge", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "passkey_challenges_user_id_idx": { + "name": "passkey_challenges_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "passkey_challenges_expires_at_idx": { + "name": "passkey_challenges_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "passkey_challenges_user_id_users_id_fk": { + "name": "passkey_challenges_user_id_users_id_fk", + "tableFrom": "passkey_challenges", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.passkey_credentials": { + "name": "passkey_credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "counter": { + "name": "counter", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "transports": { + "name": "transports", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "credential_device_type": { + "name": "credential_device_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_backed_up": { + "name": "credential_backed_up", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "passkey_credentials_user_id_idx": { + "name": "passkey_credentials_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "passkey_credentials_user_id_users_id_fk": { + "name": "passkey_credentials_user_id_users_id_fk", + "tableFrom": "passkey_credentials", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "passkey_credentials_credential_id_unique": { + "name": "passkey_credentials_credential_id_unique", + "nullsNotDistinct": false, + "columns": ["credential_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "wallet_chain": { + "name": "wallet_chain", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "wallet_address": { + "name": "wallet_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sessions_user_id_idx": { + "name": "sessions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_expires_at_idx": { + "name": "sessions_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_token_idx": { + "name": "sessions_token_idx", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.totp_setup": { + "name": "totp_setup", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_encrypted": { + "name": "secret_encrypted", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "totp_setup_user_id_users_id_fk": { + "name": "totp_setup_user_id_users_id_fk", + "tableFrom": "totp_setup", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "totp_setup_user_id_unique": { + "name": "totp_setup_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.totp": { + "name": "totp", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_encrypted": { + "name": "secret_encrypted", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "totp_user_id_users_id_fk": { + "name": "totp_user_id_users_id_fk", + "tableFrom": "totp", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "totp_user_id_unique": { + "name": "totp_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "username": { + "name": "username", + "type": "varchar(48)", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_email_idx": { + "name": "users_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + }, + "users_username_unique": { + "name": "users_username_unique", + "nullsNotDistinct": false, + "columns": ["username"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'magic_link'" + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_plain": { + "name": "token_plain", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "meta": { + "name": "meta", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "verification_expires_at_idx": { + "name": "verification_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wallet_identities": { + "name": "wallet_identities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chain": { + "name": "chain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "wallet_provider": { + "name": "wallet_provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "wallet_user_id_idx": { + "name": "wallet_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_address_idx": { + "name": "wallet_address_idx", + "columns": [ + { + "expression": "address", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wallet_identities_user_id_users_id_fk": { + "name": "wallet_identities_user_id_users_id_fk", + "tableFrom": "wallet_identities", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "wallet_chain_address_unique": { + "name": "wallet_chain_address_unique", + "nullsNotDistinct": false, + "columns": ["chain", "address"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.web3_callback": { + "name": "web3_callback", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "code_hash": { + "name": "code_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "web3_callback_code_hash_idx": { + "name": "web3_callback_code_hash_idx", + "columns": [ + { + "expression": "code_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "web3_callback_expires_at_idx": { + "name": "web3_callback_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.web3_nonce": { + "name": "web3_nonce", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "chain": { + "name": "chain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "nonce": { + "name": "nonce", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "web3_nonce_chain_address_idx": { + "name": "web3_nonce_chain_address_idx", + "columns": [ + { + "expression": "chain", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "address", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "web3_nonce_expires_at_idx": { + "name": "web3_nonce_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/apps/fastify/src/db/migrations/meta/0013_snapshot.json b/apps/fastify/src/db/migrations/meta/0013_snapshot.json new file mode 100644 index 00000000..0fce063a --- /dev/null +++ b/apps/fastify/src/db/migrations/meta/0013_snapshot.json @@ -0,0 +1,1378 @@ +{ + "id": "3912cde9-f064-4562-bae8-6b9acba4d9a7", + "prevId": "e7372605-2343-4e5f-a72c-e4ef8f3d0e35", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "account_user_id_idx": { + "name": "account_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "account_account_id_idx": { + "name": "account_account_id_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_users_id_fk": { + "name": "account_user_id_users_id_fk", + "tableFrom": "account", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_keys": { + "name": "api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "api_keys_prefix_idx": { + "name": "api_keys_prefix_idx", + "columns": [ + { + "expression": "prefix", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_keys_user_id_idx": { + "name": "api_keys_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "api_keys_user_id_users_id_fk": { + "name": "api_keys_user_id_users_id_fk", + "tableFrom": "api_keys", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_keys_prefix_unique": { + "name": "api_keys_prefix_unique", + "nullsNotDistinct": false, + "columns": ["prefix"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_attempts": { + "name": "auth_attempts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'magic_link'" + }, + "failed_attempts": { + "name": "failed_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "first_failure_at": { + "name": "first_failure_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "locked_until": { + "name": "locked_until", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "auth_attempts_key_type_idx": { + "name": "auth_attempts_key_type_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.passkey_auth_challenges": { + "name": "passkey_auth_challenges", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "challenge": { + "name": "challenge", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "passkey_auth_challenges_session_id_idx": { + "name": "passkey_auth_challenges_session_id_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "passkey_auth_challenges_expires_at_idx": { + "name": "passkey_auth_challenges_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.passkey_callback": { + "name": "passkey_callback", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "code_hash": { + "name": "code_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "callback_origin": { + "name": "callback_origin", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "passkey_callback_code_hash_idx": { + "name": "passkey_callback_code_hash_idx", + "columns": [ + { + "expression": "code_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "passkey_callback_expires_at_idx": { + "name": "passkey_callback_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.passkey_challenges": { + "name": "passkey_challenges", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "challenge": { + "name": "challenge", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "passkey_challenges_user_id_idx": { + "name": "passkey_challenges_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "passkey_challenges_expires_at_idx": { + "name": "passkey_challenges_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "passkey_challenges_user_id_users_id_fk": { + "name": "passkey_challenges_user_id_users_id_fk", + "tableFrom": "passkey_challenges", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.passkey_credentials": { + "name": "passkey_credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "counter": { + "name": "counter", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "transports": { + "name": "transports", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "credential_device_type": { + "name": "credential_device_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_backed_up": { + "name": "credential_backed_up", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "passkey_credentials_user_id_idx": { + "name": "passkey_credentials_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "passkey_credentials_user_id_users_id_fk": { + "name": "passkey_credentials_user_id_users_id_fk", + "tableFrom": "passkey_credentials", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "passkey_credentials_credential_id_unique": { + "name": "passkey_credentials_credential_id_unique", + "nullsNotDistinct": false, + "columns": ["credential_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "wallet_chain": { + "name": "wallet_chain", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "wallet_address": { + "name": "wallet_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sessions_user_id_idx": { + "name": "sessions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_expires_at_idx": { + "name": "sessions_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_token_idx": { + "name": "sessions_token_idx", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.totp_setup": { + "name": "totp_setup", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_encrypted": { + "name": "secret_encrypted", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "totp_setup_user_id_users_id_fk": { + "name": "totp_setup_user_id_users_id_fk", + "tableFrom": "totp_setup", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "totp_setup_user_id_unique": { + "name": "totp_setup_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.totp": { + "name": "totp", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_encrypted": { + "name": "secret_encrypted", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "totp_user_id_users_id_fk": { + "name": "totp_user_id_users_id_fk", + "tableFrom": "totp", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "totp_user_id_unique": { + "name": "totp_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "username": { + "name": "username", + "type": "varchar(48)", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_email_idx": { + "name": "users_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + }, + "users_username_unique": { + "name": "users_username_unique", + "nullsNotDistinct": false, + "columns": ["username"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'magic_link'" + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_plain": { + "name": "token_plain", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "meta": { + "name": "meta", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "verification_expires_at_idx": { + "name": "verification_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wallet_identities": { + "name": "wallet_identities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chain": { + "name": "chain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "wallet_provider": { + "name": "wallet_provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "wallet_user_id_idx": { + "name": "wallet_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_address_idx": { + "name": "wallet_address_idx", + "columns": [ + { + "expression": "address", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wallet_identities_user_id_users_id_fk": { + "name": "wallet_identities_user_id_users_id_fk", + "tableFrom": "wallet_identities", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "wallet_chain_address_unique": { + "name": "wallet_chain_address_unique", + "nullsNotDistinct": false, + "columns": ["chain", "address"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.web3_callback": { + "name": "web3_callback", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "code_hash": { + "name": "code_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "web3_callback_code_hash_idx": { + "name": "web3_callback_code_hash_idx", + "columns": [ + { + "expression": "code_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "web3_callback_expires_at_idx": { + "name": "web3_callback_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.web3_nonce": { + "name": "web3_nonce", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "chain": { + "name": "chain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "nonce": { + "name": "nonce", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "web3_nonce_chain_address_idx": { + "name": "web3_nonce_chain_address_idx", + "columns": [ + { + "expression": "chain", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "address", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "web3_nonce_expires_at_idx": { + "name": "web3_nonce_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/apps/fastify/src/db/migrations/meta/0014_snapshot.json b/apps/fastify/src/db/migrations/meta/0014_snapshot.json new file mode 100644 index 00000000..9f3bb2c5 --- /dev/null +++ b/apps/fastify/src/db/migrations/meta/0014_snapshot.json @@ -0,0 +1,1399 @@ +{ + "id": "5c2a1358-b81e-4c7f-8095-283fe772df3d", + "prevId": "3912cde9-f064-4562-bae8-6b9acba4d9a7", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "account_user_id_idx": { + "name": "account_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "account_account_id_idx": { + "name": "account_account_id_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_users_id_fk": { + "name": "account_user_id_users_id_fk", + "tableFrom": "account", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_keys": { + "name": "api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "api_keys_prefix_idx": { + "name": "api_keys_prefix_idx", + "columns": [ + { + "expression": "prefix", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_keys_user_id_idx": { + "name": "api_keys_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "api_keys_user_id_users_id_fk": { + "name": "api_keys_user_id_users_id_fk", + "tableFrom": "api_keys", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_keys_prefix_unique": { + "name": "api_keys_prefix_unique", + "nullsNotDistinct": false, + "columns": ["prefix"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_attempts": { + "name": "auth_attempts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'magic_link'" + }, + "failed_attempts": { + "name": "failed_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "first_failure_at": { + "name": "first_failure_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "locked_until": { + "name": "locked_until", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "auth_attempts_key_type_idx": { + "name": "auth_attempts_key_type_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "auth_attempts_key_type_unique": { + "name": "auth_attempts_key_type_unique", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.passkey_auth_challenges": { + "name": "passkey_auth_challenges", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "challenge": { + "name": "challenge", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "passkey_auth_challenges_session_id_idx": { + "name": "passkey_auth_challenges_session_id_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "passkey_auth_challenges_expires_at_idx": { + "name": "passkey_auth_challenges_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.passkey_callback": { + "name": "passkey_callback", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "code_hash": { + "name": "code_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "callback_origin": { + "name": "callback_origin", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "passkey_callback_code_hash_idx": { + "name": "passkey_callback_code_hash_idx", + "columns": [ + { + "expression": "code_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "passkey_callback_expires_at_idx": { + "name": "passkey_callback_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.passkey_challenges": { + "name": "passkey_challenges", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "challenge": { + "name": "challenge", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "passkey_challenges_user_id_idx": { + "name": "passkey_challenges_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "passkey_challenges_expires_at_idx": { + "name": "passkey_challenges_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "passkey_challenges_user_id_users_id_fk": { + "name": "passkey_challenges_user_id_users_id_fk", + "tableFrom": "passkey_challenges", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.passkey_credentials": { + "name": "passkey_credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "counter": { + "name": "counter", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "transports": { + "name": "transports", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "credential_device_type": { + "name": "credential_device_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_backed_up": { + "name": "credential_backed_up", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "passkey_credentials_user_id_idx": { + "name": "passkey_credentials_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "passkey_credentials_user_id_users_id_fk": { + "name": "passkey_credentials_user_id_users_id_fk", + "tableFrom": "passkey_credentials", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "passkey_credentials_credential_id_unique": { + "name": "passkey_credentials_credential_id_unique", + "nullsNotDistinct": false, + "columns": ["credential_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "wallet_chain": { + "name": "wallet_chain", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "wallet_address": { + "name": "wallet_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sessions_user_id_idx": { + "name": "sessions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_expires_at_idx": { + "name": "sessions_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_token_idx": { + "name": "sessions_token_idx", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.totp_setup": { + "name": "totp_setup", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_encrypted": { + "name": "secret_encrypted", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "totp_setup_user_id_users_id_fk": { + "name": "totp_setup_user_id_users_id_fk", + "tableFrom": "totp_setup", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "totp_setup_user_id_unique": { + "name": "totp_setup_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.totp": { + "name": "totp", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_encrypted": { + "name": "secret_encrypted", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "totp_user_id_users_id_fk": { + "name": "totp_user_id_users_id_fk", + "tableFrom": "totp", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "totp_user_id_unique": { + "name": "totp_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "username": { + "name": "username", + "type": "varchar(48)", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_email_idx": { + "name": "users_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + }, + "users_username_unique": { + "name": "users_username_unique", + "nullsNotDistinct": false, + "columns": ["username"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'magic_link'" + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_plain": { + "name": "token_plain", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "meta": { + "name": "meta", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "verification_expires_at_idx": { + "name": "verification_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wallet_identities": { + "name": "wallet_identities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chain": { + "name": "chain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "wallet_provider": { + "name": "wallet_provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "wallet_user_id_idx": { + "name": "wallet_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_address_idx": { + "name": "wallet_address_idx", + "columns": [ + { + "expression": "address", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wallet_identities_user_id_users_id_fk": { + "name": "wallet_identities_user_id_users_id_fk", + "tableFrom": "wallet_identities", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "wallet_chain_address_unique": { + "name": "wallet_chain_address_unique", + "nullsNotDistinct": false, + "columns": ["chain", "address"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.web3_callback": { + "name": "web3_callback", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "code_hash": { + "name": "code_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "web3_callback_code_hash_idx": { + "name": "web3_callback_code_hash_idx", + "columns": [ + { + "expression": "code_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "web3_callback_expires_at_idx": { + "name": "web3_callback_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.web3_nonce": { + "name": "web3_nonce", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "chain": { + "name": "chain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "nonce": { + "name": "nonce", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "web3_nonce_chain_address_idx": { + "name": "web3_nonce_chain_address_idx", + "columns": [ + { + "expression": "chain", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "address", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "web3_nonce_expires_at_idx": { + "name": "web3_nonce_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/apps/fastify/src/db/migrations/meta/0015_snapshot.json b/apps/fastify/src/db/migrations/meta/0015_snapshot.json new file mode 100644 index 00000000..9ee4f1d2 --- /dev/null +++ b/apps/fastify/src/db/migrations/meta/0015_snapshot.json @@ -0,0 +1,1405 @@ +{ + "id": "497d7a50-24c6-46be-a2e3-c3c92088a714", + "prevId": "5c2a1358-b81e-4c7f-8095-283fe772df3d", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "account_user_id_idx": { + "name": "account_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "account_account_id_idx": { + "name": "account_account_id_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_users_id_fk": { + "name": "account_user_id_users_id_fk", + "tableFrom": "account", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "account_provider_account_unique": { + "name": "account_provider_account_unique", + "nullsNotDistinct": false, + "columns": ["provider_id", "account_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_keys": { + "name": "api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "api_keys_prefix_idx": { + "name": "api_keys_prefix_idx", + "columns": [ + { + "expression": "prefix", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_keys_user_id_idx": { + "name": "api_keys_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "api_keys_user_id_users_id_fk": { + "name": "api_keys_user_id_users_id_fk", + "tableFrom": "api_keys", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_keys_prefix_unique": { + "name": "api_keys_prefix_unique", + "nullsNotDistinct": false, + "columns": ["prefix"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_attempts": { + "name": "auth_attempts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'magic_link'" + }, + "failed_attempts": { + "name": "failed_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "first_failure_at": { + "name": "first_failure_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "locked_until": { + "name": "locked_until", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "auth_attempts_key_type_idx": { + "name": "auth_attempts_key_type_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "auth_attempts_key_type_unique": { + "name": "auth_attempts_key_type_unique", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.passkey_auth_challenges": { + "name": "passkey_auth_challenges", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "challenge": { + "name": "challenge", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "passkey_auth_challenges_session_id_idx": { + "name": "passkey_auth_challenges_session_id_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "passkey_auth_challenges_expires_at_idx": { + "name": "passkey_auth_challenges_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.passkey_callback": { + "name": "passkey_callback", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "code_hash": { + "name": "code_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "callback_origin": { + "name": "callback_origin", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "passkey_callback_code_hash_idx": { + "name": "passkey_callback_code_hash_idx", + "columns": [ + { + "expression": "code_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "passkey_callback_expires_at_idx": { + "name": "passkey_callback_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.passkey_challenges": { + "name": "passkey_challenges", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "challenge": { + "name": "challenge", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "passkey_challenges_user_id_idx": { + "name": "passkey_challenges_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "passkey_challenges_expires_at_idx": { + "name": "passkey_challenges_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "passkey_challenges_user_id_users_id_fk": { + "name": "passkey_challenges_user_id_users_id_fk", + "tableFrom": "passkey_challenges", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.passkey_credentials": { + "name": "passkey_credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "counter": { + "name": "counter", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "transports": { + "name": "transports", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "credential_device_type": { + "name": "credential_device_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_backed_up": { + "name": "credential_backed_up", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "passkey_credentials_user_id_idx": { + "name": "passkey_credentials_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "passkey_credentials_user_id_users_id_fk": { + "name": "passkey_credentials_user_id_users_id_fk", + "tableFrom": "passkey_credentials", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "passkey_credentials_credential_id_unique": { + "name": "passkey_credentials_credential_id_unique", + "nullsNotDistinct": false, + "columns": ["credential_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "wallet_chain": { + "name": "wallet_chain", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "wallet_address": { + "name": "wallet_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sessions_user_id_idx": { + "name": "sessions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_expires_at_idx": { + "name": "sessions_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_token_idx": { + "name": "sessions_token_idx", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.totp_setup": { + "name": "totp_setup", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_encrypted": { + "name": "secret_encrypted", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "totp_setup_user_id_users_id_fk": { + "name": "totp_setup_user_id_users_id_fk", + "tableFrom": "totp_setup", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "totp_setup_user_id_unique": { + "name": "totp_setup_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.totp": { + "name": "totp", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_encrypted": { + "name": "secret_encrypted", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "totp_user_id_users_id_fk": { + "name": "totp_user_id_users_id_fk", + "tableFrom": "totp", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "totp_user_id_unique": { + "name": "totp_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "username": { + "name": "username", + "type": "varchar(48)", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_email_idx": { + "name": "users_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + }, + "users_username_unique": { + "name": "users_username_unique", + "nullsNotDistinct": false, + "columns": ["username"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'magic_link'" + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_plain": { + "name": "token_plain", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "meta": { + "name": "meta", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "verification_expires_at_idx": { + "name": "verification_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wallet_identities": { + "name": "wallet_identities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chain": { + "name": "chain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "wallet_provider": { + "name": "wallet_provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "wallet_user_id_idx": { + "name": "wallet_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "wallet_address_idx": { + "name": "wallet_address_idx", + "columns": [ + { + "expression": "address", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wallet_identities_user_id_users_id_fk": { + "name": "wallet_identities_user_id_users_id_fk", + "tableFrom": "wallet_identities", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "wallet_chain_address_unique": { + "name": "wallet_chain_address_unique", + "nullsNotDistinct": false, + "columns": ["chain", "address"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.web3_callback": { + "name": "web3_callback", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "code_hash": { + "name": "code_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "web3_callback_code_hash_idx": { + "name": "web3_callback_code_hash_idx", + "columns": [ + { + "expression": "code_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "web3_callback_expires_at_idx": { + "name": "web3_callback_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.web3_nonce": { + "name": "web3_nonce", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "chain": { + "name": "chain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "nonce": { + "name": "nonce", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "web3_nonce_chain_address_idx": { + "name": "web3_nonce_chain_address_idx", + "columns": [ + { + "expression": "chain", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "address", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "web3_nonce_expires_at_idx": { + "name": "web3_nonce_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/apps/fastify/src/db/migrations/meta/_journal.json b/apps/fastify/src/db/migrations/meta/_journal.json index 6ac625ce..6af60767 100644 --- a/apps/fastify/src/db/migrations/meta/_journal.json +++ b/apps/fastify/src/db/migrations/meta/_journal.json @@ -85,6 +85,34 @@ "when": 1773021191314, "tag": "0011_keen_eternals", "breakpoints": true + }, + { + "idx": 12, + "version": "7", + "when": 1773101583952, + "tag": "0012_aberrant_johnny_blaze", + "breakpoints": true + }, + { + "idx": 13, + "version": "7", + "when": 1773105082451, + "tag": "0013_familiar_master_chief", + "breakpoints": true + }, + { + "idx": 14, + "version": "7", + "when": 1773109695714, + "tag": "0014_slippery_puff_adder", + "breakpoints": true + }, + { + "idx": 15, + "version": "7", + "when": 1773113943058, + "tag": "0015_shallow_umar", + "breakpoints": true } ] } diff --git a/apps/fastify/src/db/schema/index.ts b/apps/fastify/src/db/schema/index.ts index 8a7caef5..d2153ad6 100644 --- a/apps/fastify/src/db/schema/index.ts +++ b/apps/fastify/src/db/schema/index.ts @@ -1,5 +1,6 @@ export * from './tables/account.js' export * from './tables/api-keys.js' +export * from './tables/auth-attempts.js' export * from './tables/passkey-auth-challenges.js' export * from './tables/passkey-callback.js' export * from './tables/passkey-challenges.js' diff --git a/apps/fastify/src/db/schema/tables/account.ts b/apps/fastify/src/db/schema/tables/account.ts index 78122b46..fe05d3bb 100644 --- a/apps/fastify/src/db/schema/tables/account.ts +++ b/apps/fastify/src/db/schema/tables/account.ts @@ -1,4 +1,4 @@ -import { index, pgTable, text, timestamp } from 'drizzle-orm/pg-core' +import { index, pgTable, text, timestamp, unique } from 'drizzle-orm/pg-core' import { users } from './users.js' // Account table for OAuth providers (future use) @@ -26,6 +26,7 @@ export const account = pgTable( table => [ index('account_user_id_idx').on(table.userId), index('account_account_id_idx').on(table.accountId), + unique('account_provider_account_unique').on(table.providerId, table.accountId), ], ) diff --git a/apps/fastify/src/db/schema/tables/auth-attempts.ts b/apps/fastify/src/db/schema/tables/auth-attempts.ts new file mode 100644 index 00000000..a5ecbd51 --- /dev/null +++ b/apps/fastify/src/db/schema/tables/auth-attempts.ts @@ -0,0 +1,24 @@ +import { index, integer, pgTable, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core' + +export const authAttempts = pgTable( + 'auth_attempts', + { + id: text('id').primaryKey(), + key: text('key').notNull(), // IP or identifier for rate limiting + type: text('type', { enum: ['magic_link'] }) + .notNull() + .default('magic_link'), + failedAttempts: integer('failed_attempts').notNull().default(0), + firstFailureAt: timestamp('first_failure_at'), + lockedUntil: timestamp('locked_until'), + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at').defaultNow().notNull(), + }, + table => [ + index('auth_attempts_key_type_idx').on(table.key, table.type), + uniqueIndex('auth_attempts_key_type_unique').on(table.key, table.type), + ], +) + +export type AuthAttempt = typeof authAttempts.$inferSelect +export type NewAuthAttempt = typeof authAttempts.$inferInsert diff --git a/apps/fastify/src/db/schema/tables/users.ts b/apps/fastify/src/db/schema/tables/users.ts index 65fa5f36..4247fdb2 100644 --- a/apps/fastify/src/db/schema/tables/users.ts +++ b/apps/fastify/src/db/schema/tables/users.ts @@ -7,6 +7,7 @@ export const users = pgTable( email: varchar('email', { length: 255 }).unique(), emailVerified: boolean('email_verified').default(false).notNull(), name: text('name'), + username: varchar('username', { length: 48 }).unique(), image: text('image'), createdAt: timestamp('created_at').defaultNow().notNull(), updatedAt: timestamp('updated_at').defaultNow().notNull(), diff --git a/apps/fastify/src/lib/api-key-auth.ts b/apps/fastify/src/lib/api-key-auth.ts index f07461e2..2ff091d7 100644 --- a/apps/fastify/src/lib/api-key-auth.ts +++ b/apps/fastify/src/lib/api-key-auth.ts @@ -10,7 +10,7 @@ type Db = Awaited> const farFuture = new Date(Date.now() + 100 * 365 * 24 * 60 * 60 * 1000) export type ApiKeySession = { - user: { id: string; email: string | null } + user: { id: string; email: string | null; name: string | null; username: string | null } session: { id: string; userId: string; expiresAt: Date } } @@ -34,7 +34,12 @@ export async function authenticateWithApiKey(token: string, db: Db): Promise { + const username = await generateFunnyUsername(tx) await tx.insert(users).values({ id: userId, email: null, emailVerified: false, + username, }) await tx.insert(walletIdentities).values({ id: randomUUID(), diff --git a/apps/fastify/src/lib/db-errors.ts b/apps/fastify/src/lib/db-errors.ts new file mode 100644 index 00000000..d8fdd181 --- /dev/null +++ b/apps/fastify/src/lib/db-errors.ts @@ -0,0 +1,11 @@ +/** Extract Postgres error code from Drizzle/Driver error (23505 = unique violation) */ +export function getPgErrorCode(err: unknown): string | undefined { + return ( + (err as { cause?: { code?: string }; code?: string }).cause?.code ?? + (err as { code?: string }).code + ) +} + +export function isUniqueViolation(err: unknown): boolean { + return getPgErrorCode(err) === '23505' +} diff --git a/apps/fastify/src/lib/env.ts b/apps/fastify/src/lib/env.ts index aadae466..8782ea01 100644 --- a/apps/fastify/src/lib/env.ts +++ b/apps/fastify/src/lib/env.ts @@ -70,6 +70,19 @@ export const env = createEnv({ RESEND_API_KEY: z.string().min(1).default('re_placeholder'), EMAIL_FROM: z.string().email().default('noreply@localhost'), EMAIL_FROM_NAME: z.string().default('App'), + APP_NAME: z + .string() + .transform(v => v.trim()) + .pipe(z.string().min(1)) + .pipe( + z + .string() + .refine( + v => process.env.NODE_ENV !== 'production' || v !== 'Your App', + 'APP_NAME must not be the placeholder in production', + ), + ) + .default('Your App'), ALLOW_TEST: z.coerce.boolean().default(false), // GitHub OAuth (optional - OAuth routes return 503 when unset) GITHUB_CLIENT_ID: z.string().min(1).optional(), diff --git a/apps/fastify/src/lib/jwt.ts b/apps/fastify/src/lib/jwt.ts index 6f25d96d..692f1c0b 100644 --- a/apps/fastify/src/lib/jwt.ts +++ b/apps/fastify/src/lib/jwt.ts @@ -1,4 +1,4 @@ -import { createHash, randomBytes } from 'node:crypto' +import { createHash, randomBytes, randomInt } from 'node:crypto' import { env } from './env.js' type AccessTokenPayload = { @@ -31,6 +31,11 @@ export function generateToken(): string { return randomBytes(32).toString('base64url') } +/** Returns 6-digit code (100000–999999) for magic link login. */ +export function generateLoginCode(): string { + return String(randomInt(100000, 1000000)) +} + export function generateJti(): string { return randomBytes(16).toString('base64url') } diff --git a/apps/fastify/src/lib/oauth-twitter.ts b/apps/fastify/src/lib/oauth-twitter.ts new file mode 100644 index 00000000..f3f64466 --- /dev/null +++ b/apps/fastify/src/lib/oauth-twitter.ts @@ -0,0 +1,192 @@ +import { randomUUID } from 'node:crypto' +import { and, eq } from 'drizzle-orm' +import { encryptAccountTokens } from '../db/account.js' +import type { getDb } from '../db/index.js' +import { account, users } from '../db/schema/index.js' +import { generateFunnyUsername } from './username.js' + +/** OAuth API response; snake_case from Twitter API */ +export type TwitterTokenResponse = { + /* biome-ignore lint/style/useNamingConvention: OAuth API uses snake_case */ + access_token?: string + /* biome-ignore lint/style/useNamingConvention: OAuth API uses snake_case */ + refresh_token?: string + /* biome-ignore lint/style/useNamingConvention: OAuth API uses snake_case */ + token_type?: string + /* biome-ignore lint/style/useNamingConvention: OAuth API uses snake_case */ + expires_in?: number + scope?: string + error?: string +} + +export class OAuthUpstreamError extends Error { + constructor( + message: string, + public readonly stage: 'token_exchange' | 'user_fetch', + public readonly status: number, + public readonly body: unknown, + ) { + super(message) + this.name = 'OAuthUpstreamError' + } +} + +export type TwitterUser = { data?: { id: string; name?: string; username?: string } } + +export type TwitterAccountData = { + accessToken: string + refreshToken: string | null + accessTokenExpiresAt: Date | null + refreshTokenExpiresAt: Date | null + scope: string +} + +export async function runTwitterExchangeTx( + db: Awaited>, + accountId: string, + name: string, + accountData: TwitterAccountData, +): Promise<{ userId: string }> { + return db.transaction(async tx => { + const [existingAccount] = await tx + .select() + .from(account) + .where(and(eq(account.providerId, 'twitter'), eq(account.accountId, accountId))) + + let user: typeof users.$inferSelect | undefined + if (existingAccount) { + ;[user] = await tx.select().from(users).where(eq(users.id, existingAccount.userId)) + } + if (!user) { + const userId = randomUUID() + const username = await generateFunnyUsername(tx) + await tx.insert(users).values({ + id: userId, + email: null, + emailVerified: false, + name, + username, + }) + ;[user] = await tx.select().from(users).where(eq(users.id, userId)) + if (!user) throw new Error('USER_CREATE_FAILED') + } + + const accountRow = { + id: existingAccount?.id ?? randomUUID(), + userId: user.id, + accountId, + providerId: 'twitter' as const, + } + + if (existingAccount) { + const encrypted = encryptAccountTokens({ + accessToken: accountData.accessToken, + refreshToken: accountData.refreshToken, + updatedAt: new Date(), + }) + await tx + .update(account) + .set({ + accessToken: encrypted.accessToken, + refreshToken: encrypted.refreshToken, + accessTokenExpiresAt: accountData.accessTokenExpiresAt, + refreshTokenExpiresAt: accountData.refreshTokenExpiresAt, + scope: accountData.scope, + updatedAt: encrypted.updatedAt ?? new Date(), + }) + .where(eq(account.id, existingAccount.id)) + } else { + const toInsert = encryptAccountTokens({ + ...accountRow, + accessToken: accountData.accessToken, + refreshToken: accountData.refreshToken, + idToken: null as string | null, + accessTokenExpiresAt: accountData.accessTokenExpiresAt, + refreshTokenExpiresAt: accountData.refreshTokenExpiresAt, + scope: accountData.scope, + }) + await tx.insert(account).values(toInsert) + } + + return { userId: user.id } + }) +} + +export async function fetchTwitterOAuthData(input: { + code: string + codeVerifier: string + oauthTwitterCallbackUrl: string + twitterClientId: string + twitterClientSecret: string +}): Promise<{ accountId: string; name: string; accountData: TwitterAccountData }> { + const { code, codeVerifier, oauthTwitterCallbackUrl, twitterClientId, twitterClientSecret } = + input + const fetchTimeoutMs = 15_000 + const tokenBody = new URLSearchParams({ + /* biome-ignore lint/style/useNamingConvention: OAuth spec uses snake_case */ + grant_type: 'authorization_code', + code, + /* biome-ignore lint/style/useNamingConvention: OAuth spec uses snake_case */ + code_verifier: codeVerifier, + /* biome-ignore lint/style/useNamingConvention: OAuth spec uses snake_case */ + redirect_uri: oauthTwitterCallbackUrl, + }) + const basicAuth = Buffer.from(`${twitterClientId}:${twitterClientSecret}`).toString('base64') + const tokenRes = await fetch('https://api.x.com/2/oauth2/token', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + /* biome-ignore lint/style/useNamingConvention: HTTP header canonical form */ + Authorization: `Basic ${basicAuth}`, + }, + body: tokenBody.toString(), + signal: AbortSignal.timeout(fetchTimeoutMs), + }) + if (!tokenRes.ok) { + const body = await tokenRes.text().catch(() => '') + throw new OAuthUpstreamError('TOKEN_EXCHANGE_FAILED', 'token_exchange', tokenRes.status, body) + } + const tokenData = (await tokenRes.json()) as TwitterTokenResponse + if (tokenData.error || !tokenData.access_token) + throw new OAuthUpstreamError( + 'TOKEN_EXCHANGE_FAILED', + 'token_exchange', + 400, + tokenData.error ? tokenData : { error: 'missing access_token' }, + ) + + const accessToken = tokenData.access_token + const userRes = await fetch('https://api.x.com/2/users/me', { + headers: { + /* biome-ignore lint/style/useNamingConvention: HTTP header canonical form */ + Authorization: `Bearer ${accessToken}`, + }, + signal: AbortSignal.timeout(fetchTimeoutMs), + }) + if (!userRes.ok) { + const body = await userRes.text().catch(() => '') + throw new OAuthUpstreamError('USER_FETCH_FAILED', 'user_fetch', userRes.status, body) + } + const userData = (await userRes.json()) as TwitterUser + const twUser = userData.data + if (!twUser?.id) throw new OAuthUpstreamError('USER_FETCH_FAILED', 'user_fetch', 200, userData) + + const accountId = twUser.id + const name = twUser.name ?? twUser.username ?? 'Twitter user' + const refreshToken = tokenData.refresh_token ?? null + const baseScope = (tokenData.scope ?? 'tweet.read users.read').trim() + const scope = + refreshToken && !baseScope.includes('offline.access') + ? `${baseScope} offline.access`.trim() + : baseScope || 'tweet.read users.read' + const accountData: TwitterAccountData = { + accessToken, + refreshToken, + accessTokenExpiresAt: tokenData.expires_in + ? new Date(Date.now() + tokenData.expires_in * 1000) + : null, + refreshTokenExpiresAt: null, + scope, + } + return { accountId, name, accountData } +} diff --git a/apps/fastify/src/lib/oauth-user.ts b/apps/fastify/src/lib/oauth-user.ts new file mode 100644 index 00000000..4652eb82 --- /dev/null +++ b/apps/fastify/src/lib/oauth-user.ts @@ -0,0 +1,41 @@ +import { randomUUID } from 'node:crypto' +import { eq } from 'drizzle-orm' +import type { getDb } from '../db/index.js' +import { users } from '../db/schema/index.js' +import { isUniqueViolation } from './db-errors.js' +import { generateFunnyUsername } from './username.js' + +type Db = Awaited> + +const maxRetries = 5 + +/** Find or create user by email with retry on username collision. For OAuth providers that use email. */ +export async function findOrCreateUserByEmail( + db: Db, + input: { email: string; name: string; emailVerified: boolean }, +): Promise { + const [existing] = await db.select().from(users).where(eq(users.email, input.email)) + if (existing) return existing + + for (let attempt = 0; attempt < maxRetries; attempt++) { + const username = await generateFunnyUsername(db) + try { + await db + .insert(users) + .values({ + id: randomUUID(), + email: input.email, + emailVerified: input.emailVerified, + name: input.name, + username, + }) + .onConflictDoNothing({ target: users.email }) + const [user] = await db.select().from(users).where(eq(users.email, input.email)) + return user ?? null + } catch (err) { + if (isUniqueViolation(err) && attempt < maxRetries - 1) continue + throw err + } + } + return null +} diff --git a/apps/fastify/src/lib/request.ts b/apps/fastify/src/lib/request.ts new file mode 100644 index 00000000..7b0ea828 --- /dev/null +++ b/apps/fastify/src/lib/request.ts @@ -0,0 +1,9 @@ +import type { FastifyRequest } from 'fastify' + +/** + * Extract trusted client IP from request. + * Uses request.ip which respects Fastify's trustProxy setting. + */ +export function getTrustedClientIp(request: FastifyRequest): string { + return request.ip ?? 'unknown' +} diff --git a/apps/fastify/src/lib/username.ts b/apps/fastify/src/lib/username.ts new file mode 100644 index 00000000..6f3b4563 --- /dev/null +++ b/apps/fastify/src/lib/username.ts @@ -0,0 +1,54 @@ +import { randomBytes } from 'node:crypto' +import { faker } from '@faker-js/faker' +import { eq } from 'drizzle-orm' +import { users } from '../db/schema/index.js' + +function slugify(str: string) { + return str + .toLowerCase() + .replace(/\s+/g, '_') + .replace(/[^a-z0-9_-]/g, '') + .slice(0, 48) +} + +import type { getDb } from '../db/index.js' + +type Db = Awaited> +type Tx = Parameters['transaction']>[0]>[0] + +export async function generateFunnyUsername(client: Db | Tx): Promise { + const base = slugify(`${faker.word.adjective()}_${faker.animal.type()}`) + if (!base) return `user_${randomBytes(4).toString('hex')}` + + const [existing] = await client + .select({ id: users.id }) + .from(users) + .where(eq(users.username, base)) + if (!existing) return base + + const suffix = randomBytes(4).toString('hex') + const candidate = + base.length + suffix.length + 1 <= 48 ? `${base}_${suffix}` : `${base.slice(0, 39)}_${suffix}` + + const [existing2] = await client + .select({ id: users.id }) + .from(users) + .where(eq(users.username, candidate)) + if (!existing2) return candidate + + for (let i = 0; i < 10; i++) { + const maxSuffixLen = Math.max(0, 48 - candidate.length) + const suffix = + maxSuffixLen > 0 + ? randomBytes(2).toString('hex').slice(0, maxSuffixLen) + : randomBytes(2).toString('hex').slice(0, 4) + const base = candidate.length + suffix.length > 48 ? candidate.slice(0, 44) : candidate + const newCandidate = `${base}${suffix}`.slice(0, 48) + const [existing3] = await client + .select({ id: users.id }) + .from(users) + .where(eq(users.username, newCandidate)) + if (!existing3) return newCandidate + } + return `${candidate.slice(0, 41)}_${randomBytes(3).toString('hex')}` +} diff --git a/apps/fastify/src/plugins/auth.ts b/apps/fastify/src/plugins/auth.ts index 981630b9..7f8b9e7e 100644 --- a/apps/fastify/src/plugins/auth.ts +++ b/apps/fastify/src/plugins/auth.ts @@ -12,6 +12,8 @@ declare module 'fastify' { user: { id: string email?: string | null + name?: string | null + username?: string | null wallet?: { chain: string; address: string } } session: { @@ -93,6 +95,8 @@ const authPlugin: FastifyPluginAsync = async fastify => { user: { id: user.id, email: user.email ?? null, + name: user.name ?? null, + username: user.username ?? null, ...(wallet && { wallet }), }, session: { diff --git a/apps/fastify/src/plugins/rate-limit.ts b/apps/fastify/src/plugins/rate-limit.ts index 3175db8f..7e4ed01e 100644 --- a/apps/fastify/src/plugins/rate-limit.ts +++ b/apps/fastify/src/plugins/rate-limit.ts @@ -2,6 +2,7 @@ import rateLimit from '@fastify/rate-limit' import type { FastifyPluginAsync } from 'fastify' import fp from 'fastify-plugin' import { env } from '../lib/env.js' +import { getTrustedClientIp } from '../lib/request.js' type RateLimitPluginOptions = Record @@ -17,16 +18,7 @@ const rateLimitPlugin: FastifyPluginAsync = async fastif 'x-ratelimit-remaining': true, 'x-ratelimit-reset': true, }, - // Custom key generator to use real IP from proxy - keyGenerator: request => { - // Get real IP from proxy headers - const forwarded = request.headers['x-forwarded-for'] - if (forwarded) { - const ips = Array.isArray(forwarded) ? forwarded[0] : forwarded - return ips.split(',')[0].trim() - } - return request.ip - }, + keyGenerator: request => getTrustedClientIp(request), // Custom error handler errorResponseBuilder: (_request, context) => { const timeWindowSeconds = Math.round(env.RATE_LIMIT_TIME_WINDOW / 1000) diff --git a/apps/fastify/src/routes/account/link/email/request.test.ts b/apps/fastify/src/routes/account/link/email/request.test.ts index 068f9f85..52f65038 100644 --- a/apps/fastify/src/routes/account/link/email/request.test.ts +++ b/apps/fastify/src/routes/account/link/email/request.test.ts @@ -59,7 +59,7 @@ describe('POST /account/link/email/request', () => { await fastify.inject({ method: 'POST', url: '/auth/magiclink/verify', - payload: { token: otherToken }, + payload: { email: 'other@test.ai', token: otherToken }, }) const jwt = await getSessionToken(fastify, 'user@test.ai') diff --git a/apps/fastify/src/routes/account/link/wallet/unlink.test.ts b/apps/fastify/src/routes/account/link/wallet/unlink.test.ts index 5187e117..8e8ad309 100644 --- a/apps/fastify/src/routes/account/link/wallet/unlink.test.ts +++ b/apps/fastify/src/routes/account/link/wallet/unlink.test.ts @@ -60,11 +60,12 @@ describe('DELETE /account/link/wallet/:id', () => { it('should return 404 for non-existent wallet', async () => { const jwt = await (async () => { + const email = 'test@test.ai' const token = await getMagicLinkTokenRaw(fastify) const verifyRes = await fastify.inject({ method: 'POST', url: '/auth/magiclink/verify', - payload: { token }, + payload: { email, token }, }) return (JSON.parse(verifyRes.body) as { token: string }).token })() @@ -80,11 +81,12 @@ describe('DELETE /account/link/wallet/:id', () => { }) it('should return 204 when unlink succeeds with JWT', async () => { + const email = 'test@test.ai' const token = await getMagicLinkTokenRaw(fastify) const verifyRes = await fastify.inject({ method: 'POST', url: '/auth/magiclink/verify', - payload: { token }, + payload: { email, token }, }) const jwt = (JSON.parse(verifyRes.body) as { token: string }).token @@ -101,11 +103,12 @@ describe('DELETE /account/link/wallet/:id', () => { it('should return 204 when unlink succeeds with API key', async () => { const apiKey = await getApiKeyToken(fastify, 'unlink-apikey@test.ai') const jwt = await (async () => { - const token = await getMagicLinkTokenRaw(fastify, 'unlink-apikey@test.ai') + const email = 'unlink-apikey@test.ai' + const token = await getMagicLinkTokenRaw(fastify, email) const verifyRes = await fastify.inject({ method: 'POST', url: '/auth/magiclink/verify', - payload: { token }, + payload: { email, token }, }) return (JSON.parse(verifyRes.body) as { token: string }).token })() diff --git a/apps/fastify/src/routes/account/link/wallet/verify.test.ts b/apps/fastify/src/routes/account/link/wallet/verify.test.ts index 75323507..7c75fabe 100644 --- a/apps/fastify/src/routes/account/link/wallet/verify.test.ts +++ b/apps/fastify/src/routes/account/link/wallet/verify.test.ts @@ -29,10 +29,11 @@ describe('POST /account/link/wallet/verify', () => { }) it('should return 401 for invalid signature', async () => { + const email = 'test@test.ai' const verifyRes = await fastify.inject({ method: 'POST', url: '/auth/magiclink/verify', - payload: { token: await getMagicLinkTokenRaw(fastify) }, + payload: { email, token: await getMagicLinkTokenRaw(fastify) }, }) const { token } = JSON.parse(verifyRes.body) @@ -67,10 +68,11 @@ describe('POST /account/link/wallet/verify', () => { }) it('should link wallet on valid signature', async () => { + const email = 'test@test.ai' const verifyRes = await fastify.inject({ method: 'POST', url: '/auth/magiclink/verify', - payload: { token: await getMagicLinkTokenRaw(fastify) }, + payload: { email, token: await getMagicLinkTokenRaw(fastify) }, }) const { token } = JSON.parse(verifyRes.body) const userId = JSON.parse( @@ -178,11 +180,12 @@ describe('POST /account/link/wallet/verify', () => { }) it('should return WALLET_ALREADY_LINKED when wallet belongs to another user', async () => { + const email1 = 'test@test.ai' const token1 = await getMagicLinkTokenRaw(fastify) const verifyRes1 = await fastify.inject({ method: 'POST', url: '/auth/magiclink/verify', - payload: { token: token1 }, + payload: { email: email1, token: token1 }, }) const { token: jwt1 } = JSON.parse(verifyRes1.body) @@ -209,11 +212,12 @@ describe('POST /account/link/wallet/verify', () => { payload: { chain: 'eip155', message: messageToSign, signature }, }) - const token2 = await getMagicLinkTokenRaw(fastify, 'other@test.ai') + const email2 = 'other@test.ai' + const token2 = await getMagicLinkTokenRaw(fastify, email2) const verifyRes2 = await fastify.inject({ method: 'POST', url: '/auth/magiclink/verify', - payload: { token: token2 }, + payload: { email: email2, token: token2 }, }) const { token: jwt2 } = JSON.parse(verifyRes2.body) diff --git a/apps/fastify/src/routes/account/profile/update.ts b/apps/fastify/src/routes/account/profile/update.ts new file mode 100644 index 00000000..190da377 --- /dev/null +++ b/apps/fastify/src/routes/account/profile/update.ts @@ -0,0 +1,117 @@ +import type { TypeBoxTypeProvider } from '@fastify/type-provider-typebox' +import { Type } from '@sinclair/typebox' +import { and, eq, ne } from 'drizzle-orm' +import type { FastifyPluginAsync } from 'fastify' +import { getDb } from '../../../db/index.js' +import { users } from '../../../db/schema/index.js' +import { ErrorResponseSchema } from '../../schemas.js' + +const usernameRegex = /^[a-zA-Z0-9_-]{1,48}$/ + +const UpdateSchema = Type.Object({ + name: Type.Optional(Type.String({ minLength: 1, maxLength: 32 })), + username: Type.Optional( + Type.Union([ + Type.String({ minLength: 1, maxLength: 48, pattern: usernameRegex.source }), + Type.Null(), + ]), + ), +}) + +const UpdateResponseSchema = Type.Object({ + user: Type.Object({ + id: Type.String(), + email: Type.Union([Type.String(), Type.Null()]), + name: Type.Union([Type.String(), Type.Null()]), + username: Type.Union([Type.String(), Type.Null()]), + }), +}) + +const profileUpdateRoute: FastifyPluginAsync = async fastify => { + fastify.withTypeProvider().patch( + '/', + { + schema: { + operationId: 'accountProfileUpdate', + description: 'Update profile (name, username)', + summary: 'Update profile', + tags: ['account'], + security: [{ bearerAuth: [] }], + body: UpdateSchema, + response: { + 200: UpdateResponseSchema, + 401: ErrorResponseSchema, + 409: ErrorResponseSchema, + }, + }, + }, + async (request, reply) => { + if (!request.session) + return reply.code(401).send({ + code: 'UNAUTHORIZED', + message: 'Authentication required', + }) + + const { name, username } = request.body + const userId = request.session.user.id + + const updates: { name?: string | null; username?: string | null; updatedAt?: Date } = { + updatedAt: new Date(), + } + if (name !== undefined) updates.name = name + if (username !== undefined) updates.username = username === '' ? null : username + + if (name === undefined && username === undefined) + return reply.code(200).send({ + user: { + id: userId, + email: request.session.user.email ?? null, + name: request.session.user.name ?? null, + username: request.session.user.username ?? null, + }, + }) + + const db = await getDb() + + if (username !== undefined && username !== null && username !== '') { + const [existing] = await db + .select({ id: users.id }) + .from(users) + .where(and(eq(users.username, username), ne(users.id, userId))) + if (existing) + return reply.code(409).send({ + code: 'USERNAME_TAKEN', + message: 'Username is already in use', + }) + } + + try { + const [row] = await db.update(users).set(updates).where(eq(users.id, userId)).returning() + + if (!row) throw new Error('Failed to update profile') + + return reply.code(200).send({ + user: { + id: row.id, + email: row.email ?? null, + name: row.name ?? null, + username: row.username ?? null, + }, + }) + } catch (err) { + const code = + (err as { cause?: { code?: string }; code?: string }).cause?.code ?? + (err as { code?: string }).code + if (code === '23505') + return reply.code(409).send({ + code: 'USERNAME_TAKEN', + message: 'Username is already in use', + }) + throw err + } + }, + ) +} + +export default profileUpdateRoute +export const prefixOverride = '/account/profile' diff --git a/apps/fastify/src/routes/auth/link/verify.test.ts b/apps/fastify/src/routes/auth/link/verify.test.ts index e482098b..e02fbe5d 100644 --- a/apps/fastify/src/routes/auth/link/verify.test.ts +++ b/apps/fastify/src/routes/auth/link/verify.test.ts @@ -24,9 +24,7 @@ describe('POST /auth/link/verify', () => { const verifyResponse = await fastify.inject({ method: 'POST', url: '/auth/magiclink/verify', - payload: { - token, - }, + payload: { email, token }, }) expect(verifyResponse.statusCode).toBe(200) @@ -42,11 +40,11 @@ describe('POST /auth/link/verify', () => { const response = await fastify.inject({ method: 'POST', url: '/auth/magiclink/verify', - payload: { - token: 'invalid-token-12345', - }, + payload: { email: 'nonexistent@example.com', token: '000000' }, }) - expect([400, 401, 404]).toContain(response.statusCode) + expect(response.statusCode).toBe(401) + const body = JSON.parse(response.body) + expect(body.code).toBe('INVALID_TOKEN') }) }) diff --git a/apps/fastify/src/routes/auth/magiclink/request.test.ts b/apps/fastify/src/routes/auth/magiclink/request.test.ts index 5f1e9fe3..88031382 100644 --- a/apps/fastify/src/routes/auth/magiclink/request.test.ts +++ b/apps/fastify/src/routes/auth/magiclink/request.test.ts @@ -87,10 +87,12 @@ describe('POST /auth/magiclink/request', () => { const sentEmail = fastify.fakeEmail?.last() expect(sentEmail).toBeDefined() expect(sentEmail?.to).toBe(email) - expect(sentEmail?.subject).toBe('Sign in to your account') + const subjectMatch = sentEmail?.subject.match(/^(\d{6}) - .* verification code$/) + const tokenFromEmail = fastify.fakeEmail?.extractToken(sentEmail) + expect(subjectMatch?.[1]).toBe(tokenFromEmail) const magicLink = fastify.fakeEmail?.extractMagicLink(sentEmail) expect(magicLink).toBeTruthy() - expect(magicLink).toContain('token=') + expect(magicLink).toContain('verificationId=') }) it('should extract magic link URL from email', async () => { @@ -111,10 +113,10 @@ describe('POST /auth/magiclink/request', () => { const magicLink = fastify.fakeEmail?.extractMagicLink(sentEmail) expect(magicLink).toBeTruthy() expect(magicLink).toContain('callback') - expect(magicLink).toContain('token=') + expect(magicLink).toContain('verificationId=') }) - it('should extract token from magic link URL', async () => { + it('should extract code from email body and verificationId from link', async () => { const email = 'test@example.com' await fastify.inject({ @@ -126,10 +128,17 @@ describe('POST /auth/magiclink/request', () => { }, }) - const token = fastify.fakeEmail?.extractToken() + const sentEmail = fastify.fakeEmail?.last() + expect(sentEmail).toBeDefined() + const subjectMatch = sentEmail?.subject.match(/^(\d{6}) - .* verification code$/) + const token = fastify.fakeEmail?.extractToken(sentEmail) + const verificationId = fastify.fakeEmail?.extractVerificationId(sentEmail) + expect(subjectMatch?.[1]).toBe(token) expect(token).toBeTruthy() expect(typeof token).toBe('string') - expect(token?.length).toBeGreaterThan(0) + expect(token).toMatch(/^\d{6}$/) + expect(verificationId).toBeTruthy() + expect(typeof verificationId).toBe('string') }) }) }) diff --git a/apps/fastify/src/routes/auth/magiclink/request.ts b/apps/fastify/src/routes/auth/magiclink/request.ts index 219f7b74..ecba5d8c 100644 --- a/apps/fastify/src/routes/auth/magiclink/request.ts +++ b/apps/fastify/src/routes/auth/magiclink/request.ts @@ -1,4 +1,5 @@ import { randomUUID } from 'node:crypto' +import { faker } from '@faker-js/faker' import type { TypeBoxTypeProvider } from '@fastify/type-provider-typebox' import MagicLinkLoginEmail from '@repo/email/emails/magic-link-login' import { render } from '@repo/email/render' @@ -7,11 +8,48 @@ import { eq } from 'drizzle-orm' import type { FastifyPluginAsync } from 'fastify' import { getDb } from '../../../db/index.js' import { users, verification } from '../../../db/schema/index.js' +import { isUniqueViolation } from '../../../lib/db-errors.js' import { env } from '../../../lib/env.js' -import { generateToken, hashToken } from '../../../lib/jwt.js' +import { generateLoginCode, hashToken } from '../../../lib/jwt.js' import { isAllowedUrl } from '../../../lib/url.js' +import { generateFunnyUsername } from '../../../lib/username.js' import { ErrorResponseSchema } from '../../schemas.js' +async function findOrCreateUserForMagicLink( + db: Awaited>, + email: string, +): Promise { + const userId = randomUUID() + const funnyName = `${faker.word.adjective()} ${faker.animal.type()}` + const maxRetries = 5 + for (let attempt = 0; attempt < maxRetries; attempt++) + try { + const [created] = await db.transaction(async tx => { + const username = await generateFunnyUsername(tx) + await tx.insert(users).values({ + id: userId, + email, + emailVerified: false, + name: funnyName, + username, + }) + const [c] = await tx.select().from(users).where(eq(users.id, userId)) + if (!c) throw new Error('Failed to create user') + return [c] + }) + return created + } catch (err) { + if (isUniqueViolation(err)) { + const [existing] = await db.select().from(users).where(eq(users.email, email)) + if (existing) return existing + if (attempt < maxRetries - 1) continue + } + throw err + } + + return undefined +} + const RequestSchema = Type.Object({ email: Type.String({ format: 'email' }), callbackUrl: Type.String({ format: 'uri' }), @@ -53,20 +91,16 @@ const magicLinkRequestRoute: FastifyPluginAsync = async fastify => { // Find or create user let [user] = await db.select().from(users).where(eq(users.email, email)) if (!user) { - const userId = randomUUID() - await db.insert(users).values({ - id: userId, - email, - emailVerified: false, - }) - ;[user] = await db.select().from(users).where(eq(users.id, userId)) - if (!user) throw new Error('Failed to create user') + const created = await findOrCreateUserForMagicLink(db, email) + if (!created) throw new Error('Failed to create user') + user = created } - // Generate verification token - const token = generateToken() - const tokenHash = hashToken(token) + // Generate 6-digit login code (delivered only in email body, never in URL) + const code = generateLoginCode() + const tokenHash = hashToken(code) const expiresAt = new Date(Date.now() + 15 * 60 * 1000) // 15 minutes + const verificationId = randomUUID() const storePlain = env.NODE_ENV !== 'production' && @@ -74,25 +108,29 @@ const magicLinkRequestRoute: FastifyPluginAsync = async fastify => { typeof email === 'string' && email.endsWith('@test.ai') await db.insert(verification).values({ - id: randomUUID(), + id: verificationId, type: 'magic_link', identifier: email, value: tokenHash, - ...(storePlain && { tokenPlain: token }), + ...(storePlain && { tokenPlain: code }), expiresAt, }) const magicLinkUrl = new URL(callbackUrl) - magicLinkUrl.searchParams.set('token', token) + magicLinkUrl.searchParams.set('verificationId', verificationId) // Send email const html = await render( - MagicLinkLoginEmail({ magicLink: magicLinkUrl.toString(), expirationMinutes: 15 }), + MagicLinkLoginEmail({ + magicLink: magicLinkUrl.toString(), + loginCode: code, + expirationMinutes: 15, + }), ) const emailResponse = await fastify.emailProvider.emails.send({ from: `${env.EMAIL_FROM_NAME} <${env.EMAIL_FROM}>`, to: email, - subject: 'Sign in to your account', + subject: `${code} - ${env.APP_NAME} verification code`, html, }) diff --git a/apps/fastify/src/routes/auth/magiclink/verify.test.ts b/apps/fastify/src/routes/auth/magiclink/verify.test.ts index bea95544..21c9ec5e 100644 --- a/apps/fastify/src/routes/auth/magiclink/verify.test.ts +++ b/apps/fastify/src/routes/auth/magiclink/verify.test.ts @@ -24,9 +24,7 @@ describe('POST /auth/magiclink/verify', () => { const verifyResponse = await fastify.inject({ method: 'POST', url: '/auth/magiclink/verify', - payload: { - token, - }, + payload: { email, token }, }) expect(verifyResponse.statusCode).toBe(200) @@ -44,12 +42,12 @@ describe('POST /auth/magiclink/verify', () => { const response = await fastify.inject({ method: 'POST', url: '/auth/magiclink/verify', - payload: { - token: 'invalid-token-12345', - }, + payload: { email: 'nonexistent@example.com', token: '000000' }, }) - expect([400, 401, 404]).toContain(response.statusCode) + expect(response.statusCode).toBe(401) + const body = JSON.parse(response.body) + expect(body.code).toBe('INVALID_TOKEN') }) it('should return error for missing token', async () => { @@ -80,9 +78,7 @@ describe('POST /auth/magiclink/verify', () => { const verifyResponse = await fastify.inject({ method: 'POST', url: '/auth/magiclink/verify', - payload: { - token, - }, + payload: { email, token }, }) expect(verifyResponse.statusCode).toBe(200) @@ -130,9 +126,7 @@ describe('POST /auth/magiclink/verify', () => { const verifyResponse = await fastify.inject({ method: 'POST', url: '/auth/magiclink/verify', - payload: { - token, - }, + payload: { email, token }, }) expect(verifyResponse.statusCode).toBe(200) const { token: jwtToken } = JSON.parse(verifyResponse.body) diff --git a/apps/fastify/src/routes/auth/magiclink/verify.ts b/apps/fastify/src/routes/auth/magiclink/verify.ts index 409d8029..bf18913f 100644 --- a/apps/fastify/src/routes/auth/magiclink/verify.ts +++ b/apps/fastify/src/routes/auth/magiclink/verify.ts @@ -1,15 +1,103 @@ +import { randomUUID } from 'node:crypto' import type { TypeBoxTypeProvider } from '@fastify/type-provider-typebox' import { Type } from '@sinclair/typebox' -import { and, eq } from 'drizzle-orm' -import type { FastifyPluginAsync } from 'fastify' +import { and, eq, sql } from 'drizzle-orm' +import type { FastifyInstance, FastifyPluginAsync, FastifyRequest } from 'fastify' import { getDb } from '../../../db/index.js' -import { users, verification } from '../../../db/schema/index.js' +import { authAttempts, users, verification } from '../../../db/schema/index.js' import { hashToken } from '../../../lib/jwt.js' +import { getTrustedClientIp } from '../../../lib/request.js' import { createSessionAndIssueTokens } from '../../../lib/session.js' import { ErrorResponseSchema } from '../../schemas.js' +const magicLinkMaxAttempts = 5 +const magicLinkLockMinutes = 15 + +/** Verify magic link token and return access JWT. Used by reference route callback and POST /verify. */ +export async function verifyMagicLinkAndIssueToken( + fastify: FastifyInstance, + request: FastifyRequest, + { token, verificationId }: { token: string; verificationId: string }, +): Promise<{ accessToken: string } | null> { + const tokenHash = hashToken(token) + const db = await getDb() + const ip = getTrustedClientIp(request) + + const [attemptRow] = await db + .select() + .from(authAttempts) + .where(and(eq(authAttempts.key, ip), eq(authAttempts.type, 'magic_link'))) + + if (attemptRow?.lockedUntil && attemptRow.lockedUntil > new Date()) return null + + const [verificationRecord] = await db + .select() + .from(verification) + .where( + and( + eq(verification.id, verificationId), + eq(verification.value, tokenHash), + eq(verification.type, 'magic_link'), + ), + ) + + async function recordFailedAttempt() { + const now = new Date() + const lockedUntilNew = new Date(now.getTime() + magicLinkLockMinutes * 60 * 1000) + await db + .insert(authAttempts) + .values({ + id: randomUUID(), + key: ip, + type: 'magic_link', + failedAttempts: 1, + firstFailureAt: now, + lockedUntil: null, + updatedAt: now, + }) + .onConflictDoUpdate({ + target: [authAttempts.key, authAttempts.type], + set: { + failedAttempts: sql`${authAttempts.failedAttempts} + 1`, + firstFailureAt: sql`COALESCE(${authAttempts.firstFailureAt}, ${now})`, + lockedUntil: sql`CASE WHEN (${authAttempts.failedAttempts} + 1) >= ${magicLinkMaxAttempts} THEN ${lockedUntilNew}::timestamptz ELSE NULL END`, + updatedAt: now, + }, + }) + } + + if (!verificationRecord) { + await recordFailedAttempt() + return null + } + + if (verificationRecord.expiresAt < new Date()) { + await db.delete(verification).where(eq(verification.id, verificationRecord.id)) + await recordFailedAttempt() + return null + } + + const [user] = await db.select().from(users).where(eq(users.email, verificationRecord.identifier)) + + if (!user) return null + + await db + .delete(authAttempts) + .where(and(eq(authAttempts.key, ip), eq(authAttempts.type, 'magic_link'))) + await db.delete(verification).where(eq(verification.id, verificationRecord.id)) + + const { accessToken } = await createSessionAndIssueTokens({ fastify, db, userId: user.id }) + return { accessToken } +} + const VerifySchema = Type.Object({ - token: Type.String(), + token: Type.String({ pattern: '^\\d{6}$', description: '6-digit code' }), + verificationId: Type.Optional( + Type.String({ format: 'uuid', description: 'Verification row id (from magic link URL)' }), + ), + email: Type.Optional( + Type.String({ format: 'email', description: 'Email (for code entry on login page)' }), + ), }) const VerifyResponseSchema = Type.Object({ @@ -30,40 +118,94 @@ const magicLinkVerifyRoute: FastifyPluginAsync = async fastify => { body: VerifySchema, response: { 200: VerifyResponseSchema, + 400: ErrorResponseSchema, 401: ErrorResponseSchema, 404: ErrorResponseSchema, + 429: ErrorResponseSchema, }, }, }, async (request, reply) => { - const { token } = request.body - const tokenHash = hashToken(token) + const { token, verificationId, email } = request.body + const hasVerificationId = Boolean(verificationId) + const hasEmail = Boolean(email) + if (hasVerificationId === hasEmail) + return reply.code(400).send({ + code: 'INVALID_INPUT', + message: 'Provide exactly one of verificationId (from link) or email (for code entry)', + }) + const tokenHash = hashToken(token) const db = await getDb() + const ip = getTrustedClientIp(request) - // Find verification record (magic_link only - link_email uses separate flow) - const [verificationRecord] = await db + const [attemptRow] = await db .select() - .from(verification) - .where(and(eq(verification.value, tokenHash), eq(verification.type, 'magic_link'))) + .from(authAttempts) + .where(and(eq(authAttempts.key, ip), eq(authAttempts.type, 'magic_link'))) - if (!verificationRecord) + if (attemptRow?.lockedUntil && attemptRow.lockedUntil > new Date()) + return reply.code(429).send({ + code: 'TOO_MANY_ATTEMPTS', + message: 'Too many failed attempts. Try again later.', + }) + + const idOrEmail = (hasVerificationId ? verificationId : email) ?? '' + const verificationWhere = hasVerificationId + ? and( + eq(verification.id, idOrEmail), + eq(verification.value, tokenHash), + eq(verification.type, 'magic_link'), + ) + : and( + eq(verification.identifier, idOrEmail), + eq(verification.value, tokenHash), + eq(verification.type, 'magic_link'), + ) + const [verificationRecord] = await db.select().from(verification).where(verificationWhere) + + async function recordFailedAttempt() { + const now = new Date() + const lockedUntilNew = new Date(now.getTime() + magicLinkLockMinutes * 60 * 1000) + await db + .insert(authAttempts) + .values({ + id: randomUUID(), + key: ip, + type: 'magic_link', + failedAttempts: 1, + firstFailureAt: now, + lockedUntil: null, + updatedAt: now, + }) + .onConflictDoUpdate({ + target: [authAttempts.key, authAttempts.type], + set: { + failedAttempts: sql`${authAttempts.failedAttempts} + 1`, + firstFailureAt: sql`COALESCE(${authAttempts.firstFailureAt}, ${now})`, + lockedUntil: sql`CASE WHEN (${authAttempts.failedAttempts} + 1) >= ${magicLinkMaxAttempts} THEN ${lockedUntilNew}::timestamptz ELSE NULL END`, + updatedAt: now, + }, + }) + } + + if (!verificationRecord) { + await recordFailedAttempt() return reply.code(401).send({ code: 'INVALID_TOKEN', message: 'Invalid or expired token', }) + } - // Check expiration if (verificationRecord.expiresAt < new Date()) { - // Clean up expired record await db.delete(verification).where(eq(verification.id, verificationRecord.id)) + await recordFailedAttempt() return reply.code(401).send({ code: 'EXPIRED_TOKEN', message: 'Token has expired', }) } - // Find user const [user] = await db .select() .from(users) @@ -75,7 +217,10 @@ const magicLinkVerifyRoute: FastifyPluginAsync = async fastify => { message: 'User not found', }) - // Delete verification record (single-use) + await db + .delete(authAttempts) + .where(and(eq(authAttempts.key, ip), eq(authAttempts.type, 'magic_link'))) + await db.delete(verification).where(eq(verification.id, verificationRecord.id)) const { accessToken, refreshToken } = await createSessionAndIssueTokens({ diff --git a/apps/fastify/src/routes/auth/oauth/facebook/exchange.ts b/apps/fastify/src/routes/auth/oauth/facebook/exchange.ts index 63d77406..7382bd8c 100644 --- a/apps/fastify/src/routes/auth/oauth/facebook/exchange.ts +++ b/apps/fastify/src/routes/auth/oauth/facebook/exchange.ts @@ -5,7 +5,7 @@ import { and, eq } from 'drizzle-orm' import type { FastifyPluginAsync } from 'fastify' import { encryptAccountTokens } from '../../../../db/account.js' import { getDb } from '../../../../db/index.js' -import { account, sessions, users, verification } from '../../../../db/schema/index.js' +import { account, sessions, verification } from '../../../../db/schema/index.js' import { env } from '../../../../lib/env.js' import { createAccessTokenPayload, @@ -13,6 +13,7 @@ import { generateJti, hashToken, } from '../../../../lib/jwt.js' +import { findOrCreateUserByEmail } from '../../../../lib/oauth-user.js' import { ErrorResponseSchema } from '../../../schemas.js' const ExchangeSchema = Type.Object({ @@ -165,16 +166,11 @@ const oauthExchangeRoute: FastifyPluginAsync = async fastify => { message: 'Could not retrieve email from Facebook', }) - await db - .insert(users) - .values({ - id: randomUUID(), - email, - emailVerified: true, - name, - }) - .onConflictDoNothing({ target: users.email }) - const [user] = await db.select().from(users).where(eq(users.email, email)) + const user = await findOrCreateUserByEmail(db, { + email, + name, + emailVerified: true, + }) if (!user) return reply.code(500).send({ code: 'USER_CREATE_FAILED', diff --git a/apps/fastify/src/routes/auth/oauth/github/exchange.ts b/apps/fastify/src/routes/auth/oauth/github/exchange.ts index 1b89664c..956a6aeb 100644 --- a/apps/fastify/src/routes/auth/oauth/github/exchange.ts +++ b/apps/fastify/src/routes/auth/oauth/github/exchange.ts @@ -13,6 +13,7 @@ import { generateJti, hashToken, } from '../../../../lib/jwt.js' +import { generateFunnyUsername } from '../../../../lib/username.js' import { ErrorResponseSchema } from '../../../schemas.js' const ExchangeSchema = Type.Object({ @@ -163,11 +164,13 @@ const oauthExchangeRoute: FastifyPluginAsync = async fastify => { let [user] = await db.select().from(users).where(eq(users.email, email)) if (!user) { const userId = randomUUID() + const username = await generateFunnyUsername(db) await db.insert(users).values({ id: userId, email, emailVerified: true, name: ghUser.name ?? ghUser.login, + username, }) ;[user] = await db.select().from(users).where(eq(users.id, userId)) if (!user) throw new Error('Failed to create user') diff --git a/apps/fastify/src/routes/auth/oauth/google/verify-id-token.ts b/apps/fastify/src/routes/auth/oauth/google/verify-id-token.ts index c7463727..e9500455 100644 --- a/apps/fastify/src/routes/auth/oauth/google/verify-id-token.ts +++ b/apps/fastify/src/routes/auth/oauth/google/verify-id-token.ts @@ -1,12 +1,12 @@ import { randomUUID } from 'node:crypto' import type { TypeBoxTypeProvider } from '@fastify/type-provider-typebox' import { Type } from '@sinclair/typebox' -import { and, eq } from 'drizzle-orm' -import type { FastifyPluginAsync } from 'fastify' +import type { FastifyInstance, FastifyPluginAsync } from 'fastify' import { OAuth2Client } from 'google-auth-library' import { getDb } from '../../../../db/index.js' -import { account, users } from '../../../../db/schema/index.js' +import { account } from '../../../../db/schema/index.js' import { env } from '../../../../lib/env.js' +import { findOrCreateUserByEmail } from '../../../../lib/oauth-user.js' import { createSessionAndIssueTokens } from '../../../../lib/session.js' import { ErrorResponseSchema } from '../../../schemas.js' @@ -19,6 +19,47 @@ const VerifyIdTokenResponseSchema = Type.Object({ refreshToken: Type.String(), }) +async function runGoogleVerifyIdTokenTx(input: { + fastify: FastifyInstance + db: Awaited> + accountId: string + email: string + name: string +}): Promise<{ token: string; refreshToken: string }> { + const { fastify, db, accountId, email, name } = input + const user = await findOrCreateUserByEmail(db, { + email, + name, + emailVerified: true, + }) + if (!user) throw new Error('Failed to create or find user') + + return db.transaction(async tx => { + const linkedUserId = user.id + const now = new Date() + await tx + .insert(account) + .values({ + id: randomUUID(), + userId: linkedUserId, + accountId, + providerId: 'google', + scope: 'openid email profile', + }) + .onConflictDoUpdate({ + target: [account.providerId, account.accountId], + set: { userId: linkedUserId, updatedAt: now }, + }) + + const { accessToken, refreshToken } = await createSessionAndIssueTokens({ + fastify, + db: tx, + userId: linkedUserId, + }) + return { token: accessToken, refreshToken } + }) +} + const oauthVerifyIdTokenRoute: FastifyPluginAsync = async fastify => { fastify.withTypeProvider().post( '/verify-id-token', @@ -85,69 +126,9 @@ const oauthVerifyIdTokenRoute: FastifyPluginAsync = async fastify => { }) const db = await getDb() - const { token, refreshToken } = await db.transaction(async tx => { - const [existingAccount] = await tx - .select() - .from(account) - .where(and(eq(account.providerId, 'google'), eq(account.accountId, accountId))) - - let user: typeof users.$inferSelect | undefined - if (existingAccount) { - ;[user] = await tx.select().from(users).where(eq(users.id, existingAccount.userId)) - } - if (!user) { - const name = payload?.name ?? email - const [byEmail] = await tx.select().from(users).where(eq(users.email, email)) - if (byEmail) user = byEmail - if (!user) { - const userId = randomUUID() - await tx.insert(users).values({ - id: userId, - email, - emailVerified: true, - name, - }) - ;[user] = await tx.select().from(users).where(eq(users.id, userId)) - if (!user) throw new Error('Failed to create user') - } - } - - const accountData = { - id: existingAccount?.id ?? randomUUID(), - userId: user.id, - accountId, - providerId: 'google' as const, - accessToken: null as string | null, - refreshToken: null as string | null, - idToken: null as string | null, - accessTokenExpiresAt: null as Date | null, - refreshTokenExpiresAt: null as Date | null, - scope: 'openid email profile', - } - - if (existingAccount) - await tx - .update(account) - .set({ updatedAt: new Date() }) - .where(eq(account.id, existingAccount.id)) - else - await tx.insert(account).values({ - id: accountData.id, - userId: accountData.userId, - accountId: accountData.accountId, - providerId: accountData.providerId, - scope: accountData.scope, - }) - - const { accessToken, refreshToken } = await createSessionAndIssueTokens({ - fastify, - db: tx, - userId: user.id, - }) - return { token: accessToken, refreshToken } - }) - - return reply.code(200).send({ token, refreshToken }) + const name = payload.name ?? email + const result = await runGoogleVerifyIdTokenTx({ fastify, db, accountId, email, name }) + return reply.code(200).send({ token: result.token, refreshToken: result.refreshToken }) }, ) } diff --git a/apps/fastify/src/routes/auth/oauth/twitter/exchange.ts b/apps/fastify/src/routes/auth/oauth/twitter/exchange.ts index 7aecd312..cf50fa00 100644 --- a/apps/fastify/src/routes/auth/oauth/twitter/exchange.ts +++ b/apps/fastify/src/routes/auth/oauth/twitter/exchange.ts @@ -1,18 +1,19 @@ -import { randomUUID } from 'node:crypto' import type { TypeBoxTypeProvider } from '@fastify/type-provider-typebox' import { Type } from '@sinclair/typebox' import { and, eq } from 'drizzle-orm' import type { FastifyPluginAsync } from 'fastify' -import { encryptAccountTokens } from '../../../../db/account.js' import { getDb } from '../../../../db/index.js' -import { account, sessions, users, verification } from '../../../../db/schema/index.js' +import { verification } from '../../../../db/schema/index.js' +import { isUniqueViolation } from '../../../../lib/db-errors.js' import { env } from '../../../../lib/env.js' +import { hashToken } from '../../../../lib/jwt.js' import { - createAccessTokenPayload, - createRefreshTokenPayload, - generateJti, - hashToken, -} from '../../../../lib/jwt.js' + fetchTwitterOAuthData, + OAuthUpstreamError, + runTwitterExchangeTx, + type TwitterAccountData, +} from '../../../../lib/oauth-twitter.js' +import { createSessionAndIssueTokens } from '../../../../lib/session.js' import { ErrorResponseSchema } from '../../../schemas.js' const ExchangeSchema = Type.Object({ @@ -25,16 +26,6 @@ const ExchangeResponseSchema = Type.Object({ refreshToken: Type.String(), }) -type TwitterTokenResponse = { - access_token?: string - refresh_token?: string - token_type?: string - expires_in?: number - error?: string -} - -type TwitterUser = { data?: { id: string; name?: string; username?: string } } - const oauthExchangeRoute: FastifyPluginAsync = async fastify => { fastify.withTypeProvider().post( '/exchange', @@ -99,214 +90,71 @@ const oauthExchangeRoute: FastifyPluginAsync = async fastify => { await db.delete(verification).where(eq(verification.id, stateRecord.id)) - const tokenBody = new URLSearchParams({ - grant_type: 'authorization_code', - code, - code_verifier: codeVerifier, - redirect_uri: oauthTwitterCallbackUrl, - }) - - const fetchTimeoutMs = 15_000 - const basicAuth = Buffer.from(`${twitterClientId}:${twitterClientSecret}`).toString('base64') - - let tokenRes: Response + let accountId: string + let name: string + let accountData: TwitterAccountData try { - tokenRes = await fetch('https://api.x.com/2/oauth2/token', { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - Authorization: `Basic ${basicAuth}`, - }, - body: tokenBody.toString(), - signal: AbortSignal.timeout(fetchTimeoutMs), + const oauthData = await fetchTwitterOAuthData({ + code, + codeVerifier, + oauthTwitterCallbackUrl, + twitterClientId, + twitterClientSecret, }) + accountId = oauthData.accountId + name = oauthData.name + accountData = oauthData.accountData } catch (err) { if (err instanceof Error && (err.name === 'AbortError' || err.name === 'TimeoutError')) return reply.code(504).send({ code: 'UPSTREAM_TIMEOUT', - message: 'Token exchange timed out', + message: 'Token exchange or user fetch timed out', }) - request.log.warn({ err }, 'Twitter token exchange failed') - return reply.code(502).send({ - code: 'UPSTREAM_SERVICE_ERROR', - message: 'Failed to exchange code for token', - }) - } - - if (!tokenRes.ok) { - request.log.warn({ status: tokenRes.status }, 'Twitter token exchange non-ok response') - return reply.code(502).send({ - code: 'UPSTREAM_SERVICE_ERROR', - message: 'Failed to exchange code for token', - }) - } - - const tokenData = (await tokenRes.json()) as TwitterTokenResponse - if (tokenData.error) - return reply.code(400).send({ - code: 'TOKEN_EXCHANGE_FAILED', - message: tokenData.error, - }) - - const accessToken = tokenData.access_token - if (!accessToken) - return reply.code(400).send({ - code: 'TOKEN_EXCHANGE_FAILED', - message: 'No access token in response', - }) - - let userRes: Response - try { - userRes = await fetch('https://api.x.com/2/users/me', { - headers: { Authorization: `Bearer ${accessToken}` }, - signal: AbortSignal.timeout(fetchTimeoutMs), - }) - } catch (err) { - if (err instanceof Error && (err.name === 'AbortError' || err.name === 'TimeoutError')) - return reply.code(504).send({ - code: 'UPSTREAM_TIMEOUT', - message: 'Failed to fetch Twitter user (timeout)', + if (err instanceof OAuthUpstreamError) { + const is4xx = err.status >= 400 && err.status < 500 + const errorCode = + err.stage === 'user_fetch' ? 'FETCH_USER_FAILED' : 'UPSTREAM_SERVICE_ERROR' + const statusCode: 400 | 401 | 502 = is4xx && err.status === 401 ? 401 : is4xx ? 400 : 502 + return reply.code(statusCode).send({ + code: errorCode, + message: + err.stage === 'user_fetch' + ? 'Invalid Twitter user response' + : 'Failed to exchange code for token or fetch user', }) - request.log.warn({ err }, 'Twitter user fetch failed') + } + request.log.warn({ err }, 'Twitter OAuth fetch failed') return reply.code(502).send({ code: 'UPSTREAM_SERVICE_ERROR', - message: 'Failed to fetch Twitter user', + message: 'Failed to exchange code for token or fetch user', }) } - if (!userRes.ok) { - request.log.warn({ status: userRes.status }, 'Twitter user fetch non-ok response') - return reply.code(502).send({ - code: 'UPSTREAM_SERVICE_ERROR', - message: 'Failed to fetch Twitter user', - }) - } - - const userData = (await userRes.json()) as TwitterUser - const twUser = userData.data - if (!twUser?.id) - return reply.code(400).send({ - code: 'FETCH_USER_FAILED', - message: 'Invalid Twitter user response', - }) - - const accountId = twUser.id - const name = twUser.name ?? twUser.username ?? 'Twitter user' - - const accountData = { - accessToken, - refreshToken: tokenData.refresh_token ?? null, - accessTokenExpiresAt: tokenData.expires_in - ? new Date(Date.now() + tokenData.expires_in * 1000) - : null, - refreshTokenExpiresAt: null as Date | null, - scope: 'tweet.read users.read offline.access', - } - - let txResult: { userId: string; sessionId: string; refreshJti: string } - try { - txResult = await db.transaction(async tx => { - const [existingAccount] = await tx - .select() - .from(account) - .where(and(eq(account.providerId, 'twitter'), eq(account.accountId, accountId))) - - let user: typeof users.$inferSelect | undefined - if (existingAccount) { - ;[user] = await tx.select().from(users).where(eq(users.id, existingAccount.userId)) - } - if (!user) { - const userId = randomUUID() - await tx.insert(users).values({ - id: userId, - email: null, - emailVerified: false, - name, - }) - ;[user] = await tx.select().from(users).where(eq(users.id, userId)) - if (!user) throw new Error('USER_CREATE_FAILED') - } - - const accountRow = { - id: existingAccount?.id ?? randomUUID(), - userId: user.id, - accountId, - providerId: 'twitter' as const, - } - - if (existingAccount) { - const encrypted = encryptAccountTokens({ - accessToken: accountData.accessToken, - refreshToken: accountData.refreshToken, - updatedAt: new Date(), - }) - await tx - .update(account) - .set({ - accessToken: encrypted.accessToken, - refreshToken: encrypted.refreshToken, - accessTokenExpiresAt: accountData.accessTokenExpiresAt, - refreshTokenExpiresAt: accountData.refreshTokenExpiresAt, - scope: accountData.scope, - updatedAt: encrypted.updatedAt ?? new Date(), - }) - .where(eq(account.id, existingAccount.id)) - } else { - const toInsert = encryptAccountTokens({ - ...accountRow, - accessToken: accountData.accessToken, - refreshToken: accountData.refreshToken, - idToken: null as string | null, - accessTokenExpiresAt: accountData.accessTokenExpiresAt, - refreshTokenExpiresAt: accountData.refreshTokenExpiresAt, - scope: accountData.scope, + const maxRetries = 5 + let txResult!: { userId: string } + for (let attempt = 0; attempt < maxRetries; attempt++) + try { + txResult = await runTwitterExchangeTx(db, accountId, name, accountData) + break + } catch (err) { + if (err instanceof Error && err.message === 'USER_CREATE_FAILED') + return reply.code(500).send({ + code: 'USER_CREATE_FAILED', + message: 'Failed to create user', }) - await tx.insert(account).values(toInsert) - } + if (isUniqueViolation(err) && attempt < maxRetries - 1) continue + throw err + } - const sessionId = randomUUID() - const refreshJti = generateJti() - const refreshJtiHash = hashToken(refreshJti) - const sessionExpiresAt = new Date(Date.now() + env.REFRESH_JWT_EXPIRES_IN_SECONDS * 1000) - - await tx.insert(sessions).values({ - id: sessionId, - userId: user.id, - token: refreshJtiHash, - expiresAt: sessionExpiresAt, - }) - - return { userId: user.id, sessionId, refreshJti } - }) - } catch (err) { - if (err instanceof Error && err.message === 'USER_CREATE_FAILED') - return reply.code(500).send({ - code: 'USER_CREATE_FAILED', - message: 'Failed to create user', - }) - throw err - } - - const accessPayload = createAccessTokenPayload({ - userId: txResult.userId, - sessionId: txResult.sessionId, - }) - const refreshPayload = createRefreshTokenPayload({ + const { accessToken, refreshToken } = await createSessionAndIssueTokens({ + fastify, + db, userId: txResult.userId, - sessionId: txResult.sessionId, - jti: txResult.refreshJti, - }) - - const jwtAccess = fastify.jwt.sign(accessPayload, { - expiresIn: `${env.ACCESS_JWT_EXPIRES_IN_SECONDS}s`, - }) - const jwtRefresh = fastify.jwt.sign(refreshPayload, { - expiresIn: `${env.REFRESH_JWT_EXPIRES_IN_SECONDS}s`, }) return reply.code(200).send({ - token: jwtAccess, - refreshToken: jwtRefresh, + token: accessToken, + refreshToken, }) }, ) diff --git a/apps/fastify/src/routes/auth/session/logout.test.ts b/apps/fastify/src/routes/auth/session/logout.test.ts index ba56e197..214fc5fe 100644 --- a/apps/fastify/src/routes/auth/session/logout.test.ts +++ b/apps/fastify/src/routes/auth/session/logout.test.ts @@ -24,9 +24,7 @@ describe('POST /auth/session/logout', () => { const verifyResponse = await fastify.inject({ method: 'POST', url: '/auth/magiclink/verify', - payload: { - token, - }, + payload: { email, token }, }) const { token: jwtToken } = JSON.parse(verifyResponse.body) diff --git a/apps/fastify/src/routes/auth/session/refresh.test.ts b/apps/fastify/src/routes/auth/session/refresh.test.ts index a8e83d27..0ca18270 100644 --- a/apps/fastify/src/routes/auth/session/refresh.test.ts +++ b/apps/fastify/src/routes/auth/session/refresh.test.ts @@ -24,9 +24,7 @@ describe('POST /auth/session/refresh', () => { const verifyResponse = await fastify.inject({ method: 'POST', url: '/auth/magiclink/verify', - payload: { - token, - }, + payload: { email, token }, }) const { token: jwtToken, refreshToken } = JSON.parse(verifyResponse.body) diff --git a/apps/fastify/src/routes/auth/session/user.ts b/apps/fastify/src/routes/auth/session/user.ts index 372d2845..d4b9a266 100644 --- a/apps/fastify/src/routes/auth/session/user.ts +++ b/apps/fastify/src/routes/auth/session/user.ts @@ -3,7 +3,7 @@ import { Type } from '@sinclair/typebox' import { eq } from 'drizzle-orm' import type { FastifyPluginAsync } from 'fastify' import { getDb } from '../../../db/index.js' -import { passkeyCredentials, totp, walletIdentities } from '../../../db/schema/index.js' +import { passkeyCredentials, totp, users, walletIdentities } from '../../../db/schema/index.js' import { ErrorResponseSchema } from '../../schemas.js' const LinkedWalletSchema = Type.Object({ @@ -23,6 +23,7 @@ const UserResponseSchema = Type.Object({ id: Type.String(), email: Type.Union([Type.String(), Type.Null()]), name: Type.Union([Type.String(), Type.Null()]), + username: Type.Union([Type.String(), Type.Null()]), emailVerified: Type.Union([Type.Boolean(), Type.Null()]), wallet: Type.Optional(Type.Object({ chain: Type.String(), address: Type.String() })), linkedWallets: Type.Array(LinkedWalletSchema), @@ -56,9 +57,10 @@ const sessionUserRoute: FastifyPluginAsync = async fastify => { }) const userId = request.session.user.id - let linkedWallets: { id: string; chain: string; address: string }[] + let linkedWallets: { id: string; chain: string; address: string }[] = [] let totpEnabled = false let passkeys: { id: string; name: string; createdAt: string }[] = [] + let userRow: { name?: string | null; username?: string | null } | undefined try { const db = await getDb() @@ -87,6 +89,11 @@ const sessionUserRoute: FastifyPluginAsync = async fastify => { name: p.name, createdAt: p.createdAt.toISOString(), })) + + ;[userRow] = await db + .select({ name: users.name, username: users.username }) + .from(users) + .where(eq(users.id, userId)) } catch (err) { logger.error({ err }, 'Failed to fetch user data') return reply.code(500).send({ @@ -99,7 +106,8 @@ const sessionUserRoute: FastifyPluginAsync = async fastify => { user: { id: request.session.user.id, email: request.session.user.email, - name: null, + name: userRow?.name ?? request.session.user.name ?? null, + username: userRow?.username ?? request.session.user.username ?? null, emailVerified: null, ...(request.session.user.wallet && { wallet: request.session.user.wallet }), linkedWallets, diff --git a/apps/fastify/src/routes/reference.ts b/apps/fastify/src/routes/reference.ts index 06f97f78..c13f7877 100644 --- a/apps/fastify/src/routes/reference.ts +++ b/apps/fastify/src/routes/reference.ts @@ -1,5 +1,6 @@ import type { FastifyPluginAsync } from 'fastify' import { env } from '../lib/env.js' +import { verifyMagicLinkAndIssueToken } from './auth/magiclink/verify.js' import { getReferenceHtml } from './reference/template.js' const referenceRoutes: FastifyPluginAsync = async fastify => { @@ -36,27 +37,25 @@ const referenceRoutes: FastifyPluginAsync = async fastify => { const openApiUrl = `${apiUrl}/reference/openapi.json` const callbackUrl = `${apiUrl}/reference` - // Handle magic link callback: verify token and get JWT + // Magic link callback: token+verificationId in URL → verify server-side; verificationId only → code form (client-side) + const query = request.query as { token?: string; verificationId?: string } + const { token: urlToken, verificationId } = query let jwtToken: string | null = null - const token = (request.query as { token?: string })?.token + if (urlToken && verificationId) { + const result = await verifyMagicLinkAndIssueToken(fastify, request, { + token: urlToken, + verificationId, + }) + jwtToken = result?.accessToken ?? null + } - if (token) - try { - const verifyResponse = await fastify.inject({ - method: 'POST', - url: '/auth/magiclink/verify', - payload: { token }, - }) - - if (verifyResponse.statusCode === 200) { - const verifyData = verifyResponse.json() as { token: string; refreshToken: string } - jwtToken = verifyData.token - } - } catch (error) { - fastify.log.error({ err: error }, 'Failed to verify magic link token') - } - - const html = getReferenceHtml(apiUrl, openApiUrl, callbackUrl, jwtToken) + const html = getReferenceHtml({ + apiUrl, + openApiUrl, + callbackUrl, + jwtToken, + verificationId: jwtToken ? undefined : (verificationId ?? undefined), + }) return reply.type('text/html').send(html) }, ) diff --git a/apps/fastify/src/routes/reference/template.ts b/apps/fastify/src/routes/reference/template.ts index 95c62ba7..2d5c679f 100644 --- a/apps/fastify/src/routes/reference/template.ts +++ b/apps/fastify/src/routes/reference/template.ts @@ -130,13 +130,16 @@ function getButtonInjectionScript(): string { }` } -function getInitScript( - apiUrl: string, - openApiUrl: string, - callbackUrl: string, - jwtToken: string | null, -): string { +function getInitScript(opts: { + apiUrl: string + openApiUrl: string + callbackUrl: string + jwtToken: string | null + verificationId?: string +}): string { + const { apiUrl, openApiUrl, callbackUrl, jwtToken, verificationId } = opts const jwtJson = jwtToken ? JSON.stringify(jwtToken) : 'null' + const verificationIdJson = verificationId ? JSON.stringify(verificationId) : 'null' const buttonScript = getButtonInjectionScript() return ` @@ -145,6 +148,7 @@ function getInitScript( const callbackUrl = ${JSON.stringify(callbackUrl)}; const openApiUrl = ${JSON.stringify(openApiUrl)}; const jwtFromServer = ${jwtJson}; + const verificationIdFromUrl = ${verificationIdJson}; function updateScalarAuth(scalarApiReference, token) { const authConfig = { @@ -179,6 +183,47 @@ function getInitScript( localStorage.setItem('scalar-token', jwtFromServer); history.replaceState({}, '', '/reference'); updateScalarAuth(scalarApiReference, jwtFromServer); + } else if (verificationIdFromUrl) { + const banner = document.createElement('div'); + banner.id = 'verify-banner'; + banner.style.cssText = 'position:fixed;top:0;left:0;right:0;padding:12px 16px;background:#1e3a5f;color:#fff;display:flex;align-items:center;justify-content:center;gap:12px;flex-wrap:wrap;z-index:10000;font-size:14px;'; + banner.innerHTML = 'Enter the 6-digit code from your email:
'; + document.body.prepend(banner); + banner.querySelector('form')?.addEventListener('submit', async (e) => { + e.preventDefault(); + const input = banner.querySelector('input'); + const code = input?.value?.trim(); + if (!code || code.length !== 6) return; + const errorEl = banner.querySelector('[data-verify-error]'); + if (errorEl) errorEl.textContent = ''; + try { + const res = await fetch(apiUrl + '/auth/magiclink/verify', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ verificationId: verificationIdFromUrl, token: code }), + }); + if (res.ok) { + const data = await res.json(); + localStorage.setItem('scalar-token', data.token); + history.replaceState({}, '', '/reference'); + updateScalarAuth(scalarApiReference, data.token); + banner.remove(); + if (window.updateLoginButton) window.updateLoginButton(); + } else { + let msg = 'Verification failed. Please try again.'; + try { + const body = await res.json(); + msg = body.message || msg; + } catch {} + const errDiv = banner.querySelector('[data-verify-error]'); + if (errDiv) errDiv.textContent = msg; + } + } catch (err) { + const msg = 'Verification failed. Please try again.'; + const errDiv = banner.querySelector('[data-verify-error]'); + if (errDiv) errDiv.textContent = msg; + } + }); } window.scalarApiReference = scalarApiReference; @@ -240,12 +285,14 @@ function getInitScript( ` } -export function getReferenceHtml( - apiUrl: string, - openApiUrl: string, - callbackUrl: string, - jwtToken: string | null = null, -): string { +export function getReferenceHtml(opts: { + apiUrl: string + openApiUrl: string + callbackUrl: string + jwtToken?: string | null + verificationId?: string +}): string { + const { apiUrl, openApiUrl, callbackUrl, jwtToken = null, verificationId } = opts return ` @@ -275,7 +322,7 @@ export function getReferenceHtml( - + ` } diff --git a/apps/fastify/src/routes/test/magic-link.test.ts b/apps/fastify/src/routes/test/magic-link.test.ts index 71b6e273..a73168b6 100644 --- a/apps/fastify/src/routes/test/magic-link.test.ts +++ b/apps/fastify/src/routes/test/magic-link.test.ts @@ -19,7 +19,7 @@ describe('GET /test/magic-link/last', () => { expect(response.statusCode).toBe(200) const body = response.json() - expect(body).toEqual({ token: null }) + expect(body).toEqual({ token: null, verificationId: null }) }) it('should return token after magic link is sent', async () => { @@ -44,5 +44,7 @@ describe('GET /test/magic-link/last', () => { const body = response.json() expect(body.token).toBeTruthy() expect(typeof body.token).toBe('string') + expect(body.verificationId).toBeTruthy() + expect(typeof body.verificationId).toBe('string') }) }) diff --git a/apps/fastify/src/routes/test/magic-link.ts b/apps/fastify/src/routes/test/magic-link.ts index a801baef..12b90e2d 100644 --- a/apps/fastify/src/routes/test/magic-link.ts +++ b/apps/fastify/src/routes/test/magic-link.ts @@ -1,13 +1,14 @@ import type { TypeBoxTypeProvider } from '@fastify/type-provider-typebox' import { Type } from '@sinclair/typebox' -import { and, desc, isNotNull, like } from 'drizzle-orm' +import { and, desc, eq, isNotNull, like } from 'drizzle-orm' import type { FastifyPluginAsync } from 'fastify' import { getDb } from '../../db/index.js' import { verification } from '../../db/schema/index.js' import { env } from '../../lib/env.js' -const MagicLinkTokenResponseSchema = Type.Object({ +const MagicLinkLastResponseSchema = Type.Object({ token: Type.Union([Type.String(), Type.Null()]), + verificationId: Type.Union([Type.String(), Type.Null()]), }) const magicLinkTestRoute: FastifyPluginAsync = async fastify => { @@ -21,27 +22,31 @@ const magicLinkTestRoute: FastifyPluginAsync = async fastify => { tags: ['test'], security: [], response: { - 200: MagicLinkTokenResponseSchema, + 200: MagicLinkLastResponseSchema, }, }, }, async (request, reply) => { if (!env.ALLOW_TEST || env.NODE_ENV === 'production') - return reply.code(200).send({ token: null }) + return reply.code(200).send({ token: null, verificationId: null }) const db = await getDb() const [row] = await db - .select({ tokenPlain: verification.tokenPlain }) + .select({ id: verification.id, tokenPlain: verification.tokenPlain }) .from(verification) - .where(and(like(verification.identifier, '%@test.ai'), isNotNull(verification.tokenPlain))) + .where( + and( + like(verification.identifier, '%@test.ai'), + eq(verification.type, 'magic_link'), + isNotNull(verification.tokenPlain), + ), + ) .orderBy(desc(verification.createdAt)) .limit(1) - const fromDb = row?.tokenPlain ?? null - if (fromDb) return reply.code(200).send({ token: fromDb }) - - const fromFake = request.server.fakeEmail?.extractToken() - return reply.code(200).send({ token: fromFake ?? null }) + const verificationId = row?.id ?? request.server.fakeEmail?.extractVerificationId() ?? null + const token = row?.tokenPlain ?? request.server.fakeEmail?.extractToken() ?? null + return reply.code(200).send({ token, verificationId }) }, ) } diff --git a/apps/fastify/test/swagger-login.e2e.spec.ts b/apps/fastify/test/swagger-login.e2e.spec.ts index b27ed4e2..45109116 100644 --- a/apps/fastify/test/swagger-login.e2e.spec.ts +++ b/apps/fastify/test/swagger-login.e2e.spec.ts @@ -4,15 +4,19 @@ const testEmail = 'test@test.ai' const apiUrl = process.env.PLAYWRIGHT_API_URL || 'http://localhost:3001' /** - * Helper function to extract magic link token from test endpoint + * Extract magic link token and verificationId from test endpoint for callback URL */ -async function extractToken(page: ReturnType['page']): Promise { +async function extractMagicLinkData( + page: ReturnType['page'], +): Promise<{ token: string; verificationId: string } | null> { try { const response = await page.request.get(`${apiUrl}/test/magic-link/last`) if (!response.ok()) return null - const data = await response.json() - return data.token || null + const data = (await response.json()) as { token?: string; verificationId?: string } + if (data.token && data.verificationId) + return { token: data.token, verificationId: data.verificationId } + return null } catch { return null } @@ -53,21 +57,18 @@ test.describe('Scalar UI Login Flow', () => { await expect(successMessage).toBeVisible({ timeout: 10000 }) await expect(successMessage).toHaveText('Check your email for the magic link') - // Step 8: Extract token from test endpoint - const token = await extractToken(page) - expect(token).toBeTruthy() - expect(typeof token).toBe('string') - - if (!token) throw new Error('Failed to extract magic link token') + // Step 8: Extract token and verificationId from test endpoint + const magicLink = await extractMagicLinkData(page) + expect(magicLink).toBeTruthy() + if (!magicLink) throw new Error('Failed to extract magic link token and verificationId') - // Step 9: Open callback URL in same window (for E2E testing) - const callbackUrl = `${apiUrl}/reference?token=${token}` + // Step 9: Open callback URL with token+verificationId (server verifies and returns HTML with JWT) + const callbackUrl = `${apiUrl}/reference?token=${magicLink.token}&verificationId=${magicLink.verificationId}` await page.goto(callbackUrl) await page.waitForLoadState('networkidle') - // Step 10: Wait for callback page to process and clean URL - // The callback verifies token, sets JWT in Scalar state, and cleans URL to /reference - await page.waitForURL(/\/reference$/, { timeout: 5000 }) + // Step 10: Wait for callback page to process and clean URL (template does history.replaceState) + await page.waitForURL(/\/reference$/, { timeout: 15000 }) // Step 11: Check that token is stored in localStorage const tokenInStorage = await page.evaluate(() => localStorage.getItem('scalar-token')) @@ -115,12 +116,12 @@ test.describe('Scalar UI Login Flow', () => { await expect(successMessage).toBeVisible({ timeout: 10000 }) await expect(successMessage).toHaveText('Check your email for the magic link') - const token = await extractToken(page) - if (!token) throw new Error('Failed to extract token') + const magicLink = await extractMagicLinkData(page) + if (!magicLink) throw new Error('Failed to extract magic link token and verificationId') - const callbackUrl = `${apiUrl}/reference?token=${token}` + const callbackUrl = `${apiUrl}/reference?token=${magicLink.token}&verificationId=${magicLink.verificationId}` await page.goto(callbackUrl) - await page.waitForURL(/\/reference$/, { timeout: 5000 }) + await page.waitForURL(/\/reference$/, { timeout: 15000 }) await page.waitForLoadState('networkidle') // Verify logged in state diff --git a/apps/fastify/test/utils/auth-helper.ts b/apps/fastify/test/utils/auth-helper.ts index 9c72d366..5487cfe9 100644 --- a/apps/fastify/test/utils/auth-helper.ts +++ b/apps/fastify/test/utils/auth-helper.ts @@ -48,12 +48,18 @@ export async function getSessionToken( `auth/magiclink/request failed: url=/auth/magiclink/request status=${requestRes.statusCode} body=${requestRes.body}`, ) - const token = app.fakeEmail?.extractToken() + const lastForEmail = app.fakeEmail + ?.all() + .filter(e => e.to === email) + .at(-1) + const token = lastForEmail + ? app.fakeEmail?.extractToken(lastForEmail) + : app.fakeEmail?.extractToken() if (!token) throw new Error('No token in fake email') const verifyRes = await app.inject({ method: 'POST', url: '/auth/magiclink/verify', - payload: { token }, + payload: { email, token }, }) if (verifyRes.statusCode < 200 || verifyRes.statusCode >= 300) throw new Error( diff --git a/apps/fastify/test/utils/db.ts b/apps/fastify/test/utils/db.ts index 171a5341..daa02cd7 100644 --- a/apps/fastify/test/utils/db.ts +++ b/apps/fastify/test/utils/db.ts @@ -85,6 +85,7 @@ const tables = [ 'sessions', 'wallet_identities', 'web3_nonce', + 'auth_attempts', 'users', 'verification', ] as const diff --git a/apps/fastify/test/utils/fake-email.ts b/apps/fastify/test/utils/fake-email.ts index ef32fbcc..a1f81671 100644 --- a/apps/fastify/test/utils/fake-email.ts +++ b/apps/fastify/test/utils/fake-email.ts @@ -85,31 +85,49 @@ export class FakeEmailProvider implements EmailProvider { if (textLink) return textLink } - // Fallback: look for any URL with token parameter (more flexible regex) - // Handles both ?token= and &token= patterns, and various quote styles - const urlMatch = decodedHtml.match(/href\s*=\s*["']([^"']*[?&]token=[^"'&]*)["']/i) + // Fallback: look for any URL with verificationId parameter (magic link) + const urlMatch = decodedHtml.match(/href\s*=\s*["']([^"']*[?&]verificationId=[^"'&]*)["']/i) const urlLink = urlMatch?.[1] if (urlLink) return urlLink - // Additional fallback: look for token parameter anywhere in HTML (not just in href) - const tokenMatch = decodedHtml.match(/(https?:\/\/[^\s"']*[?&]token=[^\s"']*)/i) - const tokenLink = tokenMatch?.[1] - if (tokenLink) return tokenLink + // Legacy: token parameter (deprecated) + const tokenUrlMatch = decodedHtml.match(/href\s*=\s*["']([^"']*[?&]token=[^"'&]*)["']/i) + if (tokenUrlMatch?.[1]) return tokenUrlMatch[1] return null } - extractToken(email?: Email): string | null { + extractVerificationId(email?: Email): string | null { const magicLink = this.extractMagicLink(email) if (!magicLink) return null - try { const url = new URL(magicLink) - return url.searchParams.get('token') + return url.searchParams.get('verificationId') ?? url.searchParams.get('token') } catch { - // If URL parsing fails, try regex extraction - const tokenMatch = magicLink.match(/[?&]token=([^&]+)/) - return tokenMatch ? tokenMatch[1] : null + const match = magicLink.match(/[?&](?:verificationId|token)=([^&]+)/) + return match ? match[1] : null } } + + /** Extract 6-digit code from email body (magic link). For @test.ai, prefer tokenPlain from DB. */ + extractToken(email?: Email): string | null { + const targetEmail = email ?? this.last() + if (!targetEmail) return null + const decodedHtml = decodeHtmlEntities(targetEmail.html) + const monoMatch = decodedHtml.match(/font-mono[^>]*>\s*(\d{6})\s*\s*(\d{6})\s* +
@@ -59,7 +59,9 @@ export function DashboardShell({
-
{children}
+ +
{children}
+
diff --git a/apps/next/app/(dashboard)/page-title.tsx b/apps/next/app/(dashboard)/page-title.tsx index 51653725..2cd1120e 100644 --- a/apps/next/app/(dashboard)/page-title.tsx +++ b/apps/next/app/(dashboard)/page-title.tsx @@ -1,30 +1,20 @@ 'use client' -import { usePathname, useSearchParams } from 'next/navigation' +import { usePathname } from 'next/navigation' export const pageTitles: Record = { - '/': 'Everything', + '/': 'Latest News', '/markets': 'Markets', '/settings': 'Profile', '/settings/security': 'Passkeys', '/settings/security/passkeys': 'Passkeys', - '/settings/security/api-keys': 'API keys', + '/settings/security/totp': 'Authenticator', + '/settings/security/apikeys': 'API keys', } -const securitySectionTitles: Record = { - passkeys: 'Passkeys', - totp: 'Authenticator', - apikeys: 'API keys', - apikey: 'API keys', -} - -export function PageTitle() { +export function PageTitle(): React.JSX.Element | null { const pathname = usePathname() - const searchParams = useSearchParams() - const section = searchParams.get('section') ?? '' - let title = pageTitles[pathname] - if (pathname === '/settings/security' && section) - title = securitySectionTitles[section.toLowerCase()] ?? pageTitles[pathname] + const title = pageTitles[pathname] if (!title) return null return

{title}

} diff --git a/apps/next/app/(dashboard)/settings/(profile)/profile-section.tsx b/apps/next/app/(dashboard)/settings/(profile)/profile-section.tsx index 0ca43fb2..18a76bf5 100644 --- a/apps/next/app/(dashboard)/settings/(profile)/profile-section.tsx +++ b/apps/next/app/(dashboard)/settings/(profile)/profile-section.tsx @@ -1,30 +1,193 @@ 'use client' import type { GetUserResponse } from '@repo/core' -import { useUser } from '@repo/react' +import { useProfileUpdate, useUser } from '@repo/react' import { Avatar, AvatarFallback } from '@repo/ui/components/avatar' import { Button } from '@repo/ui/components/button' -import { - Card, - CardContent, - CardDescription, - CardFooter, - CardHeader, - CardTitle, -} from '@repo/ui/components/card' import { Input } from '@repo/ui/components/input' -import { Label } from '@repo/ui/components/label' import { Skeleton } from '@repo/ui/components/skeleton' +import { Tooltip, TooltipContent, TooltipTrigger } from '@repo/ui/components/tooltip' import { useSetState } from 'ahooks' -import { User } from 'lucide-react' -import { useCallback, useEffect } from 'react' +import { Copy, Shuffle, User } from 'lucide-react' +import { useCallback, useEffect, useMemo } from 'react' import { toast } from 'sonner' +const adjectives = [ + 'clever', + 'swift', + 'brave', + 'cosmic', + 'lucky', + 'happy', + 'quick', + 'bright', + 'fancy', + 'royal', +] +const animals = ['panda', 'fox', 'owl', 'bear', 'wolf', 'lion', 'tiger', 'eagle', 'otter', 'hawk'] + +function generateFunnyUsername() { + const adj = adjectives[Math.floor(Math.random() * adjectives.length)] + const animal = animals[Math.floor(Math.random() * animals.length)] + return `${adj}_${animal}` +} + +function buildSavePayload( + state: { name: string | null; username: string | null }, + user: { name?: string | null; username?: string | null } | null | undefined, +): { name?: string; username?: string | null } { + const payload: { name?: string; username?: string | null } = {} + const userName = user?.name != null ? String(user.name) : null + const userUsername = user?.username != null ? String(user.username) : null + const name = state.name + if (typeof name === 'string' && name !== '' && name !== userName) payload.name = name + if (state.username !== undefined && state.username !== userUsername) + payload.username = state.username || null + return payload +} + +function ProfileFormContent({ + state, + user, + setState, + formDirty, + userId, + onSave, + onCopyId, + onGenerateUsername, + isSaving, +}: { + state: { name: string | null; username: string | null } + user: { id?: string; name?: string | null; username?: string | null; email?: string | null } + setState: (patch: Record) => void + formDirty: boolean + userId: string | null + onSave: () => void + onCopyId: () => void + onGenerateUsername: () => void + isSaving: boolean +}) { + const email = user?.email != null ? String(user.email) : null + return ( +
+
+
+

Username

+

+ Your unique identifier. Used in profile URLs and API interactions. +

+
+
+ setState({ username: e.target.value })} + placeholder="e.g. clever_fox" + maxLength={48} + className="font-mono" + /> + + + + + Generate a funny username + + {formDirty && ( + + )} +
+

Please use 48 characters at maximum.

+
+ +
+
+

Display name

+

+ Your visible name shown to other users. +

+
+ setState({ name: e.target.value })} + placeholder="Your name" + maxLength={32} + /> +

Please use 32 characters at maximum.

+
+ +
+
+

Email

+

Your primary email address.

+
+ +

Email cannot be changed here.

+
+ +
+
+ + + + + +
+

Avatar

+

+ Add an avatar to personalize your profile. +

+

An avatar is optional but recommended.

+
+
+
+ + {userId && ( +
+
+

User ID

+

+ This is your user ID within the system. +

+
+
+ {userId} + + + + + Copy to clipboard + +
+

Used when interacting with the API.

+
+ )} +
+ ) +} + type ProfileSectionProps = { - /** Server-fetched user to avoid client fetch on initial paint */ initialUser?: { + id?: string email?: string | null name?: string | null + username?: string | null emailVerified?: boolean | null } | null } @@ -33,33 +196,75 @@ export function ProfileSection({ initialUser }: ProfileSectionProps) { const { data, isLoading, isError, error } = useUser( initialUser != null ? { initialData: { user: initialUser } as GetUserResponse } : undefined, ) - const [state, setState] = useSetState<{ name: string | null }>({ name: null }) + const updateMutation = useProfileUpdate() + const [state, setState] = useSetState<{ name: string | null; username: string | null }>({ + name: null, + username: null, + }) useEffect(() => { if (data?.user?.name != null) setState({ name: String(data.user.name) }) }, [data?.user?.name, setState]) + useEffect(() => { + if (data?.user?.username != null) setState({ username: String(data.user.username) }) + }, [data?.user?.username, setState]) - const handleNameChange = useCallback( - (e: React.ChangeEvent) => setState({ name: e.target.value }), - [setState], + const user = data?.user + const userForForm = useMemo( + () => + user != null + ? { + id: user.id, + name: user.name != null ? String(user.name) : null, + username: user.username != null ? String(user.username) : null, + email: user.email != null ? String(user.email) : null, + } + : { id: undefined as string | undefined, name: null, username: null, email: null }, + [user], ) + const formDirty = + (state.name ?? '') !== (userForForm.name ?? '') || + (state.username ?? '') !== (userForForm.username ?? '') + const userId = userForForm.id != null ? String(userForForm.id) : null + + const handleSave = useCallback(async () => { + if (!formDirty) return + const payload = buildSavePayload(state, userForForm) + if (Object.keys(payload).length === 0) return + try { + await updateMutation.mutateAsync(payload) + toast.success('Profile updated') + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Failed to update profile') + } + }, [formDirty, state, userForForm, updateMutation]) - const handleSave = useCallback(() => { - toast.info('Profile update coming soon') - }, []) + const handleCopyId = useCallback(async () => { + if (!userId || !navigator.clipboard) { + toast.error('Clipboard not available') + return + } + try { + await navigator.clipboard.writeText(userId) + toast.success('Copied to clipboard') + } catch (err) { + toast.error(`Failed to copy: ${err instanceof Error ? err.message : 'Unknown error'}`) + } + }, [userId]) + + const handleGenerateUsername = useCallback( + () => setState({ username: generateFunnyUsername() }), + [setState], + ) if (isLoading) return (
- - - - - - - - - +
+ + + +
) @@ -70,54 +275,25 @@ export function ProfileSection({ initialUser }: ProfileSectionProps) { ) - const user = data?.user - const email = user?.email != null ? String(user.email) : null - return (
- - - Profile - Manage your account information - - -
- - - - - -
-

Avatar

-

Display photo coming soon

-
-
-
- - -
-
- - -

Email cannot be changed

-
-
- - - -
+ + setState(prev => ({ + ...prev, + ...(patch.name !== undefined && { name: patch.name ?? null }), + ...(patch.username !== undefined && { username: patch.username ?? null }), + })) + } + formDirty={formDirty} + userId={userId} + onSave={handleSave} + onCopyId={handleCopyId} + onGenerateUsername={handleGenerateUsername} + isSaving={updateMutation.isPending} + />
) } diff --git a/apps/next/app/(dashboard)/settings/security/apikeys/page.tsx b/apps/next/app/(dashboard)/settings/security/apikeys/page.tsx new file mode 100644 index 00000000..24c55f28 --- /dev/null +++ b/apps/next/app/(dashboard)/settings/security/apikeys/page.tsx @@ -0,0 +1,5 @@ +import { ApiKeysCard } from '../api-keys-card' + +export default function ApiKeysPage() { + return +} diff --git a/apps/next/app/(dashboard)/settings/security/layout.tsx b/apps/next/app/(dashboard)/settings/security/layout.tsx new file mode 100644 index 00000000..65ecacef --- /dev/null +++ b/apps/next/app/(dashboard)/settings/security/layout.tsx @@ -0,0 +1,14 @@ +import { SecurityTabs } from './security-tabs' + +export default function SecurityLayout({ + children, +}: { + children: React.ReactNode +}): React.JSX.Element { + return ( +
+ +
{children}
+
+ ) +} diff --git a/apps/next/app/(dashboard)/settings/security/page.tsx b/apps/next/app/(dashboard)/settings/security/page.tsx index e77f97b7..b95f045b 100644 --- a/apps/next/app/(dashboard)/settings/security/page.tsx +++ b/apps/next/app/(dashboard)/settings/security/page.tsx @@ -1,9 +1,5 @@ -import { SecuritySection } from './security-section' +import { redirect } from 'next/navigation' -export default function SecurityPage() { - return ( -
- -
- ) +export default function SecurityPage(): never { + redirect('/settings/security/passkeys') } diff --git a/apps/next/app/(dashboard)/settings/security/passkeys/page.tsx b/apps/next/app/(dashboard)/settings/security/passkeys/page.tsx new file mode 100644 index 00000000..087b23cd --- /dev/null +++ b/apps/next/app/(dashboard)/settings/security/passkeys/page.tsx @@ -0,0 +1,5 @@ +import { PasskeysCard } from '../passkeys-card' + +export default function PasskeysPage(): React.JSX.Element { + return +} diff --git a/apps/next/app/(dashboard)/settings/security/security-section.tsx b/apps/next/app/(dashboard)/settings/security/security-section.tsx deleted file mode 100644 index 1460f985..00000000 --- a/apps/next/app/(dashboard)/settings/security/security-section.tsx +++ /dev/null @@ -1,44 +0,0 @@ -'use client' - -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@repo/ui/components/tabs' -import { KeyRoundIcon, ShieldCheckIcon, TerminalIcon } from 'lucide-react' -import { parseAsStringLiteral, useQueryState } from 'nuqs' -import { ApiKeysCard } from './api-keys-card' -import { PasskeysCard } from './passkeys-card' -import { TotpCard } from './totp-card' - -const sectionParser = parseAsStringLiteral(['passkeys', 'totp', 'apikeys']).withDefault('passkeys') - -export function SecuritySection() { - const [section, setSection] = useQueryState('section', sectionParser) - - return ( -
- setSection(v as 'passkeys' | 'totp' | 'apikeys')}> - - - - Passkeys - - - - Authenticator - - - - API keys - - - - - - - - - - - - -
- ) -} diff --git a/apps/next/app/(dashboard)/settings/security/security-tabs.tsx b/apps/next/app/(dashboard)/settings/security/security-tabs.tsx new file mode 100644 index 00000000..bc3e1197 --- /dev/null +++ b/apps/next/app/(dashboard)/settings/security/security-tabs.tsx @@ -0,0 +1,50 @@ +'use client' + +import { Tabs, TabsList, TabsTrigger } from '@repo/ui/components/tabs' +import { cn } from '@repo/ui/lib/utils' +import { KeyRoundIcon, ShieldCheckIcon, TerminalIcon } from 'lucide-react' +import Link from 'next/link' +import { usePathname } from 'next/navigation' + +const tabs = [ + { + href: '/settings/security/passkeys', + value: 'passkeys', + icon: ShieldCheckIcon, + label: 'Passkeys', + }, + { href: '/settings/security/totp', value: 'totp', icon: KeyRoundIcon, label: 'Authenticator' }, + { href: '/settings/security/apikeys', value: 'apikeys', icon: TerminalIcon, label: 'API keys' }, +] as const + +const triggerStyles = 'min-w-0' + +function getActiveValue(pathname: string) { + if (pathname.endsWith('/totp')) return 'totp' + if (pathname.endsWith('/apikeys')) return 'apikeys' + return 'passkeys' +} + +export function SecurityTabs() { + const pathname = usePathname() + const active = getActiveValue(pathname) + + return ( + + + {tabs.map(({ href, value, icon: Icon, label }) => ( + + + + {label} + + + ))} + + + ) +} diff --git a/apps/next/app/(dashboard)/settings/security/totp-card.tsx b/apps/next/app/(dashboard)/settings/security/totp-card.tsx index 25ab3d10..f50018ef 100644 --- a/apps/next/app/(dashboard)/settings/security/totp-card.tsx +++ b/apps/next/app/(dashboard)/settings/security/totp-card.tsx @@ -181,7 +181,7 @@ export function TotpCard() { ) : !setupRequested ? (
) : setupData ? ( diff --git a/apps/next/app/(dashboard)/settings/security/totp/page.tsx b/apps/next/app/(dashboard)/settings/security/totp/page.tsx new file mode 100644 index 00000000..3e970588 --- /dev/null +++ b/apps/next/app/(dashboard)/settings/security/totp/page.tsx @@ -0,0 +1,5 @@ +import { TotpCard } from '../totp-card' + +export default function TotpPage(): React.JSX.Element { + return +} diff --git a/apps/next/app/auth/callback/magiclink/route.ts b/apps/next/app/auth/callback/magiclink/route.ts index 2fa8379f..29b891bf 100644 --- a/apps/next/app/auth/callback/magiclink/route.ts +++ b/apps/next/app/auth/callback/magiclink/route.ts @@ -7,22 +7,81 @@ import { env } from '@/lib/env' const client = createClient({ baseUrl: env.NEXT_PUBLIC_API_URL }) +const sixDigitCode = /^\d{6}$/ +const uuidLike = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + +function isSafeCallbackUrl(raw: string | null, requestUrl: string): string { + if (!raw || !raw.startsWith('/')) return '/' + if (raw.startsWith('//')) return '/' + try { + const url = new URL(raw, requestUrl) + if (url.origin !== new URL(requestUrl).origin) return '/' + return url.pathname + url.search + url.hash + } catch { + return '/' + } +} + export async function GET(request: Request) { const { searchParams } = new URL(request.url) - const token = searchParams.get('token') - const callbackURL = searchParams.get('callbackURL')?.startsWith('/') - ? searchParams.get('callbackURL') - : null + const verificationId = searchParams.get('verificationId') + const rawCallback = searchParams.get('callbackURL') + const callbackURL = isSafeCallbackUrl(rawCallback, request.url) - if (!token) redirect(`/auth/login?message=${encodeURIComponent('INVALID_TOKEN')}`) + if (verificationId && uuidLike.test(verificationId)) { + const html = ` + + +Enter code - Acme + + +

Enter your code

+

Enter the 6-digit code from your email to sign in.

+
+ + + + + +
+

Back to login

+ +` + return new NextResponse(html, { + headers: { 'Content-Type': 'text/html; charset=utf-8' }, + }) + } + + redirect(`/auth/login?message=${encodeURIComponent('INVALID_TOKEN')}`) +} + +export async function POST(request: Request) { + const formData = await request.formData() + const verificationId = formData.get('verificationId')?.toString() + const token = formData.get('token')?.toString() + const rawCallback = formData.get('callbackURL')?.toString() + const callbackURL = isSafeCallbackUrl(rawCallback ?? null, request.url) + + if (!verificationId || !token || !uuidLike.test(verificationId) || !sixDigitCode.test(token)) { + const backUrl = verificationId + ? `/auth/callback/magiclink?verificationId=${encodeURIComponent(verificationId)}&callbackURL=${encodeURIComponent(callbackURL)}&message=${encodeURIComponent('INVALID_CODE')}` + : `/auth/callback/magiclink?message=${encodeURIComponent('INVALID_CODE')}` + redirect(backUrl) + } try { - const response = await client.auth.magiclink.verify({ body: { token } }) + const response = await client.auth.magiclink.verify({ + body: { verificationId, token }, + }) const tokens = extractTokens(response) if (!tokens) redirect(`/auth/login?message=${encodeURIComponent('FAILED_VERIFY')}`) - const redirectUrl = callbackURL ?? '/' - const redirectResponse = NextResponse.redirect(new URL(redirectUrl, request.url), 303) + const redirectResponse = NextResponse.redirect(new URL(callbackURL ?? '/', request.url), 303) setAuthCookiesOnResponse(redirectResponse, tokens) return redirectResponse } catch (error) { diff --git a/apps/next/app/auth/callback/page.tsx b/apps/next/app/auth/callback/page.tsx index 689b5e1b..e0437aac 100644 --- a/apps/next/app/auth/callback/page.tsx +++ b/apps/next/app/auth/callback/page.tsx @@ -3,6 +3,7 @@ import { redirect } from 'next/navigation' type AuthCallbackPageProps = { searchParams: Promise<{ token?: string + verificationId?: string format?: string error?: string message?: string @@ -16,12 +17,19 @@ export default async function AuthCallbackPage({ searchParams }: AuthCallbackPag if (error) redirect(`/auth/login?message=${encodeURIComponent(error)}`) - const token = params.token const callbackURL = params.callbackURL?.startsWith('/') ? params.callbackURL : '/' - if (!token) redirect('/auth/login?message=Invalid or expired magic link') + const verificationId = params.verificationId + if (verificationId) + redirect( + `/auth/callback/magiclink?verificationId=${encodeURIComponent(verificationId)}&callbackURL=${encodeURIComponent(callbackURL)}`, + ) + + const token = params.token + if (token) + redirect( + `/auth/callback/magiclink?token=${encodeURIComponent(token)}&callbackURL=${encodeURIComponent(callbackURL)}&legacyToken=1`, + ) - redirect( - `/auth/callback/magiclink?token=${encodeURIComponent(token)}&callbackURL=${encodeURIComponent(callbackURL)}`, - ) + redirect('/auth/login?message=Invalid or expired magic link') } diff --git a/apps/next/app/auth/login/login-actions.tsx b/apps/next/app/auth/login/login-actions.tsx index 7c562ebf..e41b6baa 100644 --- a/apps/next/app/auth/login/login-actions.tsx +++ b/apps/next/app/auth/login/login-actions.tsx @@ -2,7 +2,6 @@ import { ApiError } from '@repo/core' import { - LoginForm, useOAuthLogin, useOAuthProviders, usePasskeyAuth, @@ -18,10 +17,11 @@ import { toast } from 'sonner' import { Facebook, GitHub, Google, Passkey, Twitter } from '@/components/icons' import { updateAuthTokens } from '@/lib/auth/auth-client' import { getAuthErrorMessage } from '@/lib/auth/auth-error-messages' +import { LoginForm } from './login-form' import { PasskeyShortcut } from './passkey-shortcut' import { useGoogleOneTap } from './use-google-one-tap' -type LoginActionsProps = { initialError?: string; defaultEmail?: string } +type LoginActionsProps = { initialError?: string } type OAuthButtonsProps = { anyPending: boolean @@ -146,7 +146,7 @@ function ErrorBanner({ message, onDismiss }: { message: string; onDismiss: () => ) } -export function LoginActions({ initialError, defaultEmail }: LoginActionsProps) { +export function LoginActions({ initialError }: LoginActionsProps): React.JSX.Element { const router = useRouter() const [dismissedForError, setDismissedForError] = useState(null) const [lastAuthMethod, setLastAuthMethod] = useState<'oauth' | 'passkey' | null>(null) @@ -220,7 +220,15 @@ export function LoginActions({ initialError, defaultEmail }: LoginActionsProps) /> )} { + try { + await updateAuthTokens({ token, refreshToken }) + router.push('/') + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Failed to complete sign-in') + } + }} extraActions={ ) => void + onSubmit: React.FormEventHandler + onBackToEmail: () => void +} & Omit, 'onSubmit'> + +export function LoginCodeView({ + className, + code, + codeError, + isVerifyPending, + onCodeChange, + onSubmit, + onBackToEmail, + ...props +}: LoginCodeViewProps): React.JSX.Element { + return ( +
+ +
+

Check your email

+

+ Enter the login code we sent you +

+
+ + + + + + {codeError && {codeError}} + + + +
+
+ ) +} diff --git a/packages/react/src/components/login-form.tsx b/apps/next/app/auth/login/login-form.tsx similarity index 66% rename from packages/react/src/components/login-form.tsx rename to apps/next/app/auth/login/login-form.tsx index ac4ea4cc..08cf2245 100644 --- a/packages/react/src/components/login-form.tsx +++ b/apps/next/app/auth/login/login-form.tsx @@ -1,5 +1,6 @@ 'use client' +import { useMagicLink, useMagicLinkVerify } from '@repo/react' import { Button } from '@repo/ui/components/button' import { Field, @@ -15,23 +16,28 @@ import { InputGroupInput, } from '@repo/ui/components/input-group' import { cn } from '@repo/ui/lib/utils' -import { useEffect, useState } from 'react' +import { useState } from 'react' import { z } from 'zod' -import { useMagicLink } from '../hooks/use-magic-link' +import { LoginCodeView } from './login-code-view' const emailSchema = z .string() .min(1, 'Email is required') .email('Please enter a valid email address') +const codeSchema = z + .string() + .length(6, 'Enter the 6-digit code') + .regex(/^\d{6}$/, 'Code must be 6 digits') + type LoginFormProps = React.ComponentProps<'form'> & { initialError?: string callbackUrl?: string onSuccess?: () => void - /** Default email to pre-fill */ - defaultEmail?: string /** Called when magic link request succeeds, with the email used */ onMagicLinkSent?: (email: string) => void + /** Called when code verify succeeds; caller updates tokens and redirects */ + onVerifySuccess?: (tokens: { token: string; refreshToken: string }) => Promise /** Optional content for "Or continue with" section (e.g. SIWE/SIWS wallet buttons) */ extraActions?: React.ReactNode } @@ -41,52 +47,39 @@ export function LoginForm({ initialError, callbackUrl, onSuccess, - defaultEmail, onMagicLinkSent, + onVerifySuccess, extraActions, ...props }: LoginFormProps) { - const [email, setEmail] = useState(defaultEmail ?? '') + const [email, setEmail] = useState('') const [emailValidationError, setEmailValidationError] = useState( initialError || null, ) const [catalogError, setCatalogError] = useState(null) - const [isSuccess, setIsSuccess] = useState(false) - - // Update error when initialError prop changes - syncing prop to state - // Only update if initialError is actually provided (not undefined) - useEffect(() => { - if (initialError !== undefined) - // eslint-disable-next-line react-hooks/set-state-in-effect -- Syncing prop to state - setEmailValidationError(initialError || null) - }, [initialError]) - - // Sync defaultEmail when it becomes defined - useEffect(() => { - if (defaultEmail !== undefined) - // eslint-disable-next-line react-hooks/set-state-in-effect -- Syncing prop to state - setEmail(defaultEmail ?? '') - }, [defaultEmail]) + const [codeError, setCodeError] = useState(null) + const [code, setCode] = useState('') + const [showCodeEntry, setShowCodeEntry] = useState(false) const defaultCallbackUrl = typeof window !== 'undefined' ? `${window.location.origin}/auth/callback/magiclink?callbackURL=/` : '/auth/callback/magiclink?callbackURL=/' - const { mutate, isPending } = useMagicLink({ + const { mutate: sendMagicLink, isPending: isRequestPending } = useMagicLink({ onSuccess: (data, variables) => { if (data?.ok) { - setIsSuccess(true) - setEmail('') + setShowCodeEntry(true) + setCode('') setEmailValidationError(null) setCatalogError(null) + setCodeError(null) onMagicLinkSent?.(variables.email) onSuccess?.() } }, onError: error => { const errorMessage = error.message || 'Failed to send magic link' - // Check if error has code property indicating validation error const errorWithCode = error as Error & { code?: string } const isValidationError = errorWithCode.code === 'VALIDATION_ERROR' || @@ -98,30 +91,54 @@ export function LoginForm({ setEmailValidationError(errorMessage) setCatalogError(null) } else { - // General error - don't report to Sentry here, let consuming app handle it setCatalogError('Failed to send magic link. Please try again.') setEmailValidationError(null) } }, }) + const { mutate: verifyCode, isPending: isVerifyPending } = useMagicLinkVerify({ + onSuccess: async data => { + setCodeError(null) + await onVerifySuccess?.({ token: data.token, refreshToken: data.refreshToken }) + }, + onError: error => { + const body = + error && typeof error === 'object' && 'body' in error + ? (error as { body?: { code?: string } }).body + : undefined + const code = body?.code + if (code === 'INVALID_TOKEN' || code === 'EXPIRED_TOKEN') + setCodeError('Invalid or expired code. Please try again or request a new one.') + else setCodeError(error.message || 'Verification failed. Please try again.') + }, + }) + const validateEmail = (emailValue: string): string | null => { const result = emailSchema.safeParse(emailValue) if (!result.success) return result.error.issues[0]?.message || 'Please enter a valid email address' + return null + } + const validateCode = (codeValue: string): string | null => { + const result = codeSchema.safeParse(codeValue) + if (!result.success) return result.error.issues[0]?.message || 'Enter the 6-digit code' return null } const handleEmailChange = (e: React.ChangeEvent) => { setEmail(e.currentTarget.value) - // Clear validation error and success state when user starts typing if (emailValidationError) setEmailValidationError(null) + if (catalogError) setCatalogError(null) + } - if (isSuccess) setIsSuccess(false) + const handleCodeChange = (e: React.ChangeEvent) => { + setCode(e.currentTarget.value.replace(/\D/g, '').slice(0, 6)) + if (codeError) setCodeError(null) } - const handleSubmit = (e: React.FormEvent) => { + const handleEmailSubmit = (e: React.FormEvent) => { e.preventDefault() const validationError = validateEmail(email) if (validationError) { @@ -131,25 +148,56 @@ export function LoginForm({ } setEmailValidationError(null) setCatalogError(null) - mutate({ email, callbackUrl: callbackUrl || defaultCallbackUrl }) + sendMagicLink({ email, callbackUrl: callbackUrl || defaultCallbackUrl }) + } + + const handleCodeSubmit = (e: React.FormEvent) => { + e.preventDefault() + const validationError = validateCode(code) + if (validationError) { + setCodeError(validationError) + return + } + setCodeError(null) + verifyCode({ email, token: code }) } + const handleBackToEmail = () => { + setShowCodeEntry(false) + setCodeError(null) + setCode('') + } + + if (showCodeEntry) + return ( + + ) + return (
-

Welcome to Acme

+

Welcome to Acme

Enter your email below to continue

- + @@ -166,12 +214,12 @@ export function LoginForm({ type="submit" size="icon-sm" className="cursor-pointer [&_svg]:pointer-events-auto [&_svg]:cursor-pointer [&_span]:cursor-pointer hover:bg-transparent dark:hover:bg-transparent" - disabled={isPending || isSuccess} - aria-label={isPending ? 'Sending magic link' : 'Send magic link'} - aria-busy={isPending} + disabled={isRequestPending} + aria-label={isRequestPending ? 'Sending magic link' : 'Send magic link'} + aria-busy={isRequestPending} data-testid="send-magic-link" > - {isPending ? ( + {isRequestPending ? ( ) : ( {emailValidationError} )} - {isSuccess && ( -

- Check your email for the magic link -

- )}
{catalogError && ( @@ -209,7 +252,7 @@ export function LoginForm({ Or continue with {extraActions ?? ( - diff --git a/packages/react/README.md b/packages/react/README.md index d366abc9..864f2fec 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -4,18 +4,28 @@ Provides React Query hooks for `@repo/core` API functions. ## Overview -This package provides React Query hooks that wrap `@repo/core` API client methods. All types are imported from `@repo/core`, ensuring a single source of truth for API types and eliminating duplication. +This package provides React Query hooks that wrap `@repo/core` API client methods. **Hooks and helpers only—no UI components.** Route-specific UI (e.g. login form) lives in apps, collocated by route. All types are imported from `@repo/core`, ensuring a single source of truth for API types and eliminating duplication. ## Exports - `ApiProvider` - Provider component that makes API client available to hooks +- `createReactApiConfig` - Utility function to normalize API configuration - `useReactApiConfig` - Hook to access API client and query defaults from context +- `useApiKeysList`, `useCreateApiKey`, `useRevokeApiKey` - API keys CRUD hooks +- `useChatFromConfig` - Chat hook with AI SDK integration - `useHealthCheck` - React Query hook for health check endpoint -- `useVerifyWeb3Auth` - Mutation hook: given `{ chain, message, signature, domain }`, calls auth verify endpoint (SIWE/SIWS) -- `useVerifyLinkWallet` - Mutation hook: given `{ chain, message, signature }`, calls link wallet verify endpoint -- `useMagicLink` - React Query mutation hook for magic link request endpoint -- `LoginForm` - Framework-agnostic login form component -- `createReactApiConfig` - Utility function to normalize API configuration +- `useLinkEmail` - Mutation hook for link-email request +- `useMagicLink` - Mutation hook for magic link request endpoint +- `useMagicLinkVerify` - Mutation hook for magic link verification +- `useOAuthLogin`, `useOAuthProviders` - OAuth login and provider detection hooks +- `usePasskeyAuth`, `usePasskeyDiscovery`, `usePasskeyRegister`, `usePasskeyRemove`, `usePasskeysList` - Passkey hooks +- `useProfileUpdate` - Mutation hook for profile update +- `useSession` - Session hook (decoded JWT claims) +- `useTotpSetup`, `useTotpUnlink`, `useTotpVerify` - TOTP hooks +- `useUser` - Query hook for current user (GET /auth/session/user) +- `useVerifyLinkWallet` - Mutation hook for link wallet verify +- `useVerifyWeb3Auth` - Mutation hook for Web3 auth verify (SIWE/SIWS) +- `useWebAuthnAvailable` - Hook to check WebAuthn availability ## Usage @@ -95,23 +105,6 @@ export default function RootLayout({ children }: { children: React.ReactNode }) } ``` -#### Using Components - -Components from `@repo/react` (like `LoginForm`) are client components and can be used directly in your pages: - -```tsx -// app/login/page.tsx -import { LoginForm } from '@repo/react' - -export default function LoginPage() { - return ( -
- -
- ) -} -``` - #### Using Hooks Hooks must be used in client components. Mark components with `'use client'`: diff --git a/packages/react/src/components/login-form.spec.tsx b/packages/react/src/components/login-form.spec.tsx deleted file mode 100644 index 82fb1c6e..00000000 --- a/packages/react/src/components/login-form.spec.tsx +++ /dev/null @@ -1,314 +0,0 @@ -import type { MagiclinkRequestData, MagiclinkRequestResponse } from '@repo/core' -import { createClient } from '@repo/core' -import type { UseMutationResult } from '@tanstack/react-query' -import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { act, render, screen, waitFor } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import * as useMagicLinkModule from '../hooks/use-magic-link' -import { ApiProvider } from '../provider' -import { LoginForm } from './login-form' - -// Mock the useMagicLink hook -vi.mock('../hooks/use-magic-link', () => ({ - useMagicLink: vi.fn(), -})) - -describe('LoginForm', () => { - let queryClient: QueryClient - let mockMutate: ReturnType - let mockClient: ReturnType - let capturedOnSuccess: - | (( - data: { ok: boolean }, - variables: MagiclinkRequestData['body'], - onMutateResult: unknown, - context: unknown, - ) => void) - | undefined - let capturedOnError: - | (( - error: Error, - variables: MagiclinkRequestData['body'], - onMutateResult: unknown, - context: unknown, - ) => void) - | undefined - - beforeEach(() => { - queryClient = new QueryClient({ - defaultOptions: { - queries: { - retry: false, - }, - mutations: { - retry: false, - }, - }, - }) - mockMutate = vi.fn() - mockClient = createClient({ baseUrl: 'http://localhost:3000' }) - capturedOnSuccess = undefined - capturedOnError = undefined - - // Setup default mock return value - // Capture onSuccess and onError callbacks from hook options - vi.mocked(useMagicLinkModule.useMagicLink).mockImplementation(options => { - if (options?.onSuccess) capturedOnSuccess = options.onSuccess - - if (options?.onError) capturedOnError = options.onError - - return { - mutate: mockMutate, - isPending: false, - error: null, - data: undefined, - isError: false, - isSuccess: false, - reset: vi.fn(), - mutateAsync: vi.fn(), - status: 'idle', - failureCount: 0, - failureReason: null, - submittedAt: 0, - variables: undefined, - context: undefined, - isPaused: false, - isIdle: true, - } as UseMutationResult - }) - }) - - afterEach(() => { - vi.clearAllMocks() - }) - - function renderLoginForm(opts?: { - initialError?: string - callbackUrl?: string - defaultEmail?: string - onMagicLinkSent?: (email: string) => void - }) { - return render( - - - - - , - ) - } - - it('should call mutate with email and callbackUrl on form submit', async () => { - renderLoginForm() - - const emailInput = screen.getByLabelText(/email/i) - const submitButton = screen.getByRole('button', { name: /send magic link/i }) - - await userEvent.type(emailInput, 'test@example.com') - await userEvent.click(submitButton) - - await waitFor(() => { - expect(mockMutate).toHaveBeenCalledWith({ - email: 'test@example.com', - callbackUrl: expect.any(String), - }) - }) - }) - - it('should use provided callbackUrl prop', async () => { - renderLoginForm({ callbackUrl: 'https://example.com/custom-callback' }) - - const emailInput = screen.getByLabelText(/email/i) - const submitButton = screen.getByRole('button', { name: /send magic link/i }) - - await userEvent.type(emailInput, 'test@example.com') - await userEvent.click(submitButton) - - await waitFor(() => { - expect(mockMutate).toHaveBeenCalledWith({ - email: 'test@example.com', - callbackUrl: 'https://example.com/custom-callback', - }) - }) - }) - - it('should display email validation errors below input field using FieldError', async () => { - renderLoginForm() - - const emailInput = screen.getByLabelText(/email/i) as HTMLInputElement - const form = emailInput.closest('form') as HTMLFormElement - - // Remove required attribute to allow any submission - emailInput.removeAttribute('required') - - // Type invalid email - await userEvent.type(emailInput, 'invalid-email') - form.requestSubmit() - - // Wait for validation error to appear - await waitFor(() => { - const errorElement = screen.getByRole('alert') - expect(errorElement).toBeInTheDocument() - expect(errorElement).toHaveTextContent(/valid email/i) - expect(errorElement).toHaveAttribute('data-slot', 'field-error') - }) - - // Verify error is below the input (within the same Field component) - const fieldElement = emailInput.closest('[data-slot="field"]') - expect(fieldElement).toBeInTheDocument() - const errorInField = fieldElement?.querySelector('[data-slot="field-error"]') - expect(errorInField).toBeInTheDocument() - }) - - it('should display API validation errors below input field when error has VALIDATION_ERROR code', async () => { - const validationError = new Error('Email is required') as Error & { code?: string } - validationError.code = 'VALIDATION_ERROR' - - renderLoginForm() - - const emailInput = screen.getByLabelText(/email/i) as HTMLInputElement - const submitButton = screen.getByRole('button', { name: /send magic link/i }) - - await userEvent.type(emailInput, 'test@example.com') - await userEvent.click(submitButton) - - // Simulate mutation error by calling captured onError callback - await act(async () => { - if (capturedOnError) - capturedOnError( - validationError, - { email: 'test@example.com', callbackUrl: '/' }, - undefined, - undefined, - ) - }) - - // Wait for error to appear - await waitFor(() => { - const errorElement = screen.getByRole('alert') - expect(errorElement).toBeInTheDocument() - expect(errorElement).toHaveTextContent(/email is required/i) - }) - }) - - it('should display initialError prop below input field', () => { - renderLoginForm({ initialError: 'Invalid or expired magic link' }) - - const errorElement = screen.getByRole('alert') - expect(errorElement).toBeInTheDocument() - expect(errorElement).toHaveTextContent(/invalid or expired magic link/i) - - // Verify error is below the input field - const emailInput = screen.getByLabelText(/email/i) - const fieldElement = emailInput.closest('[data-slot="field"]') - expect(fieldElement).toBeInTheDocument() - const errorInField = fieldElement?.querySelector('[data-slot="field-error"]') - expect(errorInField).toBeInTheDocument() - }) - - it('should display success message below input field when magic link is sent', async () => { - renderLoginForm() - - const emailInput = screen.getByLabelText(/email/i) as HTMLInputElement - const submitButton = screen.getByRole('button', { name: /send magic link/i }) - - await userEvent.type(emailInput, 'test@example.com') - await userEvent.click(submitButton) - - await act(async () => { - if (capturedOnSuccess) - capturedOnSuccess( - { ok: true }, - { email: 'test@example.com', callbackUrl: '/' }, - undefined, - undefined, - ) - }) - - // Wait for success message to appear - const successMessage = await screen.findByText( - /check your email for the magic link/i, - {}, - { timeout: 10000 }, - ) - expect(successMessage).toBeInTheDocument() - - // Verify success message is below the input (within the same Field component) - const currentEmailInput = screen.getByLabelText(/email/i) as HTMLInputElement - const fieldElement = currentEmailInput.closest('[data-slot="field"]') - expect(fieldElement).toBeInTheDocument() - expect(fieldElement).toHaveTextContent(/check your email for the magic link/i) - - // Verify email input is cleared - expect(currentEmailInput.value).toBe('') - }) - - it('should show pending state when mutation is in progress', async () => { - vi.mocked(useMagicLinkModule.useMagicLink).mockImplementation(options => { - if (options?.onSuccess) capturedOnSuccess = options.onSuccess - - if (options?.onError) capturedOnError = options.onError - - return { - mutate: mockMutate, - isPending: true, - error: null, - data: undefined, - isError: false, - isSuccess: false, - reset: vi.fn(), - mutateAsync: vi.fn(), - status: 'pending', - failureCount: 0, - failureReason: null, - submittedAt: Date.now(), - variables: { email: 'test@example.com', callbackUrl: '/' }, - context: undefined, - isPaused: false, - isIdle: false, - } as UseMutationResult - }) - - renderLoginForm() - - const submitButton = screen.getByRole('button', { name: /sending/i }) - expect(submitButton).toBeInTheDocument() - expect(submitButton).toBeDisabled() - }) - - it('should pre-fill email when defaultEmail prop is provided', () => { - renderLoginForm({ defaultEmail: 'prev@example.com' }) - - const emailInput = screen.getByLabelText(/email/i) as HTMLInputElement - expect(emailInput.value).toBe('prev@example.com') - }) - - it('should call onMagicLinkSent with email when magic link request succeeds', async () => { - const onMagicLinkSent = vi.fn() - - renderLoginForm({ onMagicLinkSent }) - - const emailInput = screen.getByLabelText(/email/i) as HTMLInputElement - const submitButton = screen.getByRole('button', { name: /send magic link/i }) - - await userEvent.type(emailInput, 'sent@example.com') - await userEvent.click(submitButton) - - await act(async () => { - if (capturedOnSuccess) - capturedOnSuccess( - { ok: true }, - { email: 'sent@example.com', callbackUrl: '/' }, - undefined, - undefined, - ) - }) - - expect(onMagicLinkSent).toHaveBeenCalledWith('sent@example.com') - }) -}) diff --git a/packages/react/src/hooks/use-magic-link-verify.ts b/packages/react/src/hooks/use-magic-link-verify.ts new file mode 100644 index 00000000..7ccca595 --- /dev/null +++ b/packages/react/src/hooks/use-magic-link-verify.ts @@ -0,0 +1,32 @@ +import type { MagiclinkVerifyData, MagiclinkVerifyResponse } from '@repo/core' +import type { UseMutationOptions } from '@tanstack/react-query' +import { useMutation } from '@tanstack/react-query' +import { useReactApiConfig } from '../context' + +/** + * React Query mutation hook for magic link verify endpoint. + * + * Exchanges 6-digit login code for JWTs. Uses the API client configured in + * `ReactApiProvider` and applies default mutation options from context. + * + * @param options - Additional TanStack Query mutation options (merged with context defaults) + * @returns TanStack Query mutation result + */ +export function useMagicLinkVerify( + options?: Omit< + UseMutationOptions, + 'mutationFn' + >, +) { + const { client, queryClientDefaults } = useReactApiConfig() + + return useMutation({ + mutationFn: async variables => + client.auth.magiclink.verify({ + body: variables, + throwOnError: true, + }), + ...queryClientDefaults, + ...options, + }) +} diff --git a/packages/react/src/hooks/use-profile-update.ts b/packages/react/src/hooks/use-profile-update.ts new file mode 100644 index 00000000..0220c217 --- /dev/null +++ b/packages/react/src/hooks/use-profile-update.ts @@ -0,0 +1,23 @@ +'use client' + +import type { AccountProfileUpdateData } from '@repo/core' +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { useReactApiConfig } from '../context' + +const userQueryKey = ['auth', 'session', 'user'] as const + +export function useProfileUpdate() { + const { client } = useReactApiConfig() + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (body: AccountProfileUpdateData['body']) => + client.account.profile({ + body, + throwOnError: true, + }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: userQueryKey }) + }, + }) +} diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 42c96a63..da0bfb74 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -1,5 +1,3 @@ -// Export components -export { LoginForm } from './components/login-form' export { useReactApiConfig } from './context' export { useApiKeysList, useCreateApiKey, useRevokeApiKey } from './hooks/use-api-keys' // Export hooks @@ -7,11 +5,13 @@ export { useChatFromConfig } from './hooks/use-chat' export { useHealthCheck } from './hooks/use-health-check' export { useLinkEmail } from './hooks/use-link-email' export { useMagicLink } from './hooks/use-magic-link' +export { useMagicLinkVerify } from './hooks/use-magic-link-verify' export { type OAuthRedirectProvider, useOAuthLogin } from './hooks/use-oauth-login' export { useOAuthProviders } from './hooks/use-oauth-providers' export { usePasskeyAuth } from './hooks/use-passkey-auth' export { usePasskeyDiscovery } from './hooks/use-passkey-discovery' export { usePasskeyRegister, usePasskeyRemove, usePasskeysList } from './hooks/use-passkeys' +export { useProfileUpdate } from './hooks/use-profile-update' export { useSession } from './hooks/use-session' export { useTotpSetup, useTotpUnlink, useTotpVerify } from './hooks/use-totp' export { useUser } from './hooks/use-user' diff --git a/packages/ui/src/components/scroll-area.tsx b/packages/ui/src/components/scroll-area.tsx index 527e87bd..caaca770 100644 --- a/packages/ui/src/components/scroll-area.tsx +++ b/packages/ui/src/components/scroll-area.tsx @@ -1,27 +1,38 @@ 'use client' -import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area' import { cn } from '@repo/ui/lib/utils' -import type * as React from 'react' +import { ScrollArea as ScrollAreaPrimitive } from 'radix-ui' +import type { ComponentProps } from 'react' function ScrollArea({ className, children, + orientation = 'both', ...props -}: React.ComponentProps) { +}: ComponentProps & { + orientation?: 'vertical' | 'horizontal' | 'both' +}) { return ( {children} - + {(orientation === 'vertical' || orientation === 'both') && } + {(orientation === 'horizontal' || orientation === 'both') && ( + + )} ) @@ -31,7 +42,7 @@ function ScrollBar({ className, orientation = 'vertical', ...props -}: React.ComponentProps) { +}: ComponentProps) { return ( ) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7964d890..1f96c2e9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -140,6 +140,9 @@ importers: '@ai-sdk/openai': specifier: ^3.0.0 version: 3.0.41(zod@4.3.6) + '@faker-js/faker': + specifier: ^9.4.0 + version: 9.9.0 '@fastify/autoload': specifier: ^6.3.1 version: 6.3.1 @@ -2165,6 +2168,10 @@ packages: '@noble/hashes': optional: true + '@faker-js/faker@9.9.0': + resolution: {integrity: sha512-OEl393iCOoo/z8bMezRlJu+GlRGlsKbUAN7jKB6LhnKoqKve5DXRpalbItIIcwnCjs1k/FOPjFzcA6Qn+H+YbA==} + engines: {node: '>=18.0.0', npm: '>=9.0.0'} + '@fastify/accept-negotiator@2.0.1': resolution: {integrity: sha512-/c/TW2bO/v9JeEgoD/g1G5GxGeCF1Hafdf79WPmUlgYiBXummY0oX3VVq4yFkKKVBKDNlaDUYoab7g38RpPqCQ==} @@ -14366,6 +14373,8 @@ snapshots: optionalDependencies: '@noble/hashes': 2.0.1 + '@faker-js/faker@9.9.0': {} + '@fastify/accept-negotiator@2.0.1': {} '@fastify/ajv-compiler@4.0.5': @@ -19807,14 +19816,6 @@ snapshots: optionalDependencies: vite: 7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) - '@vitest/mocker@4.0.18(vite@7.3.1(@types/node@25.3.5)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': - dependencies: - '@vitest/spy': 4.0.18 - estree-walker: 3.0.3 - magic-string: 0.30.21 - optionalDependencies: - vite: 7.3.1(@types/node@25.3.5)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) - '@vitest/pretty-format@4.0.18': dependencies: tinyrainbow: 3.0.3 @@ -23220,7 +23221,7 @@ snapshots: dependencies: foreground-child: 3.3.1 jackspeak: 3.4.3 - minimatch: 9.0.7 + minimatch: 10.2.3 minipass: 7.1.2 package-json-from-dist: 1.0.1 path-scurry: 1.11.1 @@ -28146,7 +28147,7 @@ snapshots: vitest@4.0.18(@edge-runtime/vm@3.2.0)(@opentelemetry/api@1.9.0)(@types/node@25.3.5)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): dependencies: '@vitest/expect': 4.0.18 - '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@25.3.5)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) '@vitest/pretty-format': 4.0.18 '@vitest/runner': 4.0.18 '@vitest/snapshot': 4.0.18