Commit graph

266 commits

Author SHA1 Message Date
Daniel
9bfece8532 feat: auto-version workflow — tags managed by commit messages
Adds .github/workflows/auto-version.yml that fires on every push to
main, parses commit messages since the last semver tag, and decides
whether to cut a new release:

  feat:      → minor bump   (new feature, backward-compatible)
  fix:       → patch bump   (bug fix)
  feat!:     → major bump   (breaking change)
  BREAKING CHANGE in body → major bump
  docs/chore/refactor/style/test/ci → no release

If any commit since the last tag matches feat/fix/BREAKING, the
workflow bumps versions across package.json, mobile/package.json,
mobile/android/app/build.gradle, commits the change as
"Release vX.Y.Z", tags it, and pushes. The tag push then fires the
existing android-release and docker-publish workflows.

You no longer need to remember "what version am I on?" — just commit
with a conventional-commits prefix and push. Docs-only or refactor
commits don't create releases. Add [skip ci] to any commit message
to skip this workflow for that commit.
2026-04-14 23:47:37 +02:00
Daniel
7b39c6c615 CI: fix docker multi-arch crash + add one-click version-bump workflow
docker-publish.yml:
  - Dropped linux/arm64 from the platforms matrix. The amd64 GitHub-
    hosted runner builds arm64 under QEMU emulation, which fails at
    native argon2 compile with SIGILL (exit 132). Your production
    box is x86, so arm64 isn't needed. Add it back with a native
    ARM runner the day you deploy to ARM hardware.

version-bump.yml (new):
  - Manual Actions trigger. Click "Run workflow" → pick patch / minor /
    major (or type a custom X.Y.Z). The workflow computes the next
    semver from the current package.json version, updates all three
    version sites (package.json, mobile/package.json, Android
    versionName + versionCode), commits "Release vX.Y.Z", tags it,
    and pushes. The tag push then fires android-release.yml and
    docker-publish.yml automatically — APK + Docker image published
    with no local commands required.

Typical flow now:
  Actions → "Version bump & release" → Run workflow → patch
    ↓
  Bump + tag in ~5 s
    ↓
  Parallel: android APK build (~2 m), docker image push (~4 m)
    ↓
  Both assets show up on the new release; Obtanium + docker-hub
  subscribers see the update automatically.
2026-04-14 23:44:47 +02:00
Daniel
d0009e94ed release.sh: drop node dependency, use sed for version bump 2026-04-14 23:40:38 +02:00
Daniel
b485eec828 Release v6.1.1 2026-04-14 23:40:10 +02:00
Daniel
63b8110993 Add mobile/.gitignore (should have been in prior cleanup commit) 2026-04-14 23:39:26 +02:00
Daniel
ef341671e2 Untrack Capacitor-generated files + node_modules in mobile/
The mobile/ wrapper had 1700+ node_modules files tracked, plus the
Capacitor-regenerated artifacts that get rewritten on every
`npx cap sync android` (capacitor.build.gradle, capacitor.config.json,
capacitor.plugins.json, capacitor.settings.gradle, the cordova-android-
plugins subtree). Every local dev or CI sync caused noisy drift that
blocked scripts/release.sh from running.

Added mobile/.gitignore covering node_modules, cap-sync outputs,
Android build outputs, .jks/.apk/.aab files, and .DS_Store.
Kept package-lock.json tracked for reproducible npm install.

No logic changes — only stopped tracking files that are always
regenerated.
2026-04-14 23:35:23 +02:00
Daniel
0471aee5a5 CI: GitHub Actions workflow to auto-build signed Android APK on tag push
On every v*.*.* tag push the workflow:
  1. Checks out the repo
  2. Sets up JDK 17 + Node 20 + Android SDK (cached between runs)
  3. Runs npm install + npx cap sync android in mobile/
  4. Restores the signing keystore from ANDROID_KEYSTORE_BASE64 secret
  5. Builds a signed release APK via gradle
  6. Renames to pedscribe-X.Y.Z.apk
  7. Creates/updates the matching GitHub release with the APK attached
     and make_latest=true so the /releases/latest URL always points to
     the newest build (Obtanium and the login-page link pick it up
     automatically)

Required repo secrets (set via gh secret set ... or the GitHub UI):
  ANDROID_KEYSTORE_BASE64   base64 -w0 of the .jks file
  ANDROID_KEYSTORE_PASSWORD keystore password
  ANDROID_KEY_ALIAS         key alias (pedscribe)
  ANDROID_KEY_PASSWORD      key password (same as keystore in our setup)

Typical release flow after this lands:
  scripts/release.sh 6.1.1 --push      (laptop, 5 sec)
  ── Actions builds APK in ~8-10 min ──
  ── Release updates automatically with signed APK ──
  ── Obtanium clients notice on next poll ──
2026-04-14 23:24:17 +02:00
Daniel
73398e91ed Version alignment + release script — single source of truth
Aligns every version string in the repo to 6.1.0:
  - package.json: 6.0.0 → 6.1.0
  - mobile/package.json: 1.0.0 → 6.1.0
  - mobile/android/app/build.gradle: versionCode 1 → 610,
      versionName "1.0" → "6.1.0"
  - server.js: hardcoded "v6.0" → reads root package.json at boot
  - /api/health/detailed now reports APP_VERSION from package.json

Adds scripts/release.sh — a one-command bump:
  scripts/release.sh 6.1.1                # local bump + tag
  scripts/release.sh 6.1.1 --push         # + git push
  scripts/release.sh 6.1.1 --push --gh    # + GitHub release (uploads
                                            APK if already built)

Updates all three version sites, commits "Release v6.1.1",
creates annotated tag, optionally pushes and opens a release.
versionCode encoded as MAJ*100000 + MIN*1000 + PATCH so patch
updates always increment monotonically.
2026-04-14 23:18:47 +02:00
Daniel
77bd7c5b1c Hide APK download link on the native Android app
Some checks failed
Build TWA APK / build-apk (push) Has been cancelled
Build & Push Docker Image / build (push) Has been cancelled
The "Download Android app (APK)" link on the login page is pointless
when the user is already inside the Capacitor app. Wrapped the link
in id="apk-download-link" and added a native-app-only hide pass in
auth.js that runs against a short array of web-only element IDs.

Add more entries to that array as other web-only UI appears, so the
mobile wrapper can diverge cleanly from the web UI without branching
the HTML.
2026-04-14 22:47:59 +02:00
Daniel
b4704944cb Replace all 'Johns Hopkins Kids Kard' citations with 'Harriet Lane Handbook' 2026-04-14 22:41:10 +02:00
Daniel
43ee0e7ab5 Bhutani: swap eyeballed values for pre-digitized table from codingace.net
Replaces my best-effort image readings with the pre-digitized
JavaScript data arrays extracted from codingace.net's open
Bhutani calculator (their arrays were embedded in the page
source, apparently digitized from the original Figure 2 at
6-hour granularity through 72 h).

Cross-checked against the AAP 2004 CPG reproduction of the same
Bhutani chart (Southern Health Manitoba clinical policy PDF).
Classifications at several spot-check points (24/36/41/72 h at
varying TSB) match expected zones.

User's reference case (41 h of life, TSB 9.7 mg/dL):
  p95 ≈ 13.6, p75 ≈ 11.4, p40 ≈ 9.1
  → Low-Intermediate Zone  ✓  (matches clinician expectation)

Data source now properly cited in both the in-code block comment
and the on-card footer text. Tool still documents that AAP 2022
is the correct tab for phototherapy decisions.
2026-04-14 13:05:54 +02:00
Daniel
6db52eeec4 Bhutani: replace unsourced values with image-read Stanford nomogram
The previous bhutaniZones table was introduced in the initial
calculator commit (61cf096, 2026-04-09) without any source citation
and was systematically ~0.5-1 mg/dL below the published Bhutani 1999
curves — borderline patients got pushed into the next-higher zone.

New values read directly from the Stanford Medicine Newborn Nursery
reproduction of Bhutani 1999 Figure 2:
  https://med.stanford.edu/newborns/professional-education/jaundice-and-phototherapy/bhutani-nomogram.html

Uncertainty: ±0.3 mg/dL (values eyeballed from a 556 px rendered
graph, not a published table). This is called out explicitly in
both the in-code comment and the Bhutani tab footer, which also
points clinicians to the AAP 2022 tab for actual phototherapy
decisions.

Spot-check: 41h of life, TSB 9.7 mg/dL
  Before: p75=9.33 → High-Intermediate Zone (wrong)
  After:  p75=10.1 → 9.7 below p75, above p40 → Low-Intermediate ✓

No interpolation or decision logic changed — only the lookup data
and its citation.
2026-04-14 13:02:26 +02:00
Daniel
a514405261 Vitals: simplify source to 'Harriet Lane Handbook' 2026-04-14 12:48:06 +02:00
Daniel
ef01eca8ca Citations: move Harriet Lane label to Vitals; restore honest Bhutani cite
Vitals — updated source attribution to "Harriet Lane — Johns Hopkins
Children's Center Kids Kard" (was just Kids Kard). Both the card
subtitle and the intro line.

Bhutani — reverted my incorrect "Harriet Lane" citation (the values
in our table were never transcribed from Harriet Lane). Restored the
original attribution to the 1999 paper. Data itself is unchanged from
the pre-session state; treat it as unverified pending a clinician-
supplied source.
2026-04-14 12:43:17 +02:00
Daniel
b27c79e8ae Bhutani tab: cite Harriet Lane (Johns Hopkins Kids Kard) as source 2026-04-14 12:40:04 +02:00
Daniel
868ed53bbc Revert "Fix Bhutani nomogram values — correct percentile tables"
This reverts commit 48e0749435823376efa581e33db34f09d3123b52.
2026-04-14 12:35:36 +02:00
Daniel
d1b6da4291 Fix Bhutani nomogram values — correct percentile tables
Previous table was ~0.5-1.0 mg/dL below the published Bhutani 1999
nomogram at every reference point, which pushed borderline patients
into the next-higher zone. Coarse 12-hour granularity made the
interpolation error worse between reference points.

Corrected to the PediTools-vetted values (same source we use for
AAP 2022) with 6-hour granularity. Source: Bhutani VK et al.,
Pediatrics 1999;103(1):6-14.

Example: 41h of life, TSB 9.7 mg/dL
  Before: p75=9.33 → TSB above p75 → "High-Intermediate Zone" (wrong)
  After:  p75=10.25 → TSB below p75, above p40 → "Low-Intermediate Zone"
          (matches the published nomogram)

No code-path changes — only the lookup data.
2026-04-14 12:32:07 +02:00
Daniel
290724c883 Revert "Bilirubin chart: BiliTool/PediTools-style visual polish (no data changes)"
This reverts commit e6d90f4ba686d73aaf3958e68b08bb2d1c4026de.
2026-04-14 12:27:14 +02:00
Daniel
c11cfe45b7 Revert "Bili chart: shorter labels + wider right padding to stop clipping"
This reverts commit 110924807c412cd47c56eccf3ce9ee4053f8a7a4.
2026-04-14 12:27:14 +02:00
Daniel
1c106e71db Bili chart: shorter labels + wider right padding to stop clipping
Replaced "Phototherapy" with the clinical abbreviation "Photo Tx"
(fits in the right margin without cut-off). Exchange label stays.
Bumped the layout right-padding from 40 px to 88 px so even the
longest label ("95th (High-Risk)" on the Bhutani chart) prints
fully inside the canvas on narrow viewports.
2026-04-14 12:21:42 +02:00
Daniel
e8a2283fae Bilirubin chart: BiliTool/PediTools-style visual polish (no data changes)
Pure visual improvements to renderBiliChart. Interpolation, lookup
tables, and threshold math are all untouched.

AAP chart now has three-zone shading matching BiliTool's convention:
  - Faint green tint below the phototherapy curve (safe zone)
  - Amber band between phototherapy and exchange (treatment zone)
  - Unshaded above the dashed crimson exchange line (danger)
  Phototherapy line: solid orange 2.4 px; Exchange: dashed crimson.

renderBiliChart common improvements:
  - Right-edge label on each threshold line ("Phototherapy", "Exchange",
    or "95th (High-Risk)" for Bhutani) with white halo — identifiable
    without a legend, like PediTools
  - Legend removed (replaced by the inline labels)
  - X-axis auto-ranges to fit the actual data with tick every 12 h
  - Y-axis tick every 5 mg/dL for clean BiliTool-style gridlines
  - 40 px right padding so labels don't clip
  - Patient dot shrunk from r=8 to r=5 with a 1.8 px white ring,
    redrawn on top of every label + its TSB value printed next to it
  - Bhutani chart inherits all the same improvements without changing
    its own zone/fill setup
2026-04-14 12:17:32 +02:00
Daniel
7a7fd5b4eb Growth charts: back to 7 percentile lines (drop 5th and 95th)
Reduces label crowding at the chart extremes. Clinical meaning
preserved: 3rd and 97th remain as the abnormal-threshold dashed
outermost lines, 10/25/50/75/90 give the mid trend. The bidirectional
label spread, leader lines, and dot-on-top rendering from the prior
pass all still apply to the 7-line layout.

Fills updated for new indexes (3↔97, 10↔90, 25↔75).
2026-04-14 07:16:38 +02:00
Daniel
02f4bea747 Growth charts: bidirectional label spread + leaders + dot-on-top
Labels at the top (97/95/90) crowd just as much as the bottom
trio — prior forward-only pass only spread the bottom. Now:
  - Forward pass pushes items DOWN when crowded from above
  - Backward pass pulls items UP when still crowded from below
  - Min gap bumped 13 → 15 px for breathing room
  - Labels clamped to stay inside chart top/bottom
  - Thin leader line drawn from native curve position to the label
    when the two diverge by more than 2 px, so you can still see
    which line a nudged label belongs to
  - Patient dot redrawn on top of all labels at the very end of
    the plugin so a top-region label can never cover the dot
    (Chart.js's afterDatasetsDraw fires after dataset rendering,
    so the native dot was being painted over).
2026-04-14 07:10:20 +02:00
Daniel
aa1261da4e Growth charts: fix label ordering + shrink patient dot
Label plugin rewritten:
  - Collect all percentile-line labels with their native screen Y
  - Sort top-to-bottom (97th ... 3rd) so order is always correct
  - Walk the sorted list and enforce 13px min vertical gap by
    pushing down (never reordering)
  - Second-pass pull-back if the stack would clip off the chart
    bottom
  Prior logic could nudge "10th" past "3rd" because it moved labels
  independently without respecting their natural screen order.

Patient dot: radius 8 → 5 (hover 11 → 7). The 8px ring was
dominating the chart; 5px is still clearly visible with the 1.8px
white border but no longer obscures adjacent curves.
2026-04-14 07:06:49 +02:00
Daniel
1a176f082a Growth charts: restore full 9-percentile Epic/CDC line set
Reverted from the 7-line reduced set back to the full clinical set:
3rd, 5th, 10th, 25th, 50th, 75th, 90th, 95th, 97th — matching what
Epic and the printed AAP/CDC/WHO charts display.

Kept the mobile-readability improvements from the prior pass:
  - Each percentile is a distinct hue (red / deep-orange / orange /
    amber / green / blue / violet / purple / pink)
  - Outer 3rd + 97th are dashed (abnormal-threshold convention); 5th
    and 95th use a tighter dash; rest solid
  - 50th remains bold green to anchor the center
  - Label plugin's white halo + vertical nudge keeps 3/5/10 and
    90/95/97 label stacks legible
  - Patient dot still on top, white-ringed

Fill bands updated for 9 indexes (3↔97, 5↔95, 10↔90, 25↔75).
2026-04-14 07:02:46 +02:00
Daniel
2cb99b263f Growth charts: 7 distinct-color percentile lines + readable mobile labels
Reduced reference curves from 9 to 7 (dropped 5th and 95th — they
crowd the 3rd/10th and 90th/97th labels on small screens without
adding clinical value; 3rd and 97th are the US/WHO standard
abnormal thresholds).

Each percentile now gets a distinct hue:
  3rd  red     50th green (bold)    90th violet
  10th orange  75th blue            97th pink
  25th amber                        (3rd/97th dashed)

Label plugin improvements:
  - Bolder 11px font (was 10px)
  - White halo stroke behind text so labels stay legible when the
    line they sit on is also colored
  - Vertical nudge when two labels would overlap — keeps adjacent
    percentiles readable on mobile aspect ratios
  - Solid fill color (strips alpha from borderColor)

Patient dot:
  - Moved to order: -1 (drawn on top of everything, including labels
    and fill bands)
  - White 2.5px border ring so it's visible even when it lands
    exactly on a colored curve
  - Slightly larger hover radius (11 → was 10)
2026-04-14 07:01:53 +02:00
Daniel
de0562060b Growth charts: label each percentile curve (3rd, 50th, 97th, …)
Adds a Chart.js plugin that draws the percentile label at the right
end of each reference line, matching the convention on printed
WHO/CDC growth charts. Lets clinicians identify lines at a glance
without using the legend. Canvas gets 30px right-padding so the
labels don't get clipped.
2026-04-14 06:55:12 +02:00
Daniel
e32e9977f5 Cache-busting version stamps + client-side encounter version tracking
1. Build-ID cache busting (server.js):
   - Compute a BUILD_ID at boot: git HEAD short hash if available,
     else /app/BUILD_ID file, else random-on-boot.
   - On first request for /, rewrite every local /js/*.js and
     /css/*.css reference in index.html to include ?v=BUILD_ID.
     Cached once at startup so subsequent renders are free.
   - X-Build-Id response header + GET /api/build expose it for
     debugging.
   - Eliminates the "works after hard-refresh" class of bugs: every
     deploy gets a new build ID, so browsers fetch fresh JS/CSS on
     the very next page load.

2. Optimistic encounter locking wired into the client
   (public/js/encounters.js):
   - On resumeEncounter(): stash enc.version into
     window._encounterVersions[id]
   - On saveEncounter(): send expected_version in the POST body
     when we have one.
   - Server returns 409 if another tab/device wrote first → user
     sees "Someone else edited this encounter. Reload to see the
     latest version." instead of silently clobbering the prior save.
   - On success, remember the new server-assigned version for the
     next save.
2026-04-14 05:40:42 +02:00
Daniel
2f3e608c88 Fix: local-auth users lose Password/2FA/Sessions after refresh
Previous check was strict (canLocalAuth !== true → hide). On a
transient /me hiccup or when the boot cache lagged, a legit local
user saw empty Settings with none of the sections they should see.

Inverted the predicate: hide only when canLocalAuth === false
(explicit SSO-only signal from the server). Undefined/missing now
defaults to show — local-auth users never lose their own UI.
Still hides correctly for the documented SSO-only case because the
/me endpoint sets the flag to false explicitly for those users.
2026-04-14 05:32:30 +02:00
Daniel
5b0c296a88 Prompt-injection wrap: remaining AI routes
Applied the <UNTRUSTED_*> delimiter + INJECTION_GUARD pattern to:
  - src/routes/sickVisit.js  (chief complaint, transcript, dictation,
                               ROS, physical exam, diagnoses, style hints)
  - src/routes/wellVisit.js  (SSHADESS answers + full well-visit context)
  - src/routes/chartReview.js (PMH + all visit content + labs)
  - src/routes/hospitalCourse.js (all notes/H&P/ED + clarification
                                   & update endpoints)
  - src/routes/milestones.js (narrative + summary)

Each wraps patient-derived text in <UNTRUSTED_*>…</UNTRUSTED_*>
tags and appends the INJECTION_GUARD system instruction that tells
the model to treat wrapped content strictly as data. Operator-
supplied `additionalInstructions` stays unwrapped (trusted).
2026-04-14 05:31:54 +02:00
Daniel
7a06a4aa63 Batch of security + scale fixes
Age parser (src/routes/billing.js):
  - Now sums year + month + week + day matches so "4 yr 11 mo"
    (59 months) correctly maps to the 5-11y billing bracket instead
    of being billed as 1-4y. Added bounds sanity check.

Graceful SIGTERM shutdown (server.js):
  - Closes the HTTP listener first, then drains batched audit queues,
    then ends the Postgres pool. 9-second hard deadline to beat
    Docker's 10-second SIGKILL. Previously an in-flight note save
    during a container restart could truncate the write.

Explicit LLM fallback opt-in (src/utils/ai.js):
  - The OpenRouter / LiteLLM silent fallback now requires admin
    setting `ai.allow_model_fallback = true` (default: false). If
    primary fails and fallback is disabled, the error is surfaced
    to the caller. Prevents silent spillover from a BAA-covered
    primary to a non-covered fallback.

Prompt injection delimiters (src/utils/promptSafe.js):
  - Wraps user transcripts, dictations, refine-instructions, and
    pasted documents in <UNTRUSTED_*>...</UNTRUSTED_*> tags and
    appends an explicit system instruction telling the model to
    treat the wrapped content as data rather than commands.
  - Applied to soap.js, hpi.js, refine.js. Extend to other AI
    routes incrementally.

Cross-tab logout sync (public/js/authFetch.js, auth.js):
  - BroadcastChannel('pedscribe-auth') — logout in one tab posts
    a message; all sibling tabs clear state and reload, dropping
    any PHI-containing UI immediately.

Backup code race-free consumption (src/routes/auth.js):
  - tryConsumeBackupCode() now uses a Postgres transaction with
    SELECT ... FOR UPDATE so concurrent login attempts using the
    same code serialize. First wins, second sees the already-
    shortened array.

Optimistic encounter locking (migrations/...add-encounter-version):
  - saved_encounters.version INTEGER NOT NULL DEFAULT 1
  - POST /api/encounters/saved accepts an expected_version and
    rejects with 409 if the row has advanced. Falls back to
    last-write-wins if the client doesn't pass one (backward compat).

Audit log batching (src/utils/auditQueue.js):
  - Audit / api_log / access_log writes are buffered in memory and
    flushed every 1s or every 50 entries via one multi-row INSERT.
    Under load this reduces DB pressure by ~50x. On SIGTERM the
    shutdown path drains the queue before exiting.
2026-04-14 05:24:40 +02:00
Daniel
8db25f39be Enforce server-side LLM model whitelist + scope idle timeout to writes
Two findings from review:

1. callAI() previously accepted any model string from the client.
   POST /api/hpi with { model: "openai/o1" } would call the reasoning
   model regardless of whether the operator enabled it. Added
   getAllowedModelIds() in src/utils/models.js (60s TTL DB-backed
   cache) and a guard at the top of callAI() that rejects with
   "model_not_permitted" when the requested ID isn't in the active
   roster. No model supplied → silent fallback to DEFAULT_MODEL.

2. Middleware was updating user_sessions.last_activity on every
   request, including GETs. Client-side polling (/api/auth/me
   heartbeats, dashboard refreshes, log tail calls) kept sessions
   alive indefinitely, defeating the 24h sliding idle policy. Now
   only POST/PUT/DELETE/PATCH count as "user activity". GETs are
   read-only and often automated — they no longer extend the
   session. Idle enforcement still runs on every method, so a
   24h-idle user still gets kicked on their next GET.
2026-04-14 05:15:55 +02:00
Daniel
a2b1b262cb Fix: revoked sessions now actually log the other device out
The server-side revoke was always working — it deletes user_sessions
rows, and middleware correctly returned 401 on the revoked device's
next /api/* request. The bug was entirely client-side: individual
fetch handlers swallowed the 401 (rendering "no sessions found" or
empty data) and nothing redirected to the login screen. So the
revoked device looked like it stayed signed in.

Added public/js/authFetch.js: a global fetch interceptor that
watches every /api/* response. On 401 from a non-auth endpoint
(i.e. not /login, /register, /logout, /me, etc.), it clears any
cached token/user state and reloads the page. The reload's boot
flow lands on /api/auth/me → 401 → login screen as usual.

Guarded against false positives: only triggers when the app believes
the user is currently logged in (AUTH_TOKEN set or main-app visible)
so a pre-login 401 doesn't accidentally flash the screen.

Loaded before auth.js in index.html.
2026-04-14 05:10:16 +02:00
Daniel
fcf11ec326 Hide Active Sessions for SSO-only users
Follows the same pattern as Change Password and 2FA sections —
hidden by default in the HTML, revealed only when canLocalAuth=true.

Why: revoke technically deletes the PedScribe session row and clears
the cookie on that device, but the SSO user can re-auth instantly
because their IdP session is still live. Surfacing a "revoke" button
that the IdP will immediately undo is misleading. SSO users now see
only the SSO-relevant sections of Settings.
2026-04-14 05:07:07 +02:00
Daniel
64ac4ff6bb Add node-pg-migrate for versioned schema changes + better mobile UA labels
Infrastructure only — no existing data or tables modified.

  src/db/migrate.js           — programmatic runner, fires at boot after
                                 the existing idempotent initDatabase()
  migrations/1744600000000...  — intentionally empty example, documents
                                 the file shape. Registered in the new
                                 pgmigrations tracking table so it won't
                                 rerun.
  .node-pg-migraterc.json     — CLI config (migrations-dir, utc naming)
  docs/migrations.md          — workflow + conventions
  package.json                — migrate:up/down/new/status npm scripts
                                 (status is a direct pgmigrations query
                                 since node-pg-migrate v7 lacks a status
                                 subcommand)

src/utils/sessions.js:
  - parseUserAgent now recognizes the Capacitor wrapper (UA suffix
    "PedScribe-Android" / "PedScribe-iOS") and labels sessions
    "PedScribe (Android)" instead of "Chrome on Android".

Going forward: schema changes go in /migrations as versioned files
with up() + down(); the inline init in database.js is the implicit
baseline for everything already in production.
2026-04-14 05:06:19 +02:00
Daniel
040218a7bf Fix critical auth bug: set httpOnly cookie on local login/register
After the hybrid auth migration, web users log in but the
setAuthCookie() helper was never actually called in /login or
/register — only in the OIDC callback. Result: local sign-in worked
until the first page reload, then the user appeared logged out. The
Settings page's Active Sessions list came up empty because
/api/sessions received no auth.

Added setAuthCookie(res, token) calls on successful:
  - /register (auto-verified first admin path)
  - /login (after TOTP / backup code verification)

Mobile is unaffected — it uses Bearer from Keychain and always has.
2026-04-14 04:55:12 +02:00
Daniel
ed015f3774 Fix local-auth sections not showing for normal users + backup-code modal signature
settings load2FAStatus():
  - Explicit credentials: 'same-origin' on the /me fetch (was relying
    on fetch defaults, which can behave oddly in some browsers/edges)
  - Fall back to window.CURRENT_USER (cached at login) if /me fails,
    so local-auth users still see their password/2FA sections after
    a transient error. Keeps cache in sync on each successful fetch.

enterApp():
  - Cache the logged-in user object on window.CURRENT_USER so modules
    that need the canLocalAuth flag don't have to re-fetch /me.

2FA regenerate modal:
  - Previous call passed a wrong-shape options object to showConfirm.
    Updated to the correct (message, callback, opts) signature with
    input:true, inputType:'password', placeholder, required.

OIDC email_verified check:
  - Accept boolean true or string 'true' for robustness. Some IdPs
    serialize ID-token booleans as strings.
2026-04-14 04:43:07 +02:00
Daniel
b6753d5bc9 Hide change-password + 2FA by default, show only when canLocalAuth=true
Sections were briefly visible for SSO-only users before load2FAStatus
resolved and hid them. Flipped the default: both sections now carry
style="display:none" in the HTML and are revealed only when the /me
fetch confirms the user has a real password hash.

SSO-only users never see the sections, even for a flash.
2026-04-14 04:38:28 +02:00
Daniel
f9732f25d0 Server-side SSO/local-auth enforcement + OIDC account-link hardening
Endpoint guards (defense-in-depth over hidden UI):
  - POST /api/auth/change-password: 400 with SSO-aware message if
    the caller's stored password is not a real bcrypt/argon2 hash.
    Prior behaviour was to fail at passwords.verify() with an
    ambiguous "current password is incorrect".
  - POST /api/auth/setup-2fa: 400 with same SSO-aware message for
    SSO-only accounts. Prior behaviour allowed TOTP setup on an
    account where it could never actually trigger (user never logs
    in locally).

OIDC account-link safety (src/routes/oidc.js):
  - Auto-link to an existing local account now requires the IdP to
    assert email_verified=true in the ID token (or userinfo). If
    absent/false, the callback redirects with ?error=email_unverified.
    Prevents an attacker at a misconfigured IdP from taking over a
    local account by claiming an email they don't own.
  - If an existing user already has oidc_sub set and the incoming
    sub is different, refuse with ?error=sub_mismatch. Prior
    behaviour silently did nothing, hiding a potential attack.
  - Audit 'oidc_linked' written on first successful link.

Frontend:
  - Added user-facing messages for the two new SSO error codes.
2026-04-14 04:35:00 +02:00
Daniel
97f60876c5 Idle timeout observability + cut write frequency in half + hide local-auth UI for SSO-only users
Middleware:
  - Log to console.warn + audit_log when a session is killed for
    inactivity. Shows up in Grafana/Loki so you can see how often
    users actually get kicked. Audit action: 'session_idle_timeout'
  - last_activity throttle bumped 5 min → 10 min — halves DB writes
    per active user. Idle precision slop widens to 24h00-24h10;
    still invisible in practice.

Per-user local-auth visibility:
  - /api/auth/me now returns user.canLocalAuth: true when the stored
    password is a real bcrypt / argon2 hash, false for the random
    blob OIDC auto-creates for SSO-only users.
  - Settings page hides "Change Password" and "Two-Factor
    Authentication" sections when canLocalAuth is false — those UIs
    are meaningless for users whose sign-in lives at the IdP.
  - Password hash is not leaked in the /me payload.

Mobile (restating existing behaviour for clarity): no idle check,
365-day JWT in Keychain/Keystore, never auto-logs-out. Only logout
triggers are: manual logout, password change, admin revoke, JWT hit
365d, or app uninstall.
2026-04-14 04:32:53 +02:00
Daniel
c411e5f16f Sliding 24h idle timeout (web) + persistent mobile + 2FA backup codes
Session model:
  Web     — 24h sliding idle timeout enforced server-side via
             user_sessions.last_activity. 30-day JWT + cookie are a
             safety net; middleware is the real clock. Cookie is
             re-set on active use so browsers match the sliding window.
  Mobile  — 365-day JWT, no idle timeout (stays persistent via Keychain
             / Keystore). Detected via User-Agent ("PedScribe" /
             "Capacitor") or X-Client: mobile header.

2FA backup codes:
  - 10 single-use codes generated when 2FA is first enabled
  - Stored as bcrypt hashes in new users.totp_backup_codes column
  - Consumed atomically on successful login fallback (when TOTP fails)
  - Regenerate endpoint (POST /api/auth/2fa/backup-codes) requires
    current password; invalidates prior codes
  - Count endpoint (GET /api/auth/2fa/backup-codes/count) powers a
    "N codes remaining" indicator on the 2FA settings card
  - Modal shows codes exactly once with Copy + Download .txt actions
  - Codes cleared when 2FA is disabled

New files:
  src/utils/platform.js — isMobileClient() helper

Schema migration (idempotent):
  ALTER TABLE users ADD COLUMN IF NOT EXISTS totp_backup_codes TEXT
2026-04-14 04:24:54 +02:00
Daniel
6c98e36511 Session lifetime: 7 days → 24 hours
Shortens both JWT expiresIn and httpOnly cookie maxAge to 24h in
auth.js (local + register + reset flows) and oidc.js (SSO callback).

Rationale: shorter absolute session window for a PHI-adjacent app.
No sliding idle refresh — user re-logs in once a day.
2026-04-14 04:17:54 +02:00
Daniel
4a29c496f6 Mobile app hardening — security + Android 14 compat
capacitor.config.json:
  - webContentsDebuggingEnabled: true → false
    (was leaving Chrome DevTools able to attach to released builds)
  - allowMixedContent: true → false
    (API is HTTPS-only; no need to permit cleartext loads)
  - server.allowNavigation: ["*"] → restricted to pedshub.com /
    peds.danvics.com origins
    (prevents WebView following an attacker-controlled redirect)

AndroidManifest.xml:
  - android:allowBackup="false" + data_extraction_rules.xml
    (Android system backup would otherwise copy EncryptedSharedPreferences
     containing the auth token into Google Cloud backups)
  - Removed USE_BIOMETRIC permission (feature removed earlier)

AudioRecordingService.java:
  - startForeground(id, notif, TYPE_MICROPHONE) on Android 14+
    (without the explicit type Android 14 kills the service with
     MissingForegroundServiceTypeException)
  - WakeLock cap: 1h → 8h (still bounded, onDestroy releases early)

MainActivity.java:
  - Removed dead biometric code path and androidx.biometric imports

mobile/package.json:
  - Dropped @aparajita/capacitor-biometric-auth — orphan dependency
2026-04-14 04:15:27 +02:00
Daniel
5a700a2a27 Hybrid auth: cookie-only on web, Keychain Bearer on mobile
Runtime split driven by window.Capacitor.isNativePlatform():

  Web browser
    - No token in localStorage / sessionStorage — XSS can't read it
    - Server-set httpOnly cookie carries the session
    - fetch() default credentials='same-origin' sends the cookie
    - getAuthHeaders() returns Content-Type only, no Authorization
    - Middleware already falls back to cookie when Bearer is absent

  Capacitor native (iOS / Android)
    - Unchanged — Bearer token lives in Keychain / Keystore via the
      capacitor-secure-storage-plugin SecureStorage wrapper
    - Bearer header still sent on every request

enterApp() / clearSession() / getAuthHeaders() all now branch on
isNativeApp(). Legacy localStorage entries from the dual-mode era
are wiped on clearSession() for users migrating in.

Rollback: git reset --hard pre-httponly-only-2026-04-14
2026-04-14 04:11:55 +02:00
Daniel
0bbecb76f9 Maintenance CLI + unpin postgres digest
Adds `npm run maint:check` (health report) and `npm run maint:reindex`
(REINDEX DATABASE + REFRESH COLLATION VERSION + ANALYZE) for post-
upgrade maintenance, modelled after Nextcloud's occ maintenance.
Documented in README.

Also relaxes postgres image from digest pin back to tag-pin
(pgvector/pgvector:pg16) — the auto-REINDEX-on-drift check in
database.js and the COLLATE "C" protection on critical indexes
make the digest pin redundant while blocking ordinary `compose
pull` updates.
2026-04-14 04:00:13 +02:00
Daniel
cd2513d361 Pin critical auth indexes to COLLATE "C" (ICU-drift immune)
idx_users_email and idx_sessions_token_hash now use byte-order
collation so a future ICU library bump cannot silently corrupt the
indexes the way it did this week. The columns themselves retain
their default collation; only the index comparison is C, which is
safe for these because:

  - users.email is lowercased ASCII in practice
  - user_sessions.token_hash is SHA-256 hex (pure ASCII)

Both are used for equality lookups only, never ORDER BY. Migration
is idempotent, gated on app_settings.migration.text_indexes_c.

Slug indexes on learning_* tables left at default for now — those
are also ASCII in practice but under lighter load; the startup
drift check + auto-REINDEX covers them.
2026-04-14 03:55:09 +02:00
Daniel
9423ffc3a7 Collation-drift guard + lookup-miss visibility
Root cause of recent "invalid credentials on correct password" was
a silent btree index corruption: pgvector/pgvector:pg16 was pulled
with a different ICU library than the one used to build existing
indexes. Queries returned 0 rows even though matching heap rows
existed. Postgres logged nothing (corrupt index → empty result set
is a "successful" query) and the login path never logged unknown-
user attempts (enumeration protection).

Three defenses:

  1. Pin postgres image by digest in docker-compose.yml so a
     silent pull can't change ICU under our feet.
  2. Startup collation-drift check in src/db/database.js:
     compares pg_database.datcollversion to the library's actual
     version and, on mismatch, runs REINDEX DATABASE + ALTER
     DATABASE REFRESH COLLATION VERSION. Logs "Collation versions:
     aligned" on clean boot.
  3. Server-side console.warn on login lookup-miss (no email, no
     audit row — preserves enumeration protection but gives
     Grafana/Loki a signal for unusual miss rates).
2026-04-14 03:52:29 +02:00
Daniel
43d26fd306 Login: remove temporary debug logging
Root cause for "invalid credentials" on correct password was a
corrupt btree index (idx_users_email) causing user lookups to miss
existing rows. Fixed by REINDEX DATABASE. Keeping a typed catch
around passwords.verify() so any future verify throw is logged
cleanly instead of bubbling as 500.
2026-04-14 03:48:56 +02:00
Daniel
11f53102ee Growth/BMI results: percentiles to 2 decimal places
Percentile displays in growth charts, BMI, and mid-parental height
now show 2 dp (e.g. "37.42th") instead of 1 dp ("37.4th") for more
precision at tail percentiles.
2026-04-14 03:37:40 +02:00
Daniel
7cc8a1fa99 Growth chart: accept explicit 0 in any age field
Previously any form of zero total ("0 days", all blank) rejected
with "Enter age". Newborns at birth are a legitimate entry —
distinguish blank-all (error) from explicit-zero (valid).
2026-04-14 03:33:10 +02:00