From 54798b57a1beb1f2f46319c6737de5fc43b5e2d2 Mon Sep 17 00:00:00 2001 From: Gen Tamura Date: Sat, 1 Nov 2025 13:15:23 +0900 Subject: [PATCH 1/8] chore(api): align docs and deps with @listee 0.3 --- README.md | 65 +++++++++++++++++++++++++++++++++++++++++++++++++++- bun.lock | 16 ++++++------- package.json | 8 +++---- 3 files changed, 76 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 297190a..ea24f01 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,65 @@ # 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` and `SUPABASE_ANON_KEY` – required by auth integrations. +- `LISTEE_API_AUTH_BEARER_MODE` – optional; `user-id` (default) or `access-token` to define how bearer headers are interpreted. + +## 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 | +| ------ | ---- | ----------- | +| 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 `. When `LISTEE_API_AUTH_BEARER_MODE=user-id`, the token must be the Supabase user ID. + +## 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..d3fede5 100644 --- a/bun.lock +++ b/bun.lock @@ -4,10 +4,10 @@ "": { "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.0", + "@listee/auth": "0.3.0", + "@listee/db": "0.3.0", + "@listee/types": "0.3.0", "drizzle-orm": "0.44.5", "next": "15.5.4", "react": "19.1.0", @@ -148,13 +148,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.0", "", { "dependencies": { "@listee/auth": "^0.3.0", "@listee/db": "^0.3.0", "@listee/types": "^0.3.0", "hono": "^4.4.4" } }, "sha512-QYhNnoyq0qiS3wLhiL3e5Rh778Pvuck53BA1bm/HN5MMilubG2XnRu4ddBWXGGC6XMLOnWuHW6KHudo+Qq4hkg=="], - "@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.3.0", "", { "dependencies": { "@listee/db": "^0.3.0", "@listee/types": "^0.3.0", "jose": "^5.2.3" } }, "sha512-esTAtrv+SR0gtHg/HPCyZvG2NG+bY//h3U6tZuy4SuVsN/sZ0060c47GwVdV7Ety4j3no3HZDnGTAPpN1xir7A=="], - "@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.3.0", "", { "dependencies": { "drizzle-orm": "^0.44.5", "postgres": "^3.4.7" } }, "sha512-lokU4wQCp0Wr73U4qSe7Y5Zn6H8EdIvnKSUcvT5nXXcJSdqtwvk0KGpWk7HZUIoV8I6StAiMD/wUjckIqTFnwg=="], - "@listee/types": ["@listee/types@0.2.3", "", { "dependencies": { "@listee/db": "^0.2.3" } }, "sha512-eGOVIn4nCTIWuIC8fu07vjbBf14UU8DQKJQCQOpJjFjW7YP4hCUdjc64DhtYjlsK56xi/TiJprVw7MrpJfx4FA=="], + "@listee/types": ["@listee/types@0.3.0", "", { "dependencies": { "@listee/db": "^0.3.0" } }, "sha512-yfHI2ShIARi/TqTUC2/Sn41LhJMv4Stc7IrX0CeyiGisHfS7E4mg7NhLRKuQi77F6zzqhXkpORH2dQ21V7TDCQ=="], "@next/env": ["@next/env@15.5.4", "", {}, "sha512-27SQhYp5QryzIT5uO8hq99C69eLQ7qkzkDPsk3N+GuS2XgOgoYEeOav7Pf8Tn4drECOVDsDg8oj+/DVy8qQL2A=="], diff --git a/package.json b/package.json index 4024f05..7f21e90 100644 --- a/package.json +++ b/package.json @@ -12,10 +12,10 @@ "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.0", + "@listee/auth": "0.3.0", + "@listee/db": "0.3.0", + "@listee/types": "0.3.0", "react": "19.1.0", "react-dom": "19.1.0", "next": "15.5.4", From d61c45820b729e797895e4680ab2be3deb61f6b3 Mon Sep 17 00:00:00 2001 From: Gen Tamura Date: Mon, 3 Nov 2025 17:16:14 +0900 Subject: [PATCH 2/8] feat(auth): support inline supabase jwks --- README.md | 10 +++- bun.lock | 16 ++--- package.json | 8 +-- src/app/api/handler.ts | 133 ++++++++++++++++++++++++++++++++++++++--- 4 files changed, 145 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index ea24f01..67e1568 100644 --- a/README.md +++ b/README.md @@ -16,8 +16,12 @@ listee-api exposes Listee's HTTP interface. It packages `@listee/api` inside a N ## Environment Variables Configure these values in `.env.local` for development and in production: - `POSTGRES_URL` – Supabase Postgres connection string. -- `SUPABASE_URL` and `SUPABASE_ANON_KEY` – required by auth integrations. -- `LISTEE_API_AUTH_BEARER_MODE` – optional; `user-id` (default) or `access-token` to define how bearer headers are interpreted. +- `SUPABASE_URL` – Supabase project base URL (e.g. `https://your-project.supabase.co`). +- `SUPABASE_JWT_AUDIENCE` – optional; comma-separated audience values 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 }`. @@ -38,7 +42,7 @@ Configure these values in `.env.local` for development and in production: | DELETE | `/api/tasks/:taskId` | Delete a task owned by the user | | GET | `/api/healthz` | Database connectivity probe | -All endpoints expect `Authorization: Bearer `. When `LISTEE_API_AUTH_BEARER_MODE=user-id`, the token must be the Supabase user ID. +All endpoints expect `Authorization: Bearer ` where `` is a Supabase JWT access token issued for the authenticated user. ## Local Development 1. Install dependencies: `bun install`. diff --git a/bun.lock b/bun.lock index d3fede5..9a6950d 100644 --- a/bun.lock +++ b/bun.lock @@ -4,10 +4,10 @@ "": { "name": "listee-api", "dependencies": { - "@listee/api": "0.3.0", - "@listee/auth": "0.3.0", - "@listee/db": "0.3.0", - "@listee/types": "0.3.0", + "@listee/api": "0.3.1", + "@listee/auth": "0.4.0", + "@listee/db": "0.4.0", + "@listee/types": "0.4.0", "drizzle-orm": "0.44.5", "next": "15.5.4", "react": "19.1.0", @@ -148,13 +148,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.3.0", "", { "dependencies": { "@listee/auth": "^0.3.0", "@listee/db": "^0.3.0", "@listee/types": "^0.3.0", "hono": "^4.4.4" } }, "sha512-QYhNnoyq0qiS3wLhiL3e5Rh778Pvuck53BA1bm/HN5MMilubG2XnRu4ddBWXGGC6XMLOnWuHW6KHudo+Qq4hkg=="], + "@listee/api": ["@listee/api@0.3.1", "", { "dependencies": { "@listee/auth": "^0.4.0", "@listee/db": "^0.4.0", "@listee/types": "^0.4.0", "hono": "^4.4.4" } }, "sha512-pHHxky+DgSROgx9r67eADtHPZu9ofRNx0vV6DlgFSJjmGymRiW04b7q5TrVoa0bpDfHvGdIEi9brQjQdlcyISQ=="], - "@listee/auth": ["@listee/auth@0.3.0", "", { "dependencies": { "@listee/db": "^0.3.0", "@listee/types": "^0.3.0", "jose": "^5.2.3" } }, "sha512-esTAtrv+SR0gtHg/HPCyZvG2NG+bY//h3U6tZuy4SuVsN/sZ0060c47GwVdV7Ety4j3no3HZDnGTAPpN1xir7A=="], + "@listee/auth": ["@listee/auth@0.4.0", "", { "dependencies": { "@listee/db": "^0.4.0", "@listee/types": "^0.4.0", "jose": "^5.2.3" } }, "sha512-Er3L7k1br6b/3ROLVrnI4xUzGeJMtpQdy2py9qd7d7S8SpBcZwUa1mejwfjACwrk2t/WyUNnhHF/Kgsk137q3Q=="], - "@listee/db": ["@listee/db@0.3.0", "", { "dependencies": { "drizzle-orm": "^0.44.5", "postgres": "^3.4.7" } }, "sha512-lokU4wQCp0Wr73U4qSe7Y5Zn6H8EdIvnKSUcvT5nXXcJSdqtwvk0KGpWk7HZUIoV8I6StAiMD/wUjckIqTFnwg=="], + "@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.3.0", "", { "dependencies": { "@listee/db": "^0.3.0" } }, "sha512-yfHI2ShIARi/TqTUC2/Sn41LhJMv4Stc7IrX0CeyiGisHfS7E4mg7NhLRKuQi77F6zzqhXkpORH2dQ21V7TDCQ=="], + "@listee/types": ["@listee/types@0.4.0", "", { "dependencies": { "@listee/db": "^0.4.0" } }, "sha512-UmQjgCGu23ebgHoP3zYknEDirASRADguKJleEsrU/XwBSfxb7xSyAmIz7Erf1vSjYY+qj35LnoydOk4mSdYHNQ=="], "@next/env": ["@next/env@15.5.4", "", {}, "sha512-27SQhYp5QryzIT5uO8hq99C69eLQ7qkzkDPsk3N+GuS2XgOgoYEeOav7Pf8Tn4drECOVDsDg8oj+/DVy8qQL2A=="], diff --git a/package.json b/package.json index 7f21e90..1f6cce5 100644 --- a/package.json +++ b/package.json @@ -12,10 +12,10 @@ "db:migrate": "drizzle-kit migrate --config drizzle.config.ts" }, "dependencies": { - "@listee/api": "0.3.0", - "@listee/auth": "0.3.0", - "@listee/db": "0.3.0", - "@listee/types": "0.3.0", + "@listee/api": "0.3.1", + "@listee/auth": "0.4.0", + "@listee/db": "0.4.0", + "@listee/types": "0.4.0", "react": "19.1.0", "react-dom": "19.1.0", "next": "15.5.4", diff --git a/src/app/api/handler.ts b/src/app/api/handler.ts index 9246a55..09ef220 100644 --- a/src/app/api/handler.ts +++ b/src/app/api/handler.ts @@ -8,11 +8,117 @@ import { createTaskRepository, createTaskService, } from "@listee/api"; -import { createHeaderAuthentication } from "@listee/auth"; +import type { AuthenticationProvider } from "@listee/auth"; +import { createSupabaseAuthentication } from "@listee/auth"; import { getDb } from "@listee/db"; const API_PREFIX = "/api"; +const readRequiredEnv = (key: string): string => { + const value = process.env[key]; + if (value === undefined) { + throw new Error(`${key} is not set. Configure it before starting the API.`); + } + + const trimmed = value.trim(); + if (trimmed.length === 0) { + throw new Error( + `${key} is empty. Provide a non-empty value before starting the API.`, + ); + } + + return trimmed; +}; + +const readOptionalEnv = (key: string): string | undefined => { + const value = process.env[key]; + if (value === undefined) { + return undefined; + } + + const trimmed = value.trim(); + if (trimmed.length === 0) { + return undefined; + } + + return trimmed; +}; + +const parseAudience = ( + value: string | undefined, +): string | string[] | undefined => { + if (value === undefined) { + return undefined; + } + + if (!value.includes(",")) { + return value; + } + + const parts = value + .split(",") + .map((part) => part.trim()) + .filter((part) => part.length > 0); + + if (parts.length === 0) { + throw new Error( + "SUPABASE_JWT_AUDIENCE must include at least one non-empty value.", + ); + } + + return parts; +}; + +const parseClockTolerance = (value: string | undefined): number | undefined => { + if (value === undefined) { + return undefined; + } + + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed) || parsed < 0) { + throw new Error( + "SUPABASE_JWT_CLOCK_TOLERANCE_SECONDS must be a non-negative integer.", + ); + } + + return parsed; +}; + +let cachedAuthentication: AuthenticationProvider | null = null; + +const getAuthentication = (): AuthenticationProvider => { + if (cachedAuthentication !== null) { + return cachedAuthentication; + } + + const projectUrl = readRequiredEnv("SUPABASE_URL"); + const audience = parseAudience(readOptionalEnv("SUPABASE_JWT_AUDIENCE")); + const issuer = readOptionalEnv("SUPABASE_JWT_ISSUER"); + const requiredRole = readOptionalEnv("SUPABASE_JWT_REQUIRED_ROLE"); + const clockTolerance = parseClockTolerance( + readOptionalEnv("SUPABASE_JWT_CLOCK_TOLERANCE_SECONDS"), + ); + const jwksPath = readOptionalEnv("SUPABASE_JWKS_PATH"); + + cachedAuthentication = createSupabaseAuthentication({ + projectUrl, + audience, + issuer, + requiredRole, + clockToleranceSeconds: clockTolerance, + jwksPath, + }); + + return cachedAuthentication; +}; + +const authentication: AuthenticationProvider = { + authenticate: async (context) => { + const provider = getAuthentication(); + return await provider.authenticate(context); + }, +}; + const stripApiPrefix = (pathname: string): string => { if (!pathname.startsWith(API_PREFIX)) { return pathname; @@ -38,7 +144,6 @@ const taskService = createTaskService({ }); const categoryQueries = createCategoryQueries({ service: categoryService }); const taskQueries = createTaskQueries({ service: taskService }); -const authentication = createHeaderAuthentication(); const honoFetchHandler = createFetchHandler({ databaseHealth, @@ -50,10 +155,24 @@ const honoFetchHandler = createFetchHandler({ 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 }, + ); + } }; From 85f93418d123562a8bc13be4e231e76e6a2668bc Mon Sep 17 00:00:00 2001 From: Gen Tamura Date: Tue, 4 Nov 2025 13:27:31 +0900 Subject: [PATCH 3/8] chore(env): use t3-env for Supabase config --- README.md | 2 +- bun.lock | 11 ++++++ drizzle.config.ts | 25 +++++++++--- next.config.ts | 15 +++++++- package.json | 7 +++- src/app/api/handler.ts | 86 ++++-------------------------------------- src/app/env.ts | 50 ++++++++++++++++++++++++ 7 files changed, 109 insertions(+), 87 deletions(-) create mode 100644 src/app/env.ts diff --git a/README.md b/README.md index 67e1568..63b19d1 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ listee-api exposes Listee's HTTP interface. It packages `@listee/api` inside a N 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_JWT_AUDIENCE` – optional; comma-separated audience values to enforce. +- `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`. diff --git a/bun.lock b/bun.lock index 9a6950d..947ce78 100644 --- a/bun.lock +++ b/bun.lock @@ -8,10 +8,12 @@ "@listee/auth": "0.4.0", "@listee/db": "0.4.0", "@listee/types": "0.4.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", }, }, @@ -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 1f6cce5..41fef39 100644 --- a/package.json +++ b/package.json @@ -16,10 +16,12 @@ "@listee/auth": "0.4.0", "@listee/db": "0.4.0", "@listee/types": "0.4.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/handler.ts b/src/app/api/handler.ts index 09ef220..7d0021e 100644 --- a/src/app/api/handler.ts +++ b/src/app/api/handler.ts @@ -11,79 +11,10 @@ import { import type { AuthenticationProvider } from "@listee/auth"; import { createSupabaseAuthentication } from "@listee/auth"; import { getDb } from "@listee/db"; +import { getEnv } from "../env"; const API_PREFIX = "/api"; -const readRequiredEnv = (key: string): string => { - const value = process.env[key]; - if (value === undefined) { - throw new Error(`${key} is not set. Configure it before starting the API.`); - } - - const trimmed = value.trim(); - if (trimmed.length === 0) { - throw new Error( - `${key} is empty. Provide a non-empty value before starting the API.`, - ); - } - - return trimmed; -}; - -const readOptionalEnv = (key: string): string | undefined => { - const value = process.env[key]; - if (value === undefined) { - return undefined; - } - - const trimmed = value.trim(); - if (trimmed.length === 0) { - return undefined; - } - - return trimmed; -}; - -const parseAudience = ( - value: string | undefined, -): string | string[] | undefined => { - if (value === undefined) { - return undefined; - } - - if (!value.includes(",")) { - return value; - } - - const parts = value - .split(",") - .map((part) => part.trim()) - .filter((part) => part.length > 0); - - if (parts.length === 0) { - throw new Error( - "SUPABASE_JWT_AUDIENCE must include at least one non-empty value.", - ); - } - - return parts; -}; - -const parseClockTolerance = (value: string | undefined): number | undefined => { - if (value === undefined) { - return undefined; - } - - const parsed = Number.parseInt(value, 10); - if (!Number.isFinite(parsed) || parsed < 0) { - throw new Error( - "SUPABASE_JWT_CLOCK_TOLERANCE_SECONDS must be a non-negative integer.", - ); - } - - return parsed; -}; - let cachedAuthentication: AuthenticationProvider | null = null; const getAuthentication = (): AuthenticationProvider => { @@ -91,14 +22,13 @@ const getAuthentication = (): AuthenticationProvider => { return cachedAuthentication; } - const projectUrl = readRequiredEnv("SUPABASE_URL"); - const audience = parseAudience(readOptionalEnv("SUPABASE_JWT_AUDIENCE")); - const issuer = readOptionalEnv("SUPABASE_JWT_ISSUER"); - const requiredRole = readOptionalEnv("SUPABASE_JWT_REQUIRED_ROLE"); - const clockTolerance = parseClockTolerance( - readOptionalEnv("SUPABASE_JWT_CLOCK_TOLERANCE_SECONDS"), - ); - const jwksPath = readOptionalEnv("SUPABASE_JWKS_PATH"); + 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, diff --git a/src/app/env.ts b/src/app/env.ts new file mode 100644 index 0000000..7787e32 --- /dev/null +++ b/src/app/env.ts @@ -0,0 +1,50 @@ +import { createEnv } from "@t3-oss/env-nextjs"; +import { z } from "zod"; + +const urlString = z.url(); +const optionalNonEmptyString = z + .string() + .min(1) + .refine((value) => value === value.trim(), { + message: "Value must not include leading or trailing whitespace.", + }) + .optional(); + +const buildEnv = () => { + return createEnv({ + server: { + POSTGRES_URL: urlString, + SUPABASE_URL: urlString, + 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: {}, + 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; +}; From 5a603201d606246295b6459ab5d95a74580bd94b Mon Sep 17 00:00:00 2001 From: Gen Tamura Date: Wed, 5 Nov 2025 13:59:56 +0900 Subject: [PATCH 4/8] feat(auth): add Supabase proxy endpoints --- README.md | 4 + src/app/api/auth/login/route.ts | 34 ++++++ src/app/api/auth/signup/route.ts | 35 +++++++ src/app/api/auth/token/route.ts | 36 +++++++ src/app/api/auth/utils.ts | 53 ++++++++++ src/app/env.ts | 7 +- src/services/supabase-auth.ts | 173 +++++++++++++++++++++++++++++++ 7 files changed, 339 insertions(+), 3 deletions(-) create mode 100644 src/app/api/auth/login/route.ts create mode 100644 src/app/api/auth/signup/route.ts create mode 100644 src/app/api/auth/token/route.ts create mode 100644 src/app/api/auth/utils.ts create mode 100644 src/services/supabase-auth.ts diff --git a/README.md b/README.md index 63b19d1..98b2085 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ listee-api exposes Listee's HTTP interface. It packages `@listee/api` inside a N 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`. @@ -30,6 +31,9 @@ Configure these values in `.env.local` for development and in production: ## 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 | diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts new file mode 100644 index 0000000..1e4a447 --- /dev/null +++ b/src/app/api/auth/login/route.ts @@ -0,0 +1,34 @@ +import { z } from "zod"; + +import { + ApiError, + handleRoute, + parseJsonBody, + respondWithData, +} from "@/app/api/auth/utils"; +import { SupabaseRequestError, supabaseLogin } from "@/services/supabase-auth"; + +const loginSchema = z.object({ + email: z.string().trim().email("Email must be a valid email address."), + password: z + .string() + .min(1, "Password must not be empty.") + .refine((value) => value === value.trim(), { + message: "Password must not include leading or trailing whitespace.", + }), +}); + +export async function POST(request: Request): Promise { + return handleRoute(async () => { + const input = await parseJsonBody(request, loginSchema); + try { + const tokenResponse = await supabaseLogin(input); + return respondWithData(tokenResponse, 200); + } catch (error) { + if (error instanceof SupabaseRequestError) { + 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..325d85a --- /dev/null +++ b/src/app/api/auth/signup/route.ts @@ -0,0 +1,35 @@ +import { z } from "zod"; + +import { + ApiError, + handleRoute, + parseJsonBody, + respondWithData, +} from "@/app/api/auth/utils"; +import { SupabaseRequestError, supabaseSignup } from "@/services/supabase-auth"; + +const signupSchema = z.object({ + email: z.string().trim().email("Email must be a valid email address."), + password: z + .string() + .min(1, "Password must not be empty.") + .refine((value) => value === value.trim(), { + message: "Password must not include leading or trailing whitespace.", + }), + redirectUrl: z.string().trim().url().optional(), +}); + +export async function POST(request: Request): Promise { + return handleRoute(async () => { + const input = await parseJsonBody(request, signupSchema); + try { + await supabaseSignup(input); + } catch (error) { + if (error instanceof SupabaseRequestError) { + 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..e70a165 --- /dev/null +++ b/src/app/api/auth/token/route.ts @@ -0,0 +1,36 @@ +import { z } from "zod"; + +import { + ApiError, + handleRoute, + parseJsonBody, + respondWithData, +} from "@/app/api/auth/utils"; +import { + SupabaseRequestError, + supabaseRefreshToken, +} from "@/services/supabase-auth"; + +const tokenSchema = z.object({ + refreshToken: z + .string() + .min(1, "refreshToken must not be empty.") + .refine((value) => value === value.trim(), { + message: "refreshToken must not include leading or trailing whitespace.", + }), +}); + +export async function POST(request: Request): Promise { + return handleRoute(async () => { + const input = await parseJsonBody(request, tokenSchema); + try { + const tokenResponse = await supabaseRefreshToken(input); + return respondWithData(tokenResponse, 200); + } catch (error) { + if (error instanceof SupabaseRequestError) { + 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/env.ts b/src/app/env.ts index 7787e32..2f7e473 100644 --- a/src/app/env.ts +++ b/src/app/env.ts @@ -2,19 +2,20 @@ import { createEnv } from "@t3-oss/env-nextjs"; import { z } from "zod"; const urlString = z.url(); -const optionalNonEmptyString = z +const nonEmptyString = z .string() .min(1) .refine((value) => value === value.trim(), { message: "Value must not include leading or trailing whitespace.", - }) - .optional(); + }); +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(), diff --git a/src/services/supabase-auth.ts b/src/services/supabase-auth.ts new file mode 100644 index 0000000..d55822d --- /dev/null +++ b/src/services/supabase-auth.ts @@ -0,0 +1,173 @@ +import { getEnv } from "@/app/env"; + +export type SupabaseTokenResponse = { + readonly access_token: string; + readonly refresh_token: string; + readonly token_type: string; + readonly expires_in: number; +}; + +export class SupabaseRequestError extends Error { + readonly statusCode: number; + + constructor(statusCode: number, message: string) { + super(message); + this.statusCode = statusCode; + } +} + +const isRecord = (value: unknown): value is Record => { + return typeof value === "object" && value !== null; +}; + +const isString = (value: unknown): value is string => { + return typeof value === "string"; +}; + +const isNumber = (value: unknown): value is number => { + return typeof value === "number" && Number.isFinite(value); +}; + +const isSupabaseTokenResponse = ( + value: unknown, +): value is SupabaseTokenResponse => { + if (!isRecord(value)) { + return false; + } + + return ( + isString(value.access_token) && + isString(value.refresh_token) && + isString(value.token_type) && + isNumber(value.expires_in) + ); +}; + +const readPayload = async (response: Response): Promise => { + const raw = await response.text(); + if (raw.trim().length === 0) { + return null; + } + + try { + return JSON.parse(raw); + } catch (error) { + const message = + error instanceof Error ? error.message : "Failed to parse response."; + throw new SupabaseRequestError(502, message); + } +}; + +const formatSupabaseError = (payload: unknown, fallback: string): string => { + if (isRecord(payload)) { + const candidates = [ + payload.error, + payload.error_description, + payload.message, + payload.msg, + ]; + for (const candidate of candidates) { + if (isString(candidate) && candidate.trim().length > 0) { + return candidate.trim(); + } + } + } + + return fallback; +}; + +const requestSupabase = async ( + path: string, + body: Record, +): Promise => { + const env = getEnv(); + const base = new URL(env.SUPABASE_URL); + const targetPath = path.startsWith("/") ? path : `/${path}`; + const url = new URL(targetPath, base); + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + apikey: env.SUPABASE_PUBLISHABLE_KEY, + Authorization: `Bearer ${env.SUPABASE_PUBLISHABLE_KEY}`, + }, + body: JSON.stringify(body), + }); + + const payload = await readPayload(response); + if (!response.ok) { + const message = formatSupabaseError( + payload, + `Supabase request failed with status ${response.status}`, + ); + throw new SupabaseRequestError(response.status, message); + } + + return payload; +}; + +const extractTokenResponse = (payload: unknown): SupabaseTokenResponse => { + if (isSupabaseTokenResponse(payload)) { + return payload; + } + + if (isRecord(payload)) { + const nested = payload.data; + if (isSupabaseTokenResponse(nested)) { + return nested; + } + } + + throw new SupabaseRequestError( + 502, + "Supabase response did not include token details.", + ); +}; + +export type SignupParams = { + readonly email: string; + readonly password: string; + readonly redirectUrl?: string; +}; + +export const supabaseSignup = async (params: SignupParams): Promise => { + const path = + params.redirectUrl === undefined + ? "/auth/v1/signup" + : `/auth/v1/signup?redirect_to=${encodeURIComponent(params.redirectUrl)}`; + await requestSupabase(path, { + email: params.email, + password: params.password, + }); +}; + +export type LoginParams = { + readonly email: string; + readonly password: string; +}; + +export const supabaseLogin = async ( + params: LoginParams, +): Promise => { + const payload = await requestSupabase("/auth/v1/token?grant_type=password", { + email: params.email, + password: params.password, + }); + return extractTokenResponse(payload); +}; + +export type RefreshTokenParams = { + readonly refreshToken: string; +}; + +export const supabaseRefreshToken = async ( + params: RefreshTokenParams, +): Promise => { + const payload = await requestSupabase( + "/auth/v1/token?grant_type=refresh_token", + { + refresh_token: params.refreshToken, + }, + ); + return extractTokenResponse(payload); +}; From c519d4c275dbf8a6e7d92b2ebc0a01bb4925b4c5 Mon Sep 17 00:00:00 2001 From: Gen Tamura Date: Fri, 7 Nov 2025 15:32:40 +0900 Subject: [PATCH 5/8] chore: pin env and zod versions --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 41fef39..b9afcbb 100644 --- a/package.json +++ b/package.json @@ -16,12 +16,12 @@ "@listee/auth": "0.4.0", "@listee/db": "0.4.0", "@listee/types": "0.4.0", - "@t3-oss/env-nextjs": "^0.13.8", + "@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" + "zod": "4.1.12" }, "devDependencies": { "@biomejs/biome": "2.2.0", From 41ca8e84768ded2a414c9529fc1201b2ebcb64a4 Mon Sep 17 00:00:00 2001 From: Gen Tamura Date: Tue, 11 Nov 2025 15:50:03 +0900 Subject: [PATCH 6/8] feat(auth): reuse shared Supabase client and schemas --- bun.lock | 18 ++-- package.json | 4 +- src/app/api/auth/login/route.ts | 20 ++-- src/app/api/auth/signup/route.ts | 21 ++-- src/app/api/auth/token/route.ts | 22 ++-- src/app/api/auth/validation.ts | 35 +++++++ src/app/api/handler.ts | 23 ++-- src/app/supabase-auth-client.ts | 20 ++++ src/services/supabase-auth.ts | 173 ------------------------------- 9 files changed, 99 insertions(+), 237 deletions(-) create mode 100644 src/app/api/auth/validation.ts create mode 100644 src/app/supabase-auth-client.ts delete mode 100644 src/services/supabase-auth.ts diff --git a/bun.lock b/bun.lock index 947ce78..5e9c628 100644 --- a/bun.lock +++ b/bun.lock @@ -5,15 +5,15 @@ "name": "listee-api", "dependencies": { "@listee/api": "0.3.1", - "@listee/auth": "0.4.0", + "@listee/auth": "file:../listee-libs/packages/auth/dist", "@listee/db": "0.4.0", - "@listee/types": "0.4.0", - "@t3-oss/env-nextjs": "^0.13.8", + "@t3-oss/env-nextjs": "0.13.8", "drizzle-orm": "0.44.5", + "@listee/types": "file:../listee-libs/packages/types/dist", "next": "15.5.4", "react": "19.1.0", "react-dom": "19.1.0", - "zod": "^4.1.12", + "zod": "4.1.12", }, "devDependencies": { "@biomejs/biome": "2.2.0", @@ -153,11 +153,11 @@ "@listee/api": ["@listee/api@0.3.1", "", { "dependencies": { "@listee/auth": "^0.4.0", "@listee/db": "^0.4.0", "@listee/types": "^0.4.0", "hono": "^4.4.4" } }, "sha512-pHHxky+DgSROgx9r67eADtHPZu9ofRNx0vV6DlgFSJjmGymRiW04b7q5TrVoa0bpDfHvGdIEi9brQjQdlcyISQ=="], - "@listee/auth": ["@listee/auth@0.4.0", "", { "dependencies": { "@listee/db": "^0.4.0", "@listee/types": "^0.4.0", "jose": "^5.2.3" } }, "sha512-Er3L7k1br6b/3ROLVrnI4xUzGeJMtpQdy2py9qd7d7S8SpBcZwUa1mejwfjACwrk2t/WyUNnhHF/Kgsk137q3Q=="], + "@listee/auth": ["@listee/auth@file:../listee-libs/packages/auth/dist", { "dependencies": { "@listee/db": "^0.4.0", "@listee/types": "^0.4.0", "jose": "^5.2.3" } }], "@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.4.0", "", { "dependencies": { "@listee/db": "^0.4.0" } }, "sha512-UmQjgCGu23ebgHoP3zYknEDirASRADguKJleEsrU/XwBSfxb7xSyAmIz7Erf1vSjYY+qj35LnoydOk4mSdYHNQ=="], + "@listee/types": ["@listee/types@file:../listee-libs/packages/types/dist", { "dependencies": { "@listee/db": "^0.4.0" } }], "@next/env": ["@next/env@15.5.4", "", {}, "sha512-27SQhYp5QryzIT5uO8hq99C69eLQ7qkzkDPsk3N+GuS2XgOgoYEeOav7Pf8Tn4drECOVDsDg8oj+/DVy8qQL2A=="], @@ -259,6 +259,12 @@ "@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=="], + "@listee/api/@listee/auth": ["@listee/auth@0.4.0", "", { "dependencies": { "@listee/db": "^0.4.0", "@listee/types": "^0.4.0", "jose": "^5.2.3" } }, "sha512-Er3L7k1br6b/3ROLVrnI4xUzGeJMtpQdy2py9qd7d7S8SpBcZwUa1mejwfjACwrk2t/WyUNnhHF/Kgsk137q3Q=="], + + "@listee/api/@listee/types": ["@listee/types@0.4.0", "", { "dependencies": { "@listee/db": "^0.4.0" } }, "sha512-UmQjgCGu23ebgHoP3zYknEDirASRADguKJleEsrU/XwBSfxb7xSyAmIz7Erf1vSjYY+qj35LnoydOk4mSdYHNQ=="], + + "@listee/auth/@listee/types": ["@listee/types@0.4.0", "", { "dependencies": { "@listee/db": "^0.4.0" } }, "sha512-UmQjgCGu23ebgHoP3zYknEDirASRADguKJleEsrU/XwBSfxb7xSyAmIz7Erf1vSjYY+qj35LnoydOk4mSdYHNQ=="], + "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="], "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="], diff --git a/package.json b/package.json index b9afcbb..ec7bb31 100644 --- a/package.json +++ b/package.json @@ -13,9 +13,9 @@ }, "dependencies": { "@listee/api": "0.3.1", - "@listee/auth": "0.4.0", + "@listee/auth": "file:../listee-libs/packages/auth/dist", "@listee/db": "0.4.0", - "@listee/types": "0.4.0", + "@listee/types": "file:../listee-libs/packages/types/dist", "@t3-oss/env-nextjs": "0.13.8", "drizzle-orm": "0.44.5", "next": "15.5.4", diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index 1e4a447..b13cb03 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -1,4 +1,4 @@ -import { z } from "zod"; +import { SupabaseAuthError } from "@listee/auth"; import { ApiError, @@ -6,26 +6,18 @@ import { parseJsonBody, respondWithData, } from "@/app/api/auth/utils"; -import { SupabaseRequestError, supabaseLogin } from "@/services/supabase-auth"; - -const loginSchema = z.object({ - email: z.string().trim().email("Email must be a valid email address."), - password: z - .string() - .min(1, "Password must not be empty.") - .refine((value) => value === value.trim(), { - message: "Password must not include leading or trailing whitespace.", - }), -}); +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 tokenResponse = await supabaseLogin(input); + const authClient = getSupabaseAuthClient(); + const tokenResponse = await authClient.login(input); return respondWithData(tokenResponse, 200); } catch (error) { - if (error instanceof SupabaseRequestError) { + 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 index 325d85a..25104d0 100644 --- a/src/app/api/auth/signup/route.ts +++ b/src/app/api/auth/signup/route.ts @@ -1,4 +1,4 @@ -import { z } from "zod"; +import { SupabaseAuthError } from "@listee/auth"; import { ApiError, @@ -6,26 +6,17 @@ import { parseJsonBody, respondWithData, } from "@/app/api/auth/utils"; -import { SupabaseRequestError, supabaseSignup } from "@/services/supabase-auth"; - -const signupSchema = z.object({ - email: z.string().trim().email("Email must be a valid email address."), - password: z - .string() - .min(1, "Password must not be empty.") - .refine((value) => value === value.trim(), { - message: "Password must not include leading or trailing whitespace.", - }), - redirectUrl: z.string().trim().url().optional(), -}); +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 { - await supabaseSignup(input); + const authClient = getSupabaseAuthClient(); + await authClient.signup(input); } catch (error) { - if (error instanceof SupabaseRequestError) { + if (error instanceof SupabaseAuthError) { throw new ApiError(error.statusCode, error.message); } throw error; diff --git a/src/app/api/auth/token/route.ts b/src/app/api/auth/token/route.ts index e70a165..ec405af 100644 --- a/src/app/api/auth/token/route.ts +++ b/src/app/api/auth/token/route.ts @@ -1,4 +1,4 @@ -import { z } from "zod"; +import { SupabaseAuthError } from "@listee/auth"; import { ApiError, @@ -6,28 +6,18 @@ import { parseJsonBody, respondWithData, } from "@/app/api/auth/utils"; -import { - SupabaseRequestError, - supabaseRefreshToken, -} from "@/services/supabase-auth"; - -const tokenSchema = z.object({ - refreshToken: z - .string() - .min(1, "refreshToken must not be empty.") - .refine((value) => value === value.trim(), { - message: "refreshToken must not include leading or trailing whitespace.", - }), -}); +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 tokenResponse = await supabaseRefreshToken(input); + const authClient = getSupabaseAuthClient(); + const tokenResponse = await authClient.refresh(input); return respondWithData(tokenResponse, 200); } catch (error) { - if (error instanceof SupabaseRequestError) { + if (error instanceof SupabaseAuthError) { throw new ApiError(error.statusCode, error.message); } throw error; 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 7d0021e..7c284e5 100644 --- a/src/app/api/handler.ts +++ b/src/app/api/handler.ts @@ -8,16 +8,19 @@ import { createTaskRepository, createTaskService, } from "@listee/api"; -import type { AuthenticationProvider } from "@listee/auth"; import { createSupabaseAuthentication } from "@listee/auth"; import { getDb } from "@listee/db"; import { getEnv } from "../env"; const API_PREFIX = "/api"; -let cachedAuthentication: AuthenticationProvider | null = null; +let cachedAuthentication: ReturnType< + typeof createSupabaseAuthentication +> | null = null; -const getAuthentication = (): AuthenticationProvider => { +const getAuthentication = (): ReturnType< + typeof createSupabaseAuthentication +> => { if (cachedAuthentication !== null) { return cachedAuthentication; } @@ -42,13 +45,6 @@ const getAuthentication = (): AuthenticationProvider => { return cachedAuthentication; }; -const authentication: AuthenticationProvider = { - authenticate: async (context) => { - const provider = getAuthentication(); - return await provider.authenticate(context); - }, -}; - const stripApiPrefix = (pathname: string): string => { if (!pathname.startsWith(API_PREFIX)) { return pathname; @@ -79,7 +75,12 @@ const honoFetchHandler = createFetchHandler({ databaseHealth, categoryQueries, taskQueries, - authentication, + authentication: { + authenticate: async (context) => { + const provider = getAuthentication(); + return await provider.authenticate(context); + }, + }, }); export const dispatchToListeeApi = async ( 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; +}; diff --git a/src/services/supabase-auth.ts b/src/services/supabase-auth.ts deleted file mode 100644 index d55822d..0000000 --- a/src/services/supabase-auth.ts +++ /dev/null @@ -1,173 +0,0 @@ -import { getEnv } from "@/app/env"; - -export type SupabaseTokenResponse = { - readonly access_token: string; - readonly refresh_token: string; - readonly token_type: string; - readonly expires_in: number; -}; - -export class SupabaseRequestError extends Error { - readonly statusCode: number; - - constructor(statusCode: number, message: string) { - super(message); - this.statusCode = statusCode; - } -} - -const isRecord = (value: unknown): value is Record => { - return typeof value === "object" && value !== null; -}; - -const isString = (value: unknown): value is string => { - return typeof value === "string"; -}; - -const isNumber = (value: unknown): value is number => { - return typeof value === "number" && Number.isFinite(value); -}; - -const isSupabaseTokenResponse = ( - value: unknown, -): value is SupabaseTokenResponse => { - if (!isRecord(value)) { - return false; - } - - return ( - isString(value.access_token) && - isString(value.refresh_token) && - isString(value.token_type) && - isNumber(value.expires_in) - ); -}; - -const readPayload = async (response: Response): Promise => { - const raw = await response.text(); - if (raw.trim().length === 0) { - return null; - } - - try { - return JSON.parse(raw); - } catch (error) { - const message = - error instanceof Error ? error.message : "Failed to parse response."; - throw new SupabaseRequestError(502, message); - } -}; - -const formatSupabaseError = (payload: unknown, fallback: string): string => { - if (isRecord(payload)) { - const candidates = [ - payload.error, - payload.error_description, - payload.message, - payload.msg, - ]; - for (const candidate of candidates) { - if (isString(candidate) && candidate.trim().length > 0) { - return candidate.trim(); - } - } - } - - return fallback; -}; - -const requestSupabase = async ( - path: string, - body: Record, -): Promise => { - const env = getEnv(); - const base = new URL(env.SUPABASE_URL); - const targetPath = path.startsWith("/") ? path : `/${path}`; - const url = new URL(targetPath, base); - const response = await fetch(url, { - method: "POST", - headers: { - "Content-Type": "application/json", - apikey: env.SUPABASE_PUBLISHABLE_KEY, - Authorization: `Bearer ${env.SUPABASE_PUBLISHABLE_KEY}`, - }, - body: JSON.stringify(body), - }); - - const payload = await readPayload(response); - if (!response.ok) { - const message = formatSupabaseError( - payload, - `Supabase request failed with status ${response.status}`, - ); - throw new SupabaseRequestError(response.status, message); - } - - return payload; -}; - -const extractTokenResponse = (payload: unknown): SupabaseTokenResponse => { - if (isSupabaseTokenResponse(payload)) { - return payload; - } - - if (isRecord(payload)) { - const nested = payload.data; - if (isSupabaseTokenResponse(nested)) { - return nested; - } - } - - throw new SupabaseRequestError( - 502, - "Supabase response did not include token details.", - ); -}; - -export type SignupParams = { - readonly email: string; - readonly password: string; - readonly redirectUrl?: string; -}; - -export const supabaseSignup = async (params: SignupParams): Promise => { - const path = - params.redirectUrl === undefined - ? "/auth/v1/signup" - : `/auth/v1/signup?redirect_to=${encodeURIComponent(params.redirectUrl)}`; - await requestSupabase(path, { - email: params.email, - password: params.password, - }); -}; - -export type LoginParams = { - readonly email: string; - readonly password: string; -}; - -export const supabaseLogin = async ( - params: LoginParams, -): Promise => { - const payload = await requestSupabase("/auth/v1/token?grant_type=password", { - email: params.email, - password: params.password, - }); - return extractTokenResponse(payload); -}; - -export type RefreshTokenParams = { - readonly refreshToken: string; -}; - -export const supabaseRefreshToken = async ( - params: RefreshTokenParams, -): Promise => { - const payload = await requestSupabase( - "/auth/v1/token?grant_type=refresh_token", - { - refresh_token: params.refreshToken, - }, - ); - return extractTokenResponse(payload); -}; From 67bb8c066c9aa868d023c76bf6aecd41101006e1 Mon Sep 17 00:00:00 2001 From: Gen Tamura Date: Wed, 12 Nov 2025 10:01:19 +0900 Subject: [PATCH 7/8] chore(deps): bump listee packages --- bun.lock | 18 ++++++------------ package.json | 6 +++--- 2 files changed, 9 insertions(+), 15 deletions(-) diff --git a/bun.lock b/bun.lock index 5e9c628..a9bdf74 100644 --- a/bun.lock +++ b/bun.lock @@ -4,12 +4,12 @@ "": { "name": "listee-api", "dependencies": { - "@listee/api": "0.3.1", - "@listee/auth": "file:../listee-libs/packages/auth/dist", + "@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", - "@listee/types": "file:../listee-libs/packages/types/dist", "next": "15.5.4", "react": "19.1.0", "react-dom": "19.1.0", @@ -151,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.3.1", "", { "dependencies": { "@listee/auth": "^0.4.0", "@listee/db": "^0.4.0", "@listee/types": "^0.4.0", "hono": "^4.4.4" } }, "sha512-pHHxky+DgSROgx9r67eADtHPZu9ofRNx0vV6DlgFSJjmGymRiW04b7q5TrVoa0bpDfHvGdIEi9brQjQdlcyISQ=="], + "@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@file:../listee-libs/packages/auth/dist", { "dependencies": { "@listee/db": "^0.4.0", "@listee/types": "^0.4.0", "jose": "^5.2.3" } }], + "@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.4.0", "", { "dependencies": { "drizzle-orm": "^0.44.5", "postgres": "^3.4.7" } }, "sha512-6WOSt6UZy+fX2TnsrLl812P9NdWPocCGrOzN6kVnfdR61rbh2OE8k6KPC3cXnGzbamIQBueeut8v66ho0lHsiw=="], - "@listee/types": ["@listee/types@file:../listee-libs/packages/types/dist", { "dependencies": { "@listee/db": "^0.4.0" } }], + "@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=="], @@ -259,12 +259,6 @@ "@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=="], - "@listee/api/@listee/auth": ["@listee/auth@0.4.0", "", { "dependencies": { "@listee/db": "^0.4.0", "@listee/types": "^0.4.0", "jose": "^5.2.3" } }, "sha512-Er3L7k1br6b/3ROLVrnI4xUzGeJMtpQdy2py9qd7d7S8SpBcZwUa1mejwfjACwrk2t/WyUNnhHF/Kgsk137q3Q=="], - - "@listee/api/@listee/types": ["@listee/types@0.4.0", "", { "dependencies": { "@listee/db": "^0.4.0" } }, "sha512-UmQjgCGu23ebgHoP3zYknEDirASRADguKJleEsrU/XwBSfxb7xSyAmIz7Erf1vSjYY+qj35LnoydOk4mSdYHNQ=="], - - "@listee/auth/@listee/types": ["@listee/types@0.4.0", "", { "dependencies": { "@listee/db": "^0.4.0" } }, "sha512-UmQjgCGu23ebgHoP3zYknEDirASRADguKJleEsrU/XwBSfxb7xSyAmIz7Erf1vSjYY+qj35LnoydOk4mSdYHNQ=="], - "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="], "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="], diff --git a/package.json b/package.json index ec7bb31..40ddc45 100644 --- a/package.json +++ b/package.json @@ -12,10 +12,10 @@ "db:migrate": "drizzle-kit migrate --config drizzle.config.ts" }, "dependencies": { - "@listee/api": "0.3.1", - "@listee/auth": "file:../listee-libs/packages/auth/dist", + "@listee/api": "0.3.2", + "@listee/auth": "0.5.0", "@listee/db": "0.4.0", - "@listee/types": "file:../listee-libs/packages/types/dist", + "@listee/types": "0.5.0", "@t3-oss/env-nextjs": "0.13.8", "drizzle-orm": "0.44.5", "next": "15.5.4", From ebb4ba8a11d86e177652b2922192c388a5faf7be Mon Sep 17 00:00:00 2001 From: Gen Tamura Date: Wed, 12 Nov 2025 10:43:45 +0900 Subject: [PATCH 8/8] chore(env): surface runtime env to createEnv --- src/app/env.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/env.ts b/src/app/env.ts index 2f7e473..cc4881e 100644 --- a/src/app/env.ts +++ b/src/app/env.ts @@ -30,7 +30,7 @@ const buildEnv = () => { .optional(), }, client: {}, - experimental__runtimeEnv: {}, + experimental__runtimeEnv: process.env, emptyStringAsUndefined: true, }); };