migrate auth v2 code

This commit is contained in:
JCEEE
2026-08-16 10:11:39 +01:00
parent 3dd94b8a7c
commit c73ced7894
45 changed files with 1965 additions and 1519 deletions
+5
View File
@@ -14,3 +14,8 @@ export const PB_ENDPOINT =
(SERVER_IP ? `http://${SERVER_IP}:8090` : `http://127.0.0.1:8090`);
export const PB_EMAIL = process.env.PB_EMAIL || "debug@famchamp.dev";
export const PB_PASSWORD = process.env.PB_PASSWORD || "debug123";
// Shared secret used to DERIVE a child's users password as
// `MEMBER_SECRET + famSlug + username` (same formula as the frontend
// member-otp.ts). Never typed by anyone; OTP is the access gate.
export const MEMBER_SECRET =
process.env.MEMBER_SECRET || "famchamp-member-secret";
+190 -299
View File
@@ -1,9 +1,9 @@
import crypto from "node:crypto";
import { serve } from "@hono/node-server";
import { Hono } from "hono";
import { pb } from "./pb.ts";
import { migrate } from "./migrate.ts";
import { PROXY_PORT } from "@shared/config.ts";
import { PB_ENDPOINT, MEMBER_SECRET } from "./env.ts";
import {
weekStart as tzWeekStart,
addDaysStr,
@@ -12,7 +12,8 @@ import {
resolveTz,
nextPaydayAfter as nextPaydayAfterTz,
periodWindow,
} from "@shared/timezone.ts";
} from "@shared/timezone.ts";
import { slugify, handle, famUsername } from "@shared/slugify.ts";
const app = new Hono();
@@ -124,10 +125,9 @@ async function getFamPaydayTime(famId: string): Promise<string> {
async function getFamTimezone(famId: string): Promise<string> {
try {
const fam = await pb.getList("fams", `id = '${famId}'`);
const tz = fam.items?.[0]?.timezone;
return tz || "auto";
return resolveTz(fam.items?.[0]?.timezone);
} catch {
return "auto";
return resolveTz("auto");
}
}
@@ -139,35 +139,42 @@ async function requireAdmin(c: any, next: any) {
if (!famId || !userId) {
return c.json({ error: "Unauthorized" }, 401);
}
const admins = await pb.getList(
"fam_admins",
`famId = '${famId}' && userId = '${userId}'`,
const parents = await pb.getList(
"users",
`famId = '${famId}' && role = 'parent' && id = '${userId}'`,
);
if (!admins.items?.length) {
if (!parents.items?.length) {
return c.json({ error: "Unauthorized" }, 401);
}
c.set("famId", famId);
return next();
}
async function requireDeviceToken(c: any, next: any) {
const deviceToken = c.req.header("x-device-token");
const famId = c.req.header("x-device-famid");
if (!famId || !deviceToken) {
return c.json(
{ error: "x-device-token and x-device-famid headers required" },
400,
);
// Member (child) auth: validates the user's pb_token JWT and identifies the
// child. Children live in the `users` auth collection (role='child'); memberId
// (the child-scoped foreign key) is now the users record id. auth-refresh both
// cryptographically validates the token and returns the record in one call.
async function requireMember(c: any, next: any) {
const auth = c.req.header("Authorization") || "";
const token = auth.startsWith("Bearer ")
? auth.slice(7)
: c.req.header("x-pb-token");
if (!token) {
return c.json({ error: "Member auth required" }, 401);
}
const hashHex = crypto.createHash("sha256").update(deviceToken).digest("hex");
const members = await pb.getList(
"members",
`famId = '${famId}' && deviceToken = '${hashHex}'`,
);
const member = members.items?.[0];
if (!member) return c.json({ error: "Invalid device token" }, 401);
c.set("famId", famId);
c.set("memberId", member.id);
const res = await fetch(`${PB_ENDPOINT}/api/collections/users/auth-refresh`, {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) return c.json({ error: "Unauthorized" }, 401);
const body = await res.json().catch(() => ({}));
const record = body?.record;
if (!record || record.role !== "child") {
return c.json({ error: "Forbidden" }, 403);
}
c.set("famId", record.famId);
c.set("memberId", record.id);
c.set("user", record);
return next();
}
@@ -179,24 +186,16 @@ app.post("/api/admin/signup", async (c) => {
if (!email || !password || !famName) {
return c.json({ error: "email, password, famName required" }, 400);
}
const slug = famName
.toLowerCase()
.replace(/\s+/g, "-")
.replace(/[^a-z0-9-]/g, "");
const inviteCode = Math.random().toString(36).substring(2, 8).toUpperCase();
const slug = slugify(famName);
const user = await pb.createUser(email, password);
const fam = await pb.create("fams", {
name: famName,
slug,
inviteCode,
featureFlags: {},
timezone: "auto",
});
const adminName = parentName || email.split("@")[0];
const admin = await pb.create("fam_admins", {
famId: fam.id,
userId: user.id,
email,
await pb.update("users", user.id, {
name: adminName,
color: "#6366f1",
});
@@ -207,9 +206,9 @@ app.post("/api/admin/signup", async (c) => {
famSlug: slug,
userId: user.id,
token: authResult.token,
memberId: admin.id,
memberName: admin.name,
memberColor: admin.color,
memberId: user.id,
memberName: adminName,
memberColor: "#6366f1",
role: "parent",
});
} catch (err) {
@@ -224,28 +223,30 @@ app.post("/api/admin/login", async (c) => {
return c.json({ error: "email, password required" }, 400);
const authResult = await pb.authWithPassword(email, password);
const userId = authResult.record.id;
const admins = await pb.getList("fam_admins", `userId = '${userId}'`);
const admin = admins.items?.[0];
if (!admin) return c.json({ error: "No fam found for user" }, 404);
const fams = await pb.getList("fams", `id = '${admin.famId}'`);
const parents = await pb.getList(
"users",
`famId != '' && role = 'parent' && id = '${userId}'`,
);
const parent = parents.items?.[0];
if (!parent) return c.json({ error: "No fam found for user" }, 404);
const fams = await pb.getList("fams", `id = '${parent.famId}'`);
const fam = fams.items?.[0];
if (!fam) return c.json({ error: "Fam not found" }, 404);
// Update fam_admins name/color on login
const defaultName = email.split("@")[0];
const adminPatch: Record<string, string> = {
const parentPatch: Record<string, string> = {
name: defaultName,
color: "#6366f1",
};
if (!admin.email) adminPatch.email = email;
const updatedAdmin = await pb.update("fam_admins", admin.id, adminPatch);
if (!parent.email) parentPatch.email = email;
await pb.update("users", parent.id, parentPatch);
return c.json({
famId: fam.id,
famSlug: fam.slug,
userId,
token: authResult.token,
memberId: updatedAdmin.id,
memberName: updatedAdmin.name,
memberColor: updatedAdmin.color,
memberId: parent.id,
memberName: defaultName,
memberColor: "#6366f1",
role: "parent",
});
} catch (err) {
@@ -253,124 +254,7 @@ app.post("/api/admin/login", async (c) => {
}
});
async function joinMemberFlow(
famId: string,
name: string,
deviceToken: string,
) {
const existing = await pb.getList(
"members",
`famId = '${famId}' && name = '${name}'`,
);
const slot = existing.items?.[0];
if (!slot) throw new Error(`No member named "${name}" in this family`);
const hashHex = crypto.createHash("sha256").update(deviceToken).digest("hex");
const tokenHint = deviceToken.substring(0, 8);
await pb.update("members", slot.id, {
deviceToken: hashHex,
deviceTokenHint: tokenHint,
});
const newCode = Math.random().toString(36).substring(2, 8).toUpperCase();
await pb.update("fams", famId, { inviteCode: newCode });
return {
memberId: slot.id,
deviceToken,
name: slot.name,
inviteCode: newCode,
};
}
app.post("/api/members/join", async (c) => {
try {
const { inviteCode, name, deviceToken } = await c.req.json();
if (!inviteCode || !name || !deviceToken)
return c.json({ error: "inviteCode, name, deviceToken required" }, 400);
const fams = await pb.getList("fams", `inviteCode = '${inviteCode}'`);
const fam = fams.items?.[0];
if (!fam) return c.json({ error: "Invalid invite code" }, 404);
const result = await joinMemberFlow(fam.id, name, deviceToken);
return c.json({ famId: fam.id, famSlug: fam.slug, ...result });
} catch (err) {
return handleError(c, err);
}
});
app.post("/api/members/direct-join", async (c) => {
try {
const { inviteCode, name } = await c.req.json();
if (!inviteCode || !name)
return c.json({ error: "inviteCode, name required" }, 400);
const fams = await pb.getList("fams", `inviteCode = '${inviteCode}'`);
const fam = fams.items?.[0];
if (!fam) return c.json({ error: "Invalid invite code" }, 404);
const deviceToken = crypto.randomUUID();
const result = await joinMemberFlow(fam.id, name, deviceToken);
return c.json({ famId: fam.id, famSlug: fam.slug, ...result });
} catch (err) {
return handleError(c, err);
}
});
app.post("/api/members/verify", async (c) => {
try {
const { famId, deviceToken } = await c.req.json();
if (!famId || !deviceToken)
return c.json({ error: "famId, deviceToken required" }, 400);
const hashHex = crypto
.createHash("sha256")
.update(deviceToken)
.digest("hex");
const members = await pb.getList(
"members",
`famId = '${famId}' && deviceToken = '${hashHex}'`,
);
const member = members.items?.[0];
if (!member) return c.json({ error: "Invalid device token" }, 401);
return c.json({
famId: member.famId,
memberId: member.id,
name: member.name,
});
} catch (err) {
return handleError(c, err);
}
});
app.post("/api/members/verify-token", async (c) => {
try {
const { deviceToken, famSlug } = await c.req.json();
if (!deviceToken || !famSlug)
return c.json({ error: "deviceToken, famSlug required" }, 400);
const hashHex = crypto
.createHash("sha256")
.update(deviceToken)
.digest("hex");
// Look the member up by its (unique) device token hash, then confirm the
// requested slug belongs to that member's own fam. Looking up by slug first
// is unsafe because slug isn't unique — duplicate fams (e.g. after dev↔prod
// store drift) would resolve to the wrong family and reject valid tokens.
const members = await pb.getList(
"members",
`deviceToken = '${hashHex}'`,
);
const member = members.items?.[0];
if (!member) return c.json({ error: "Invalid device token" }, 401);
const fams = await pb.getList("fams", `id = '${member.famId}'`);
const fam = fams.items?.[0];
if (!fam || fam.slug !== famSlug)
return c.json({ error: "Invalid device token" }, 401);
return c.json({
famId: member.famId,
memberId: member.id,
name: member.name,
color: member.color,
});
} catch (err) {
return handleError(c, err);
}
});
app.patch("/api/members/me", requireDeviceToken, async (c) => {
app.patch("/api/members/me", requireMember, async (c) => {
try {
const body = await c.req.json();
const memberId = c.get("memberId");
@@ -379,7 +263,7 @@ app.patch("/api/members/me", requireDeviceToken, async (c) => {
if (body.color) update.color = body.color;
if (!Object.keys(update).length)
return c.json({ error: "Nothing to update" }, 400);
const record = await pb.update("members", memberId, update);
const record = await pb.update("users", memberId, update);
return c.json({ id: record.id, name: record.name, color: record.color });
} catch (err) {
return handleError(c, err);
@@ -435,7 +319,10 @@ app.delete("/api/admin/:famId/chore-templates/:id", requireAdmin, async (c) => {
app.get("/api/admin/:famId/members", requireAdmin, async (c) => {
try {
const { famId } = c.req.param();
const data = await pb.getList("members", `famId = '${famId}'`);
const data = await pb.getList(
"users",
`famId = '${famId}' && role = 'child'`,
);
return c.json(data.items);
} catch (err) {
return handleError(c, err);
@@ -446,7 +333,22 @@ app.post("/api/admin/:famId/members", requireAdmin, async (c) => {
try {
const { famId } = c.req.param();
const body = await c.req.json();
const record = await pb.create("members", { famId, ...body });
const fam = (await pb.getList("fams", `id = '${famId}'`)).items?.[0];
if (!fam) return c.json({ error: "Fam not found" }, 404);
const handleName = handle(body.name || body.username || "");
if (!handleName) return c.json({ error: "username required" }, 400);
const username = famUsername(fam.slug, handleName);
const password = `${MEMBER_SECRET}${fam.slug}${handleName}`;
const record = await pb.create("users", {
famId,
role: "child",
username,
name: body.name || handleName,
color: body.color || "#6366f1",
emailVisibility: false,
password,
passwordConfirm: password,
});
return c.json(record);
} catch (err) {
return handleError(c, err);
@@ -457,7 +359,7 @@ app.patch("/api/admin/:famId/members/:id", requireAdmin, async (c) => {
try {
const { famId, id } = c.req.param();
const body = await c.req.json();
const record = await pb.update("members", id, body);
const record = await pb.update("users", id, body);
return c.json(record);
} catch (err) {
return handleError(c, err);
@@ -467,7 +369,7 @@ app.patch("/api/admin/:famId/members/:id", requireAdmin, async (c) => {
app.delete("/api/admin/:famId/members/:id", requireAdmin, async (c) => {
try {
const { id } = c.req.param();
await pb.delete("members", id);
await pb.delete("users", id);
return c.json({ ok: true });
} catch (err) {
return handleError(c, err);
@@ -592,7 +494,6 @@ app.get("/api/admin/:famId/fam", requireAdmin, async (c) => {
return c.json({
name: fam.name,
slug: fam.slug,
inviteCode: fam.inviteCode,
payday: fam.payday,
paydayTime: fam.paydayTime || "18:00",
timezone: fam.timezone || "auto",
@@ -609,10 +510,7 @@ app.patch("/api/admin/:famId/fam", requireAdmin, async (c) => {
if (body.name !== undefined) {
const name = body.name;
if (!name) return c.json({ error: "name required" }, 400);
const slug = name
.toLowerCase()
.replace(/\s+/g, "-")
.replace(/[^a-z0-9-]/g, "");
const slug = slugify(name);
const record = await pb.update("fams", famId, { name, slug });
return c.json({
name: record.name,
@@ -667,20 +565,6 @@ app.get("/api/admin/:famId/verify", requireAdmin, async (c) => {
return c.json({ verified: true });
});
app.post("/api/admin/:famId/regen-invite", requireAdmin, async (c) => {
try {
const { famId } = c.req.param();
const inviteCode = Math.random().toString(36).substring(2, 8).toUpperCase();
const fams = await pb.getList("fams", `id = '${famId}'`);
const fam = fams.items?.[0];
if (!fam) return c.json({ error: "Fam not found" }, 404);
await pb.update("fams", famId, { inviteCode });
return c.json({ inviteCode });
} catch (err) {
return handleError(c, err);
}
});
// ── Admin: Weekly Summary ──────────────────────────────
app.get("/api/admin/:famId/weekly-summary", requireAdmin, async (c) => {
@@ -690,7 +574,7 @@ app.get("/api/admin/:famId/weekly-summary", requireAdmin, async (c) => {
const tz = await getFamTimezone(famId);
const ws = weekStart(payday, tz);
const [members, assigned, completions] = await Promise.all([
pb.getList("members", `famId = '${famId}'`),
pb.getList("users", `famId = '${famId}' && role = 'child'`),
pb.getList("assigned_chores", `famId = '${famId}'`),
pb.getList("completions", `famId = '${famId}' && date >= '${ws}'`),
]);
@@ -854,7 +738,7 @@ app.get("/api/admin/:famId/debug/eow-preview", requireAdmin, async (c) => {
const [members, assigned, completions, configs, rewards] =
await Promise.all([
pb.getList("members", `famId = '${famId}'`),
pb.getList("users", `famId = '${famId}' && role = 'child'`),
pb.getList("assigned_chores", `famId = '${famId}'`),
pb.getList("completions", `famId = '${famId}' && date >= '${ws}'`),
pb
@@ -1183,7 +1067,7 @@ app.get("/api/admin/:famId/bonus-configs/progress", requireAdmin, async (c) => {
);
} catch {}
const [members, assigned, completions] = await Promise.all([
pb.getList("members", `famId = '${famId}'`),
pb.getList("users", `famId = '${famId}' && role = 'child'`),
pb.getList("assigned_chores", `famId = '${famId}'`),
pb.getList("completions", `famId = '${famId}'`),
]);
@@ -1310,7 +1194,7 @@ async function evaluateFam(famId: string): Promise<void> {
if (!configs.length) return;
const [allMembers, allAssigned, allCompletions] = await Promise.all([
pb.getList("members", `famId = '${famId}'`),
pb.getList("users", `famId = '${famId}' && role = 'child'`),
pb.getList("assigned_chores", `famId = '${famId}'`),
pb.getList("completions", `famId = '${famId}'`),
]);
@@ -1581,7 +1465,10 @@ app.post(
`famId = '${famId}' && bonusConfigId = '${cfg.id}'`,
);
const members = await pb.getList("members", `famId = '${famId}'`);
const members = await pb.getList(
"users",
`famId = '${famId}' && role = 'child'`,
);
const targetMembers: any[] = [];
if (cfg.target === "competitive" || cfg.target === "collaborative") {
@@ -1672,7 +1559,7 @@ app.post(
// ── Member: Completion Toggle ────────────────────────────
app.post("/api/completions/toggle", requireDeviceToken, async (c) => {
app.post("/api/completions/toggle", requireMember, async (c) => {
try {
const famId = c.get("famId");
const memberId = c.get("memberId");
@@ -1737,19 +1624,11 @@ app.post(
// ── Member: Get seasons ─────────────────────────────────
app.get("/api/members/seasons", async (c) => {
app.get("/api/members/seasons", requireMember, async (c) => {
try {
const deviceToken = c.req.header("x-device-token");
if (!deviceToken) return c.json({ error: "x-device-token required" }, 400);
const hashHex = crypto
.createHash("sha256")
.update(deviceToken)
.digest("hex");
const members = await pb.getList("members", `deviceToken = '${hashHex}'`);
const member = members.items?.[0];
if (!member) return c.json({ error: "Invalid device token" }, 401);
const seasons = await pb.getList("seasons", `famId = '${member.famId}'`);
return c.json({ seasons: seasons.items, famId: member.famId });
const famId = c.get("famId");
const seasons = await pb.getList("seasons", `famId = '${famId}'`);
return c.json({ seasons: seasons.items, famId });
} catch (err) {
return handleError(c, err);
}
@@ -1757,7 +1636,7 @@ app.get("/api/members/seasons", async (c) => {
// ── Member: Get chores (for kanban) ──────────────────────
app.post("/api/members/my-chores", requireDeviceToken, async (c) => {
app.post("/api/members/my-chores", requireMember, async (c) => {
try {
const famId = c.get("famId");
const memberId = c.get("memberId");
@@ -1926,56 +1805,56 @@ app.post(
);
app.get("/api/admin/:famId/profile", requireAdmin, async (c) => {
try {
const { famId } = c.req.param();
const userId = c.req.header("x-session-userid");
const admins = await pb.getList(
"fam_admins",
`famId = '${famId}' && userId = '${userId}'`,
);
const admin = admins.items?.[0];
if (!admin) return c.json({ error: "Admin not found" }, 404);
return c.json({
id: admin.id,
name: admin.name,
color: admin.color,
email: admin.email || "",
});
} catch (err) {
return handleError(c, err);
}
});
try {
const { famId } = c.req.param();
const userId = c.req.header("x-session-userid");
const parents = await pb.getList(
"users",
`famId = '${famId}' && role = 'parent' && id = '${userId}'`,
);
const parent = parents.items?.[0];
if (!parent) return c.json({ error: "Admin not found" }, 404);
return c.json({
id: parent.id,
name: parent.name || "",
color: parent.color || "#6366f1",
email: parent.email || "",
});
} catch (err) {
return handleError(c, err);
}
});
app.patch("/api/admin/:famId/profile", requireAdmin, async (c) => {
try {
const { famId } = c.req.param();
const userId = c.req.header("x-session-userid");
const body = await c.req.json();
const admins = await pb.getList(
"fam_admins",
`famId = '${famId}' && userId = '${userId}'`,
);
const admin = admins.items?.[0];
if (!admin) return c.json({ error: "Admin not found" }, 404);
const update: Record<string, string> = {};
if (body.name) update.name = body.name;
if (body.color) update.color = body.color;
if (body.email !== undefined) update.email = body.email;
const record = await pb.update("fam_admins", admin.id, update);
return c.json({
id: record.id,
name: record.name,
color: record.color,
email: record.email || "",
});
} catch (err) {
return handleError(c, err);
}
});
app.patch("/api/admin/:famId/profile", requireAdmin, async (c) => {
try {
const { famId } = c.req.param();
const userId = c.req.header("x-session-userid");
const body = await c.req.json();
const parents = await pb.getList(
"users",
`famId = '${famId}' && role = 'parent' && id = '${userId}'`,
);
const parent = parents.items?.[0];
if (!parent) return c.json({ error: "Admin not found" }, 404);
const update: Record<string, string> = {};
if (body.name) update.name = body.name;
if (body.color) update.color = body.color;
if (body.email !== undefined) update.email = body.email;
const record = await pb.update("users", parent.id, update);
return c.json({
id: record.id,
name: record.name || "",
color: record.color || "#6366f1",
email: record.email || "",
});
} catch (err) {
return handleError(c, err);
}
});
// ── Member: Claim a reward ─────────────────────────────
app.post("/api/members/rewards/:id/claim", requireDeviceToken, async (c) => {
app.post("/api/members/rewards/:id/claim", requireMember, async (c) => {
try {
const { id } = c.req.param();
const famId = c.get("famId");
@@ -2008,7 +1887,7 @@ app.post("/api/members/rewards/:id/claim", requireDeviceToken, async (c) => {
// ── Member: Request all unclaimed rewards ────────────────
app.post("/api/members/rewards/request-all", requireDeviceToken, async (c) => {
app.post("/api/members/rewards/request-all", requireMember, async (c) => {
try {
const famId = c.get("famId");
const memberId = c.get("memberId");
@@ -2064,7 +1943,10 @@ async function releaseWeek(famId: string) {
if (fam.lastIssued === wsToday) return { settled: false, weekStart: wsToday };
const members = await pb.getList("members", `famId = '${famId}'`);
const members = await pb.getList(
"users",
`famId = '${famId}' && role = 'child'`,
);
let cashRewards: any = { items: [] };
try {
cashRewards = await pb.getList(
@@ -2118,21 +2000,26 @@ async function authorizeFamReq(c: any): Promise<string | null> {
const sessFam = c.req.header("x-session-famid");
const sessUser = c.req.header("x-session-userid");
if (sessFam && sessUser) {
const admins = await pb.getList(
"fam_admins",
`famId = '${sessFam}' && userId = '${sessUser}'`,
const parents = await pb.getList(
"users",
`famId = '${sessFam}' && role = 'parent' && id = '${sessUser}'`,
);
if (admins.items?.length) return sessFam;
if (parents.items?.length) return sessFam;
}
const devToken = c.req.header("x-device-token");
const devFam = c.req.header("x-device-famid");
if (devFam && devToken) {
const hashHex = crypto.createHash("sha256").update(devToken).digest("hex");
const members = await pb.getList(
"members",
`famId = '${devFam}' && deviceToken = '${hashHex}'`,
const auth = c.req.header("Authorization") || "";
const token = auth.startsWith("Bearer ")
? auth.slice(7)
: c.req.header("x-pb-token");
if (token) {
const res = await fetch(
`${PB_ENDPOINT}/api/collections/users/auth-refresh`,
{ method: "POST", headers: { Authorization: `Bearer ${token}` } },
);
if (members.items?.length) return devFam;
if (res.ok) {
const body = await res.json().catch(() => ({}));
const record = body?.record;
if (record?.famId) return record.famId;
}
}
return null;
}
@@ -2162,7 +2049,7 @@ app.post("/api/admin/:famId/complete-week", requireAdmin, async (c) => {
// Fetch data for summary
const [members, assigned, completions] = await Promise.all([
pb.getList("members", `famId = '${famId}'`),
pb.getList("users", `famId = '${famId}' && role = 'child'`),
pb.getList("assigned_chores", `famId = '${famId}'`),
pb.getList("completions", `famId = '${famId}' && date >= '${ws}'`),
]);
@@ -2252,7 +2139,7 @@ app.post("/api/admin/:famId/debug/generate-data", requireAdmin, async (c) => {
const days = body.days || 7;
const [members, templates] = await Promise.all([
pb.getList("members", `famId = '${famId}'`),
pb.getList("users", `famId = '${famId}' && role = 'child'`),
pb.getList("chore_templates", `famId = '${famId}'`),
]);
@@ -2329,42 +2216,46 @@ async function resolveChatActor(c: any) {
const hsFamId = c.req.header("x-session-famid");
const hsUserId = c.req.header("x-session-userid");
if (hsFamId && hsUserId) {
const admins = await pb.getList(
"fam_admins",
`famId = '${hsFamId}' && userId = '${hsUserId}'`,
const parents = await pb.getList(
"users",
`famId = '${hsFamId}' && role = 'parent' && id = '${hsUserId}'`,
);
const admin = admins.items?.[0];
if (admin) {
const parent = parents.items?.[0];
if (parent) {
return {
famId: hsFamId,
actor: {
id: admin.id,
id: parent.id,
type: "admin",
name: admin.name,
color: admin.color,
name: parent.name || "",
color: parent.color || "#6366f1",
},
};
}
}
const famId = c.req.header("x-device-famid");
const deviceToken = c.req.header("x-device-token");
if (famId && deviceToken) {
const hash = crypto.createHash("sha256").update(deviceToken).digest("hex");
const members = await pb.getList(
"members",
`famId = '${famId}' && deviceToken = '${hash}'`,
const auth = c.req.header("Authorization") || "";
const token = auth.startsWith("Bearer ")
? auth.slice(7)
: c.req.header("x-pb-token");
if (token) {
const res = await fetch(
`${PB_ENDPOINT}/api/collections/users/auth-refresh`,
{ method: "POST", headers: { Authorization: `Bearer ${token}` } },
);
const member = members.items?.[0];
if (member) {
return {
famId,
actor: {
id: member.id,
type: "member",
name: member.name,
color: member.color,
},
};
if (res.ok) {
const body = await res.json().catch(() => ({}));
const user = body?.record;
if (user?.famId) {
return {
famId: user.famId,
actor: {
id: user.id,
type: user.role === "child" ? "member" : "admin",
name: user.name || user.username || "",
color: user.color || "",
},
};
}
}
}
return null;
+567
View File
@@ -75,6 +75,10 @@ async function ensureSchema(): Promise<void> {
console.log("[migrate] Bootstrapping base schema on fresh PocketBase...");
const ids: Record<string, string> = {};
// `users` is PocketBase's native auth collection (created on first boot),
// not part of SCHEMA_PLAN. Pre-register its id so relations can point at it.
const nativeUsers = await getCollection("users");
if (nativeUsers) ids.users = nativeUsers.id;
for (const entry of SCHEMA_PLAN) {
const createdId = await createCollection(entry.build(ids));
if (createdId) ids[entry.name] = createdId;
@@ -1520,5 +1524,568 @@ export async function migrate(): Promise<void> {
console.log(` ↳ fams collection not found — chat collections deferred`);
}
// ── 26. Add famId + role to the users auth collection ──
// Admin identity now lives on the auth record so PB rules can scope via
// @request.auth.famId. role defaults to "parent" (only admins log in for now;
// children become a separate auth collection in the membership phase).
{
const usersCol = await getCollection("users");
if (usersCol) {
const famsCol2 = await getCollection("fams");
const hasFamId = usersCol.fields.some((f: any) => f.name === "famId");
const hasRole = usersCol.fields.some((f: any) => f.name === "role");
if (!hasFamId || !hasRole) {
console.log("[migrate] Adding famId/role to users collection...");
if (!hasFamId && famsCol2) {
usersCol.fields.push({
name: "famId",
type: "relation",
required: false,
collectionId: famsCol2.id,
maxSelect: 1,
cascadeDelete: false,
});
}
if (!hasRole) {
usersCol.fields.push({
name: "role",
type: "select",
required: false,
values: ["parent", "child"],
maxSelect: 1,
});
}
await updateCollection(usersCol.id, {
name: "users",
type: "auth",
listRule: usersCol.listRule,
viewRule: usersCol.viewRule,
createRule: usersCol.createRule,
updateRule: usersCol.updateRule,
deleteRule: usersCol.deleteRule,
fields: usersCol.fields,
});
console.log(" ✓ users.famId/role added");
} else {
console.log(" ↳ users.famId/role already present");
}
}
}
// ── 27. Backfill users.famId from fam_admins ──
{
const usersCol = await getCollection("users");
if (usersCol) {
const hasFamId = usersCol.fields.some((f: any) => f.name === "famId");
if (hasFamId) {
try {
const t = await auth();
const adminsRes = await fetch(
`${PB_ENDPOINT}/api/collections/fam_admins/records?perPage=1000`,
{ headers: { Authorization: `Bearer ${t}` } },
);
const adminsData = await adminsRes.json();
let count = 0;
for (const a of adminsData?.items || []) {
const u = await fetch(
`${PB_ENDPOINT}/api/collections/users/records/${a.userId}`,
{ headers: { Authorization: `Bearer ${t}` } },
);
if (!u.ok) continue;
const user = await u.json();
if (!user.famId) {
await fetch(
`${PB_ENDPOINT}/api/collections/users/records/${user.id}`,
{
method: "PATCH",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${t}`,
},
body: JSON.stringify({ famId: a.famId, role: "parent" }),
},
);
count++;
}
}
if (count > 0)
console.log(` ✓ Backfilled users.famId/role for ${count} users`);
else console.log(" ↳ users.famId already backfilled");
} catch (e) {
console.log(
" ↳ users.famId backfill skipped:",
e instanceof Error ? e.message : e,
);
}
}
}
}
// ── 28. Lock family-scoped WRITE rules to the caller's famId ──
// Replaces the old "superuser-only writes via Hono" model. Once SvelteKit
// writes as the authenticated user, PB itself enforces famId scoping — no
// more internet CRUD (anonymous `@request.auth` is null → rule fails).
// Reads stay public until children become authenticated (membership phase).
{
const WRITE_RULE = "@request.body.famId = @request.auth.famId";
const SCOPED_RULE = "famId = @request.auth.famId";
const WRITE_SCOPED_COLLECTIONS = [
"members",
"chore_templates",
"assigned_chores",
"completions",
"bonus_configs",
"bonus_templates",
"rewards",
"seasons",
"settings",
"weekly_history",
"monthly_bonuses",
"messages",
"chat_typing",
];
for (const name of WRITE_SCOPED_COLLECTIONS) {
const c = await getCollection(name);
if (!c) continue;
if (
c.createRule === WRITE_RULE &&
c.updateRule === SCOPED_RULE &&
c.deleteRule === SCOPED_RULE
) {
continue;
}
await updateCollection(c.id, {
name,
type: c.type,
listRule: c.listRule,
viewRule: c.viewRule,
createRule: WRITE_RULE,
updateRule: SCOPED_RULE,
deleteRule: SCOPED_RULE,
fields: c.fields,
});
console.log(` ↳ Locked ${name} write rules to famId scoping`);
}
}
// ── 28b. Allow each admin to UPDATE their own fam record ──
// fams is the root collection: its record id IS the famId, and it has no
// famId field pointing to itself. So the scoping rule compares the record id
// to the caller's famId. Reads + create/delete stay superuser-only.
{
const c = await getCollection("fams");
if (c && c.updateRule !== "id = @request.auth.famId") {
await updateCollection(c.id, {
name: "fams",
type: c.type,
listRule: c.listRule,
viewRule: c.viewRule,
createRule: c.createRule,
updateRule: "id = @request.auth.famId",
deleteRule: c.deleteRule,
fields: c.fields,
});
console.log(" ↳ fams.updateRule scoped to own record (id = @request.auth.famId)");
}
}
// ── 29. Add username identity to the users auth collection ──
// Members now live in `users` alongside admins. They never type a password;
// OTP is the gate. username is registered as a password-auth IDENTITY so the
// server can authWithPassword(username, derivedPassword) at join time — the
// password is derived from (MEMBER_SECRET + famSlug + username), never
// user-supplied. email is made optional (admins log in via email; members use
// username only and have no email). In PB v0.23+ an identity field must have
// a single-column UNIQUE index AND be listed in passwordAuth.identityFields.
{
const usersCol = await getCollection("users");
if (usersCol) {
const famsCol2 = await getCollection("fams");
const hasUsername = usersCol.fields.some((f: any) => f.name === "username");
// email: required → optional (members have no email)
const emailField = usersCol.fields.find((f: any) => f.name === "email");
if (emailField && emailField.required) {
emailField.required = false;
}
if (!hasUsername && famsCol2) {
usersCol.fields.push({ name: "username", type: "text", required: true });
}
// ensure a UNIQUE index on username exists (identity requirement)
let indexes = usersCol.indexes || [];
const hasUsernameIdx = indexes.some((i: string) => /username/i.test(i));
if (!hasUsernameIdx) {
indexes = [
...indexes,
"CREATE UNIQUE INDEX `idx_username__users` ON `users` (`username`) WHERE `username` != ''",
];
}
// register username as a password-auth identity field
const pwAuth = usersCol.passwordAuth || { enabled: true, identityFields: ["email"] };
const identityFields = Array.isArray(pwAuth.identityFields)
? pwAuth.identityFields
: ["email"];
if (!identityFields.includes("username")) identityFields.push("username");
await updateCollection(usersCol.id, {
name: "users",
type: "auth",
listRule: usersCol.listRule,
viewRule: usersCol.viewRule,
createRule: usersCol.createRule,
updateRule: usersCol.updateRule,
deleteRule: usersCol.deleteRule,
fields: usersCol.fields,
indexes,
passwordAuth: { enabled: true, identityFields },
});
console.log(" ✓ users: email optional, username auth identity");
}
}
// ── 30. user_configs: identity + OTP store (superuser-only) ──
// Holds the rotating one-time code, colour, and the OTP-issue timestamp used
// for the 20-minute window. Sensitive (OTPs) → not public; read/written via
// createSuperClient in SvelteKit. `created`/`updated` are PB built-ins; the
// manual `updatedAt` is written ONLY on OTP (re)issue so the window stays
// accurate (colour edits must not bump it). userId links to the users auth
// record so each child's config is uniquely addressable.
{
if (!(await getCollection("user_configs"))) {
const famsCol = await getCollection("fams");
const usersCol = await getCollection("users");
if (!famsCol || !usersCol) throw new Error("fams/users collection not found");
console.log("[migrate] Creating user_configs collection...");
await createCollection({
name: "user_configs",
type: "base",
listRule: null,
viewRule: null,
createRule: null,
updateRule: null,
deleteRule: null,
fields: [
{
name: "famId",
type: "relation",
required: true,
collectionId: famsCol.id,
maxSelect: 1,
cascadeDelete: false,
},
{
name: "userId",
type: "relation",
required: true,
collectionId: usersCol.id,
maxSelect: 1,
cascadeDelete: false,
},
{ name: "otp", type: "text", required: false },
{ name: "colour", type: "text", required: false },
{ name: "updatedAt", type: "text", required: false },
],
});
} else {
console.log(" ↳ user_configs already exists");
}
}
// ── 31. Add name + color to users (child display fields) ──
// Children are now `users` records; admin views (weekly summary, ledger,
// bonus progress, kanban) render name/color from the users record directly
// instead of a separate members row.
{
const usersCol = await getCollection("users");
if (usersCol) {
const needName = !usersCol.fields.some((f: any) => f.name === "name");
const needColor = !usersCol.fields.some((f: any) => f.name === "color");
if (needName || needColor) {
console.log("[migrate] Adding name/color to users collection...");
if (needName)
usersCol.fields.push({ name: "name", type: "text", required: false });
if (needColor)
usersCol.fields.push({ name: "color", type: "text", required: false });
await updateCollection(usersCol.id, {
name: "users",
type: "auth",
listRule: usersCol.listRule,
viewRule: usersCol.viewRule,
createRule: usersCol.createRule,
updateRule: usersCol.updateRule,
deleteRule: usersCol.deleteRule,
fields: usersCol.fields,
});
console.log(" ✓ users.name/color added");
// Backfill display fields for existing child users (created before the
// name/color fields existed).
try {
const t = await auth();
const childRes = await fetch(
`${PB_ENDPOINT}/api/collections/users/records?perPage=1000&filter=${encodeURIComponent(
"role = 'child'",
)}`,
{ headers: { Authorization: `Bearer ${t}` } },
);
const childData = await childRes.json();
for (const u of childData?.items || []) {
if (!u.name || !u.color) {
await fetch(`${PB_ENDPOINT}/api/collections/users/records/${u.id}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${t}`,
},
body: JSON.stringify({
name: u.name || u.username || "",
color: u.color || "#6366f1",
}),
});
}
}
if ((childData?.items || []).length)
console.log(" ↳ Backfilled child users name/color");
} catch (e) {
console.log(
" ↳ child name/color backfill skipped:",
e instanceof Error ? e.message : e,
);
}
} else {
console.log(" ↳ users.name/color already present");
}
}
}
// ── 31b. Backfill parent role on users ──
// Legacy parent users were created before users.role existed (or before it
// was set), leaving role empty. The app falls back `role || 'parent'`, but we
// normalize it here so records are self-consistent.
{
const t = await auth();
const headers = {
Authorization: `Bearer ${t}`,
"Content-Type": "application/json",
};
try {
const res = await fetch(
`${PB_ENDPOINT}/api/collections/users/records?perPage=1000&filter=${encodeURIComponent(
"role = ''",
)}`,
{ headers },
);
const data = await res.json();
for (const u of data?.items || []) {
await fetch(`${PB_ENDPOINT}/api/collections/users/records/${u.id}`, {
method: "PATCH",
headers,
body: JSON.stringify({ role: "parent" }),
});
}
if ((data?.items || []).length)
console.log(` ↳ Backfilled ${data.items.length} users -> role=parent`);
} catch (e) {
console.log(
" ↳ parent role backfill skipped:",
e instanceof Error ? e.message : e,
);
}
}
// ── 32. Repoint memberId relations from members -> users ──
// Every child-scoped collection's memberId field now points at the users
// auth collection (children live there as role='child'). Must run before
// the members collection is deleted (a collection referenced by a relation
// field cannot be removed). PB (v0.39) forbids changing a relation field's
// target collection in place, so we drop the field and re-add it targeting
// users in two separate collection updates.
{
const usersCol = await getCollection("users");
const membersCol = await getCollection("members");
if (usersCol && membersCol) {
for (const name of [
"assigned_chores",
"completions",
"rewards",
"weekly_history",
"bonus_configs",
]) {
const c = await getCollection(name);
if (!c) continue;
const f = c.fields.find((x: any) => x.name === "memberId");
if (f && f.collectionId === membersCol.id) {
const base = {
name,
type: c.type,
listRule: c.listRule,
viewRule: c.viewRule,
createRule: c.createRule,
updateRule: c.updateRule,
deleteRule: c.deleteRule,
};
const without = c.fields.filter((x: any) => x.name !== "memberId");
// 1) drop memberId
await updateCollection(c.id, { ...base, fields: without });
// 2) re-add memberId pointing at users
const withUsers = without.concat({
name: "memberId",
type: "relation",
required: false,
collectionId: usersCol.id,
cascadeDelete: false,
minSelect: 0,
maxSelect: 1,
});
await updateCollection(c.id, { ...base, fields: withUsers });
console.log(` ↳ Repointed ${name}.memberId -> users`);
} else {
console.log(` ↳ ${name}.memberId already -> users`);
}
}
} else if (usersCol) {
console.log(" ↳ members already gone; memberId rels unchanged");
}
}
// ── 32b. Scope users read/write rules for the child model ──
// Children live in `users`. Family reads (admin famStore, kanban, loads) run
// as the authenticated user via pbUser/pb.authStore, so the collection needs
// list/view rules keyed to the caller's famId. Creating children stays
// superuser-only (issueAccess/createChild use createSuperClient); parents may
// update/delete their own fam's child users via pbUser.
{
const usersCol = await getCollection("users");
if (usersCol) {
const listRule = "famId = @request.auth.famId";
const parentWrite = "famId = @request.auth.famId && @request.auth.role = 'parent'";
if (
usersCol.listRule !== listRule ||
usersCol.viewRule !== listRule ||
usersCol.updateRule !== parentWrite ||
usersCol.deleteRule !== parentWrite
) {
console.log("[migrate] Scoping users read/write rules...");
await updateCollection(usersCol.id, {
name: "users",
type: "auth",
listRule,
viewRule: listRule,
createRule: usersCol.createRule,
updateRule: parentWrite,
deleteRule: parentWrite,
fields: usersCol.fields,
});
console.log(" ↳ users rules: list/view = famId scope, parent write");
} else {
console.log(" ↳ users rules already scoped");
}
}
}
// ── 33. Delete legacy members collection + stale child-scoped data ──
// Old member rows and the data keyed to them are not preserved (accounts
// won't be reused). Wipe child-scoped rows whose memberId pointed at the old
// members rows, clear bonus_configs member targets, then drop the collection.
{
const membersCol = await getCollection("members");
if (membersCol) {
console.log("[migrate] Removing legacy members collection + stale data...");
const t = await auth();
const headers = { Authorization: `Bearer ${t}` };
const wipeCollection = async (name: string) => {
let page = 0;
for (;;) {
const res = await fetch(
`${PB_ENDPOINT}/api/collections/${name}/records?perPage=100&page=${page + 1}`,
{ headers },
);
const data = await res.json();
const items: any[] = data?.items || [];
if (!items.length) break;
for (const r of items) {
await fetch(
`${PB_ENDPOINT}/api/collections/${name}/records/${r.id}`,
{ method: "DELETE", headers },
);
}
if (items.length < 100) break;
page++;
}
};
for (const name of [
"assigned_chores",
"completions",
"rewards",
"weekly_history",
]) {
await wipeCollection(name);
}
console.log(" ↳ Wiped stale child-scoped data");
const cfgRes = await fetch(
`${PB_ENDPOINT}/api/collections/bonus_configs/records?perPage=200`,
{ headers },
);
const cfgData = await cfgRes.json();
for (const cfg of cfgData?.items || []) {
if (cfg.memberId) {
await fetch(
`${PB_ENDPOINT}/api/collections/bonus_configs/records/${cfg.id}`,
{
method: "PATCH",
headers: { ...headers, "Content-Type": "application/json" },
body: JSON.stringify({ memberId: null }),
},
);
}
}
console.log(" ↳ Cleared bonus_configs.memberId targets");
await fetch(`${PB_ENDPOINT}/api/collections/${membersCol.id}`, {
method: "DELETE",
headers,
});
console.log(" ✓ members collection deleted");
} else {
console.log(" ↳ members already removed");
}
}
// ── 34. Drop legacy fam_admins collection + unused fams.inviteCode ──
// Parent identity now lives entirely on the `users` record (famId, role,
// name, color, email); the join flow is OTP-based, so inviteCode is unused.
{
const adminsCol = await getCollection("fam_admins");
if (adminsCol) {
const t = await auth();
const headers = { Authorization: `Bearer ${t}` };
await fetch(`${PB_ENDPOINT}/api/collections/${adminsCol.id}`, {
method: "DELETE",
headers,
});
console.log(" ✓ fam_admins collection deleted");
} else {
console.log(" ↳ fam_admins already removed");
}
const famsCol = await getCollection("fams");
if (famsCol && famsCol.fields.some((f: any) => f.name === "inviteCode")) {
famsCol.fields = famsCol.fields.filter(
(f: any) => f.name !== "inviteCode",
);
await updateCollection(famsCol.id, {
name: "fams",
type: "base",
listRule: famsCol.listRule || "",
viewRule: famsCol.viewRule || "",
createRule: famsCol.createRule,
updateRule: famsCol.updateRule,
deleteRule: famsCol.deleteRule,
fields: famsCol.fields,
});
console.log(" ✓ fams.inviteCode field removed");
}
}
console.log("[migrate] Done");
}