Skip to content

F6b — speak songbird's real cookie-session auth (corrects F6's brief contract) - #16

Merged
kbennett2000 merged 4 commits into
mainfrom
fix/f6b-songbird-auth
Jun 10, 2026
Merged

F6b — speak songbird's real cookie-session auth (corrects F6's brief contract)#16
kbennett2000 merged 4 commits into
mainfrom
fix/f6b-songbird-auth

Conversation

@kbennett2000

Copy link
Copy Markdown
Owner

F6b — speak songbird's real auth (cookie-session)

The F6 integration gate failed: songbird has no bearer-token auth. F6b reworks the finalize integration against songbird source (github.com/kbennett2000/songbird @ 89f894e546c7b8203f2f8a5faf503b686eb34858). The brief stays unedited; CLAUDE.md carries the errata.

Verified contract (file/line citations)

  • Login POST /api/v1/auth/login, body {username,password} (api/auth.py login; api/schemas.py:23-25 LoginRequest{username:str,password:str}). Success 200 + Set-Cookie: songbird_session=… (httponly, Path=/, SameSite=Lax, Max-Age 30d; Secure off for LAN-HTTP) (core/cookies.py COOKIE_NAME="songbird_session"). Bad creds → 401 {"detail":{"code":"INVALID_CREDENTIALS","message":"Invalid username or password"}} (api/auth.py; envelope core/errors.py raise_http).
  • No header/token auth exists — cookie-only (api/deps.py get_current_user). Unauthenticated routes: /healthz, /api/v1/auth/register, /api/v1/auth/login.
  • Import POST /api/v1/import (api/import_export.py import_notes). Success 200 = ImportSummary (api/schemas.py:211-222): {"annotations":{created,skipped,failed},"sermon_notes":{created,skipped,failed},"errors":[…]}. Verified by tests/import_export_test.py:120 (created:2), :165 (re-import skipped:1), :221-223 (failed:1+2 errors). Import w/o cookie → 401 (:245); Concord down → 502 (:235). The brief's {"annotations":{"created","skipped"}} was missing failed, sermon_notes, errors.
  • Sessions: 30-day TTL, sliding window, one row per login (core/sessions.py SESSION_TTL), cleaned only when expired → login-per-send accumulates. logout (POST /api/v1/auth/logout → 204) deletes the row.

As built

  • Auth = login-per-send (HttpImportPoster): login → capture songbird_session from Set-Cookie → cookied POST /importbest-effort logout (your approved resolution: net-zero session rows). Explicit cookie handling, no global CookieManager; connect 5s/read 15s, no retries (idempotent). Credentials are login-body/cookie-header only — never logged.
  • Seam: ImportPoster.send(baseUrl, username, password, json) → SongbirdExchange{login, imported}.
  • Settings: base URL + username + masked password (EncryptedSharedPreferences); the dead bearer-token pref is gone (no migration — it never worked). canSend/isConfigured require all three.

Failure matrix (ImportResult.from(SongbirdExchange))

condition status message
login network error UNREACHABLE offer Share JSON
login 401/403 LOGIN_REJECTED "songbird rejected the username/password" → Settings
login other non-2xx HTTP_ERROR status + ≤200-char snippet
login ok, import network error UNREACHABLE offer Share
login ok, import non-2xx (e.g. 401 NOT_AUTHENTICATED, 502 Concord, 422) HTTP_ERROR status + snippet — no creds in any message
login ok, import 2xx SUCCESS created/skipped; if failed>0, surfaced prominently with the first errors[] reason

Tests (real shape; fake two-step poster — no MockWebServer)

  • ImportResultTest (13) — first import (committed fixture), idempotent skipped, failed>0+errors, sermon-notes failed counted, missing/malformed summary, login 401→LOGIN_REJECTED, login network→UNREACHABLE, login 500→HTTP_ERROR, import-401-after-login→HTTP_ERROR (not login-rejected), import 502→HTTP_ERROR, import network→UNREACHABLE, snippet truncation.
  • FinalizeViewModelTest (4) — login-ok/import-ok (asserts creds passed), login-fail, login-ok/import-fail, login-unreachable.
  • SongbirdSettingsTest (4) — three-field gating + normalize.
  • Fixture app/src/test/resources/songbird/import_summary.json — provenance: songbird tests/import_export_test.py @ 89f894e (values = our single-annotation first import).

Step-0 findings

  • No token/header auth path anywhere — confirmed cookie-only.
  • Sessions accumulate (30-day, one per login) → added best-effort logout-per-send (your call).
  • Live probe: http://192.168.1.62:8077/healthz + a junk-credential login timed out from this session (songbird not reachable now; no real creds used). Contract is fully source-pinned; your real-login round-trip is the gate.

Verification

  • Clean :app:assemblePaddleDebug + :app:testPaddleDebugUnitTestgreen. Full suite 1192 tests, 0 failures, 0 skipped.
  • grep clean: no Bearer/getToken/KEY_TOKEN/UNAUTHORIZED/settings_token_hint remain; no password/username near any Log./Toast.
  • Frozen-core zero-diff; no new deps (security-crypto already added in F6); APK identity unchanged; 22 paddle assets intact. 16 files, +445/−153.

Gate

Real end-to-end with your login: configure Settings (URL + username + password) → scan → edit → Send → verify in songbird → send again, confirm skipped=1. Then merge.

🤖 Generated with Claude Code

kbennett2000 and others added 3 commits June 10, 2026 07:08
The brief's §4 contract was wrong (no bearer-token auth in songbird). Reworked
the finalize integration against songbird source @ 89f894e:

- Auth: LOGIN-PER-SEND. HttpImportPoster does POST /api/v1/auth/login {username,
  password} → captures the songbird_session cookie from Set-Cookie → POST
  /api/v1/import with a Cookie header → best-effort POST /api/v1/auth/logout
  (net-zero session rows; sessions are 30-day, accumulate per login). Explicit
  cookie handling, no global CookieManager. Credentials are header/body only,
  never logged.
- Seam: ImportPoster.send(baseUrl, username, password, json) -> SongbirdExchange
  {login, imported}; PostResult unchanged.
- ImportResult.from(SongbirdExchange): UNREACHABLE / LOGIN_REJECTED (login 401) /
  HTTP_ERROR (login other non-2xx, or any import non-2xx incl. 401/502 after a
  good login) / SUCCESS parsing the real ImportSummary
  {annotations,sermon_notes each created/skipped/failed, errors[]}; failed>0 is
  surfaced prominently with the first error. Tolerant parsing kept.
- Settings: base URL + username + masked password (EncryptedSharedPreferences);
  the dead bearer token pref is gone (no migration — it never worked). canSend +
  isConfigured now require all three.
- UI: SettingsFragment username/password fields; FinalizeFragment passes creds +
  renders login-rejected / with-failures.

Tests reworked against the real shape: ImportResultTest (13) + committed fixture
import_summary.json (provenance: songbird tests/import_export_test.py @ 89f894e),
FinalizeViewModelTest (4, fake two-step poster), SongbirdSettingsTest (3-field).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a Brief errata under Environment: brief §4's Bearer-token auth and
{"annotations":{created,skipped}} response are superseded — songbird is Argon2
cookie-session (login → songbird_session cookie → cookied import → logout) and
the import response is ImportSummary (annotations/sermon_notes each
created/skipped/failed + errors[]). Verified against songbird source @ 89f894e;
the brief stays unedited. Secrets line now: username+password, encrypted.

Slice map: annotate F6 as built to a wrong brief auth contract; add F6b.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The merge gate runs :app:lintPaddleDebug (build-release.yml), which my slice
verifications never ran (assemble + unit tests only). Lint aborts on
MissingDefaultResource: no_document_to_share_export_first existed in 10 locale
files (values-{de,es,fa,hi,hu,it,pl,pt,ro,ru}) with no default — dead cruft left
by F1c's incomplete locale sweep (an export/share message; nothing references it).
Removed it from all 10 locales; the full gate (compile + testPaddleDebugUnitTest +
lintPaddleDebug) is now green.

CLAUDE.md: document that lintPaddleDebug is part of the gate (run it, not just
assemble), and correct the first-run note to username/password (cookie-session).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@kbennett2000

Copy link
Copy Markdown
Owner Author

Build fix pushed (c7a0ac2).

The failure was Android Lint, not the F6b auth code. The merge gate runs :app:lintPaddleDebug (build-release.yml:117), which my slice verifications never ran (I used assemblePaddleDebug + testPaddleDebugUnitTest). Lint aborts (abortOnError default, no baseline) on MissingDefaultResource: no_document_to_share_export_first existed in 10 locale values-*/strings.xml with no default in values/strings.xml — dead cruft from F1c's incomplete locale sweep (an export/share message; nothing references it, so compile passed). Pre-existing, surfaced now because this is the first run of the lint gate.

Removed the dead string from all 10 locales. The full gate is now green:
./gradlew :app:compilePaddleDebugJavaWithJavac :app:testPaddleDebugUnitTest :app:lintPaddleDebug → BUILD SUCCESSFUL (no Error/Fatal lint), and clean assemblePaddleDebug green. Frozen-core zero-diff; APK identity unchanged.

CLAUDE.md now documents that lintPaddleDebug is part of the gate (run it, not just assemble). The on-device login round-trip is still the remaining gate before merge.

On-device Send failed with "couldn't reach songbird" though songbird was online
and reachable. Root cause: Android (targetSdk 36) blocks cleartext HTTP by
default and the manifest set no policy — so the http:// login POST threw
IOException → mapped to UNREACHABLE. Not a server/contract issue: live
GET /healthz → 200 (songbird 1.6.0) and POST /api/v1/auth/login → 200 +
Set-Cookie: songbird_session both succeed with the supplied URL+creds.

Add res/xml/network_security_config.xml (cleartextTrafficPermitted=true) and wire
it via <application android:networkSecurityConfig>. The operator points the app at
an arbitrary LAN/Tailscale http host (no TLS, no fixed domain), and this is a
private non-store sideloaded app — cleartext is the correct policy.

Full gate green (compile + testPaddleDebugUnitTest + lintPaddleDebug) + assemble;
APK manifest carries networkSecurityConfig; identity unchanged; frozen-core
zero-diff.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@kbennett2000

Copy link
Copy Markdown
Owner Author

Cleartext fix pushed (00190cc) — this is why Send showed "couldn't reach songbird".

Root cause: Android, not songbird. targetSdk 36 blocks cleartext HTTP by default and the manifest set no policy, so the http://192.168.1.62:8055 login POST threw IOException: Cleartext HTTP traffic … not permitted, which HttpImportPoster maps to UNREACHABLE.

Proven against the live server (supplied URL + creds): GET /healthz → 200 (songbird 1.6.0, concord reachable); POST /api/v1/auth/login {kbennett/…}200 + Set-Cookie: songbird_session=…; HttpOnly; Max-Age=2592000; Path=/; SameSite=lax. The auth contract is exactly right — cleartext was the sole blocker.

Fix: res/xml/network_security_config.xml with cleartextTrafficPermitted="true", wired via <application android:networkSecurityConfig>. The operator points the app at an arbitrary LAN/Tailscale http host (no TLS, no fixed domain) and this is a private non-store sideloaded app, so cleartext is the correct policy. Verified: APK manifest carries the config; full gate (compile + testPaddleDebugUnitTest + lintPaddleDebug) + assemble green; frozen-core zero-diff; APK identity unchanged.

Please re-send on-device with the same Settings — it should now reach songbird and report created/skipped (and skipped=1 on a second send). I did not curl a real import, to avoid writing a junk annotation under your user; your on-device send imports the actual scanned note.

@kbennett2000
kbennett2000 merged commit 5680fb1 into main Jun 10, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant