diff --git a/frontend/src/lib/server/member-otp.ts b/frontend/src/lib/server/member-otp.ts new file mode 100644 index 0000000..a3cc112 --- /dev/null +++ b/frontend/src/lib/server/member-otp.ts @@ -0,0 +1,125 @@ +import { randomBytes } from 'node:crypto'; +import { MEMBER_SECRET } from '$app/env/private'; +import { createSuperClient, createPbClient } from '$lib/server/pocketbase'; +import { famUsername, handle } from '@shared/slugify'; + +const OTP_TTL_MS = 20 * 60 * 1000; // 20 minutes + +// A child member's PB password is derived from (secret + famSlug + handle), so +// the server can authWithPassword at join time. The user never sees or types it; +// OTP is the access gate. +export function derivePassword(famSlug: string, handleName: string) { + return `${String(MEMBER_SECRET)}${famSlug}${handleName}`; +} + +function generateOtp() { + const n = randomBytes(3).readUIntBE(0, 3) % 1_000_000; + return n.toString().padStart(6, '0'); +} + +// Create (or fetch existing) a child users record. The PB username is the +// composite `{famSlug}:{handle}` (globally unique auth identity); `name` keeps +// the raw display name. The password is derived from (secret + famSlug + handle) +// so the server can authWithPassword at join time. +export async function createChild(opts: { + famId: string; + famSlug: string; + name: string; + colour?: string; +}) { + const pb = await createSuperClient(); + const handleName = handle(opts.name); + const username = famUsername(opts.famSlug, handleName); + const password = derivePassword(opts.famSlug, handleName); + + let user = await pb + .collection('users') + .getFirstListItem(`famId='${opts.famId}' && username='${username}'`) + .catch(() => null); + + if (!user) { + user = await pb.collection('users').create({ + username, + name: opts.name, + color: opts.colour || '#6366f1', + password, + passwordConfirm: password, + famId: opts.famId, + role: 'child' + }); + } else if (!user.name || !user.color) { + user = await pb.collection('users').update(user.id, { + name: user.name || opts.name, + color: user.color || opts.colour || '#6366f1' + }); + } + + if (!user) throw new Error('Failed to create child'); + return user; +} + +// Admin grants access to a child: creates the users auth record (or re-issues +// OTP if they already exist) + upserts their user_configs. Returns the OTP and +// shareable join link (using the whitespace-free handle) for QR display. +export async function issueAccess(opts: { + famId: string; + famSlug: string; + name: string; + colour?: string; +}) { + const { famId, famSlug, name, colour } = opts; + const username = handle(name); + const otp = generateOtp(); + const updatedAt = new Date().toISOString(); + + const user = await createChild({ famId, famSlug, name, colour }); + + const pb = await createSuperClient(); + let config = await pb + .collection('user_configs') + .getFirstListItem(`famId='${famId}' && userId='${user.id}'`) + .catch(() => null); + + if (config) { + await pb.collection('user_configs').update(config.id, { otp, colour, updatedAt }); + } else { + await pb.collection('user_configs').create({ famId, userId: user.id, otp, colour, updatedAt }); + } + + return { otp, joinUrl: `/${famSlug}/join/${encodeURIComponent(username)}` }; +} + +// Child redeems their OTP at /{famSlug}/join/{username}. Verifies the code, +// the 20-minute window, and that the account is a child, then authenticates via +// authWithPassword and returns a fresh PB JWT. Throws on any failure. +export async function redeemOtp(opts: { famSlug: string; username: string; otp: string }) { + const { famSlug, username, otp } = opts; + const handleName = handle(username); // normalize whatever was in the URL + const fullUsername = famUsername(famSlug, handleName); + + const pb = await createSuperClient(); + + const fam = await pb.collection('fams').getFirstListItem(`slug='${famSlug}'`); + if (!fam) throw new Error('Invalid join link'); + + let user = await pb + .collection('users') + .getFirstListItem(`famId='${fam.id}' && username='${fullUsername}'`) + .catch(() => null); + if (!user || user.role !== 'child') throw new Error('Invalid join link'); + + let config = await pb + .collection('user_configs') + .getFirstListItem(`famId='${fam.id}' && userId='${user.id}'`) + .catch(() => null); + if (!config || config.otp !== otp) throw new Error('Invalid code'); + + const issued = Date.parse(config.updatedAt || ''); + if (!issued || Date.now() - issued > OTP_TTL_MS) throw new Error('Code expired'); + + const authPb = createPbClient(); + await authPb + .collection('users') + .authWithPassword(fullUsername, derivePassword(famSlug, handleName)); + return authPb.authStore.token; +} \ No newline at end of file diff --git a/frontend/src/lib/server/pocketbase.ts b/frontend/src/lib/server/pocketbase.ts new file mode 100644 index 0000000..465c80d --- /dev/null +++ b/frontend/src/lib/server/pocketbase.ts @@ -0,0 +1,65 @@ +import PocketBase from 'pocketbase'; +import { redirect } from '@sveltejs/kit'; +import type { RequestEvent } from '@sveltejs/kit'; +import { SERVER_IP } from '$app/env/public'; +import { PB_EMAIL, PB_PASSWORD } from '$app/env/private'; + +export const PB_ENDPOINT = import.meta.env.PROD ? '/pb' : `http://${SERVER_IP}:8090`; + +// Server-side PB client, pre-authenticated as the given user's token. +// Used for CRUD as the authenticated user so PB collection rules apply +// (famId scoping) instead of running everything as superuser. +export function createPbClient(token?: string) { + const pb = new PocketBase(PB_ENDPOINT); + if (token) pb.authStore.save(token, null); + return pb; +} + +// Authenticated PB client for the current request's admin/member session. +// Requires an active session; redirects to /login otherwise. +export function pbUser(event: RequestEvent) { + if (!event.locals.user || !event.locals.pbToken) { + throw redirect(303, '/login'); + } + return createPbClient(event.locals.pbToken); +} + +// Superuser PB client (memoized). Reserved for server-only privileged +// operations that must bypass collection rules: creating child users, minting +// OTP-login tokens, and verifying OTPs against the superuser-only user_configs. +let superClient: PocketBase | null = null; +export async function createSuperClient() { + if (superClient) return superClient; + const pb = new PocketBase(PB_ENDPOINT); + await pb.collection('_superusers').authWithPassword(String(PB_EMAIL), String(PB_PASSWORD)); + superClient = pb; + return pb; +} + +// Superuser CRUD facade, built on the memoized SDK superuser client. Replaces +// the old raw-fetch `pb-admin.ts`/`ensureToken` path so all server PB access +// (authenticated user + superuser) lives in this one module. +export const pbAdmin = { + async getList(collection: string, filter = '') { + const pb = await createSuperClient(); + const options: { filter?: string } = {}; + if (filter) options.filter = filter; + return pb.collection(collection).getFullList(options); + }, + async getOne(collection: string, id: string) { + const pb = await createSuperClient(); + return pb.collection(collection).getOne(id); + }, + async create(collection: string, data: Record) { + const pb = await createSuperClient(); + return pb.collection(collection).create(data); + }, + async update(collection: string, id: string, data: Record) { + const pb = await createSuperClient(); + return pb.collection(collection).update(id, data); + }, + async remove(collection: string, id: string) { + const pb = await createSuperClient(); + return pb.collection(collection).delete(id); + } +}; \ No newline at end of file diff --git a/frontend/src/lib/server/session.ts b/frontend/src/lib/server/session.ts new file mode 100644 index 0000000..71d5008 --- /dev/null +++ b/frontend/src/lib/server/session.ts @@ -0,0 +1,24 @@ +import type { Cookies } from '@sveltejs/kit'; + +// The PocketBase JWT lives in a single cookie shared by: +// - the server hooks (authRefresh -> locals.user) +// - the client SDK (seeded from page.data.pbToken -> authenticated famStore reads/subscribe) +// httpOnly keeps the token out of reach of browser JS/XSS; the client receives +// the token server-side via the layout load (pbToken) and seeds pb.authStore. +// Secure flag is set in prod so it's only sent over HTTPS. +export const SESSION_COOKIE = 'pb_token'; +const MAX_AGE = 60 * 60 * 24; // 24h, PB token exp is the real ceiling + +export function setSessionCookie(cookies: Cookies, token: string) { + cookies.set(SESSION_COOKIE, token, { + httpOnly: true, + sameSite: 'lax', + path: '/', + maxAge: MAX_AGE, + secure: import.meta.env.PROD + }); +} + +export function clearSessionCookie(cookies: Cookies) { + cookies.delete(SESSION_COOKIE, { path: '/' }); +} \ No newline at end of file diff --git a/frontend/src/lib/server/types.ts b/frontend/src/lib/server/types.ts new file mode 100644 index 0000000..6e97c34 --- /dev/null +++ b/frontend/src/lib/server/types.ts @@ -0,0 +1,8 @@ +export interface SessionUser { + id: string; + name: string; + username?: string; + role: 'parent' | 'child'; + famId: string; + color?: string; +} \ No newline at end of file