diff --git a/AGENTS.md b/AGENTS.md index 08f985a..20204b1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,7 +33,7 @@ Run from repo root unless noted. ## 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`. ## Config (env-var names only — never hardcode secrets) @@ -71,10 +71,11 @@ Run from repo root unless noted. ## 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. - **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. -- **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. diff --git a/public/js/app.js b/public/js/app.js index 29d3134..6ab272b 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -386,8 +386,12 @@ function openSettings() { 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 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) : '', - style: 'max-width:90px', ...(m.track_record ? {} : { disabled: true }) }); + // Manual personal-best override. Blank = follow logged data; the live record + // 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; }); const mSave = h('button', { class: 'btn-ghost', onclick: async () => { diff --git a/public/sw.js b/public/sw.js index c2c4860..660d284 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'premier-gunner-v6'; +const CACHE = 'premier-gunner-v7'; const SHELL = [ '/', '/index.html', '/login.html', '/css/styles.css', diff --git a/s9pk/startos/actions/index.ts b/s9pk/startos/actions/index.ts index 8a6ef89..221f583 100644 --- a/s9pk/startos/actions/index.ts +++ b/s9pk/startos/actions/index.ts @@ -8,12 +8,13 @@ const inputSpec = InputSpec.of({ password: Value.text({ name: i18n('Password'), 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, default: null, masked: true, - minLength: 4, + minLength: 8, + maxLength: 72, }), }) diff --git a/s9pk/startos/i18n/dictionaries/default.ts b/s9pk/startos/i18n/dictionaries/default.ts index 318c3c5..a87a156 100644 --- a/s9pk/startos/i18n/dictionaries/default.ts +++ b/s9pk/startos/i18n/dictionaries/default.ts @@ -15,7 +15,7 @@ const dict = { 'Set Login Password': 6, 'Set the password Gunner uses to log in to Premier Gunner': 7, '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 /** diff --git a/s9pk/startos/i18n/dictionaries/translations.ts b/s9pk/startos/i18n/dictionaries/translations.ts index d7cc135..888b62d 100644 --- a/s9pk/startos/i18n/dictionaries/translations.ts +++ b/s9pk/startos/i18n/dictionaries/translations.ts @@ -11,7 +11,7 @@ export default { 6: 'Establecer contraseña de acceso', 7: 'Establece la contraseña que Gunner usa para iniciar sesión en Premier Gunner', 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: { 0: 'Starte Premier Gunner!', @@ -23,7 +23,7 @@ export default { 6: 'Anmeldepasswort festlegen', 7: 'Lege das Passwort fest, mit dem Gunner sich bei Premier Gunner anmeldet', 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: { 0: 'Uruchamianie Premier Gunner!', @@ -35,7 +35,7 @@ export default { 6: 'Ustaw hasło logowania', 7: 'Ustaw hasło, którego Gunner używa do logowania w Premier Gunner', 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: { 0: 'Démarrage de Premier Gunner !', @@ -47,6 +47,6 @@ export default { 6: 'Définir le mot de passe de connexion', 7: 'Définissez le mot de passe que Gunner utilise pour se connecter à Premier Gunner', 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 diff --git a/s9pk/startos/versions/current.ts b/s9pk/startos/versions/current.ts index b30a978..fadff47 100644 --- a/s9pk/startos/versions/current.ts +++ b/s9pk/startos/versions/current.ts @@ -2,13 +2,13 @@ import { IMPOSSIBLE, utils, VersionInfo } from '@start9labs/start-sdk' import { store } from '../fileModels/store' export const current = VersionInfo.of({ - version: '0.1.6:0', + version: '0.1.7:0', 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.', - 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.', - 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.', - 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.', - 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.", + 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: '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: '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: '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: "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: { up: async ({ effects }) => { diff --git a/src/db.js b/src/db.js index 2b2d501..889d819 100644 --- a/src/db.js +++ b/src/db.js @@ -37,6 +37,7 @@ function addColumn(table, col, def) { // these ALTERs bring existing databases up to date). addColumn('category_metrics', 'track_record', 'INTEGER NOT NULL DEFAULT 0'); addColumn('category_metrics', 'record', 'REAL'); +addColumn('category_metrics', 'record_floor', 'REAL'); addColumn('entries', 'note', "TEXT NOT NULL DEFAULT ''"); // One-off data migration: turn on juggling records (step 1) and add the diff --git a/src/records.js b/src/records.js new file mode 100644 index 0000000..1ade200 --- /dev/null +++ b/src/records.js @@ -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); +} diff --git a/src/routes/auth.js b/src/routes/auth.js index 004a3e4..3fe4f2e 100644 --- a/src/routes/auth.js +++ b/src/routes/auth.js @@ -12,12 +12,48 @@ const cookieOpts = { 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) { 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 || {}; if (!verifyPassword(password)) { + noteLoginFail(req.ip, now); return reply.code(401).send({ error: 'Wrong password' }); } + loginAttempts.delete(req.ip); const token = createSession(); reply.setCookie(COOKIE_NAME, token, cookieOpts); return { ok: true }; @@ -36,8 +72,10 @@ export default async function authRoutes(app) { app.post('/api/password', async (req, reply) => { const { current, next } = req.body || {}; 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' }); - setPassword(next); + const pw = String(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(); reply.setCookie(COOKIE_NAME, token, cookieOpts); return { ok: true }; diff --git a/src/routes/categories.js b/src/routes/categories.js index 78260d1..c8f3ab3 100644 --- a/src/routes/categories.js +++ b/src/routes/categories.js @@ -1,4 +1,5 @@ import { db } from '../db.js'; +import { recomputeRecord } from '../records.js'; function categoriesWithMetrics({ includeArchived = false } = {}) { 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); if (!m) return reply.code(404).send({ error: 'Not found' }); const b = req.body || {}; - // `record` may be explicitly set to null to clear it. - const record = 'record' in b + // A manually-entered record is stored as a floor (`record_floor`); the live + // `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)) - : m.record; + : m.record_floor; db.prepare( '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( b.name != null ? String(b.name) : m.name, 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.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, - record, + recordFloor, id, ); + recomputeRecord(id); return db.prepare('SELECT * FROM category_metrics WHERE id = ?').get(id); }); diff --git a/src/routes/entries.js b/src/routes/entries.js index 75523bf..3c4706f 100644 --- a/src/routes/entries.js +++ b/src/routes/entries.js @@ -1,4 +1,5 @@ import { db } from '../db.js'; +import { recomputeRecord } from '../records.js'; const ISO = /^\d{4}-\d{2}-\d{2}$/; @@ -96,8 +97,19 @@ export default async function entryRoutes(app) { if (note !== undefined) { 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); 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(); return { ...dayPayload(entry.day), newRecords }; @@ -106,7 +118,15 @@ export default async function entryRoutes(app) { app.delete('/api/entries/:id', async (req, reply) => { const id = Number(req.params.id); const row = db.prepare('SELECT day FROM entries WHERE id = ?').get(id); - db.prepare('DELETE FROM entries WHERE id = ?').run(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); + for (const mid of affected) recomputeRecord(mid); + }); + tx(); return reply.send(row ? dayPayload(row.day) : { ok: true }); }); diff --git a/src/schema.sql b/src/schema.sql index 7be362c..e4c9ed2 100644 --- a/src/schema.sql +++ b/src/schema.sql @@ -21,6 +21,7 @@ CREATE TABLE IF NOT EXISTS category_metrics ( higher_is_better INTEGER NOT NULL DEFAULT 1, track_record INTEGER NOT NULL DEFAULT 0, -- 1 = keep a personal-best record record REAL, -- current personal best (NULL = unset) + record_floor REAL, -- manually-pinned best; recompute never crosses it sort_order INTEGER NOT NULL DEFAULT 0 );