health: drop hair_progress; owner = Stan's account email only
All checks were successful
Deploy production / deploy (push) Successful in 2m22s
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:
@@ -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: [
|
||||
|
||||
@@ -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',
|
||||
{
|
||||
|
||||
@@ -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' } } });
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user