health: drop hair_progress; owner = Stan's account email only
All checks were successful
Deploy production / deploy (push) Successful in 2m22s

Regenerated the (not yet deployed) health migration without hair_progress.
This commit is contained in:
2026-10-24 00:22:24 +00:00
parent 8275f1fb22
commit 8aa689af64
10 changed files with 50 additions and 268 deletions

View File

@@ -111,20 +111,7 @@ export const healthTables = {
notes
]
},
hair_progress: {
label: 'Hair progress',
fields: [
date,
{
key: 'photo_ref',
label: 'Photo key (private storage, not a URL)',
type: 'text',
maxLength: 500,
placeholder: 'health/hair/2026-10-24-top.jpg'
},
notes
]
},
appointments: {
label: 'Appointments & conditions',
fields: [

View File

@@ -158,26 +158,6 @@ export const healthMedications = healthSchema.table(
]
);
export const healthHairProgress = healthSchema.table(
'hair_progress',
{
id: pk(),
date: day().notNull(),
// Key/path into private storage (never a public URL), e.g. "health/hair/2026-10-24-top.jpg".
photoRef: text('photo_ref'),
notes: text('notes'),
...timestamps
},
(t) => [
index('hair_progress_date_idx').on(t.date),
check(
'hair_progress_photo_ref_check',
sql`${t.photoRef} IS NULL OR (${t.photoRef} !~* '^[a-z][a-z0-9+.-]*://' AND ${t.photoRef} !~ '^//')`
),
check('hair_progress_date_check', sane('date'))
]
);
export const healthAppointments = healthSchema.table(
'appointments',
{

View File

@@ -25,9 +25,6 @@ describe('health import', () => {
expect(cleanRow('workouts', { date: '2026-01-02', exercise: 'squat', bogus: 1 }).ok).toBe(
false
);
expect(cleanRow('hair_progress', { date: '2026-01-02', photo_ref: 'https://x/y.jpg' }).ok).toBe(
false
);
expect(
cleanRow('vaccinations', { vaccine: 'X', date: '2026-01-02', country: 'nl' })
).toMatchObject({ ok: true, row: { values: { country: 'NL' } } });

View File

@@ -6,7 +6,6 @@ import type { PgAsyncDatabase } from 'drizzle-orm/pg-core';
import {
healthAppointments,
healthBodyMeasurements,
healthHairProgress,
healthMedications,
healthNutritionDaily,
healthSleep,
@@ -22,7 +21,6 @@ export const drizzleTables = {
nutrition_daily: healthNutritionDaily,
sleep: healthSleep,
medications: healthMedications,
hair_progress: healthHairProgress,
appointments: healthAppointments
} as const;
@@ -184,12 +182,6 @@ export function cleanRow(table: HealthTable, input: Row): RowResult {
return { ok: false, error: 'stop_date is before start_date' };
}
if (table === 'appointments') v.type ??= 'appointment';
if (
table === 'hair_progress' &&
typeof v.photo_ref === 'string' &&
/^([a-z][a-z0-9+.-]*:|\/\/)/i.test(v.photo_ref)
)
return { ok: false, error: 'photo_ref must be a private storage key/path, not a URL' };
if (table === 'sleep') {
const day = String(v.date);
const wakeRaw = String(input.wake_time).trim();

View File

@@ -5,11 +5,10 @@ const event = (user: Partial<NonNullable<App.Locals['user']>> | null) =>
({ locals: { user, session: null } }) as unknown as { locals: App.Locals };
describe('owner gate', () => {
it('needs admin role and the owner email', () => {
it('is only the owner account', () => {
expect(isOwner(event(null))).toBe(false);
expect(isOwner(event({ role: 'admin', email: 'someone@example.com' }))).toBe(false);
expect(isOwner(event({ role: 'user', email: 'stan@stanrunge.dev' }))).toBe(false);
expect(isOwner(event({ role: 'admin', email: 'Stan@StanRunge.dev' }))).toBe(true);
expect(isOwner(event({ role: 'user', email: 'Stan@StanRunge.dev' }))).toBe(true);
});
it('matches owner-only paths', () => {
expect(isOwnerOnlyPath('/trackers/health')).toBe(true);

View File

@@ -1,15 +1,15 @@
import { error } from '@sveltejs/kit';
// The site has one owner (Stan). Owner-only data, such as the health tracker, needs both
// the admin role and the owner's account email, so granting someone else `admin` (e.g. to
// moderate Animaldex) never exposes it. Override with OWNER_EMAIL if the address changes.
const OWNER_EMAIL = (process.env.OWNER_EMAIL || 'stan@stanrunge.dev').toLowerCase();
// The site has exactly one owner: Stan's account (prod auth.user id tfn6OZDOpiQvMARIH65HQ2XJuaVvmGEe).
// Owner-only data, such as the health tracker, is gated on that account's email, not on
// a role, so other users (now or later, e.g. on a public tracking project) never see it.
// Override with OWNER_EMAIL if the address ever changes.
const OWNER_EMAIL = (process.env.OWNER_EMAIL || 'stan@stanrunge.dev').trim().toLowerCase();
type OwnerEvent = { locals: App.Locals };
export function isOwner(event: OwnerEvent): boolean {
const user = event.locals.user;
return !!user && user.role === 'admin' && user.email?.toLowerCase() === OWNER_EMAIL;
return event.locals.user?.email?.trim().toLowerCase() === OWNER_EMAIL;
}
/** Owner-only routes answer 404 to everyone else, so they don't reveal that they exist. */