Harden login and make personal-best records self-correct

Login: add an in-memory per-IP throttle (8 failed attempts -> 15-min lockout, 429 + Retry-After), raise the change-password minimum to 8 with a 72-char cap, and apply the same minimum on the StartOS Set Login Password action.

Records: add a record_floor column for manually-pinned bests plus recomputeRecord(); the live record is now the direction-aware best of the best logged value and the floor, recomputed on entry edit/delete so it can drop again (never below the floor). Settings exposes the floor as an override and shows the live best as a placeholder.

Bump package 0.1.6:0 -> 0.1.7:0 and the service-worker cache to v7.
This commit is contained in:
Keysat
2026-06-15 13:22:41 -05:00
parent bbddebc3d6
commit fe66575ffe
13 changed files with 125 additions and 28 deletions
+5 -4
View File
@@ -33,7 +33,7 @@ Run from repo root unless noted.
## Data model notes ## Data model notes
- Metrics live in `category_metrics`; `kind` is one of `count | duration | score | decimal`. Records are `track_record` (bool) + `record` (REAL) on the metric; bumped automatically when a logged value beats them (respecting `higher_is_better`). - Metrics live in `category_metrics`; `kind` is one of `count | duration | score | decimal`. Records are `track_record` (bool) + `record` (REAL, the live best) + `record_floor` (REAL, optional manually-pinned best) on the metric. `record` = the direction-aware best of the best logged value and `record_floor` (`src/records.js` `recomputeRecord`, respecting `higher_is_better`); it bumps up on logging and is **recomputed on entry edit/delete** so it can drop again, but never below `record_floor`. The Settings record field sets the floor.
- Entries: one row per logged session (`entries`, with `note`); metric readings in `entry_values`. - Entries: one row per logged session (`entries`, with `note`); metric readings in `entry_values`.
## Config (env-var names only — never hardcode secrets) ## Config (env-var names only — never hardcode secrets)
@@ -71,10 +71,11 @@ Run from repo root unless noted.
## Current state ## Current state
Live on StartOS (deploy host set in `~/.startos/config.yaml` `host:`, not in this repo) at **v0.1.6:0**; `make install` deploys and the PWA self-updates via the in-app banner. Pushed to self-hosted Gitea (`origin`). Live on StartOS (deploy host set in `~/.startos/config.yaml` `host:`, not in this repo) at **v0.1.7:0**; `make install` deploys and the PWA self-updates via the in-app banner. Pushed to self-hosted Gitea (`origin`).
- **Working**: daily logging, weekly planning, goals + thermometer, dashboard (streak calendar, radar, line/series charts, records), personal-best records (auto + manual set), per-session notes, EPA Max/Weighted Speed, tap-to-type number fields, full category/metric management in Settings, "Set Login Password" action. - **Working**: daily logging, weekly planning, goals + thermometer, dashboard (streak calendar, radar, line/series charts, records), personal-best records (auto + manual floor, self-correcting on edit/delete), per-session notes, EPA Max/Weighted Speed, tap-to-type number fields, full category/metric management in Settings, "Set Login Password" action. Login is rate-limited (per-IP, 8 fails → 15-min lockout) and the password minimum is 8 chars.
- **In progress**: none — all requested features are built, committed, and deployed. - **In progress**: none — all requested features are built, committed, and deployed.
- **Decided, not yet done**: reconcile in-app password change with the StartOS action (env wins on restart); optional "log another" for a second same-category session in a day. See `ROADMAP.md`. - **Decided, not yet done**: reconcile in-app password change with the StartOS action (env wins on restart); optional "log another" for a second same-category session in a day. See `ROADMAP.md`.
- **Known issues**: changing the password from the app's own Settings reverts on restart under StartOS — use the action. - **Known issues**: changing the password from the app's own Settings reverts on restart under StartOS — use the action.
- **Next steps**: (1) set a real login password via the "Set Login Password" action; (2) confirm speed unit (`mph` vs `km/h`); (3) decide whether to add a "log another" same-category session. - **Eval backlog**: a full evaluation lives in `EVALUATION.md` — remaining items include the `@fastify/static` upgrade, input-validation gaps (metric `kind`, calendar dates, FK 500), CSRF, and no test suite. Registry-submission blockers are intentionally parked (not publishing).
- **Next steps**: (1) set a real login password via the "Set Login Password" action; (2) confirm speed unit (`mph` vs `km/h`); (3) work the `EVALUATION.md` P2 backlog if desired.
+6 -2
View File
@@ -386,8 +386,12 @@ function openSettings() {
const mKind = h('select', {}, kindOptions()); mKind.value = m.kind; const mKind = h('select', {}, kindOptions()); mKind.value = m.kind;
const stepIn = h('input', { type: 'number', min: '1', value: String(m.step || 1), style: 'max-width:64px' }); const stepIn = h('input', { type: 'number', min: '1', value: String(m.step || 1), style: 'max-width:64px' });
const trackChk = h('input', { type: 'checkbox', ...(m.track_record ? { checked: true } : {}) }); const trackChk = h('input', { type: 'checkbox', ...(m.track_record ? { checked: true } : {}) });
const recordIn = h('input', { type: 'number', placeholder: 'record', value: m.record != null ? String(m.record) : '', // Manual personal-best override. Blank = follow logged data; the live record
style: 'max-width:90px', ...(m.track_record ? {} : { disabled: true }) }); // shows as the placeholder so a plain save never re-pins the current best.
const recordIn = h('input', { type: 'number',
placeholder: m.record != null ? `best: ${m.record}` : 'set best',
value: m.record_floor != null ? String(m.record_floor) : '',
style: 'max-width:110px', ...(m.track_record ? {} : { disabled: true }) });
trackChk.addEventListener('change', () => { recordIn.disabled = !trackChk.checked; }); trackChk.addEventListener('change', () => { recordIn.disabled = !trackChk.checked; });
const mSave = h('button', { class: 'btn-ghost', onclick: async () => { const mSave = h('button', { class: 'btn-ghost', onclick: async () => {
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'premier-gunner-v6'; const CACHE = 'premier-gunner-v7';
const SHELL = [ const SHELL = [
'/', '/index.html', '/login.html', '/', '/index.html', '/login.html',
'/css/styles.css', '/css/styles.css',
+3 -2
View File
@@ -8,12 +8,13 @@ const inputSpec = InputSpec.of({
password: Value.text({ password: Value.text({
name: i18n('Password'), name: i18n('Password'),
description: i18n( description: i18n(
'The password Gunner types on the login screen (at least 4 characters)', 'The password Gunner types on the login screen (at least 8 characters)',
), ),
required: true, required: true,
default: null, default: null,
masked: true, masked: true,
minLength: 4, minLength: 8,
maxLength: 72,
}), }),
}) })
+1 -1
View File
@@ -15,7 +15,7 @@ const dict = {
'Set Login Password': 6, 'Set Login Password': 6,
'Set the password Gunner uses to log in to Premier Gunner': 7, 'Set the password Gunner uses to log in to Premier Gunner': 7,
'Password': 8, 'Password': 8,
'The password Gunner types on the login screen (at least 4 characters)': 9, 'The password Gunner types on the login screen (at least 8 characters)': 9,
} as const } as const
/** /**
@@ -11,7 +11,7 @@ export default {
6: 'Establecer contraseña de acceso', 6: 'Establecer contraseña de acceso',
7: 'Establece la contraseña que Gunner usa para iniciar sesión en Premier Gunner', 7: 'Establece la contraseña que Gunner usa para iniciar sesión en Premier Gunner',
8: 'Contraseña', 8: 'Contraseña',
9: 'La contraseña que Gunner escribe en la pantalla de inicio de sesión (al menos 4 caracteres)', 9: 'La contraseña que Gunner escribe en la pantalla de inicio de sesión (al menos 8 caracteres)',
}, },
de_DE: { de_DE: {
0: 'Starte Premier Gunner!', 0: 'Starte Premier Gunner!',
@@ -23,7 +23,7 @@ export default {
6: 'Anmeldepasswort festlegen', 6: 'Anmeldepasswort festlegen',
7: 'Lege das Passwort fest, mit dem Gunner sich bei Premier Gunner anmeldet', 7: 'Lege das Passwort fest, mit dem Gunner sich bei Premier Gunner anmeldet',
8: 'Passwort', 8: 'Passwort',
9: 'Das Passwort, das Gunner auf dem Anmeldebildschirm eingibt (mindestens 4 Zeichen)', 9: 'Das Passwort, das Gunner auf dem Anmeldebildschirm eingibt (mindestens 8 Zeichen)',
}, },
pl_PL: { pl_PL: {
0: 'Uruchamianie Premier Gunner!', 0: 'Uruchamianie Premier Gunner!',
@@ -35,7 +35,7 @@ export default {
6: 'Ustaw hasło logowania', 6: 'Ustaw hasło logowania',
7: 'Ustaw hasło, którego Gunner używa do logowania w Premier Gunner', 7: 'Ustaw hasło, którego Gunner używa do logowania w Premier Gunner',
8: 'Hasło', 8: 'Hasło',
9: 'Hasło, które Gunner wpisuje na ekranie logowania (co najmniej 4 znaki)', 9: 'Hasło, które Gunner wpisuje na ekranie logowania (co najmniej 8 znaków)',
}, },
fr_FR: { fr_FR: {
0: 'Démarrage de Premier Gunner !', 0: 'Démarrage de Premier Gunner !',
@@ -47,6 +47,6 @@ export default {
6: 'Définir le mot de passe de connexion', 6: 'Définir le mot de passe de connexion',
7: 'Définissez le mot de passe que Gunner utilise pour se connecter à Premier Gunner', 7: 'Définissez le mot de passe que Gunner utilise pour se connecter à Premier Gunner',
8: 'Mot de passe', 8: 'Mot de passe',
9: "Le mot de passe que Gunner saisit sur l'écran de connexion (au moins 4 caractères)", 9: "Le mot de passe que Gunner saisit sur l'écran de connexion (au moins 8 caractères)",
}, },
} satisfies Record<string, LangDict> } satisfies Record<string, LangDict>
+6 -6
View File
@@ -2,13 +2,13 @@ import { IMPOSSIBLE, utils, VersionInfo } from '@start9labs/start-sdk'
import { store } from '../fileModels/store' import { store } from '../fileModels/store'
export const current = VersionInfo.of({ export const current = VersionInfo.of({
version: '0.1.6:0', version: '0.1.7:0',
releaseNotes: { releaseNotes: {
en_US: 'Full category management in Settings: rename categories and metrics, change units & type, add or remove metrics, and archive or permanently delete categories.', en_US: 'Security hardening — login attempts are now rate-limited and the minimum password length was raised. Personal-best records now correct themselves when the session that set them is edited down or deleted, while any best you set by hand is kept.',
es_ES: 'Gestión completa de categorías en Ajustes: renombra categorías y métricas, cambia unidades y tipo, añade o elimina métricas, y archiva o elimina categorías permanentemente.', es_ES: 'Refuerzo de seguridad: los intentos de inicio de sesión ahora tienen límite de frecuencia y se aumentó la longitud mínima de la contraseña. Los récords personales se corrigen al editar a la baja o eliminar la sesión que los estableció, conservando cualquier récord fijado manualmente.',
de_DE: 'Vollständige Kategorienverwaltung in den Einstellungen: Kategorien und Metriken umbenennen, Einheiten & Typ ändern, Metriken hinzufügen/entfernen sowie Kategorien archivieren oder endgültig löschen.', de_DE: 'Sicherheitsverbesserungen: Anmeldeversuche sind jetzt ratenbegrenzt und die Mindestlänge des Passworts wurde erhöht. Persönliche Bestwerte korrigieren sich selbst, wenn die zugehörige Sitzung nach unten bearbeitet oder gelöscht wird, während manuell gesetzte Bestwerte erhalten bleiben.',
pl_PL: 'Pełne zarządzanie kategoriami w Ustawieniach: zmiana nazw kategorii i metryk, zmiana jednostek i typu, dodawanie/usuwanie metryk oraz archiwizacja lub trwałe usuwanie kategorii.', pl_PL: 'Wzmocnienie bezpieczeństwa: próby logowania są teraz ograniczane, a minimalna długość hasła została zwiększona. Rekordy osobiste same się korygują po obniżeniu lub usunięciu sesji, która je ustanowiła, zachowując rekordy ustawione ręcznie.',
fr_FR: "Gestion complète des catégories dans les Réglages : renommer catégories et métriques, changer unités et type, ajouter ou supprimer des métriques, et archiver ou supprimer définitivement des catégories.", fr_FR: "Renforcement de la sécurité : les tentatives de connexion sont désormais limitées et la longueur minimale du mot de passe a été augmentée. Les records personnels se corrigent lorsqu'on réduit ou supprime la séance qui les a établis, tout en conservant les records définis manuellement.",
}, },
migrations: { migrations: {
up: async ({ effects }) => { up: async ({ effects }) => {
+1
View File
@@ -37,6 +37,7 @@ function addColumn(table, col, def) {
// these ALTERs bring existing databases up to date). // these ALTERs bring existing databases up to date).
addColumn('category_metrics', 'track_record', 'INTEGER NOT NULL DEFAULT 0'); addColumn('category_metrics', 'track_record', 'INTEGER NOT NULL DEFAULT 0');
addColumn('category_metrics', 'record', 'REAL'); addColumn('category_metrics', 'record', 'REAL');
addColumn('category_metrics', 'record_floor', 'REAL');
addColumn('entries', 'note', "TEXT NOT NULL DEFAULT ''"); addColumn('entries', 'note', "TEXT NOT NULL DEFAULT ''");
// One-off data migration: turn on juggling records (step 1) and add the // One-off data migration: turn on juggling records (step 1) and add the
+27
View File
@@ -0,0 +1,27 @@
import { db } from './db.js';
// Recompute a metric's stored personal-best `record` from the source of truth:
// the best value actually logged, combined with any manually-pinned floor
// (`record_floor`) the user set in Settings. The floor is never crossed — for a
// higher-is-better metric the record is at least the floor; for a lower-is-better
// metric it is at most the floor. With no floor the record is purely the best
// logged value (NULL when nothing is logged). This is what lets a record drop
// again when the entry that set it is edited down or deleted.
export function recomputeRecord(metricId) {
const m = db.prepare(
'SELECT higher_is_better, record_floor FROM category_metrics WHERE id = ? AND track_record = 1'
).get(metricId);
if (!m) return;
const best = db.prepare(
`SELECT ${m.higher_is_better ? 'MAX' : 'MIN'}(value) AS v FROM entry_values WHERE metric_id = ?`
).get(metricId).v;
let record = best;
if (m.record_floor != null) {
record = record == null
? m.record_floor
: (m.higher_is_better ? Math.max(record, m.record_floor) : Math.min(record, m.record_floor));
}
db.prepare('UPDATE category_metrics SET record = ? WHERE id = ?').run(record ?? null, metricId);
}
+40 -2
View File
@@ -12,12 +12,48 @@ const cookieOpts = {
maxAge: config.sessionDays * 86400, maxAge: config.sessionDays * 86400,
}; };
const MIN_PASSWORD = 8;
const MAX_PASSWORD = 72; // bcrypt silently truncates beyond 72 bytes
// In-memory brute-force throttle for the single shared password. Keyed by client
// IP; behind the StartOS reverse proxy that collapses to one global lockout,
// which is acceptable for a single-user app. State is per process — fine here.
const MAX_FAILS = 8;
const LOCKOUT_MS = 15 * 60_000;
const loginAttempts = new Map(); // ip -> { fails, lockUntil }
function lockRemainingMs(ip, now) {
const rec = loginAttempts.get(ip);
if (!rec) return 0;
if (rec.lockUntil > now) return rec.lockUntil - now;
// Lockout elapsed — evict so the map can't grow unbounded. Only entries that
// actually locked (lockUntil set) are evictable; one still accumulating fails
// has lockUntil 0 and must be kept or the counter would reset every attempt.
if (rec.lockUntil) loginAttempts.delete(ip);
return 0;
}
function noteLoginFail(ip, now) {
const rec = loginAttempts.get(ip) || { fails: 0, lockUntil: 0 };
rec.fails += 1;
// On hitting the limit, lock and reset the counter so the next cycle starts fresh.
if (rec.fails >= MAX_FAILS) { rec.lockUntil = now + LOCKOUT_MS; rec.fails = 0; }
loginAttempts.set(ip, rec);
}
export default async function authRoutes(app) { export default async function authRoutes(app) {
app.post('/api/login', async (req, reply) => { app.post('/api/login', async (req, reply) => {
const now = Date.now();
const wait = lockRemainingMs(req.ip, now);
if (wait > 0) {
reply.header('Retry-After', Math.ceil(wait / 1000));
return reply.code(429).send({ error: 'Too many attempts. Try again later.' });
}
const { password } = req.body || {}; const { password } = req.body || {};
if (!verifyPassword(password)) { if (!verifyPassword(password)) {
noteLoginFail(req.ip, now);
return reply.code(401).send({ error: 'Wrong password' }); return reply.code(401).send({ error: 'Wrong password' });
} }
loginAttempts.delete(req.ip);
const token = createSession(); const token = createSession();
reply.setCookie(COOKIE_NAME, token, cookieOpts); reply.setCookie(COOKIE_NAME, token, cookieOpts);
return { ok: true }; return { ok: true };
@@ -36,8 +72,10 @@ export default async function authRoutes(app) {
app.post('/api/password', async (req, reply) => { app.post('/api/password', async (req, reply) => {
const { current, next } = req.body || {}; const { current, next } = req.body || {};
if (!verifyPassword(current)) return reply.code(401).send({ error: 'Wrong current password' }); if (!verifyPassword(current)) return reply.code(401).send({ error: 'Wrong current password' });
if (!next || String(next).length < 4) return reply.code(400).send({ error: 'New password too short' }); const pw = String(next || '');
setPassword(next); if (pw.length < MIN_PASSWORD) return reply.code(400).send({ error: `New password must be at least ${MIN_PASSWORD} characters` });
if (pw.length > MAX_PASSWORD) return reply.code(400).send({ error: `New password must be at most ${MAX_PASSWORD} characters` });
setPassword(pw);
const token = createSession(); const token = createSession();
reply.setCookie(COOKIE_NAME, token, cookieOpts); reply.setCookie(COOKIE_NAME, token, cookieOpts);
return { ok: true }; return { ok: true };
+9 -5
View File
@@ -1,4 +1,5 @@
import { db } from '../db.js'; import { db } from '../db.js';
import { recomputeRecord } from '../records.js';
function categoriesWithMetrics({ includeArchived = false } = {}) { function categoriesWithMetrics({ includeArchived = false } = {}) {
const cats = db.prepare( const cats = db.prepare(
@@ -87,13 +88,15 @@ export default async function categoryRoutes(app) {
const m = db.prepare('SELECT * FROM category_metrics WHERE id = ?').get(id); const m = db.prepare('SELECT * FROM category_metrics WHERE id = ?').get(id);
if (!m) return reply.code(404).send({ error: 'Not found' }); if (!m) return reply.code(404).send({ error: 'Not found' });
const b = req.body || {}; const b = req.body || {};
// `record` may be explicitly set to null to clear it. // A manually-entered record is stored as a floor (`record_floor`); the live
const record = 'record' in b // `record` is then recomputed from that floor plus the best logged value.
// '' / null clears the floor, letting the record follow the logged data.
const recordFloor = 'record' in b
? (b.record === null || b.record === '' ? null : Number(b.record)) ? (b.record === null || b.record === '' ? null : Number(b.record))
: m.record; : m.record_floor;
db.prepare( db.prepare(
'UPDATE category_metrics SET name = ?, unit = ?, kind = ?, step = ?, ' + 'UPDATE category_metrics SET name = ?, unit = ?, kind = ?, step = ?, ' +
'higher_is_better = ?, track_record = ?, record = ? WHERE id = ?' 'higher_is_better = ?, track_record = ?, record_floor = ? WHERE id = ?'
).run( ).run(
b.name != null ? String(b.name) : m.name, b.name != null ? String(b.name) : m.name,
b.unit != null ? String(b.unit) : m.unit, b.unit != null ? String(b.unit) : m.unit,
@@ -101,9 +104,10 @@ export default async function categoryRoutes(app) {
b.step != null ? Number(b.step) || 1 : m.step, b.step != null ? Number(b.step) || 1 : m.step,
b.higher_is_better != null ? (b.higher_is_better ? 1 : 0) : m.higher_is_better, b.higher_is_better != null ? (b.higher_is_better ? 1 : 0) : m.higher_is_better,
b.track_record != null ? (b.track_record ? 1 : 0) : m.track_record, b.track_record != null ? (b.track_record ? 1 : 0) : m.track_record,
record, recordFloor,
id, id,
); );
recomputeRecord(id);
return db.prepare('SELECT * FROM category_metrics WHERE id = ?').get(id); return db.prepare('SELECT * FROM category_metrics WHERE id = ?').get(id);
}); });
+20
View File
@@ -1,4 +1,5 @@
import { db } from '../db.js'; import { db } from '../db.js';
import { recomputeRecord } from '../records.js';
const ISO = /^\d{4}-\d{2}-\d{2}$/; const ISO = /^\d{4}-\d{2}-\d{2}$/;
@@ -96,8 +97,19 @@ export default async function entryRoutes(app) {
if (note !== undefined) { if (note !== undefined) {
db.prepare('UPDATE entries SET note = ? WHERE id = ?').run(String(note || ''), id); db.prepare('UPDATE entries SET note = ? WHERE id = ?').run(String(note || ''), id);
} }
// Metrics touched by this edit = those the entry had before plus those it has
// now; both sets need their record recomputed (a value may have been lowered
// or removed entirely, which can pull the personal best back down).
const affected = new Set(
db.prepare('SELECT DISTINCT metric_id FROM entry_values WHERE entry_id = ?').all(id)
.map((r) => r.metric_id)
);
db.prepare('DELETE FROM entry_values WHERE entry_id = ?').run(id); db.prepare('DELETE FROM entry_values WHERE entry_id = ?').run(id);
writeValues(id, values, newRecords); writeValues(id, values, newRecords);
if (Array.isArray(values)) {
for (const v of values) if (v && v.metric_id != null) affected.add(Number(v.metric_id));
}
for (const mid of affected) recomputeRecord(mid);
}); });
tx(); tx();
return { ...dayPayload(entry.day), newRecords }; return { ...dayPayload(entry.day), newRecords };
@@ -106,7 +118,15 @@ export default async function entryRoutes(app) {
app.delete('/api/entries/:id', async (req, reply) => { app.delete('/api/entries/:id', async (req, reply) => {
const id = Number(req.params.id); const id = Number(req.params.id);
const row = db.prepare('SELECT day FROM entries WHERE id = ?').get(id); const row = db.prepare('SELECT day FROM entries WHERE id = ?').get(id);
const tx = db.transaction(() => {
// Capture the affected metrics before the cascade removes their values, so
// the record can fall back to the next-best logged value (or its floor).
const affected = db.prepare('SELECT DISTINCT metric_id FROM entry_values WHERE entry_id = ?')
.all(id).map((r) => r.metric_id);
db.prepare('DELETE FROM entries WHERE id = ?').run(id); db.prepare('DELETE FROM entries WHERE id = ?').run(id);
for (const mid of affected) recomputeRecord(mid);
});
tx();
return reply.send(row ? dayPayload(row.day) : { ok: true }); return reply.send(row ? dayPayload(row.day) : { ok: true });
}); });
+1
View File
@@ -21,6 +21,7 @@ CREATE TABLE IF NOT EXISTS category_metrics (
higher_is_better INTEGER NOT NULL DEFAULT 1, higher_is_better INTEGER NOT NULL DEFAULT 1,
track_record INTEGER NOT NULL DEFAULT 0, -- 1 = keep a personal-best record track_record INTEGER NOT NULL DEFAULT 0, -- 1 = keep a personal-best record
record REAL, -- current personal best (NULL = unset) record REAL, -- current personal best (NULL = unset)
record_floor REAL, -- manually-pinned best; recompute never crosses it
sort_order INTEGER NOT NULL DEFAULT 0 sort_order INTEGER NOT NULL DEFAULT 0
); );