auth + supabase + public-scope hook (mirrors flexsiebels gate, no API keys)

- src/lib/server/supabase.ts: getSupabaseAdmin/Anon (lazy singletons, env-driven URL)
- src/lib/server/fdb.ts: schema accessor for the fdbck Postgres schema
- src/lib/server/auth.ts: cookie-based JWT auth (access+refresh), Supabase getUser/refreshSession. NO API key path — fdbck has no api_keys table; if needed later, add a separate module.
- src/lib/server/request-context.ts + public-scope.ts: public-scope policy gate ported from flexsiebels#59. Allowlist /api/auth/* and /api/public/* by default.
- src/lib/server/response.ts + errors.ts: json/requireAuth + parseBody/handleApiError
- src/hooks.server.ts: validate cookies, set locals.userId, refresh tokens, run handler inside RequestState scope, evaluatePolicy after.
- src/routes/+layout.svelte: minimal naked shell (only loads feedback.css). NO sidebar/footer/bottom-nav per spec.
- src/routes/+page.svelte: brief landing page + admin-login link.
- src/lib/styles/feedback.css: copied verbatim from flexsiebels worktree.

bun run check: 0 errors, 0 warnings.
This commit is contained in:
mAi
2026-05-05 11:30:13 +02:00
parent ae2984088a
commit fa1ad92517
11 changed files with 700 additions and 0 deletions

40
src/hooks.server.ts Normal file
View File

@@ -0,0 +1,40 @@
import type { Handle } from '@sveltejs/kit';
import { authenticateRequest, COOKIE_OPTS, ACCESS_MAX_AGE, REFRESH_MAX_AGE, ACCESS_TOKEN_COOKIE, REFRESH_TOKEN_COOKIE } from '$lib/server/auth';
import { runWithRequestState, type RequestState } from '$lib/server/request-context';
import { evaluatePolicy, violationResponse } from '$lib/server/public-scope';
export const handle: Handle = async ({ event, resolve }) => {
const auth = await authenticateRequest(event.request);
event.locals.userId = auth.userId;
if (auth.refreshedTokens) {
event.cookies.set(ACCESS_TOKEN_COOKIE, auth.refreshedTokens.access_token, {
...COOKIE_OPTS,
maxAge: ACCESS_MAX_AGE,
});
event.cookies.set(REFRESH_TOKEN_COOKIE, auth.refreshedTokens.refresh_token, {
...COOKIE_OPTS,
maxAge: REFRESH_MAX_AGE,
});
}
const state: RequestState = {
pathname: event.url.pathname,
method: event.request.method,
userId: auth.userId,
authChecked: false,
dbAccessed: false,
visibilityFiltered: false,
};
const response = await runWithRequestState(state, () => resolve(event));
const decision = evaluatePolicy({
pathname: event.url.pathname,
userId: auth.userId,
responseStatus: response.status,
state,
});
return decision.allow ? response : violationResponse(event, decision.reason);
};

67
src/lib/server/auth.ts Normal file
View File

@@ -0,0 +1,67 @@
/**
* Cookie-based JWT auth — single-user (m). Anon Supabase client validates
* the access token; expired access tokens are refreshed via the refresh cookie.
*
* No API key path here (fdbck has no api_keys table) — keep it minimal.
*/
import { getSupabaseAnon } from './supabase';
export const ACCESS_TOKEN_COOKIE = 'access_token';
export const REFRESH_TOKEN_COOKIE = 'refresh_token';
/** 1-year cookie max-age — match flexsiebels pattern. */
export const ACCESS_MAX_AGE = 365 * 24 * 60 * 60;
export const REFRESH_MAX_AGE = 365 * 24 * 60 * 60;
export const COOKIE_OPTS = {
httpOnly: true,
sameSite: 'lax' as const,
path: '/',
secure: true,
};
export interface AuthResult {
userId: string | null;
refreshedTokens?: { access_token: string; refresh_token: string };
}
export async function authenticateRequest(request: Request): Promise<AuthResult> {
const access = getCookie(request, ACCESS_TOKEN_COOKIE);
if (access) {
try {
const { data } = await getSupabaseAnon().auth.getUser(access);
if (data.user?.id) return { userId: data.user.id };
} catch {
// fall through to refresh
}
}
const refresh = getCookie(request, REFRESH_TOKEN_COOKIE);
if (!refresh) return { userId: null };
try {
const { data, error } = await getSupabaseAnon().auth.refreshSession({
refresh_token: refresh,
});
if (error || !data.session) return { userId: null };
return {
userId: data.session.user.id,
refreshedTokens: {
access_token: data.session.access_token,
refresh_token: data.session.refresh_token,
},
};
} catch {
return { userId: null };
}
}
function getCookie(request: Request, name: string): string | null {
const header = request.headers.get('Cookie');
if (!header) return null;
for (const part of header.split(';')) {
const [k, ...rest] = part.trim().split('=');
if (k === name) return rest.join('=');
}
return null;
}

55
src/lib/server/errors.ts Normal file
View File

@@ -0,0 +1,55 @@
import { json } from '@sveltejs/kit';
import { z } from 'zod';
export class ApiError extends Error {
constructor(
message: string,
public status: number = 500,
public details?: unknown,
) {
super(message);
this.name = 'ApiError';
}
}
export function badRequest(message: string, details?: unknown): Response {
return json({ error: message, ...(details ? { details } : {}) }, { status: 400 });
}
export function unauthorized(message = 'Unauthorized'): Response {
return json({ error: message }, { status: 401 });
}
export function notFound(message = 'Not found'): Response {
return json({ error: message }, { status: 404 });
}
export function serverError(message = 'Internal server error'): Response {
return json({ error: message }, { status: 500 });
}
export async function parseBody<T extends z.ZodType>(
request: Request,
schema: T,
): Promise<z.infer<T>> {
let raw: unknown;
try {
raw = await request.json();
} catch {
throw new ApiError('Invalid JSON body', 400);
}
const parsed = schema.safeParse(raw);
if (!parsed.success) {
throw new ApiError('Validation failed', 400, parsed.error.format());
}
return parsed.data;
}
export function handleApiError(err: unknown, context?: string): Response {
if (err instanceof ApiError) {
return json({ error: err.message, ...(err.details ? { details: err.details } : {}) }, { status: err.status });
}
const msg = err instanceof Error ? err.message : 'Internal error';
if (context) console.error(`[${context}]`, err);
return json({ error: msg }, { status: 500 });
}

10
src/lib/server/fdb.ts Normal file
View File

@@ -0,0 +1,10 @@
/**
* fdb() — schema accessor for the `fdbck` Supabase schema.
* Mirrors the flex()/mb() helper pattern from msbls' SvelteKit projects.
*/
import type { SupabaseClient } from '@supabase/supabase-js';
import { getSupabaseAdmin } from './supabase';
export function fdb(): ReturnType<SupabaseClient['schema']> {
return getSupabaseAdmin().schema('fdbck');
}

View File

@@ -0,0 +1,70 @@
/**
* Public-scope policy gate.
*
* For anonymous requests to /api/*, the gate enforces that the handler either:
* - invoked requireAuth (which short-circuited to 401), OR
* - is on the explicit allowlist (its own access mechanism), OR
* - never touched the DB.
*
* Otherwise the response is replaced with a 401 — fail closed.
*/
import type { RequestEvent } from '@sveltejs/kit';
import type { RequestState } from './request-context';
export const PUBLIC_API_ALLOWLIST: RegExp[] = [
/^\/api\/auth(\/|$)/, // sign-in / sign-out
/^\/api\/public(\/|$)/, // explicit public endpoints — slug-gated
];
export interface PolicyDecision {
allow: boolean;
reason: string;
}
export function isApiPath(pathname: string): boolean {
return pathname.startsWith('/api/');
}
export function isAllowlisted(pathname: string): boolean {
return PUBLIC_API_ALLOWLIST.some((re) => re.test(pathname));
}
export function evaluatePolicy(args: {
pathname: string;
userId: string | null;
responseStatus: number;
state: RequestState | undefined;
}): PolicyDecision {
const { pathname, userId, responseStatus, state } = args;
if (!isApiPath(pathname)) return { allow: true, reason: 'non-api' };
if (userId) return { allow: true, reason: 'authenticated' };
if (isAllowlisted(pathname)) return { allow: true, reason: 'allowlisted' };
if (responseStatus >= 400) return { allow: true, reason: 'error-response' };
if (!state) return { allow: true, reason: 'no-state' };
if (state.visibilityFiltered) return { allow: true, reason: 'visibility-filtered' };
if (!state.dbAccessed) return { allow: true, reason: 'no-db-access' };
return {
allow: false,
reason: 'anonymous DB access without requireAuth or visibility filter',
};
}
export function violationResponse(event: RequestEvent, reason: string): Response {
const payload = {
error: 'public_scope_violation',
message: 'This endpoint requires authentication.',
path: event.url.pathname,
method: event.request.method,
reason,
};
console.error(`[public-scope] BLOCKED ${event.request.method} ${event.url.pathname}: ${reason}`);
return new Response(JSON.stringify(payload), {
status: 401,
headers: { 'Content-Type': 'application/json' },
});
}

View File

@@ -0,0 +1,43 @@
/**
* Per-request scope used by the public-scope policy gate (mirrors
* flexsiebels#59). Each /api/* request runs inside an AsyncLocalStorage
* scope; helpers that touch the DB or perform auth checks report into the
* scope so the hook can assert at response time that anonymous responses
* either short-circuited via requireAuth, applied a visibility filter, or
* never touched the DB at all.
*/
import { AsyncLocalStorage } from 'node:async_hooks';
export interface RequestState {
pathname: string;
method: string;
userId: string | null;
authChecked: boolean;
dbAccessed: boolean;
visibilityFiltered: boolean;
}
const store = new AsyncLocalStorage<RequestState>();
export function runWithRequestState<T>(state: RequestState, fn: () => T | Promise<T>): Promise<T> {
return Promise.resolve(store.run(state, fn));
}
export function getRequestState(): RequestState | undefined {
return store.getStore();
}
export function markAuthChecked(): void {
const s = store.getStore();
if (s) s.authChecked = true;
}
export function markDbAccessed(): void {
const s = store.getStore();
if (s) s.dbAccessed = true;
}
export function markVisibilityFiltered(): void {
const s = store.getStore();
if (s) s.visibilityFiltered = true;
}

View File

@@ -0,0 +1,12 @@
import { json as kitJson } from '@sveltejs/kit';
import { markAuthChecked } from './request-context';
export function json(data: unknown, status = 200): Response {
return kitJson(data, { status });
}
export function requireAuth(userId: string | null): Response | null {
markAuthChecked();
if (!userId) return json({ error: 'Unauthorized' }, 401);
return null;
}

View File

@@ -0,0 +1,35 @@
import { createClient, type SupabaseClient } from '@supabase/supabase-js';
import { env } from '$env/dynamic/private';
function getSupabaseUrl(): string {
const host = env.SUPABASE_HOST;
if (host) return host.startsWith('http') ? host : `https://${host}`;
return env.SUPABASE_URL || 'http://127.0.0.1:54321';
}
let _admin: SupabaseClient | null = null;
let _anon: SupabaseClient | null = null;
/** Service-role client — full access, server-side only */
export function getSupabaseAdmin(): SupabaseClient {
if (!_admin) {
const key = env.SUPABASE_SERVICE_KEY;
if (!key) throw new Error('SUPABASE_SERVICE_KEY is required');
_admin = createClient(getSupabaseUrl(), key, {
auth: { autoRefreshToken: false, persistSession: false },
});
}
return _admin;
}
/** Anon client — used to validate JWTs and run sign-in flows */
export function getSupabaseAnon(): SupabaseClient {
if (!_anon) {
const key = env.SUPABASE_ANON_KEY;
if (!key) throw new Error('SUPABASE_ANON_KEY is required');
_anon = createClient(getSupabaseUrl(), key, {
auth: { autoRefreshToken: false, persistSession: false },
});
}
return _anon;
}

338
src/lib/styles/feedback.css Normal file
View File

@@ -0,0 +1,338 @@
/* Feedback feature — participant + admin shared styles (issue #63). */
:root {
--fb-bg: #fafafa;
--fb-fg: #1a1a1a;
--fb-muted: #666;
--fb-border: #e0e0e0;
--fb-accent: #2563eb;
--fb-accent-hover: #1d4ed8;
--fb-error: #dc2626;
--fb-success: #16a34a;
--fb-card-bg: #fff;
--fb-mine-bg: #eff6ff;
--fb-hidden-bg: #f3f3f3;
}
@media (prefers-color-scheme: dark) {
:root {
--fb-bg: #0a0a0a;
--fb-fg: #f5f5f5;
--fb-muted: #9a9a9a;
--fb-border: #2a2a2a;
--fb-accent: #60a5fa;
--fb-accent-hover: #93c5fd;
--fb-card-bg: #161616;
--fb-mine-bg: #1e293b;
--fb-hidden-bg: #181818;
}
}
.fb-page {
min-height: 100vh;
background: var(--fb-bg);
color: var(--fb-fg);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
-webkit-text-size-adjust: 100%;
}
.fb-shell {
max-width: 720px;
margin: 0 auto;
padding: 1.25rem 1rem 4rem;
}
.fb-header h1 {
font-size: 1.5rem;
margin: 0 0 0.25rem;
font-weight: 600;
line-height: 1.3;
}
.fb-header p {
margin: 0 0 1.25rem;
color: var(--fb-muted);
line-height: 1.5;
}
.fb-banner {
padding: 0.75rem 1rem;
border-radius: 6px;
background: var(--fb-hidden-bg);
border: 1px solid var(--fb-border);
margin-bottom: 1rem;
font-size: 0.95rem;
}
.fb-banner--closed {
background: #fef3c7;
color: #78350f;
border-color: #fde68a;
}
.fb-banner--error {
background: #fee2e2;
color: #7f1d1d;
border-color: #fecaca;
}
.fb-section {
background: var(--fb-card-bg);
border: 1px solid var(--fb-border);
border-radius: 8px;
padding: 1rem;
margin-bottom: 1.25rem;
}
.fb-section h2 {
font-size: 1.1rem;
margin: 0 0 0.75rem;
font-weight: 600;
}
.fb-name-row {
display: flex;
gap: 0.5rem;
align-items: center;
margin-bottom: 1rem;
}
.fb-name-row label {
font-size: 0.875rem;
color: var(--fb-muted);
white-space: nowrap;
}
.fb-input,
.fb-textarea,
.fb-select {
width: 100%;
padding: 0.625rem 0.75rem;
border: 1px solid var(--fb-border);
border-radius: 6px;
font-size: 1rem;
background: var(--fb-card-bg);
color: var(--fb-fg);
font-family: inherit;
box-sizing: border-box;
}
.fb-textarea {
min-height: 4rem;
resize: vertical;
}
.fb-input:focus,
.fb-textarea:focus,
.fb-select:focus {
outline: 2px solid var(--fb-accent);
outline-offset: -1px;
border-color: transparent;
}
.fb-question {
margin-bottom: 1.25rem;
}
.fb-question__label {
display: block;
font-weight: 500;
margin-bottom: 0.4rem;
font-size: 0.95rem;
}
.fb-question__required {
color: var(--fb-error);
margin-left: 0.2rem;
}
.fb-question__help {
font-size: 0.825rem;
color: var(--fb-muted);
margin-top: 0.25rem;
}
.fb-options {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.fb-option-row {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.625rem;
border: 1px solid var(--fb-border);
border-radius: 6px;
cursor: pointer;
background: var(--fb-card-bg);
}
.fb-option-row:hover {
border-color: var(--fb-accent);
}
.fb-option-row input {
margin: 0;
}
.fb-scale {
display: flex;
gap: 0.25rem;
flex-wrap: wrap;
}
.fb-scale__btn {
min-width: 2.5rem;
min-height: 2.5rem;
padding: 0.5rem 0.75rem;
border: 1px solid var(--fb-border);
border-radius: 6px;
background: var(--fb-card-bg);
color: var(--fb-fg);
font-size: 1rem;
cursor: pointer;
}
.fb-scale__btn--active {
background: var(--fb-accent);
color: white;
border-color: var(--fb-accent);
}
.fb-scale__labels {
display: flex;
justify-content: space-between;
font-size: 0.8rem;
color: var(--fb-muted);
margin-top: 0.4rem;
}
.fb-btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.625rem 1.25rem;
border: 1px solid transparent;
border-radius: 6px;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
background: var(--fb-accent);
color: white;
font-family: inherit;
min-height: 2.75rem;
}
.fb-btn:hover {
background: var(--fb-accent-hover);
}
.fb-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.fb-btn--ghost {
background: transparent;
color: var(--fb-fg);
border-color: var(--fb-border);
}
.fb-btn--ghost:hover {
background: var(--fb-hidden-bg);
}
.fb-btn--danger {
background: var(--fb-error);
}
.fb-honeypot {
position: absolute;
left: -9999px;
width: 1px;
height: 1px;
overflow: hidden;
}
/* Chat */
.fb-chat {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.fb-chat__list {
display: flex;
flex-direction: column;
gap: 0.5rem;
max-height: 60vh;
overflow-y: auto;
padding: 0.25rem;
margin: -0.25rem;
}
.fb-chat__post {
padding: 0.625rem 0.75rem;
border-radius: 8px;
background: var(--fb-hidden-bg);
border: 1px solid var(--fb-border);
}
.fb-chat__post--mine {
background: var(--fb-mine-bg);
border-color: var(--fb-accent);
}
.fb-chat__post--hidden {
background: var(--fb-hidden-bg);
color: var(--fb-muted);
font-style: italic;
}
.fb-chat__meta {
display: flex;
gap: 0.5rem;
font-size: 0.8rem;
color: var(--fb-muted);
margin-bottom: 0.25rem;
}
.fb-chat__name {
font-weight: 500;
}
.fb-chat__body {
white-space: pre-wrap;
word-break: break-word;
line-height: 1.45;
font-size: 0.95rem;
}
.fb-chat__form {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.fb-chat__form-actions {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
}
.fb-chat__empty {
text-align: center;
color: var(--fb-muted);
padding: 1.5rem 0;
font-size: 0.95rem;
}
.fb-foot {
text-align: center;
color: var(--fb-muted);
font-size: 0.8rem;
margin-top: 2rem;
}

View File

@@ -0,0 +1,9 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import '$lib/styles/feedback.css';
let { children }: { children: Snippet } = $props();
</script>
<div class="fb-page">
{@render children()}
</div>

21
src/routes/+page.svelte Normal file
View File

@@ -0,0 +1,21 @@
<svelte:head>
<title>fdbck — Feedback per Link</title>
<meta name="robots" content="noindex,nofollow" />
</svelte:head>
<div class="fb-shell">
<header class="fb-header">
<h1>fdbck</h1>
<p>
Per-Link Feedback-Forms und Live-Chat-Masken.
Anonym, ohne Anmeldung, nur mit langem Link.
</p>
</header>
<div class="fb-section">
<p>Diese Seite ist nur über persönlich geteilte Links erreichbar.</p>
<p style="margin-top: 0.75rem;">
<a href="/login" class="fb-btn fb-btn--ghost">Admin-Login</a>
</p>
</div>
</div>