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:
5
bun.lock
5
bun.lock
@@ -13,6 +13,7 @@
|
||||
"@sveltejs/adapter-node": "^5.5.4",
|
||||
"@sveltejs/kit": "^2.15.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
"@types/bun": "^1.3.13",
|
||||
"svelte": "^5.0.0",
|
||||
"svelte-check": "^4.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=="],
|
||||
|
||||
"@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/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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"@sveltejs/adapter-node": "^5.5.4",
|
||||
"@sveltejs/kit": "^2.15.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
"@types/bun": "^1.3.13",
|
||||
"svelte": "^5.0.0",
|
||||
"svelte-check": "^4.0.0",
|
||||
"typescript": "^5.0.0",
|
||||
|
||||
64
src/lib/server/feedback.ts
Normal file
64
src/lib/server/feedback.ts
Normal 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);
|
||||
}
|
||||
110
src/lib/server/public-scope.test.ts
Normal file
110
src/lib/server/public-scope.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
35
src/lib/server/rate-limit.test.ts
Normal file
35
src/lib/server/rate-limit.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
53
src/lib/server/rate-limit.ts
Normal file
53
src/lib/server/rate-limit.ts
Normal 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
109
src/lib/server/schemas.ts
Normal 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>;
|
||||
@@ -9,6 +9,7 @@
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler"
|
||||
"moduleResolution": "bundler",
|
||||
"types": ["bun"]
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user