diff --git a/bun.lock b/bun.lock index c7c16ff..22a8e7d 100644 --- a/bun.lock +++ b/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=="], diff --git a/package.json b/package.json index ac381b4..b517511 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/lib/server/feedback.ts b/src/lib/server/feedback.ts new file mode 100644 index 0000000..e82137e --- /dev/null +++ b/src/lib/server/feedback.ts @@ -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 { + 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 { + 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); +} diff --git a/src/lib/server/public-scope.test.ts b/src/lib/server/public-scope.test.ts new file mode 100644 index 0000000..51a376f --- /dev/null +++ b/src/lib/server/public-scope.test.ts @@ -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 { + 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); + }); +}); diff --git a/src/lib/server/rate-limit.test.ts b/src/lib/server/rate-limit.test.ts new file mode 100644 index 0000000..48139f9 --- /dev/null +++ b/src/lib/server/rate-limit.test.ts @@ -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); + }); +}); diff --git a/src/lib/server/rate-limit.ts b/src/lib/server/rate-limit.ts new file mode 100644 index 0000000..5635c39 --- /dev/null +++ b/src/lib/server/rate-limit.ts @@ -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(); + +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(); +} diff --git a/src/lib/server/schemas.ts b/src/lib/server/schemas.ts new file mode 100644 index 0000000..a2599f9 --- /dev/null +++ b/src/lib/server/schemas.ts @@ -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; +export type FeedbackFormDefinition = z.infer; diff --git a/tsconfig.json b/tsconfig.json index a8f10c8..782ad14 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,6 +9,7 @@ "skipLibCheck": true, "sourceMap": true, "strict": true, - "moduleResolution": "bundler" + "moduleResolution": "bundler", + "types": ["bun"] } }