Commit Graph

220 Commits

Author SHA1 Message Date
serversdown 76330f6137 feat: live monitoring section on internal project Overview
The client portal has a live dashboard but the internal project page only
showed static counts. Add a portal-style live section to the Overview tab
so operators can see real-time sound levels at a glance.

Backend:
- New GET /api/projects/{id}/live-stats — resolves each sound NRL to its
  active SLM unit and returns SLMM's cached /status snapshot (concurrent
  fetch). Internal-rich: includes battery/power/reachability the portal
  scrubs. Degrades to no_device/unreachable/no_data per location.

Frontend (project detail Overview tab):
- Rollup strip (live / offline / loudest-now) + a live tile per NRL with a
  Live/Stopped/Offline/Wedged badge, color-coded Leq (55/70 thresholds),
  Lp/Lmax, last-seen, and battery/power.
- Self-refreshes every 15s, pauses when the browser tab is hidden, and sits
  outside the 30s htmx dashboard swap so it never flickers. Polls only for
  projects with the sound module.

Reuses the same SLMM /status source as the portal; no SLMM changes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 21:15:23 +00:00
serversdown c049ac8a41 fix: SLM control no longer shows false "Unknown error" on start
Starting a measurement could pop "Error: Unknown error" in the browser
even though the device started recording fine. Two causes: the proxy's
10s timeout was shorter than a real device start over cellular, and on
an httpx timeout str(e) is empty, so the relayed detail was "" -> the
frontend's `result.detail || 'Unknown error'` rendered "Unknown error".

- Raise the control proxy timeout to 30s so a healthy start isn't cut off.
- Surface SLMM's own error detail on non-200 responses.
- Add an explicit, honest timeout message.
- Never return an empty detail (which rendered as "Unknown error").

Pairs with the SLMM-side fix that makes /start confirm promptly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 20:22:52 +00:00
serversdown c9bb25e7e1 chore(release): 0.15.0 — operator authentication (version, CHANGELOG split, SW cache, test isolation) 2026-06-18 22:04:16 +00:00
serversdown fdcb1ca532 Merge pull request 'Operator-Auth full implementation.' (#70) from feat/operator-auth into dev
Reviewed-on: #70
2026-06-18 16:35:59 -04:00
serversdown 68161298a4 fix(auth): hide /admin/users when flag off; pass OPTIONS preflight through gate
- operator_users router now depends on _require_auth_enabled, which raises
  404 when OPERATOR_AUTH_ENABLED is false — prevents world-open pre-seeding
  of a superadmin while the flag is off (the default).  Flag is read as a
  live module attribute (operator_auth.OPERATOR_AUTH_ENABLED) so monkeypatching
  in tests and a runtime flip both take effect.
- operator_gate passes OPTIONS requests through immediately before the exempt-
  path check, so CORS preflight reaches CORSMiddleware rather than being
  303/401'd by the gate.
- Two new tests: test_admin_surface_404s_when_flag_off (test_operator_users)
  and test_options_preflight_passes_through_gate (test_operator_gate).
  Full suite: 90 passed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 20:27:01 +00:00
serversdown 7a4453108a feat(auth): operator admin/break-glass CLI
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 19:50:37 +00:00
serversdown bff9a4af4a feat(auth): superadmin user-management page + CRUD
/admin/users page and /api/admin/users/* JSON CRUD endpoints, all behind
require_role("superadmin"). Temp passwords are returned once on create/reset
and never stored in plaintext. Admins get 403; password_hash is never leaked.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 19:48:13 +00:00
serversdown 41ab900c33 feat(auth): login/logout/change-password routes + pages
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 19:38:55 +00:00
serversdown 2879abb355 feat(auth): deny-by-default gate middleware + require_role
Adds operator_gate Starlette HTTP middleware that gates every route
except an explicit allow-list. Flag defaults OFF so all existing
behaviour and tests are unchanged. wire_operator_auth helper in
conftest lets tests monkeypatch the module-global SessionLocal and
flag, keeping the gate's own DB session pointed at the test engine.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 19:27:41 +00:00
serversdown e8fe4845aa feat(auth): authenticate + lockout + operator data helpers
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 19:19:36 +00:00
serversdown a6e1cb4f87 feat(auth): operator session cookie + current_operator DB re-validation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 19:11:41 +00:00
serversdown 4abfcbc293 feat(auth): OperatorUser model + role ladder
Add OperatorUser SQLAlchemy model (operator_users table, auto-created by
create_all) with email uniqueness, default active/must_change_password/
failed_login_count, and sessions_valid_from truncated to whole seconds.
Add backend/operator_auth.py with feature flag, cookie constants, _ROLE_RANK
map, role_at_least(), and _norm_email() helpers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 19:08:18 +00:00
serversdown 8e817ec48d feat(auth): generic HMAC signed-cookie module for operator auth
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 19:05:56 +00:00
serversdown abdb3869bd docs: consolidate changelog into 0.14.0 + refresh README
Cut [0.14.0] consolidating SLM live monitoring, the FTP night-report
pipeline (was missing from the changelog entirely), the client portal,
and portal auth Phase 1 under one entry. Bump VERSION + README to 0.14.0
and add the sound-monitoring / night-report / client-portal features to
the README.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 01:29:32 +00:00
serversdown b0e5dcdc52 Merge branch 'dev' into feat/ftp-report-pipeline 2026-06-16 21:52:06 +00:00
serversdown 98bbbcfa86 Merge pull request 'Client portal auth (Phase 1): per-project link + password gate' (#63) from feat/portal-auth into dev
Reviewed-on: #63
2026-06-16 14:59:57 -04:00
serversdown 3c5e830f9c feat(reports): one safe restart-retry in the cycle before alerting
If the post-restart DOD check shows the meter isn't measuring, retry once with start_recording (a plain start that does NOT re-index, unlike start_cycle) and re-verify before raising the schedule-failed alert. Retry fires only on a confident not-measuring reading — never on a failed/inconclusive DOD read — so a flaky read can't disrupt an already-running measurement or split the night across two store folders. Turns a transient restart hiccup into a self-heal instead of a meter left stopped overnight.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 02:54:37 +00:00
serversdown 766f64f35f refactor: final-review cleanup
- delete dead magic-link helpers (resolve_token, ensure_project_client,
  mint_link_token, provision_preview_session) + now-unused datetime import
- key brute-force lockout on link_token alone (IP term only enabled a
  source-IP-rotation bypass; behind the proxy all clients share one IP)
- drop unused PORTAL_BASE_URL from the retired CLI
- add WebSocket ownership tests (unauth + cross-project both close 1008)
2026-06-16 00:28:23 +00:00
serversdown 20f62a5c0a feat: env-driven Secure flag on portal session cookie
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 00:16:54 +00:00
serversdown 01180d5725 fix: retire portal_admin mint-link (dead /portal/enter URL); refresh docstrings; assert revoke route gone 2026-06-16 00:15:09 +00:00
serversdown f0a13ea2ff refactor: retire interim magic-link/open-link in favor of password gate
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 00:06:02 +00:00
serversdown 25a4a28433 feat: operator portal-access endpoints (enable/password/disable/state)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 23:55:10 +00:00
serversdown b8e4718318 fix: link project to its portal client (project.client_id) so the portal isn't empty
Caught by adversarial review of the scope test: portal_client_for_project minted a
dedicated client but never set project.client_id, so the client-scoped routes found
no projects — every location 404'd, including the client's own (empty portal). Now
links the project + adds a positive-case test.
2026-06-15 23:53:19 +00:00
serversdown c74dada8b3 fix: treat enabled-but-passwordless portal as inactive (no dead form / self-lockout) 2026-06-15 23:46:14 +00:00
serversdown d75f405857 feat: per-project portal password gate (/portal/p/{token}) + lockout
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 23:41:37 +00:00
serversdown 446d8704f9 refactor: hoist Project import to top; drop unused test import 2026-06-15 23:39:14 +00:00
serversdown c04830a0ad feat: per-project portal session mint + link-token resolve + lockout
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 23:35:48 +00:00
serversdown b11e1a554f feat: add per-project portal gate columns + migration
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 23:32:41 +00:00
serversdown ad6de946b5 refactor: simplify verify_password except clause; drop unused import 2026-06-15 23:31:14 +00:00
serversdown d44625374d feat: argon2 password hashing helpers for the portal 2026-06-15 23:29:26 +00:00
serversdown aa21c81c2e feat(reports): per-NRL Data Files tab reaches parity with the project-wide tab
The per-NRL Data Files tab now reuses the same FTP browser + unified-files partials as the project-wide tab, scoped to the one NRL: ftp-browser and files-unified take an optional location_id. nrl_detail.html drops the flat file_list view for 'Download Files from SLMs' (Browse Files -> Download & Save) plus the grouped 'Project Files' view (edit times / download-all / delete), keeping the NRL upload and adding a refresh button.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 18:46:59 +00:00
serversdown 7716a4b51d feat(reports): manual FTP "Download & Save" saves a parsed session
ftp-download-folder-to-server and ftp-download-to-server now route NRL data through the shared ingest (ingest_nrl_zip / _ingest_file_entries) instead of hand-rolling DataFile rows on a now/zero-duration session. Folder save requires the unit be assigned to a location; non-NRL single files keep the generic save path. The FTP browser popup now reports how long the measurement ran.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 18:12:15 +00:00
serversdown 2ecf1f54d5 fix(reports): derive session recording window from the Leq rows
The NL-43 .rnh carries no measurement timestamps, so _ingest_file_entries was stamping every session with utcnow() and no duration. Derive started_at/stopped_at/duration from the Leq .rnd 'Start Time' column when the header lacks them (interval from the .rnh, else inferred from row spacing). Adds an optional unit_id so callers that know the recording unit attribute the session at creation, and returns duration_seconds.

Side effect: NL-43 dedupe now works (it keyed on a previously-empty start_time_str). Affects all ingest paths: manual upload, FTP cycle, stop, download, and manual FTP download.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 18:12:15 +00:00
serversdown abcfba179f refactor(reports): funnel scheduler stop/download/cycle through one ingest
_execute_stop and _execute_download no longer hand-roll ZIP extraction; all three actions now call a shared _ingest_and_link helper (ingest via ingest_nrl_zip, link the unit, drop the empty placeholder session). Every capture path produces the same clean, .rnh-parsed, percentile-aware, deduped, Leq-only session. _execute_download previously created no session at all (TODO); it now ingests like the others.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 18:12:15 +00:00
serversdown 5c38f6ec1e Merge branch 'dev' into feat/ftp-report-pipeline 2026-06-12 06:49:27 +00:00
serversdown 7fcd1261b4 Merge pull request 'feat(reports): FTP night-report pipeline foundation' (#62) from feat/ftp-report-pipeline into dev
Reviewed-on: #62
2026-06-11 23:27:34 -04:00
serversdown fdd0426884 fix(reports): code-review findings — XSS, SMTP, blocking, unit link, email guard
- #1 XSS: escape user-controlled values (location name, baseline values, recent-
  report fields, SMTP status message) in the modals via the existing _mergeEsc
  helper — they were concatenated raw into innerHTML (stored XSS via location name).
- #2 SMTP: an unrecognized REPORT_SMTP_SECURITY no longer silently downgrades to a
  plaintext connection while still calling login() — it falls back to starttls and
  warns; warn on intentional security=none + auth.
- #3 scheduler: run the (blocking smtplib + Excel) nightly report in a worker thread
  (asyncio.to_thread + its own DB session) so it can't stall the loop that drives
  time-sensitive device cycles. New _run_one_report helper.
- #4 cycle ingest: set unit_id on the ingested data session (ingest_nrl_zip leaves
  it None) before dropping the empty placeholder, preserving the unit<->session link;
  repoint old_session_id at the real row.
- #7 robustness: wrap send_report_email in the orchestrator and run_nightly_report in
  /view + /run so a render/SMTP error returns a clean error instead of a raw 500
  after artifacts are written.

Verified: SMTP paths (typo->starttls, none, starttls, ssl), off-thread tick stamps
last_run_date + writes the file, /view 200, escaping wired, app imports.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 02:37:28 +00:00
serversdown fe7cf91488 fix(portal): pre-merge security hardening from code review
- PORTAL_OPEN_LINKS now defaults OFF — /portal/open/* is an unauthenticated,
  proxy-reachable session-minting path (and a linked project's open link grants
  the whole client's scope), so it must be explicitly enabled in dev.
- Session cookie: enforce server-side expiry (check iat vs COOKIE_MAX_AGE — was
  browser-only) and guard a non-dict signed body (was an uncaught AttributeError →
  500, reachable if SECRET_KEY is the insecure default).
- Escape operator-set strings (location/rule/event names) before innerHTML +
  Leaflet tooltips — they're client-facing, so a name with markup was stored XSS
  in the client's browser. Global esc() helper applied at every injection point.
- WS _scrub_frame drops a non-JSON frame instead of forwarding it raw; /history
  rows now whitelisted like the other scoped endpoints.
- Preview-client slug uses the full project id (an 8-char prefix could collide
  two projects onto one client).

Verified: cookie reader (fresh/expired/non-dict/missing-iat) + open-links default
off; templates parse; scoped scrubbing intact.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 23:40:52 +00:00
serversdown c1bc391ba2 feat(portal): show the client their active alert limits
New scoped GET /portal/api/location/{id}/thresholds returns the enabled alert
rules (scrubbed: name/metric/comparison/threshold/duration/schedule — no cooldown
or hysteresis internals). Location page renders an "Alert limits" panel above the
history, e.g. "Night noise · Leq above 65 dB for 60s · 22:00–07:00", hidden when
no limits are set. Gives the breach history context.

Verified: portal.py compiles; location script balances; template parses.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 23:28:57 +00:00
serversdown ccb70698ba feat(reports): Excel renderer + attachment + archive download
render_excel(report): one worksheet per location — interval table, a line chart,
and a Last/Base/Δ summary per window. Metric-driven, so it tracks whatever metric
set is configured.

- orchestrator: render report.xlsx alongside report.html, attach it to the email
  (dry-run until SMTP set), expose xlsx_path. Never lets a spreadsheet error sink
  the report.
- reports router: /list includes xlsx_url when present; new
  GET /archive/{date}/xlsx serves the saved spreadsheet.
- UI: Recent-reports rows get an "Excel" download link.

Verified: real Feb data -> valid .xlsx (sheet per NRL, interval table + chart +
summary with real values), attachment path runs, both archive routes registered.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 22:49:32 +00:00
serversdown 88887a92d8 feat(reports): #2 capture hook — cycle auto-ingests + verifies restart
Extend _execute_cycle (daily stop/download/increment/restart) so the nightly
report's data lands automatically:
- Step 4b: after the device download, fetch the just-finished Auto_#### folder
  from SLMM and ingest via ingest_nrl_zip (clean session + DataFiles, Lp filtered,
  dedup). Drops the empty "recording" placeholder session once the real data
  session exists. New helper _ingest_cycle_folder.
- Step 6b: after restart, verify the meter resumed measuring via a fresh DOD
  (measurement_state) — advisory: alerts loudly on failure but doesn't fail the
  cycle (keepalive polling re-confirms within ~10s).

Both wrapped defensively so they never break the cycle. Ingest-hook logic verified
with a mocked SLMM (real Feb folder -> session + 2 DataFiles, dedup, empty/HTTP
guards). Device-control paths (restart-verify, live download) are field-untested
— no meter available in dev.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 20:55:44 +00:00
serversdown c1b5efae56 feat(reports): reference-baseline mode (typed limits / prior averages)
Baseline can now come from fixed values typed per location, not just captured
data — for a spec limit ("L10 = 85") or a prior report's averages when the raw
data isn't available.

- SoundReportConfig.baseline_mode ("captured" | "reference").
- report_pipeline: _location_reference_baseline() reads per-location values from
  location_metadata; build_*_night_report honor baseline_mode (reference cells
  use the typed value; unset metrics compare against nothing).
- reports router: GET/PUT /reports/baseline (mode on config + per-location values
  in location_metadata); config carries baseline_mode; manual view/run fall back
  to the saved config's baseline when no explicit dates are given.
- orchestrator + scheduler tick thread baseline_mode through.

Verified end-to-end: PUT/GET /baseline, reference deltas (L10 66.6 vs 85 -> -18.4),
unset metrics compare against nothing, captured-mode regression intact.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 20:26:23 +00:00
serversdown 0914cf0a75 feat(portal): M2b-2 — surface alert state + breach history (internal + portal)
Internal (SLM detail page): live alarm-state badge in the Alerts header
(● N active / ✓ all clear), a History list of fired events (onset → clear, peak
dB, ack status) with an Ack button, refreshed every 20s. Reads the existing SLMM
/alerts/events + /ack via the proxy.

Portal (client, read-only, scoped): new GET /portal/api/location/{id}/events —
ownership-gated, returns a scrubbed projection (rule_name/metric/threshold/onset/
peak/clear/status only; no internal ids or ack-by) plus an `active` count. The
location page shows a red "Currently above threshold" banner when active and a
read-only breach history, polled every 20s. No ack on the client side.

Verified: portal.py compiles; both scripts balance; both templates parse.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 19:02:56 +00:00
serversdown bececafe78 feat(portal): plain no-token "open" links for dev feedback (PORTAL_OPEN_LINKS)
Adds a frictionless shareable link so anyone can open a project's client portal
during dev without minting/copying a magic token. GET /portal/open/{project_id}
(gated by PORTAL_OPEN_LINKS) provisions the client session and lands on /portal;
lives under /portal so it works through a proxy exposing only /portal/*.

The project page's "Copy client link" modal now leads with this Quick share link
(amber, host taken from window.location.origin so it always matches the host you
copied it from — no more LAN-vs-public foot-gun). The token-based generate/list/
revoke stays below for the eventual secure path.

PORTAL_OPEN_LINKS defaults ON for the prototype (whole app is open anyway) and logs
a warning; set =false before real clients. The get_current_client seam is
untouched, so M4 auth still layers in front of the same routes regardless.

Verified: compiles, share script balances, detail.html parses, flag default
on / =false off.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 17:26:37 +00:00
serversdown 7fb4ba0343 feat(reports): wire run-now, archive, test-email, last-run status into the UI
Backend (reports router):
- POST /reports/test-email — send a test email (body/config recipients; dry-run
  if SMTP unset) to verify the relay.
- GET  /reports/list — list generated report artifacts on disk (newest first).
- GET  /reports/archive/{date} — serve a saved report.html (traversal-guarded).

Frontend (sound project header modals):
- Night Report modal: "Run & Email" button (POST /run) + a "Recent reports" list
  (GET /list → opens the archived report.html in a new tab).
- Settings modal: schedule + last-run status line, and a "Send test email" button.

Verified: endpoints (run→list→archive, traversal blocked, test-email recipient
fallback) and the template renders with all four wired + gated to sound projects.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 17:19:30 +00:00
serversdown 2da9493cb5 feat(portal): "Copy client link" — generate/copy/revoke shareable links from the project page
No-CLI way to get a real shareable magic link (/portal/enter/<token>) for a
project's client. Project page gets a "Copy client link" button next to the
preview; opens a modal that lists active links (with revoke), generates a fresh
one, and copies it to the clipboard.

Backend (operator, internal /projects/*):
- POST /projects/{id}/portal-link  -> mint a fresh token, return the full URL
  (built from request.base_url so it uses the operator's host).
- GET  /projects/{id}/portal-links -> list active links (label/created/last-used).
- POST /projects/{id}/portal-link/{tid}/revoke -> revoke one (scoped to the
  project's client).

Refactor: split ensure_project_client() + mint_link_token() out of
provision_preview_session() so minting a shareable link and the preview cookie
share one provisioning path.

Verified: ensure/mint persistence across commits + sessions, minted link resolves,
token stored hashed, second mint = distinct active link (4/4); compiles; share
script balances; detail.html parses.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 17:11:34 +00:00
serversdown b2c54caebd feat(reports): per-project report config + automatic morning run
Add SoundReportConfig (one row per project) + the scheduler tick that runs the
nightly report on its own:
- model SoundReportConfig (enabled, report_time, metric_keys, baseline range,
  recipients, last_run_date) — new table, auto-created by create_all (no migration).
- GET/PUT /api/projects/{id}/reports/config with validation.
- SchedulerService.run_due_reports(): each loop, for every enabled config past
  its report_time, run last night's report once (dedup via last_run_date),
  writing the file + emailing (dry-run until SMTP is set).
- UI: gear button beside "Night Report" opens a settings modal (enable, time,
  baseline range, metrics, recipients) that GET/PUTs the config.

Verified: table registers + auto-creates, config CRUD + validation, tick
runs/dedups, templates render and gate to sound projects.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 17:08:58 +00:00
serversdown 0103917870 feat(portal): live ~1Hz WS stream with auto-close (visibility + idle cap)
The portal location view is now genuinely live, not a 15s poll. Scoped WS endpoint
/portal/api/location/{id}/stream: authenticates via the session cookie, enforces
ownership (resolve_client_location), then bridges the unit's shared SLMM /monitor
fan-out feed to the browser — a viewer is just one more subscriber, no extra
device connection. Frames are scrubbed to the portal whitelist (drops unit_id,
raw_payload, counter, lmin) before reaching the client.

location.html: cache prefill for instant first paint, then upgrades to the live
socket (cards tick ~1Hz, chart scrolls). Auto-close so an abandoned tab can't pin
the device at 1Hz polling (~8x cellular data):
- closes when the tab is hidden, reopens when visible (Page Visibility) — the main
  guard;
- hard 15-min cap -> "Live paused — click to resume" overlay.

Refactor: client_from_cookie() extracted from get_current_client so the WS handler
(no Request-based Depends) can auth the same way.

Verified: scrub drops internal fields / keeps metrics + heartbeat (7/7), auth
refactor (3/3), portal compiles, location.html JS balances + parses.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 03:16:32 +00:00
serversdown 3fc20e104a feat(portal): one-click "View client portal" preview from the project page
Adds a "View client portal" button on the project detail page that opens the
client portal scoped to that project — no CLI. GET /projects/{id}/portal-preview
auto-provisions a client + access token for the project (provision_preview_session)
and seals a portal session cookie, then redirects to /portal.

- Reuses the project's linked client if it has one; otherwise creates/reuses a
  per-project 'preview-<id>' client. Only sets project.client_id when unset, so it
  never clobbers a real client link. Idempotent — repeat clicks reuse the same
  client/token.
- Lives under /projects (not /portal), so a future public proxy exposing only
  /portal/* won't expose this operator shortcut.

Verified: provisioning (unlinked creates+links, idempotent, linked-no-clobber) 7/7.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 02:18:06 +00:00
serversdown dd77f27cf6 Merge branch 'dev' into feat/ftp-report-pipeline
pulled in the live slm stuff
2026-06-11 01:54:21 +00:00