# ADR-105: Hub Issues a Signed JWT Alongside the localStorage Session — For Proxy Services and Future Native Clients Only

## Status

Accepted, 2026-07-19 (Proposed 2026-07-03).

## Status History

```yaml
status_history:
  - date: 2026-07-03
    status: Proposed
    changed_by: RESEARCH session (hkl requested)
    reason: |
      Two independent triggers now met: (1) tour-pg-proxy's "Authorization: Hub
      <base64(...)>" scheme is unsigned and forgeable — verified in server.js;
      (2) Flutter/native migration feasibility audit (2026-07-03) flagged
      session portability as a blocking gap. ADR-012 explicitly named
      "multi-device concurrent sessions needed or token expiry audit required"
      as its own upgrade trigger — both conditions are now present.
    changed_via: manual (adr-kit conventions followed by hand)
  - date: 2026-07-19
    status: Accepted
    changed_by: hkl (interactive decision, FLUTTER-JWT-PHASE1 session)
    reason: |
      Dev build complete and independently verified (pyjwt): hub.login mint RPC
      delegating to expense.verify_pin, hashed refresh tokens, deactivation
      live-check, anon PostgREST + verify_pin regression-confirmed unbroken.
      Fable text review passed (two supersessions recorded reconciling the
      original body with the built design). Deferred under this ADR: proxy
      dual-accept step, Hub-side minting, legacy-header removal, prod
      promotion, 11-proxy audit.
    changed_via: manual (adr-kit conventions followed by hand)
```

## Context

Three prior ADRs govern identity today:

- **ADR-011** — employees authenticate with a PIN, not a password or OAuth. This is correct for the workforce and is NOT being revisited.
- **ADR-012** — Hub is the single SSO gateway; after `verify_pin` succeeds, Hub writes a plain JSON object to `localStorage['lm360-session']`. ADR-012 explicitly rejected a "Central auth API (separate service, e.g. JWT issuer)" as over-engineered for the scale at the time, but recorded its own reversal condition: *"ponytail: upgrade trigger=multi-device concurrent sessions needed or token expiry audit required."*
- **ADR-026** — cross-PWA handoff of that same session uses shared `localStorage` on one origin (`srv1111289.hstgr.cloud`). It also rejected JWT, for a narrower reason: browser-to-browser handoff on the same origin has no forgery surface a JWT would close, so the added token-issuing/verification infrastructure bought nothing there.

Both rejections were reasoned correctly for the problem they were solving (25 same-origin PWAs authenticating each other via `localStorage`). Neither ADR was written with two things that are now true:

**1. The session object has leaked past the browser, unsigned.** `tour-pg-proxy` (one of the 12 backend proxy services documented in the 2026-07-03 Flutter-readiness audit) expects:

```
Authorization: Hub <base64(JSON({empId, name, role, loginAt}))>
```

Verified in `/opt/tour-pg-proxy/server.js:287-301`: the handler does

```js
const m = auth.match(/^Hub (.+)$/);
session = JSON.parse(Buffer.from(m[1], 'base64').toString('utf-8'));
// ...checks (Date.now() - session.loginAt) < SESSION_TTL_MS, nothing else
```

There is no signature, no HMAC, no server-side lookup against `expense.employees`. Anyone who can reach the container (internal Docker network today; would be the public internet if this pattern is ever reused on an externally-exposed proxy) can construct that header for `role: 'admin'` and any `empId`, with a `loginAt` far enough in the future to never expire in the 12h window. **This is a real vulnerability in production code, independent of any Flutter timeline.** Whether the other 11 proxy services (`slides-proxy`, `recce-view-proxy`, `print-ai-proxy`, `ocr-proxy`, `ai-finance-proxy`, `track-proxy`, `sales-proxy`, etc.) copied the same pattern is ⚠️ UNVERIFIED — an audit is listed under Implementation Notes.

**2. A native/Flutter client has no `localStorage`.** ADR-026's session-key contract (`lm360-session`, `lm360-activity-session`, `lm360-client-session`) is a browser-only artifact. A Flutter app needs a credential it can put in `flutter_secure_storage` and attach to outbound requests — the same shape of problem the proxies already have, just for a different client type.

Both problems have the same fix: a credential that is **signed** (so it can be verified without a database round trip and cannot be forged) and **portable** (so it works for a Docker-network proxy call and a native app equally well). That is a JWT. The question this ADR answers is *how much of the existing architecture that requires*, not whether JWTs are the right primitive.

## Decision

**Add a signed JWT as a second, optional artifact issued at the same moment as today's plain-JSON session — do not replace the existing browser-to-browser mechanism.**

### 1. What stays exactly as-is (ADR-011, ADR-012, ADR-026, ADR-080 unchanged)

- PIN entry UX, `verify_pin` RPC, and its `[{id, name, role}]` response shape.
- `localStorage['lm360-session']` and the other two documented session keys, written and read exactly as today.
- All 25 existing browser PWAs. **Zero migration required** — they never see or handle a JWT unless a maintainer chooses to adopt one later for a specific reason (e.g. moving a proxy call server-side).

This is the core scoping decision: ADR-026's reasoning ("localStorage on one origin has no forgery surface a JWT would close") is still correct for the case it covers. This ADR does not reopen that.

### 2. What's new

**Hub's post-`verify_pin` step also mints a signed access token:**

```
POST /db/rpc/verify_pin  { p_id, p_pin }  →  [{ id, name, role }]   (unchanged)
                          ↓ (Hub-side, after success)
                     issue_hub_token(empId, name, role)
                          ↓
        access_token  (JWT, HS256, 30 min exp, claims: sub=empId, name, role, iat, exp)
        refresh_token (opaque random string, 12h exp — matches today's session TTL, stored
                        server-side in a new hub.refresh_tokens table keyed by empId)
```

- Signing secret `HUB_JWT_SECRET`: one value, distributed as an env var to the Hub container and every proxy container that needs to verify it — the same distribution pattern already used for `MCP_MAPS_BEARER`, `MCP_TOMTOM_BEARER`, `ANTHROPIC_API_KEY` (see any `docker-compose.yml` service block). No new secret-management infrastructure.
- Access token is short-lived (30 min) so a stolen token has a small blast radius; refresh token lets a long field session avoid re-entering a PIN every 30 minutes.
- **New RPC** `hub.refresh_hub_token(p_refresh_token)` → issues a new access token IF the refresh token is unexpired AND the employee is still `active` in `expense.employees` (a live check — today's plain-JSON session has no equivalent; a deactivated employee's existing `localStorage` session still works for up to 12h. This makes the JWT path strictly more secure than the status quo it sits beside, not just "parity for Flutter.")

### 3. Who has to use it

| Client | Uses JWT? | Why |
|---|---|---|
| Existing 25 browser PWAs | No (unchanged) | Same-origin `localStorage` handoff has no forgery risk (ADR-026 stands) |
| The 12 backend proxy services | **Yes — migrate over time** | Currently receive a self-asserted, unsigned claim from the browser; must move to verifying `Authorization: Bearer <access_token>` (signature + `exp` + optionally re-check `role` is still current) |
| Future Flutter/native client | **Yes** | No `localStorage`; needs a portable, verifiable credential for both proxy calls and (later, out of scope here) any PostgREST access |

Proxy migration order: **`tour-pg-proxy` first** (confirmed vulnerable), then audit the remaining 11 and migrate any using the same unsigned pattern. A proxy that already had no meaningful authorization gate (e.g. purely internal, no PII) may be deprioritized — judgment call per service, documented at migration time.

### 4. What is explicitly OUT of scope for this ADR

- **PostgREST row-level access control.** Table/view grants stay on the current `web_anon`-role, schema-isolation pattern (ADR-009). PostgREST has native JWT support (`pgrst.jwt_secret`, role-per-claim, which would enable real RLS) — that is a materially bigger change (every schema's grants would need auditing against claims) and is **not** needed to fix the proxy vulnerability or unblock Flutter's first PoC. Flag as a future ADR trigger if/when RLS becomes a requirement (e.g. the multi-tenant consultancy offering in the ERP-Lite roadmap).
- **Replacing `localStorage` sessions for existing PWAs.** Covered above — deliberately not touched.

## Alternatives Considered

- **Do nothing until the Flutter build actually starts.** Rejected: the unsigned proxy credential is a present-day defect in deployed code, not a future-Flutter-only concern. Fixing it now is a security fix that happens to also unblock Flutter, not the reverse.
- **Full replacement — every PWA switches to JWT/Bearer immediately.** Rejected: 25 PWAs would need simultaneous, coordinated changes for a same-origin handoff case that has no attacker model requiring it (ADR-026's original reasoning holds). High blast radius for zero security benefit in that path.
- **External auth service (Auth0, Keycloak, Firebase Auth).** Rejected for the same reason ADR-012 rejected it: new external dependency, credential/PII residency questions, overkill for ~50 employees and a handful of proxies. A 30-line RPC + HS256 library covers the actual requirement.
- **PostgREST-native JWT with full RLS from day one.** Rejected as this ADR's scope: valuable eventually, but bundling it here would block the proxy-vulnerability fix behind a much larger grants/RLS audit across every schema. Split into a future ADR when a concrete driver (e.g. multi-tenant consultancy, or the Flutter PoC needing direct table access rather than only proxy calls) makes it necessary.
- **Rotate to a signed cookie instead of a JWT.** Rejected: proxies and a future Flutter app are not browsers: a `Set-Cookie`/cookie-jar model adds no value over an `Authorization` header a non-browser client can set directly, and it re-introduces same-origin/SameSite complexity for zero benefit.

## Consequences

**Positive:**
- Closes a real, currently-exploitable forgery vector in `tour-pg-proxy` (and any sibling proxy found to share the pattern).
- Directly resolves ADR-012's own recorded reversal trigger ("token expiry audit required").
- Unblocks the #1 gap named in the 2026-07-03 Flutter-readiness audit, with no migration cost imposed on the existing 25 PWAs.
- Refresh-token path adds a live active-employee check that today's plain session lacks — a security improvement independent of Flutter.
- Reuses the existing per-container env-var secret distribution pattern; no new infra service.

**Negative / Trade-offs:**
- A new secret (`HUB_JWT_SECRET`) must be distributed to and kept in sync across Hub + N proxy containers; rotation requires a coordinated restart of all of them (document a rotation runbook when first rotated).
- `hub.refresh_tokens` is a new stateful table (opaque refresh tokens must be revocable — e.g. on manual employee deactivation) — a small but real new piece of DB surface area, unlike today's fully stateless `localStorage` session.
- Proxy migration is incremental, meaning for a transition period some proxies verify signatures and others still trust the unsigned header — track this explicitly (checklist below) so the vulnerability doesn't quietly persist in unmigrated services.
- Two parallel identity artifacts (plain session + JWT) now exist for browser PWAs that happen to also call a migrated proxy directly from client-side JS — those call sites must be updated to attach the new `Authorization: Bearer` header when calling a migrated proxy, even though the PWA's own login flow is otherwise untouched.

**Risks and mitigations:**

| Risk | Mitigation |
|---|---|
| `HUB_JWT_SECRET` leaks (e.g. committed to git, logged) | Env-var only, never in code; add to any secret-scanning pre-commit hook if one exists; rotate immediately if suspected |
| A proxy is migrated to verify JWT but a caller still sends the old unsigned header | Migrated proxies must reject the old `Hub <base64>` scheme outright, not accept either — fail closed, not fail open |
| Refresh-token table grows unbounded / stale tokens never expire | `exp` column + a periodic cleanup (reuse the existing nightly cron slot per project CLAUDE.md orchestration notes) deletes expired rows |
| Deactivated employee's already-issued access token still works for up to 30 min | Accepted risk — smaller window than today's 12h plain-session exposure; tighten further only if a concrete incident demands it |
| Scope creep into full PostgREST RLS during implementation | Explicitly out of scope (see Decision §4); a future ADR must justify it separately |

## Implementation Notes (not yet executed — this ADR is the design; build is a separate task)

1. **Audit the other 11 proxies** for the same unsigned-header pattern before assuming only `tour-pg-proxy` is affected: `slides-proxy`, `recce-view-proxy`, `print-ai-proxy`, `cad-proxy`, `ai-finance-proxy`, `ocr-proxy`, `pdf-proxy`, `claude-oauth-proxy`, `sales-proxy`, `track-proxy`, `print-bridge`.
2. Add `hub.refresh_tokens (token text PK, emp_id text, issued_at, expires_at)` — DB_MIGRATIONS + DB_GRANTS zone locks required (per parallel_sessions.md), dev-first per `feedback_db_migrations_dev_first.md`.
3. Write `issue_hub_token` / `hub.refresh_hub_token` RPCs (SECURITY DEFINER where they touch `expense.employees.active`, per ADR-007 precedent in ADR-080 §4).
4. Migrate `tour-pg-proxy` first; write its test suite (it already has `test.js` with `Authorization: Hub bad` / `expired` / `valid` cases — extend, don't replace, that test file).
5. Update the Flutter-readiness audit's Gap 1 status once step 4 lands.

## Related Decisions

- **ADR-011** — PIN-based auth. Unaffected; this ADR governs what happens strictly after a successful PIN check.
- **ADR-012** — Hub as SSO gateway. This ADR resolves ADR-012's own recorded upgrade trigger; the plain-session mechanism ADR-012 describes for browser PWAs is otherwise unchanged.
- **ADR-026** — localStorage session bridge. Explicitly reaffirmed for its original scope (same-origin browser handoff); this ADR adds a parallel mechanism for non-browser clients only.
- **ADR-080** — Hub session registry / cross-schema sync. The active-state propagation trigger described there (`fn_sync_custodian_active`) is the same signal the refresh-token check reads for the "still active" gate.
- **ADR-009** — per-PWA schema isolation. PostgREST grants are unaffected by this ADR (see Decision §4).
- **ADR-010** — cross-schema access via proxy, not PostgREST. The proxies this ADR hardens are exactly the ones ADR-010 established as the pattern for cross-schema/external calls.

## References

- `/opt/tour-pg-proxy/server.js:287-301` — the unsigned `Authorization: Hub <base64>` verification code (the concrete defect this ADR fixes)
- `/opt/tour-pg-proxy/test.js` — existing auth test cases to extend
- `docs/research_erp_lite_landscape_2026-07-03.md` §8 — Flutter-readiness audit naming this as Gap 1
- `hub/MDD_hub.md` §4.3 — verified `verify_pin` RPC contract
- `docs/adr/ADR-012-hub-as-sso-gateway.md`, `ADR-026-cross-pwa-session-bridge-via-localstorage.md`, `ADR-080-hub-session-registry-cross-schema-sync.md`

---

## Implementation Note (2026-07-19) — DEV built + verified; awaiting Fable text review + hkl Accept

Built and verified on **dev (`lm360`) only** by the FLUTTER-JWT-PHASE1 session (Opus, from the
developer laptop over SSH, user-directed). Migration file: `hub/migrate_hub_jwt_v1.sql`.

**What was resolved beyond the original Decision (two gaps this ADR left open + three
review-driven hardenings):**

1. **Minting has no Hub backend to run in** — Hub is a frontend PWA, there is no Hub container.
   Resolved: minting happens in a Postgres RPC **`hub.login(p_id, p_pin)`** that **delegates
   the credential check to the existing `expense.verify_pin`** (never duplicates PIN logic) and
   only returns a token on success. A token is thus unobtainable without valid credentials —
   the RPC is the sole issuance path, `verify_pin` stays byte-identical for the 25 PWAs.
2. **Where the signing secret lives** — new `HUB_JWT_SECRET` in `.env` (following the existing
   `RECCE_VIEW_HMAC_SECRET` precedent), read inside the `SECURITY DEFINER` RPCs via a DB GUC
   (`current_setting(app.hub_jwt_secret)`, set with `ALTER DATABASE`). `web_anon` can CALL
   the RPCs but cannot read the raw secret. Fresh secret, independent of `PGRST_JWT_SECRET`.
3. **(Fable) Refresh tokens stored HASHED** — `hub.refresh_tokens.token_digest` holds
   `sha256(token)`, never the raw token; a DB read-leak cannot be replayed as sessions.
   Verified: querying by raw token returns 0 rows.
4. **(Fable) `hub.login` delegates, never re-implements** the credential check — see (1).
5. **(Opus/Fable) The JWT is for PROXIES + native identity ONLY — never sent to PostgREST.**
   PostgREST-native JWT/RLS stays out of scope (unchanged from the original §4). The Flutter
   apps PostgREST client was corrected the same day to be permanently anonymous

---

## Implementation Note (2026-07-19) — DEV built + verified; awaiting Fable text review + hkl Accept

Built and verified on **dev (`lm360`) only** by the FLUTTER-JWT-PHASE1 session (Opus, from the
developer laptop over SSH, user-directed). Migration file: `hub/migrate_hub_jwt_v1.sql`.

**What was resolved beyond the original Decision (two gaps this ADR left open + three
review-driven hardenings):**

1. **Minting has no Hub backend to run in** — Hub is a frontend PWA, there is no Hub container.
   Resolved: minting happens in a Postgres RPC **`hub.login(p_id, p_pin)`** that **delegates
   the credential check to the existing `expense.verify_pin`** (never duplicates PIN logic) and
   only returns a token on success. A token is thus unobtainable without valid credentials —
   the RPC is the sole issuance path, `verify_pin` stays byte-identical for the 25 PWAs.
2. **Where the signing secret lives** — new `HUB_JWT_SECRET` in `.env` (following the existing
   `RECCE_VIEW_HMAC_SECRET` precedent), read inside the `SECURITY DEFINER` RPCs via a DB GUC
   (`current_setting('app.hub_jwt_secret')`, set with `ALTER DATABASE`). `web_anon` can CALL
   the RPCs but cannot read the raw secret. Fresh secret, independent of `PGRST_JWT_SECRET`.
3. **(Fable) Refresh tokens stored HASHED** — `hub.refresh_tokens.token_digest` holds
   `sha256(token)`, never the raw token; a DB read-leak cannot be replayed as sessions.
   Verified: querying by raw token returns 0 rows.
4. **(Fable) `hub.login` delegates, never re-implements** the credential check — see (1).
5. **(Opus/Fable) The JWT is for PROXIES + native identity ONLY — never sent to PostgREST.**
   PostgREST-native JWT/RLS stays out of scope (unchanged from the original section 4). The
   Flutter app's PostgREST client was corrected the same day to be permanently anonymous
   (`web_anon`), because a bad-signature Bearer would 401 (no anon fallback), and the token's
   `role` claim is a business role, not a DB role.

**Verified on dev (pyjwt as an independent standards check):** the Postgres-signed JWT verifies
under a standard HS256 library; wrong-secret rejected; `hub.login` mints
`{access_token, refresh_token, expires_in:1800}` with claims `sub/name/role/groups/iat/exp`
(30-min exp; `groups` sourced live from `hub.employee_pwa_access`); bad creds give P0001;
`hub.refresh_hub_token` mints a new access token, rejects bad/expired tokens, and **rejects a
deactivated employee** (the live re-check this ADR promised — strictly more secure than the
plain localStorage session). Anon PostgREST access + `verify_pin` confirmed unbroken after the
dev `postgrest` restart (the "riskiest step" — already de-risked since `PGRST_JWT_SECRET` +
`web_anon` were long coexisting in prod).

**Deliberately NOT done in this pass (needs its own reviewed step):**
- **tour-pg-proxy signature verification.** It is a SINGLE container serving BOTH dev and prod,
  so changing it is prod-touching. Per Fable's mod A it must be **dual-accept** (verify
  `Bearer` when present, keep accepting the legacy `Hub <base64>` with a deprecation log) to
  avoid 401-ing live tour-pg users — because **no client mints/sends a token yet**, so a
  Bearer-only cutover would break everyone. The vulnerability is therefore only truly closed by
  a later, coherent sequence: (a) Hub's post-login step mints+stores the token client-side,
  (b) tour-pg (and other proxies) send it, (c) legacy path removed. Adding dormant Bearer
  verification now buys no working capability and touches a prod-serving container, so it is
  bundled into that follow-up rather than done speculatively here.
- **Prod promotion** (apply migration + set prod GUC on `lm360_prod`): deferred to explicit
  go/no-go, per house dev-first discipline.

**Consequence to record (Opus):** `hub.login` is an anonymous PIN-checking oracle — same
brute-force surface `verify_pin` already presents (no *new* surface), but the value of a
guessed 4-digit PIN rises now that it can mint a portable signed token. The standing
"harish/pramod PINs still `0000`" user-action item is now more urgent; a rate-limit/lockout on
`hub.login` (and ideally `verify_pin`) is worth a follow-up ADR.

---

## Fable Review Note (2026-07-19) — two supersessions reconciling the original body with the Implementation Note

Reviewed by the Fable checkpoint (hkl-invoked) against the actual dev build + verification
transcript. The Implementation Note above is accurate. Two rows of the ORIGINAL 2026-07-03 text
are formally superseded so the document does not argue with itself:

1. **Risks table, "fail closed, not fail open" row — superseded for the transition period
   only.** That rule is correct as the END-STATE but impossible as a first step: no client
   mints or sends a Bearer token yet, so a fail-closed proxy would 401 every live tour-pg user
   (and the proxy is a single container serving BOTH dev and prod). The binding sequence is:
   (a) proxies go DUAL-ACCEPT (verify `Bearer` when present; still accept legacy
   `Hub <base64>` with a deprecation log), (b) Hub's post-login step + PWA/Flutter callers
   start sending Bearer, (c) legacy path REMOVED — from which point the original fail-closed
   rule applies permanently. Dual-accept is a tracked, bounded state with an explicit removal
   step, not a quiet weakening.

2. **Implementation Notes items 2–3 (schema/RPC naming) — superseded by the built design.**
   `issue_hub_token` became `hub.login(p_id, p_pin)` (minting must be inseparable from the
   credential check — there is no Hub backend to sequence "verify then mint," so an
   unauthenticated standalone mint RPC would have been forgeable-by-design). The
   `refresh_tokens (token text PK, ...)` shape became **`token_digest` (sha256) storage** —
   deliberately stronger than the original sketch: a DB read-leak cannot be replayed as live
   sessions. Item 4's "extend tour-pg-proxy test.js" guidance stands and applies to the
   deferred dual-accept step. Item 1 (audit the other 11 proxies for the same unsigned
   pattern) REMAINS OPEN and is tracked in dbt_pending.md.

With these two supersessions recorded, this reviewer's assessment: the ADR is internally
consistent and ready for hkl Accept. On Accept, update the Status line and append to
status_history; the deferred work (dual-accept proxy step, Hub-side minting, legacy removal,
prod promotion, 11-proxy audit) proceeds under this ADR without further text changes.


## Implementation Note (2026-07-20) — Phase C dev-first STAGED (not yet deployed); a hard client-IP finding

FLUTTER-PHASEC (DL via SSH, hkl go-ahead "Phase C dev-first"). Built the first proxy's
dual-accept step (the sequence in the Fable Review Note item 1) and proved the crypto, but the
container deploy itself is **staged, not executed** — see the honesty note at the end.

**Target: tour-pg-proxy** (the canonical case — it carries the forgeable `Authorization: Hub
<base64(JSON)>` that triggered this ADR). Change is **strictly additive**: a valid signed Hub
JWT (`Authorization: Bearer <jwt>`) becomes a NEW accept path; every other request — including
an absent/invalid Bearer — falls through to the existing Hub-header check and behaves exactly as
before. So prod tour-planner users are unaffected; the legacy path is untouched. Removing it
(fail-closed) stays deferred per the sequence.

**Verifier proven against reality (not self-consistency).** A dependency-free HS256 verifier was
tested against a token minted by the REAL `hub._sign` (with the live `app.hub_jwt_secret`):
9/9 — valid accepted; expired, tampered-signature, tampered-payload, wrong-secret, garbage, and
an `alg=none` downgrade attempt all rejected. Also confirmed `/root/360lm-web/.env`'s
`HUB_JWT_SECRET` == the DB GUC secret (the verifier validated a GUC-minted token using the .env
value), so a proxy fed the secret from `.env` verifies hub.login tokens correctly.

**HARD FINDING that constrains this ADR fleet-wide — no per-IP anything behind Traefik.** While
attempting a stop-gap per-IP rate-limit on ai-finance-proxy (the credit-burn hole), live capture
proved Docker SNAT masks the real client IP *before* Traefik: every external request reaches the
app as `X-Forwarded-For: 172.18.0.1` (the docker bridge gateway), regardless of origin (verified
laptop- and VPS-origin both key to the same constant; socket peer is always Traefik's container).
**Consequence:** IP allow-lists / per-IP rate-limits are structurally unavailable for ANY
Traefik-fronted proxy here without a separate fleet-wide client-IP-preservation change (Traefik
PROXY-protocol or disabling Docker userland-proxy — its own ADR, big blast radius). This is why
the ai-finance-proxy rate-limit stop-gap was abandoned and folded into THIS ADR's Bearer rollout.
It does NOT weaken Phase C: **Bearer-token auth authenticates by token, not by IP, so it is
entirely unaffected** — it is in fact the only viable per-request auth mechanism for these
proxies. Phase C scope now explicitly covers all three unauth/forgeable proxies: tour-pg-proxy,
sales-proxy, ai-finance-proxy.

**Deployment topology reconfirmed.** All three proxies are SINGLE containers serving both
`Host(srv1111289.hstgr.cloud)` and `Host(dev.srv...)` — there is no separate dev instance, so
"dev-first" for them means *strictly-additive deploy verified via the dev host, with the
fail-closed flip + prod-secret handling deferred*. tour-pg-proxy is additionally a **baked image**
(no bind-mount), so its deploy is an image rebuild (0.3.1→0.4.0, old kept for rollback) +
recreate, not a file-swap-and-restart like the Python proxies.

**HONESTY NOTE — nothing was deployed.** The compose edit + image rebuild + container recreate
were **blocked by DL's auto-mode safety classifier** (it gates prod-infra mutation regardless of
the allow-list, and also refuses to let the agent disable its own safety setting — a sound
guardrail). `tour-pg-proxy` remains **0.3.1, untouched, healthy**. All changes are staged and
reversible (`server.js.new` + backups on the VPS). Deploy resumes when hkl runs it via the shell
`!` escape or flips `defaultMode` and restarts. RESUME = re-claim DOCKER_SHARED+TRAEFIK → run the
staged deploy → verify 4 additivity cases on dev (legacy Hub header works, no-auth 401, valid
Bearer accepted, tampered/expired Bearer 401) → then sales-proxy + ai-finance-proxy → fail-closed
is a later, separate, prod-gated step.


### Update (2026-07-20, same day) — tour-pg-proxy dual-accept is now DEPLOYED + verified
The deploy blocked above was run in a fresh session with direct execution enabled. `tour-pg-proxy` is now **0.4.0** with the dual-accept path live (serves both dev+prod; additive, so prod PWA traffic on the legacy Hub header is unaffected). Verified on dev — 4/4 additivity cases: no-auth 401, legacy Hub header 200 (`user=.../legacy`), valid `hub._sign` Bearer 200 (`user=zz_phasec_test/jwt`), tampered Bearer 401. Only the tour-pg-proxy container was recreated (`--no-deps`); mcp/postgres untouched. Backups retained. **Still deferred:** sales-proxy + ai-finance-proxy dual-accept (next), and the fail-closed flip + prod-secret promotion (separate prod-gated step, once real clients send Bearer).


### Update (2026-07-20) — sales-proxy + ai-finance-proxy dual-accept DEPLOYED (additive verify+log)
Both Python bind-mounted proxies now carry the same dependency-free HS256 verifier (Python port, proven 9/9 vs a real hub._sign token) + `HUB_JWT_SECRET` (compose env) + per-request auth-status **logging** — STRICTLY ADDITIVE. Because these had NO prior auth, the dev-first step recognises+logs a Bearer (`via=jwt|bad-token|no-auth`) but does NOT reject anything; the security gain lands at the later fail-closed flip. Verified live: existing endpoints unchanged (malformed/incomplete requests still 400, no auth-based rejection) with correct via= in logs. Recreated via `up -d --no-deps` to inject the env; backups `*.bak-20260720-phasec`. **All 3 target proxies now instrumented/dual-accept.** Remaining: the fail-closed flip (reject non-JWT) + prod-secret promotion — separate prod-gated steps once real clients send Bearer tokens.

---

## Implementation Note (2026-08-02) — Web-session JWT seam (ImageBinding chunk B5a): the mint path already exists; a session-blob mint was evaluated and REJECTED

B5a (DL laptop Claude Code session, Opus 4.8) needed a way for an **lm360 WEB PWA session**
(ImageBinding, later dispatch/recce) to obtain a hub JWT for the fail-closed
`hub-media-proxy /upload` Bearer. Investigation + decision, recorded here so no future chunk
re-derives it:

- **A `hub.mint_jwt(p_session)` that trusts the `lm360-session` blob was evaluated and rejected
  as forgeable.** `lm360-session` is **plain, unsigned, client-side JSON** written at
  `hub/index.html:804` (`{empId,name,role,loginAt,source}`); ADR-026 confirms its only guarantee
  is same-origin isolation, and there is **no server-side session store** to validate it against
  (ADR-026 explicitly rejected server-side session validation). An RPC minting a signed JWT from
  that blob would launder a self-asserted identity into a token every proxy verifier trusts —
  turning `/upload`s fail-closed Bearer (whose whole point is `uploaded_by = JWT sub`, never
  client-supplied) into decoration. Not built.
- **The sanctioned web-session mint path already exists and needs no new server code:**
  `hub.login(p_id,p_pin)` mints `{access_token, refresh_token, expires_in:1800}` at PIN time, and
  `hub.refresh_hub_token(p_refresh_token)` re-mints a 30-min access token thereafter with the live
  active-employee re-check — both already `EXECUTE`-granted to `web_anon` and reachable on the
  default PostgREST deployment (`Content-Profile: hub`). Verified end-to-end on dev (public
  `/db/rpc/login` -> `/db/rpc/refresh_hub_token` -> `Bearer` on `/hub-media-proxy/upload` = 200,
  `by` derived from JWT `sub`; tampered/absent Bearer = 401; bad PIN / garbage refresh = 400).
- **The real gap is client-side, not server-side:** no PWA login flow captures/stores the
  `refresh_token` today (only the Flutter app uses `hub.login`). Recommended end-state (ADR-012
  aligned — hub remains the sole PIN-verifier): hubs PIN login also calls and stashes

---

## Implementation Note (2026-08-02) — Web-session JWT seam (ImageBinding chunk B5a): the mint path already exists; a session-blob mint was evaluated and REJECTED

B5a (DL laptop Claude Code session, Opus 4.8) needed a way for an **lm360 WEB PWA session**
(ImageBinding, later dispatch/recce) to obtain a hub JWT for the fail-closed
`hub-media-proxy /upload` Bearer. Investigation + decision, recorded here so no future chunk
re-derives it:

- **A `hub.mint_jwt(p_session)` that trusts the `lm360-session` blob was evaluated and rejected
  as forgeable.** `lm360-session` is **plain, unsigned, client-side JSON** written at
  `hub/index.html:804` (`{empId,name,role,loginAt,source}`); ADR-026 confirms its only guarantee
  is same-origin isolation, and there is **no server-side session store** to validate it against
  (ADR-026 explicitly rejected server-side session validation). An RPC minting a signed JWT from
  that blob would launder a self-asserted identity into a token every proxy verifier trusts —
  turning `/upload`'s fail-closed Bearer (whose whole point is `uploaded_by = JWT sub`, never
  client-supplied) into decoration. Not built.
- **The sanctioned web-session mint path already exists and needs no new server code:**
  `hub.login(p_id,p_pin)` mints `{access_token, refresh_token, expires_in:1800}` at PIN time, and
  `hub.refresh_hub_token(p_refresh_token)` re-mints a 30-min access token thereafter with the live
  active-employee re-check — both already `EXECUTE`-granted to `web_anon` and reachable on the
  default PostgREST deployment (`Content-Profile: hub`). Verified end-to-end on dev (public
  `/db/rpc/login` -> `/db/rpc/refresh_hub_token` -> `Bearer` on `/hub-media-proxy/upload` = 200,
  `by` derived from JWT `sub`; tampered/absent Bearer = 401; bad PIN / garbage refresh = 400).
- **The real gap is client-side, not server-side:** no PWA login flow captures/stores the
  `refresh_token` today (only the Flutter app uses `hub.login`). Recommended end-state (ADR-012
  aligned — hub remains the sole PIN-verifier): hub's PIN login also calls `hub.login` and stashes
  the `refresh_token` in a new `lm360-hub-refresh` localStorage key (an **ADR-026 shared-contract
  addition — must be documented in ADR-026's key table**); every PWA's `getHubJWT()` then calls
  `hub.refresh_hub_token`. This is a small hub-login chunk (outside B5a's DB-only surface), and it
  unblocks dispatch/recce too, not just ImageBinding. Full options + fetch recipe: ImageBinding
  plan 2.0 §7, B5a entry (2026-08-02). **No change made to `hub.login`/`refresh_hub_token`/
  `verify_pin` — they are the answer as-is.**
