From 02b7522b876ec053d11e6987814e88801c6fd20d Mon Sep 17 00:00:00 2001 From: Bas van Rossem Date: Wed, 17 Jun 2026 18:53:39 +0200 Subject: [PATCH] feat(api): include role in /api/me + allow admin origin in CORS Add `role: Role` to the shared `PublicUser` contract and return it from `GET /api/me` (defaulting to 'worker' when the session user has no role). This lets the planned admin app gate access by role. Also add the admin dev origin `http://localhost:5174` to the default `WEB_ORIGINS` (env.ts) and to `.env.example`, so the admin SPA on :5174 can reach the API at :3000 cross-origin (drives both hono/cors and better-auth trustedOrigins). --- apps/api/.env.example | 5 +++-- apps/api/src/env.ts | 8 ++++++-- apps/api/src/routes/me.ts | 3 ++- apps/api/test/cors.test.ts | 15 +++++++++++++++ apps/api/test/me.test.ts | 20 ++++++++++++++++++++ packages/shared/src/index.ts | 7 ++++--- 6 files changed, 50 insertions(+), 8 deletions(-) diff --git a/apps/api/.env.example b/apps/api/.env.example index f62b165..460910a 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -3,7 +3,8 @@ BETTER_AUTH_SECRET=change-me-to-a-long-random-string BETTER_AUTH_URL=http://localhost:3000 PORT=3000 -# Comma-separated browser origins allowed for CORS + better-auth (the worker SPA). +# Comma-separated browser origins allowed for CORS + better-auth (the worker SPA on 5173 +# and the admin SPA on 5174). # Add your phone's LAN origin to test on a device — no code edit needed, e.g.: # CORS_ORIGINS=http://localhost:5173,http://192.168.1.50:5173 -CORS_ORIGINS=http://localhost:5173 +CORS_ORIGINS=http://localhost:5173,http://localhost:5174 diff --git a/apps/api/src/env.ts b/apps/api/src/env.ts index 882c883..177ba2e 100644 --- a/apps/api/src/env.ts +++ b/apps/api/src/env.ts @@ -9,6 +9,10 @@ export const env = { PORT: Number(process.env.PORT ?? 3000), // Browser origins allowed for CORS + better-auth trustedOrigins. Set CORS_ORIGINS to a // comma-separated list (e.g. "http://localhost:5173,http://192.168.1.50:5173") to let a - // phone on the LAN reach the API — no code edit needed. Defaults to the local Vite origin. - WEB_ORIGINS: webOrigins && webOrigins.length ? webOrigins : ['http://localhost:5173'], + // phone on the LAN reach the API — no code edit needed. Defaults to the local Vite origins + // for the worker (5173) and admin (5174) SPAs. + WEB_ORIGINS: + webOrigins && webOrigins.length + ? webOrigins + : ['http://localhost:5173', 'http://localhost:5174'], }; diff --git a/apps/api/src/routes/me.ts b/apps/api/src/routes/me.ts index 5c962ef..af65f9e 100644 --- a/apps/api/src/routes/me.ts +++ b/apps/api/src/routes/me.ts @@ -1,5 +1,5 @@ import { Hono } from 'hono'; -import type { MeResponse } from '@solelog/shared'; +import type { MeResponse, Role } from '@solelog/shared'; import { auth } from '../auth'; export const me = new Hono(); @@ -14,6 +14,7 @@ me.get('/api/me', async (c) => { id: session.user.id, email: session.user.email, name: session.user.name, + role: ((session.user as { role?: string | null }).role ?? 'worker') as Role, }, }; return c.json(body); diff --git a/apps/api/test/cors.test.ts b/apps/api/test/cors.test.ts index 9a6e525..9f56822 100644 --- a/apps/api/test/cors.test.ts +++ b/apps/api/test/cors.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'; import { createApp } from '../src/app'; const ORIGIN = 'http://localhost:5173'; +const ADMIN_ORIGIN = 'http://localhost:5174'; describe('cors', () => { it('answers a CORS preflight for the SPA origin', async () => { @@ -18,6 +19,20 @@ describe('cors', () => { expect(allowMethods).toContain('GET'); }); + it('answers a CORS preflight for the admin SPA origin', async () => { + const app = createApp(); + const res = await app.request('/api/activities', { + method: 'OPTIONS', + headers: { + Origin: ADMIN_ORIGIN, + 'Access-Control-Request-Method': 'GET', + }, + }); + expect(res.headers.get('access-control-allow-origin')).toBe(ADMIN_ORIGIN); + const allowMethods = res.headers.get('access-control-allow-methods') ?? ''; + expect(allowMethods).toContain('GET'); + }); + it('exposes set-auth-token to the SPA origin', async () => { const app = createApp(); const res = await app.request('/api/activities', { diff --git a/apps/api/test/me.test.ts b/apps/api/test/me.test.ts index f773936..fcd6264 100644 --- a/apps/api/test/me.test.ts +++ b/apps/api/test/me.test.ts @@ -19,4 +19,24 @@ describe('GET /api/me', () => { const body = await res.json(); expect(body.user.email).toBe(email); }); + + it('returns role "worker" for a worker token', async () => { + const app = createApp(); + const token = await authToken(app, 'worker-role@example.com', 'worker'); + + const res = await app.request('/api/me', { headers: bearer(token) }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.user.role).toBe('worker'); + }); + + it('returns role "admin" for an admin token', async () => { + const app = createApp(); + const token = await authToken(app, 'admin-role@example.com', 'admin'); + + const res = await app.request('/api/me', { headers: bearer(token) }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.user.role).toBe('admin'); + }); }); diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index bd1dfbb..86401c2 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -5,10 +5,14 @@ export const HealthResponse = z.object({ }); export type HealthResponse = z.infer; +export const Role = z.enum(['worker', 'admin']); +export type Role = z.infer; + export const PublicUser = z.object({ id: z.string(), email: z.string().email(), name: z.string(), + role: Role, }); export type PublicUser = z.infer; @@ -20,9 +24,6 @@ export type MeResponse = z.infer; export const InsoleType = z.enum(['Kurk', 'Berk', '3D']); export type InsoleType = z.infer; -export const Role = z.enum(['worker', 'admin']); -export type Role = z.infer; - export const Activity = z.object({ id: z.number().int(), name: z.string(),