create new auth files

This commit is contained in:
JCEEE
2026-08-16 10:11:58 +01:00
parent c73ced7894
commit 55fe84a8d9
4 changed files with 222 additions and 0 deletions
+125
View File
@@ -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;
}