diff --git a/README.md b/README.md index 297190a..98b2085 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,73 @@ # listee-api -API server for Listee + +listee-api exposes Listee's HTTP interface. It packages `@listee/api` inside a Next.js App Router host so the CLI and web clients share the same business logic, validation, and database access. + +## Overview +- Next.js 15 application that forwards requests to `createFetchHandler` from `@listee/api`. +- Supabase supplies authentication (JWT) and the Postgres database. +- Shared models and utilities come from the `@listee/*` packages (auth, db, types, api). + +## Architecture +- `src/app/api/handler.ts` is the single hand-off into `@listee/api`. +- `@listee/api` (Hono + Drizzle ORM) defines routes, validation, and service orchestration. +- `@listee/db` provides Drizzle schema definitions and Postgres connection management. +- Authentication is header-based via `@listee/auth`. + +## Environment Variables +Configure these values in `.env.local` for development and in production: +- `POSTGRES_URL` – Supabase Postgres connection string. +- `SUPABASE_URL` – Supabase project base URL (e.g. `https://your-project.supabase.co`). +- `SUPABASE_PUBLISHABLE_KEY` – Supabase publishable (anon) key used to call Auth endpoints. +- `SUPABASE_JWT_AUDIENCE` – optional; audience value to enforce. +- `SUPABASE_JWT_REQUIRED_ROLE` – optional; enforce a specific `role` claim (e.g. `authenticated`). +- `SUPABASE_JWT_ISSUER` – optional; override the expected issuer. Defaults to `${SUPABASE_URL}/auth/v1`. +- `SUPABASE_JWKS_PATH` – optional; override the JWKS endpoint path. Defaults to `/auth/v1/.well-known/jwks.json`. +- `SUPABASE_JWT_CLOCK_TOLERANCE_SECONDS` – optional; non-negative integer clock skew tolerance. + +## Response Contract +- Success responses always return JSON with a top-level `data` property. DELETE operations respond with `{ "data": null }`. +- Error responses return `{ "error": "message" }` plus the appropriate HTTP status code (`400` validation, `401/403` auth, `404` missing resources, `500` unexpected failures). + +## API Surface +| Method | Path | Description | +| ------ | ---- | ----------- | +| POST | `/api/auth/signup` | Forward email/password signups to Supabase Auth | +| POST | `/api/auth/login` | Exchange email/password for Supabase access + refresh tokens | +| POST | `/api/auth/token` | Refresh Supabase access tokens using a stored refresh token | +| GET | `/api/users/:userId/categories` | List categories for the authenticated user | +| POST | `/api/users/:userId/categories` | Create a new category | +| GET | `/api/categories/:categoryId` | Fetch category details | +| PATCH | `/api/categories/:categoryId` | Update a category name | +| DELETE | `/api/categories/:categoryId` | Delete a category owned by the user | +| GET | `/api/categories/:categoryId/tasks` | List tasks in a category | +| POST | `/api/categories/:categoryId/tasks` | Create a task inside the category | +| GET | `/api/tasks/:taskId` | Fetch task details | +| PATCH | `/api/tasks/:taskId` | Update task name, description, or status | +| DELETE | `/api/tasks/:taskId` | Delete a task owned by the user | +| GET | `/api/healthz` | Database connectivity probe | + +All endpoints expect `Authorization: Bearer ` where `` is a Supabase JWT access token issued for the authenticated user. + +## Local Development +1. Install dependencies: `bun install`. +2. Provide environment variables in `.env.local`. +3. Run the dev server: `bun run dev` (Next.js on port 3000). +4. Lint the project: `bun run lint`. +5. Build for production verification: `bun run build`. + +### Database Migrations +- Schema definitions live in `@listee/db`. Do not hand-edit generated SQL. +- Generate migrations with `bun run db:generate` after schema changes. +- Apply migrations with `bun run db:migrate` (uses `POSTGRES_URL`). + +## Testing +Automated tests are not yet in place. Use CLI smoke tests (e.g. `listee categories update`, `listee tasks delete`) to verify JSON contracts until formal integration tests land. + +## Deployment Notes +- `bun run build` produces the Next.js bundle for production. Deploy on Vercel or any Node 20+ platform capable of running Next.js 15. +- Confirm environment variables for each target environment before deploy. +- Monitor `/api/healthz` after rollout to confirm database access. + +## Conventions +- Keep repository documentation and comments in English. +- Follow Listee org standards: Bun 1.3.x, Biome linting, Drizzle migrations, and semantic versioning via Changesets when publishing packages. diff --git a/bun.lock b/bun.lock index 70203b8..a9bdf74 100644 --- a/bun.lock +++ b/bun.lock @@ -4,14 +4,16 @@ "": { "name": "listee-api", "dependencies": { - "@listee/api": "0.2.3", - "@listee/auth": "0.2.3", - "@listee/db": "0.2.3", - "@listee/types": "0.2.3", + "@listee/api": "0.3.2", + "@listee/auth": "0.5.0", + "@listee/db": "0.4.0", + "@listee/types": "0.5.0", + "@t3-oss/env-nextjs": "0.13.8", "drizzle-orm": "0.44.5", "next": "15.5.4", "react": "19.1.0", "react-dom": "19.1.0", + "zod": "4.1.12", }, "devDependencies": { "@biomejs/biome": "2.2.0", @@ -19,6 +21,7 @@ "@types/react": "^19", "@types/react-dom": "^19", "drizzle-kit": "^0.31.0", + "jiti": "^2.6.1", "typescript": "^5", }, }, @@ -148,13 +151,13 @@ "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.4", "", { "os": "win32", "cpu": "x64" }, "sha512-xIyj4wpYs8J18sVN3mSQjwrw7fKUqRw+Z5rnHNCy5fYTxigBz81u5mOMPmFumwjcn8+ld1ppptMBCLic1nz6ig=="], - "@listee/api": ["@listee/api@0.2.3", "", { "dependencies": { "@listee/auth": "^0.2.3", "@listee/db": "^0.2.3", "@listee/types": "^0.2.3", "hono": "^4.4.4" } }, "sha512-OEHmKEBh0CG0NvaoWAHEuQMDWbRY0S0LCh8i6a2gxszS9O1DQKvnTOnIAwk2GTJ2E30bfwZKZVeIsKGCu94oew=="], + "@listee/api": ["@listee/api@0.3.2", "", { "dependencies": { "@listee/auth": "^0.5.0", "@listee/db": "^0.4.0", "@listee/types": "^0.5.0", "hono": "^4.4.4" } }, "sha512-BjXE7lGe64Eksqz/6xrwxncd3uY3hjKTR5z2MD95ZUMLScADPr0bZbbdhfnTHIHwAwj1NvM0fPRawmwq3biFDA=="], - "@listee/auth": ["@listee/auth@0.2.3", "", { "dependencies": { "@listee/db": "^0.2.3", "@listee/types": "^0.2.3", "jose": "^5.2.3" } }, "sha512-uKWmlxL1wji+/qILopv1iOc8G98fpBhmpTTAc7CgtaNK3bXdrbsKSQCNKUXxu9Q5bPsdWNcbLP1AyYznwEE9wQ=="], + "@listee/auth": ["@listee/auth@0.5.0", "", { "dependencies": { "@listee/db": "^0.4.0", "@listee/types": "^0.5.0", "jose": "^5.2.3" } }, "sha512-MzRBBmo9FlEjS0l/QlSZot+mz7zchjqPQqqtMq8H/uADvNacSysNpkVmvjvV5YFBCifR6IJ1ocWdifIcOaE4Mw=="], - "@listee/db": ["@listee/db@0.2.3", "", { "dependencies": { "drizzle-orm": "^0.44.5", "postgres": "^3.4.7" } }, "sha512-RNijZvSbavrMITk+mKUwPon2XwcbxVYiQSLWPfrv83g+B3JpG1ClYId4qk21cJkUVH6Hn6O97rXN1XOQSKdKNw=="], + "@listee/db": ["@listee/db@0.4.0", "", { "dependencies": { "drizzle-orm": "^0.44.5", "postgres": "^3.4.7" } }, "sha512-6WOSt6UZy+fX2TnsrLl812P9NdWPocCGrOzN6kVnfdR61rbh2OE8k6KPC3cXnGzbamIQBueeut8v66ho0lHsiw=="], - "@listee/types": ["@listee/types@0.2.3", "", { "dependencies": { "@listee/db": "^0.2.3" } }, "sha512-eGOVIn4nCTIWuIC8fu07vjbBf14UU8DQKJQCQOpJjFjW7YP4hCUdjc64DhtYjlsK56xi/TiJprVw7MrpJfx4FA=="], + "@listee/types": ["@listee/types@0.5.0", "", { "dependencies": { "@listee/db": "^0.4.0" } }, "sha512-jRBGTrsmuezx6x7g9XD4XWrooHL7sAqST3dNzTmoiFgv3wqsnwF/uSqay9ln0f8woCjwULJfifm9ko7gHI0DKg=="], "@next/env": ["@next/env@15.5.4", "", {}, "sha512-27SQhYp5QryzIT5uO8hq99C69eLQ7qkzkDPsk3N+GuS2XgOgoYEeOav7Pf8Tn4drECOVDsDg8oj+/DVy8qQL2A=="], @@ -176,6 +179,10 @@ "@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="], + "@t3-oss/env-core": ["@t3-oss/env-core@0.13.8", "", { "peerDependencies": { "arktype": "^2.1.0", "typescript": ">=5.0.0", "valibot": "^1.0.0-beta.7 || ^1.0.0", "zod": "^3.24.0 || ^4.0.0-beta.0" }, "optionalPeers": ["arktype", "typescript", "valibot", "zod"] }, "sha512-L1inmpzLQyYu4+Q1DyrXsGJYCXbtXjC4cICw1uAKv0ppYPQv656lhZPU91Qd1VS6SO/bou1/q5ufVzBGbNsUpw=="], + + "@t3-oss/env-nextjs": ["@t3-oss/env-nextjs@0.13.8", "", { "dependencies": { "@t3-oss/env-core": "0.13.8" }, "peerDependencies": { "arktype": "^2.1.0", "typescript": ">=5.0.0", "valibot": "^1.0.0-beta.7 || ^1.0.0", "zod": "^3.24.0 || ^4.0.0-beta.0" }, "optionalPeers": ["arktype", "typescript", "valibot", "zod"] }, "sha512-QmTLnsdQJ8BiQad2W2nvV6oUpH4oMZMqnFEjhVpzU0h3sI9hn8zb8crjWJ1Amq453mGZs6A4v4ihIeBFDOrLeQ=="], + "@types/node": ["@types/node@20.19.21", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-CsGG2P3I5y48RPMfprQGfy4JPRZ6csfC3ltBZSRItG3ngggmNY/qs2uZKp4p9VbrpqNNSMzUZNFZKzgOGnd/VA=="], "@types/react": ["@types/react@19.2.2", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA=="], @@ -206,6 +213,8 @@ "hono": ["hono@4.10.3", "", {}, "sha512-2LOYWUbnhdxdL8MNbNg9XZig6k+cZXm5IjHn2Aviv7honhBMOHb+jxrKIeJRZJRmn+htUCKhaicxwXuUDlchRA=="], + "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], + "jose": ["jose@5.10.0", "", {}, "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], @@ -246,6 +255,8 @@ "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "zod": ["zod@4.1.12", "", {}, "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ=="], + "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="], diff --git a/drizzle.config.ts b/drizzle.config.ts index 50e09d6..3da05ef 100644 --- a/drizzle.config.ts +++ b/drizzle.config.ts @@ -1,14 +1,29 @@ import { schemaPath } from "@listee/db"; import { loadEnvConfig } from "@next/env"; import { defineConfig } from "drizzle-kit"; +import { ZodError } from "zod"; +import { getEnv } from "./src/app/env"; loadEnvConfig(process.cwd()); -const databaseUrl = process.env.POSTGRES_URL; - -if (databaseUrl === undefined || databaseUrl.length === 0) { - throw new Error("POSTGRES_URL is not set."); -} +const databaseUrl = (() => { + try { + return getEnv().POSTGRES_URL; + } catch (error) { + if (error instanceof ZodError) { + const issue = error.issues.find((entry) => { + return entry.path.join(".") === "POSTGRES_URL"; + }); + if (issue !== undefined) { + if (issue.code === "invalid_type") { + throw new Error("POSTGRES_URL is not set."); + } + throw new Error(issue.message); + } + } + throw error; + } +})(); export default defineConfig({ dialect: "postgresql", diff --git a/next.config.ts b/next.config.ts index e9ffa30..1216117 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,7 +1,20 @@ +import { createJiti } from "jiti"; import type { NextConfig } from "next"; +type EnvModule = typeof import("./src/app/env"); + +const jiti = createJiti(import.meta.url); + +const loadEnvModule = async (): Promise => { + const envModule = await jiti.import("./src/app/env"); + envModule.getEnv(); +}; + const nextConfig: NextConfig = { /* config options here */ }; -export default nextConfig; +export default async (): Promise => { + await loadEnvModule(); + return nextConfig; +}; diff --git a/package.json b/package.json index 4024f05..40ddc45 100644 --- a/package.json +++ b/package.json @@ -12,14 +12,16 @@ "db:migrate": "drizzle-kit migrate --config drizzle.config.ts" }, "dependencies": { - "@listee/api": "0.2.3", - "@listee/auth": "0.2.3", - "@listee/db": "0.2.3", - "@listee/types": "0.2.3", + "@listee/api": "0.3.2", + "@listee/auth": "0.5.0", + "@listee/db": "0.4.0", + "@listee/types": "0.5.0", + "@t3-oss/env-nextjs": "0.13.8", + "drizzle-orm": "0.44.5", + "next": "15.5.4", "react": "19.1.0", "react-dom": "19.1.0", - "next": "15.5.4", - "drizzle-orm": "0.44.5" + "zod": "4.1.12" }, "devDependencies": { "@biomejs/biome": "2.2.0", @@ -27,6 +29,7 @@ "@types/react": "^19", "@types/react-dom": "^19", "drizzle-kit": "^0.31.0", + "jiti": "^2.6.1", "typescript": "^5" } } diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts new file mode 100644 index 0000000..b13cb03 --- /dev/null +++ b/src/app/api/auth/login/route.ts @@ -0,0 +1,26 @@ +import { SupabaseAuthError } from "@listee/auth"; + +import { + ApiError, + handleRoute, + parseJsonBody, + respondWithData, +} from "@/app/api/auth/utils"; +import { loginSchema } from "@/app/api/auth/validation"; +import { getSupabaseAuthClient } from "@/app/supabase-auth-client"; + +export async function POST(request: Request): Promise { + return handleRoute(async () => { + const input = await parseJsonBody(request, loginSchema); + try { + const authClient = getSupabaseAuthClient(); + const tokenResponse = await authClient.login(input); + return respondWithData(tokenResponse, 200); + } catch (error) { + if (error instanceof SupabaseAuthError) { + throw new ApiError(error.statusCode, error.message); + } + throw error; + } + }); +} diff --git a/src/app/api/auth/signup/route.ts b/src/app/api/auth/signup/route.ts new file mode 100644 index 0000000..25104d0 --- /dev/null +++ b/src/app/api/auth/signup/route.ts @@ -0,0 +1,26 @@ +import { SupabaseAuthError } from "@listee/auth"; + +import { + ApiError, + handleRoute, + parseJsonBody, + respondWithData, +} from "@/app/api/auth/utils"; +import { signupSchema } from "@/app/api/auth/validation"; +import { getSupabaseAuthClient } from "@/app/supabase-auth-client"; + +export async function POST(request: Request): Promise { + return handleRoute(async () => { + const input = await parseJsonBody(request, signupSchema); + try { + const authClient = getSupabaseAuthClient(); + await authClient.signup(input); + } catch (error) { + if (error instanceof SupabaseAuthError) { + throw new ApiError(error.statusCode, error.message); + } + throw error; + } + return respondWithData(null, 200); + }); +} diff --git a/src/app/api/auth/token/route.ts b/src/app/api/auth/token/route.ts new file mode 100644 index 0000000..ec405af --- /dev/null +++ b/src/app/api/auth/token/route.ts @@ -0,0 +1,26 @@ +import { SupabaseAuthError } from "@listee/auth"; + +import { + ApiError, + handleRoute, + parseJsonBody, + respondWithData, +} from "@/app/api/auth/utils"; +import { tokenSchema } from "@/app/api/auth/validation"; +import { getSupabaseAuthClient } from "@/app/supabase-auth-client"; + +export async function POST(request: Request): Promise { + return handleRoute(async () => { + const input = await parseJsonBody(request, tokenSchema); + try { + const authClient = getSupabaseAuthClient(); + const tokenResponse = await authClient.refresh(input); + return respondWithData(tokenResponse, 200); + } catch (error) { + if (error instanceof SupabaseAuthError) { + throw new ApiError(error.statusCode, error.message); + } + throw error; + } + }); +} diff --git a/src/app/api/auth/utils.ts b/src/app/api/auth/utils.ts new file mode 100644 index 0000000..8cfd51a --- /dev/null +++ b/src/app/api/auth/utils.ts @@ -0,0 +1,53 @@ +import type { ZodType } from "zod"; + +export class ApiError extends Error { + readonly statusCode: number; + + constructor(statusCode: number, message: string) { + super(message); + this.statusCode = statusCode; + } +} + +export async function parseJsonBody( + request: Request, + schema: ZodType, +): Promise { + let parsed: unknown; + try { + parsed = await request.json(); + } catch { + throw new ApiError(400, "Request body must be valid JSON."); + } + + const result = schema.safeParse(parsed); + if (!result.success) { + const issue = result.error.issues[0]; + const message = issue?.message ?? "Invalid request body."; + throw new ApiError(400, message); + } + + return result.data; +} + +export const respondWithData = (data: T, status = 200): Response => { + return Response.json({ data }, { status }); +}; + +export const respondWithError = (message: string, status: number): Response => { + return Response.json({ error: message }, { status }); +}; + +export const handleRoute = async ( + handler: () => Promise, +): Promise => { + try { + return await handler(); + } catch (error) { + if (error instanceof ApiError) { + return respondWithError(error.message, error.statusCode); + } + console.error("Unhandled auth route error:", error); + return respondWithError("Internal server error.", 500); + } +}; diff --git a/src/app/api/auth/validation.ts b/src/app/api/auth/validation.ts new file mode 100644 index 0000000..01b922b --- /dev/null +++ b/src/app/api/auth/validation.ts @@ -0,0 +1,35 @@ +import { z } from "zod"; + +const trimmedNonEmpty = (label: string) => + z + .string() + .min(1, `${label} must not be empty.`) + .refine((value) => value === value.trim(), { + message: `${label} must not include leading or trailing whitespace.`, + }); + +export const emailSchema = z + .string() + .trim() + .pipe(z.email("Email must be a valid email address.")); + +export const passwordSchema = trimmedNonEmpty("Password"); + +export const refreshTokenSchema = trimmedNonEmpty("refreshToken"); + +export const loginSchema = z.object({ + email: emailSchema, + password: passwordSchema, +}); + +export const signupSchema = loginSchema.extend({ + redirectUrl: z + .string() + .trim() + .pipe(z.url("redirectUrl must be a valid URL.")) + .optional(), +}); + +export const tokenSchema = z.object({ + refreshToken: refreshTokenSchema, +}); diff --git a/src/app/api/handler.ts b/src/app/api/handler.ts index 9246a55..7c284e5 100644 --- a/src/app/api/handler.ts +++ b/src/app/api/handler.ts @@ -8,11 +8,43 @@ import { createTaskRepository, createTaskService, } from "@listee/api"; -import { createHeaderAuthentication } from "@listee/auth"; +import { createSupabaseAuthentication } from "@listee/auth"; import { getDb } from "@listee/db"; +import { getEnv } from "../env"; const API_PREFIX = "/api"; +let cachedAuthentication: ReturnType< + typeof createSupabaseAuthentication +> | null = null; + +const getAuthentication = (): ReturnType< + typeof createSupabaseAuthentication +> => { + if (cachedAuthentication !== null) { + return cachedAuthentication; + } + + const appEnv = getEnv(); + const projectUrl = appEnv.SUPABASE_URL; + const audience = appEnv.SUPABASE_JWT_AUDIENCE; + const issuer = appEnv.SUPABASE_JWT_ISSUER; + const requiredRole = appEnv.SUPABASE_JWT_REQUIRED_ROLE; + const clockTolerance = appEnv.SUPABASE_JWT_CLOCK_TOLERANCE_SECONDS; + const jwksPath = appEnv.SUPABASE_JWKS_PATH; + + cachedAuthentication = createSupabaseAuthentication({ + projectUrl, + audience, + issuer, + requiredRole, + clockToleranceSeconds: clockTolerance, + jwksPath, + }); + + return cachedAuthentication; +}; + const stripApiPrefix = (pathname: string): string => { if (!pathname.startsWith(API_PREFIX)) { return pathname; @@ -38,22 +70,40 @@ const taskService = createTaskService({ }); const categoryQueries = createCategoryQueries({ service: categoryService }); const taskQueries = createTaskQueries({ service: taskService }); -const authentication = createHeaderAuthentication(); const honoFetchHandler = createFetchHandler({ databaseHealth, categoryQueries, taskQueries, - authentication, + authentication: { + authenticate: async (context) => { + const provider = getAuthentication(); + return await provider.authenticate(context); + }, + }, }); export const dispatchToListeeApi = async ( request: Request, ): Promise => { - const originalUrl = new URL(request.url); - const targetUrl = new URL(request.url); - targetUrl.pathname = stripApiPrefix(originalUrl.pathname); + try { + const originalUrl = new URL(request.url); + const targetUrl = new URL(request.url); + targetUrl.pathname = stripApiPrefix(originalUrl.pathname); - const honoRequest = new Request(targetUrl, request); - return await honoFetchHandler(honoRequest); + const honoRequest = new Request(targetUrl, request); + return await honoFetchHandler(honoRequest); + } catch (error) { + const message = + error instanceof Error + ? error.message + : "An unexpected error occurred while handling the request."; + console.error("Unhandled Listee API error:", error); + return Response.json( + { + error: message, + }, + { status: 500 }, + ); + } }; diff --git a/src/app/env.ts b/src/app/env.ts new file mode 100644 index 0000000..cc4881e --- /dev/null +++ b/src/app/env.ts @@ -0,0 +1,51 @@ +import { createEnv } from "@t3-oss/env-nextjs"; +import { z } from "zod"; + +const urlString = z.url(); +const nonEmptyString = z + .string() + .min(1) + .refine((value) => value === value.trim(), { + message: "Value must not include leading or trailing whitespace.", + }); +const optionalNonEmptyString = nonEmptyString.optional(); + +const buildEnv = () => { + return createEnv({ + server: { + POSTGRES_URL: urlString, + SUPABASE_URL: urlString, + SUPABASE_PUBLISHABLE_KEY: nonEmptyString, + SUPABASE_JWT_AUDIENCE: optionalNonEmptyString, + SUPABASE_JWT_REQUIRED_ROLE: optionalNonEmptyString, + SUPABASE_JWT_ISSUER: urlString.optional(), + SUPABASE_JWKS_PATH: optionalNonEmptyString, + SUPABASE_JWT_CLOCK_TOLERANCE_SECONDS: z.coerce + .number() + .int() + .min(0, { + message: + "SUPABASE_JWT_CLOCK_TOLERANCE_SECONDS must be a non-negative integer.", + }) + .optional(), + }, + client: {}, + experimental__runtimeEnv: process.env, + emptyStringAsUndefined: true, + }); +}; + +export type AppEnv = ReturnType; + +let cachedEnv: AppEnv | null = null; + +export const getEnv = (): AppEnv => { + if (cachedEnv === null) { + cachedEnv = buildEnv(); + } + return cachedEnv; +}; + +export const resetEnvCache = (): void => { + cachedEnv = null; +}; diff --git a/src/app/supabase-auth-client.ts b/src/app/supabase-auth-client.ts new file mode 100644 index 0000000..bd8a9bf --- /dev/null +++ b/src/app/supabase-auth-client.ts @@ -0,0 +1,20 @@ +import { + createSupabaseAuthClient, + type SupabaseAuthClient, +} from "@listee/auth"; +import { getEnv } from "@/app/env"; + +let cachedClient: SupabaseAuthClient | null = null; + +export const getSupabaseAuthClient = (): SupabaseAuthClient => { + if (cachedClient !== null) { + return cachedClient; + } + + const env = getEnv(); + cachedClient = createSupabaseAuthClient({ + projectUrl: env.SUPABASE_URL, + publishableKey: env.SUPABASE_PUBLISHABLE_KEY, + }); + return cachedClient; +};