schemas + rate-limit + feedback helpers + tests

- src/lib/server/schemas.ts: feedback Zod schemas (Question discriminated union + FormDefinition + Instance create/update + Submission/Post/Hide + SignIn).
- src/lib/server/rate-limit.ts (+ test): in-memory token bucket — direct port from flexsiebels.
- src/lib/server/feedback.ts: generateSlug (32-char base62), getInstanceBySlug/ById via fdb(), RATE_LIMIT constants, clampUserAgent.
- src/lib/server/public-scope.test.ts: gate behaviour tests (allowlist coverage + 6 evaluatePolicy cases). Adapted for fdbck's allowlist (no /api/share, no /api/gotify-public).
- @types/bun added so svelte-check resolves bun:test imports — clean baseline (no 'Cannot find bun:test' tech debt that the flexsiebels project carries).

bun run check: 0 errors, 0 warnings.
bun run test: 20/20 pass.
This commit is contained in:
mAi
2026-05-05 11:32:23 +02:00
parent fa1ad92517
commit f5992ebc5b
8 changed files with 379 additions and 1 deletions

View File

@@ -13,6 +13,7 @@
"@sveltejs/adapter-node": "^5.5.4", "@sveltejs/adapter-node": "^5.5.4",
"@sveltejs/kit": "^2.15.0", "@sveltejs/kit": "^2.15.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0", "@sveltejs/vite-plugin-svelte": "^5.0.0",
"@types/bun": "^1.3.13",
"svelte": "^5.0.0", "svelte": "^5.0.0",
"svelte-check": "^4.0.0", "svelte-check": "^4.0.0",
"typescript": "^5.0.0", "typescript": "^5.0.0",
@@ -169,6 +170,8 @@
"@sveltejs/vite-plugin-svelte-inspector": ["@sveltejs/vite-plugin-svelte-inspector@4.0.1", "", { "dependencies": { "debug": "^4.3.7" }, "peerDependencies": { "@sveltejs/vite-plugin-svelte": "^5.0.0", "svelte": "^5.0.0", "vite": "^6.0.0" } }, "sha512-J/Nmb2Q2y7mck2hyCX4ckVHcR5tu2J+MtBEQqpDrrgELZ2uvraQcK/ioCV61AqkdXFgriksOKIceDcQmqnGhVw=="], "@sveltejs/vite-plugin-svelte-inspector": ["@sveltejs/vite-plugin-svelte-inspector@4.0.1", "", { "dependencies": { "debug": "^4.3.7" }, "peerDependencies": { "@sveltejs/vite-plugin-svelte": "^5.0.0", "svelte": "^5.0.0", "vite": "^6.0.0" } }, "sha512-J/Nmb2Q2y7mck2hyCX4ckVHcR5tu2J+MtBEQqpDrrgELZ2uvraQcK/ioCV61AqkdXFgriksOKIceDcQmqnGhVw=="],
"@types/bun": ["@types/bun@1.3.13", "", { "dependencies": { "bun-types": "1.3.13" } }, "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw=="],
"@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="], "@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
@@ -187,6 +190,8 @@
"axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="], "axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="],
"bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="],
"chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],

View File

@@ -15,6 +15,7 @@
"@sveltejs/adapter-node": "^5.5.4", "@sveltejs/adapter-node": "^5.5.4",
"@sveltejs/kit": "^2.15.0", "@sveltejs/kit": "^2.15.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0", "@sveltejs/vite-plugin-svelte": "^5.0.0",
"@types/bun": "^1.3.13",
"svelte": "^5.0.0", "svelte": "^5.0.0",
"svelte-check": "^4.0.0", "svelte-check": "^4.0.0",
"typescript": "^5.0.0", "typescript": "^5.0.0",

View File

@@ -0,0 +1,64 @@
/**
* Feedback feature — DB helpers + slug generator + rate-limit constants.
*
* Public participant routes and m's admin routes share the same primitives.
*/
import { randomBytes } from 'node:crypto';
import { fdb } from './fdb';
const BASE62_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
const SLUG_LENGTH = 32;
export const RATE_LIMIT = {
post: { max: 30, windowMs: 5 * 60_000 },
submit: { max: 10, windowMs: 5 * 60_000 },
};
/** 32-char base62 slug — ~190 bits of entropy. */
export function generateSlug(): string {
const buf = randomBytes(SLUG_LENGTH * 2);
let out = '';
for (let i = 0; i < SLUG_LENGTH; i++) {
out += BASE62_ALPHABET[buf[i] % BASE62_ALPHABET.length];
}
return out;
}
export interface FeedbackInstance {
id: string;
slug: string;
title: string;
description: string | null;
owner_user_id: string;
form_definition: unknown | null;
chat_enabled: boolean;
status: 'open' | 'closed';
closed_at: string | null;
created_at: string;
updated_at: string;
}
export async function getInstanceBySlug(slug: string): Promise<FeedbackInstance | null> {
const { data, error } = await fdb()
.from('feedback_instances')
.select('*')
.eq('slug', slug)
.maybeSingle();
if (error) throw error;
return data as FeedbackInstance | null;
}
export async function getInstanceById(id: string): Promise<FeedbackInstance | null> {
const { data, error } = await fdb()
.from('feedback_instances')
.select('*')
.eq('id', id)
.maybeSingle();
if (error) throw error;
return data as FeedbackInstance | null;
}
export function clampUserAgent(ua: string | null): string | null {
if (!ua) return null;
return ua.slice(0, 500);
}

View File

@@ -0,0 +1,110 @@
import { describe, test, expect } from 'bun:test';
import {
evaluatePolicy,
isAllowlisted,
isApiPath,
PUBLIC_API_ALLOWLIST,
} from './public-scope';
import type { RequestState } from './request-context';
function makeState(over: Partial<RequestState> = {}): RequestState {
return {
pathname: '/api/test',
method: 'GET',
userId: null,
authChecked: false,
dbAccessed: false,
visibilityFiltered: false,
...over,
};
}
describe('isApiPath', () => {
test('matches /api/* prefixes', () => {
expect(isApiPath('/api/foo')).toBe(true);
expect(isApiPath('/api/')).toBe(true);
expect(isApiPath('/foo')).toBe(false);
});
});
describe('isAllowlisted', () => {
test.each([
['/api/auth/sign-in', true],
['/api/auth', true],
['/api/public/feedback/abc', true],
['/api/public/feedback/abc/posts', true],
['/api/public', true],
['/api/admin/feedback', false],
['/api/admin/feedback/123', false],
['/api/authority', false], // must NOT match prefix-only
['/api/publication', false],
] as [string, boolean][])('%s → %s', (path: string, expected: boolean) => {
expect(isAllowlisted(path)).toBe(expected);
});
test('allowlist non-empty', () => {
expect(PUBLIC_API_ALLOWLIST.length).toBeGreaterThan(0);
});
});
describe('evaluatePolicy', () => {
test('non-api → allow', () => {
const d = evaluatePolicy({
pathname: '/about',
userId: null,
responseStatus: 200,
state: makeState(),
});
expect(d.allow).toBe(true);
});
test('authenticated /api/* → allow', () => {
const d = evaluatePolicy({
pathname: '/api/admin/feedback',
userId: 'u1',
responseStatus: 200,
state: makeState({ userId: 'u1', dbAccessed: true }),
});
expect(d.allow).toBe(true);
});
test('allowlisted /api/public/* → allow', () => {
const d = evaluatePolicy({
pathname: '/api/public/feedback/abc',
userId: null,
responseStatus: 200,
state: makeState({ pathname: '/api/public/feedback/abc', dbAccessed: true }),
});
expect(d.allow).toBe(true);
});
test('anonymous DB access without filter → block', () => {
const d = evaluatePolicy({
pathname: '/api/admin/feedback',
userId: null,
responseStatus: 200,
state: makeState({ dbAccessed: true }),
});
expect(d.allow).toBe(false);
});
test('anonymous, no DB access → allow', () => {
const d = evaluatePolicy({
pathname: '/api/admin/feedback',
userId: null,
responseStatus: 200,
state: makeState({ dbAccessed: false }),
});
expect(d.allow).toBe(true);
});
test('error response (>=400) → allow (let handler stand)', () => {
const d = evaluatePolicy({
pathname: '/api/admin/feedback',
userId: null,
responseStatus: 401,
state: makeState({ dbAccessed: true }),
});
expect(d.allow).toBe(true);
});
});

View File

@@ -0,0 +1,35 @@
import { describe, test, expect, beforeEach } from 'bun:test';
import { checkRate, _resetRateLimitForTests } from './rate-limit';
// generateSlug lives in feedback.ts which transitively imports $env/dynamic/private
// (via flex → supabase). bun:test can't resolve SvelteKit's virtual modules, so the
// slug generator is exercised at runtime via the create-instance integration path
// rather than here. The rate limiter is pure and lives in its own module.
describe('checkRate', () => {
beforeEach(() => _resetRateLimitForTests());
test('allows up to max calls, then blocks', () => {
const opts = { max: 3, windowMs: 60_000 };
expect(checkRate('k', opts)).toBe(true);
expect(checkRate('k', opts)).toBe(true);
expect(checkRate('k', opts)).toBe(true);
expect(checkRate('k', opts)).toBe(false);
});
test('different keys are independent', () => {
const opts = { max: 1, windowMs: 60_000 };
expect(checkRate('a', opts)).toBe(true);
expect(checkRate('b', opts)).toBe(true);
expect(checkRate('a', opts)).toBe(false);
expect(checkRate('b', opts)).toBe(false);
});
test('window reset re-allows calls', async () => {
const opts = { max: 1, windowMs: 10 };
expect(checkRate('w', opts)).toBe(true);
expect(checkRate('w', opts)).toBe(false);
await new Promise((r) => setTimeout(r, 20));
expect(checkRate('w', opts)).toBe(true);
});
});

View File

@@ -0,0 +1,53 @@
/**
* In-memory per-key rate limiter with sliding window.
*
* Lives in the SvelteKit Node process — sufficient for single-instance deploys.
* If the app ever scales horizontally, replace with Redis or a DB-backed store
* keyed the same way.
*
* Usage:
* const allowed = checkRate(`fb:post:${ip}:${slug}`, { max: 30, windowMs: 5*60_000 });
* if (!allowed) return tooMany();
*/
interface Bucket {
count: number;
resetAt: number;
}
const buckets = new Map<string, Bucket>();
const MAX_BUCKETS = 10_000;
function sweep(now: number): void {
if (buckets.size < MAX_BUCKETS) return;
for (const [key, b] of buckets) {
if (b.resetAt <= now) buckets.delete(key);
}
}
export interface RateOpts {
max: number;
windowMs: number;
}
export function checkRate(key: string, opts: RateOpts): boolean {
const now = Date.now();
const existing = buckets.get(key);
if (!existing || existing.resetAt <= now) {
sweep(now);
buckets.set(key, { count: 1, resetAt: now + opts.windowMs });
return true;
}
if (existing.count >= opts.max) return false;
existing.count += 1;
return true;
}
/** Test-only: reset all buckets. */
export function _resetRateLimitForTests(): void {
buckets.clear();
}

109
src/lib/server/schemas.ts Normal file
View File

@@ -0,0 +1,109 @@
/**
* Zod schemas for fdbck request body validation.
*/
import { z } from 'zod';
const FeedbackQuestionBaseSchema = z.object({
id: z.string().min(1).max(64),
label: z.string().min(1).max(200),
required: z.boolean().optional(),
help: z.string().max(500).optional(),
});
export const FeedbackQuestionSchema = z.discriminatedUnion('type', [
FeedbackQuestionBaseSchema.extend({
type: z.literal('short_text'),
placeholder: z.string().max(100).optional(),
}),
FeedbackQuestionBaseSchema.extend({
type: z.literal('long_text'),
placeholder: z.string().max(100).optional(),
}),
FeedbackQuestionBaseSchema.extend({
type: z.literal('single_choice'),
options: z.array(z.string().min(1).max(200)).min(2).max(20),
}),
FeedbackQuestionBaseSchema.extend({
type: z.literal('multi_choice'),
options: z.array(z.string().min(1).max(200)).min(2).max(20),
}),
FeedbackQuestionBaseSchema.extend({
type: z.literal('scale'),
min: z.number().int().min(0).max(100),
max: z.number().int().min(1).max(100),
min_label: z.string().max(50).optional(),
max_label: z.string().max(50).optional(),
}),
FeedbackQuestionBaseSchema.extend({
type: z.literal('boolean'),
}),
]);
export const FeedbackFormDefinitionSchema = z.object({
intro: z.string().max(2000).optional(),
outro: z.string().max(2000).optional(),
questions: z.array(FeedbackQuestionSchema).min(1).max(50),
}).refine(
(def) => {
const ids = def.questions.map((q) => q.id);
return new Set(ids).size === ids.length;
},
{ message: 'Question ids must be unique' },
);
export const FeedbackInstanceCreateSchema = z.object({
title: z.string().min(1).max(200),
description: z.string().max(2000).optional(),
form_definition: FeedbackFormDefinitionSchema.nullable().optional(),
chat_enabled: z.boolean().optional(),
}).refine(
(v) => v.form_definition != null || v.chat_enabled === true,
{ message: 'Either form_definition or chat_enabled must be set' },
);
export const FeedbackInstanceUpdateSchema = z.object({
title: z.string().min(1).max(200).optional(),
description: z.string().max(2000).nullable().optional(),
form_definition: FeedbackFormDefinitionSchema.nullable().optional(),
chat_enabled: z.boolean().optional(),
status: z.enum(['open', 'closed']).optional(),
});
const ANSWER_MAX = 5000;
export const FeedbackSubmissionSchema = z.object({
display_name: z.string().max(80).nullable().optional()
.transform((v) => (v && v.trim() ? v.trim().replace(/[\r\n]+/g, ' ') : null)),
client_session_id: z.string().min(1).max(100),
answers: z.record(
z.string().min(1).max(64),
z.union([
z.string().max(ANSWER_MAX),
z.number(),
z.boolean(),
z.array(z.string().max(200)).max(20),
z.null(),
]),
),
company: z.string().max(200).optional(), // honeypot
});
export const FeedbackPostSchema = z.object({
display_name: z.string().max(80).nullable().optional()
.transform((v) => (v && v.trim() ? v.trim().replace(/[\r\n]+/g, ' ') : null)),
client_session_id: z.string().min(1).max(100),
body: z.string().min(1).max(2000),
company: z.string().max(200).optional(), // honeypot
});
export const FeedbackPostHideSchema = z.object({
hidden: z.boolean(),
});
export const SignInSchema = z.object({
email: z.string().email(),
password: z.string().min(6).max(200),
});
export type FeedbackQuestion = z.infer<typeof FeedbackQuestionSchema>;
export type FeedbackFormDefinition = z.infer<typeof FeedbackFormDefinitionSchema>;

View File

@@ -9,6 +9,7 @@
"skipLibCheck": true, "skipLibCheck": true,
"sourceMap": true, "sourceMap": true,
"strict": true, "strict": true,
"moduleResolution": "bundler" "moduleResolution": "bundler",
"types": ["bun"]
} }
} }