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'); // Auto-create the child's pocket-money droplet. The amount (rewardValue) // starts empty — the parent sets it on the Bonuses page. This keeps pocket // money a first-class, always-present feature without a separate field. const pm = await pb .collection('bonus_configs') .getFirstListItem(`famId='${opts.famId}' && memberId='${user.id}' && isPocketMoney=true`) .catch(() => null); if (!pm) { await pb .collection('bonus_configs') .create({ famId: opts.famId, name: 'Pocket Money', target: 'individual', type: 'threshold', thresholdType: 'percent', occurrence: 'recurring', rewardType: 'cash', rewardValue: '', criteriaValue: 50, memberId: user.id, period: 'weekly', status: 'active', isPocketMoney: true }) .catch(() => null); } return user; } // Admin grants access to a child: creates the users auth record (or re-issues // OTP if they already exist) + upserts their otp. 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 } = opts; const username = handle(name); const otp = generateOtp(); const updatedAt = new Date().toISOString(); const user = await createChild({ famId, famSlug, name, colour: opts.colour }); const pb = await createSuperClient(); let config = await pb .collection('otp') .getFirstListItem(`famId='${famId}' && userId='${user.id}'`) .catch(() => null); if (config) { await pb.collection('otp').update(config.id, { otp, updatedAt }); } else { await pb.collection('otp').create({ famId, userId: user.id, otp, updatedAt }); } return { otp, joinUrl: `/${famSlug}/join/${encodeURIComponent(username)}` }; } // Admin invites a second parent: creates a role='parent' users record with the // shared derived password (never known — the invited parent sets their own at // the join page) + issues an OTP email code. Rejects duplicates in the family. export async function inviteParent(opts: { famId: string; famSlug: string; name: string; email: string; }) { const { famId, famSlug, name, email } = opts; const handleName = handle(name); const username = famUsername(famSlug, handleName); const password = derivePassword(famSlug, handleName); const pb = await createSuperClient(); const existing = await pb .collection('users') .getFirstListItem(`famId='${famId}' && (username='${username}' || email='${email}')`) .catch(() => null); if (existing) { throw new Error('A user with that name or email already exists in this family'); } const user = await pb.collection('users').create({ username, name, email, emailVisibility: false, password, passwordConfirm: password, famId, role: 'parent' }); const otp = generateOtp(); await pb.collection('otp').create({ famId, userId: user.id, otp, updatedAt: new Date().toISOString() }); return { otp, joinUrl: `/${famSlug}/join/${encodeURIComponent(handleName)}` }; } // Invited parent redeems their OTP at the join page, sets their own password, // and is logged in. Single-use — the OTP record is deleted on success. export async function redeemParentOtp(opts: { famSlug: string; username: string; otp: string; password: string; }) { const { famSlug, username, otp, password } = opts; const handleName = handle(username); 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 !== 'parent') throw new Error('Invalid join link'); const config = await pb .collection('otp') .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'); await pb.collection('users').update(user.id, { password, passwordConfirm: password }); await pb .collection('otp') .delete(config.id) .catch(() => null); const authPb = createPbClient(); await authPb.collection('users').authWithPassword(user.email, password); return authPb.authStore.token; } // 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('otp') .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; }