diff --git a/src/app.d.ts b/src/app.d.ts index a3a50f7..46dcd8e 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -18,6 +18,8 @@ declare global { id: string; expiresAt: Date; } | null; + /** API token scope — set when auth is via Bearer token, null for JWT sessions */ + apiTokenScope: 'read' | 'write' | 'admin' | null; } interface PageData { diff --git a/src/hooks.server.ts b/src/hooks.server.ts index ee31b47..9155239 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -27,6 +27,7 @@ export const handle: Handle = async ({ event, resolve }) => { // Initialize locals event.locals.user = null; event.locals.session = null; + event.locals.apiTokenScope = null; const accessToken = event.cookies.get(ACCESS_TOKEN_COOKIE); const refreshToken = event.cookies.get(REFRESH_TOKEN_COOKIE); @@ -111,6 +112,7 @@ export const handle: Handle = async ({ event, resolve }) => { id: user.id, expiresAt: new Date(Date.now() + 15 * 60 * 1000) }; + event.locals.apiTokenScope = tokenResult.scope as 'read' | 'write' | 'admin'; } } catch { // API token validation failed — continue as unauthenticated @@ -118,9 +120,31 @@ export const handle: Handle = async ({ event, resolve }) => { } } - // Route protection const { pathname } = event.url; + // API token scope enforcement + if (event.locals.apiTokenScope) { + const method = event.request.method; + const scope = event.locals.apiTokenScope; + const isWriteMethod = method === 'POST' || method === 'PATCH' || method === 'PUT' || method === 'DELETE'; + const isAdminPath = pathname.startsWith('/api/admin') || pathname.startsWith('/admin'); + + if (scope === 'read' && isWriteMethod) { + return new Response(JSON.stringify({ success: false, data: null, error: 'API token scope "read" does not allow write operations' }), { + status: 403, + headers: { 'Content-Type': 'application/json' } + }); + } + if (scope !== 'admin' && isAdminPath) { + return new Response(JSON.stringify({ success: false, data: null, error: 'API token scope does not allow admin operations' }), { + status: 403, + headers: { 'Content-Type': 'application/json' } + }); + } + } + + // Route protection + if (!event.locals.user && !isPublicPath(pathname)) { // Check if this is a guest-accessible board route const boardMatch = pathname.match(/^\/boards\/([^/]+)/);