docs/architecture/provider-catalog.md
Rama main · réplica de lectura del repositorio original.
# Provider catalog (admin-configured sources)
__Status:__ live for patient connect and admin management (foundation [#304](https://github.com/jwilleke/yourphr/issues/304) / picker [#306](https://github.com/jwilleke/yourphr/issues/306) / sandbox env split [#291](https://github.com/jwilleke/yourphr/issues/291)).
__Related:__ as-built SMART map [`docs/architecture/SMART-flow-map.md`](./SMART-flow-map.md) · connection policy [`docs/architecture/connection-policy.md`](./connection-policy.md) · Medicare / Blue Button [`docs/EHR-vendors/cms-medicare/medicare-bluebutton.md`](../EHR-vendors/cms-medicare/medicare-bluebutton.md) · production catalog path [#432](https://github.com/jwilleke/yourphr/issues/432)
## Why this exists
A patient should connect a data source by __picking it from a list__ and logging in with __their__ provider account — and should __never__ see or handle a `client_id` or `client_secret`.
That used to require __bring-your-own credentials (BYO)__ for every user (register a developer app, paste secrets into the connect form). That is wrong for a family PHR. It only existed because this fork __lost upstream Fasten's hosted catalog + Lighthouse__ (which held app credentials centrally). Lighthouse moved into commercial Fasten Connect.
This catalog is the __self-hosted replacement__: the __instance admin__ registers provider apps once; users of that instance pick and log in. Nothing here calls Lighthouse. The self-hosted OAuth __relay__ ([#50](https://github.com/jwilleke/yourphr/issues/50)) keeps tokens off the browser. EPIC [#20](https://github.com/jwilleke/yourphr/issues/20) is the live-sync umbrella; `fasten-sources-stub` is [#288](https://github.com/jwilleke/yourphr/issues/288).
## Roles and UI
| Role | UI | Sees credentials? |
|---|---|---|
| __Admin__ | `/admin/provider-catalog`, `/admin/users` | Yes (once, centrally) — create/edit entries; mark test accounts |
| __Patient__ (any authenticated user) | `/sources` (often `/web/sources`) | __No__ — enabled __production__ entries only (a test account: __sandbox__ only); connect by catalog id |
On a single-user self-hosted instance admin and patient may be the same person; credentials still must not appear in the normal connect flow.
__Connected sources__ (sync, download, disconnect) also live on __`/sources`__. Account-wide controls (PP/ToS, delete account) live on __`/account-profile`__. See [`connection-policy.md`](./connection-policy.md).
## Environments: production vs sandbox
| `environment` | Shown on | Purpose |
|---|---|---|
| `production` (default) | `/sources` for every real account, the admin's included | Real enrollee path |
| `sandbox` | `/sources` for a __test account__ only | Test sandboxes; __never__ listed to a real account |
Empty/legacy rows are treated as production for back-compat.
### Test accounts ([#919](https://github.com/jwilleke/yourphr/issues/919))
Test data and real data never share an account. An admin marks an account as a __test account__ on `/admin/users` (at creation or later) — ngdpbase's `isTest` flag ([ngdpbase#1805](https://github.com/jwilleke/ngdpbase/issues/1805)), exposed as `test_account` on `/api/secure/account/me` and `ApiContext.isTest`. It is an attribute, not a role: it grants nothing.
- `GET /provider-catalog/connectable` answers the enabled __sandbox__ entries to a test account, the enabled __production__ entries to everyone else.
- `authorize` and `connect` refuse the other kind with `403` / `error_code: test_account_mismatch` — the server enforces it, not only the list.
- A test account shows a banner on every screen; its uploads and devices are labelled `sandbox`.
- At startup the server names every source whose kind does not match its account (a log line per source, and one notice on the admin home).
To test a sandbox provider, sign in to a test account and connect it from `/sources`. The old admin `/sandbox` page is gone; its address redirects to `/sources`.
## Data model
`ProviderCatalogEntry` (GORM; secrets encrypted at rest with the DB like `SourceCredential`):
| Field | JSON | Notes |
|---|---|---|
| `ID` | `id` | uuid (ModelBase) |
| `Display` | `display` | unique; admin/operator label |
| `Environment` | `environment` | `production` or `sandbox` |
| `ApiEndpointBaseUrl` | `api_endpoint_base_url` | FHIR base; SSRF-checked before server fetch |
| `Scopes` | `scopes` | space-delimited SMART scopes |
| `ClientId` | `client_id` | admin/CRUD only; __not__ on patient connectable |
| `ClientSecret` | — | __never serialized__ (`json:"-"`); DB-encrypted ([#286](https://github.com/jwilleke/yourphr/issues/286)) |
| `PlatformType` | `platform_type` | e.g. `ehr` |
| `BrandLogoUrl` | `brand_logo_url` | optional picker logo |
| `Enabled` | `enabled` | patients only see enabled production entries |
| `AuthorizeUrlOverride` | `authorize_url_override` | optional; pin authorize URL when discovery is wrong ([#338](https://github.com/jwilleke/yourphr/issues/338)) |
| `ConsentPolicy` | `consent_policy` | `required` (default) or `skip` — modular PP/ToS gate |
| `PreConnectProfile` | `pre_connect_profile` | `auto` (default), `generic`, `medicare`, `none` |
Admin responses include `has_client_secret` (bool), never the secret value.
### Patient projection (`ConnectableProvider`)
`GET …/connectable` returns credential-free fields plus __resolved__ connection policy:
| Field | Meaning |
|---|---|
| `id`, `display`, `brand_logo_url` | Picker |
| `requires_user_consent` | PP/ToS required before connect (default true) |
| `pre_connect_profile` | Resolved: `none` \| `generic` \| `medicare` |
| `medicare_class` | CMS Blue Button-class (attribution; production label __Medicare__) |
| `requires_legal_consent` | Deprecated alias of `requires_user_consent` |
__Patient-facing display:__ production Blue Button-class sources are forced to the label __`Medicare`__ ([#429](https://github.com/jwilleke/yourphr/issues/429)), even if the admin stored a longer name. Sandbox keeps operator-explicit names (e.g. `Medicare — Blue Button 2.0 (Sandbox)`).
Policy resolution and overrides: [`docs/architecture/connection-policy.md`](./connection-policy.md).
## Endpoints
Admin (`UserRole == admin`):
- `POST /api/secure/provider-catalog` — create
- `GET /api/secure/provider-catalog` — list all (`client_id` shown; secret → `has_client_secret`)
- `GET /api/secure/provider-catalog/:id` — get one
- `PUT /api/secure/provider-catalog/:id` — update (omit `client_secret` to keep stored secret)
- `DELETE /api/secure/provider-catalog/:id` — delete
Patient (any authenticated user):
- `GET /api/secure/provider-catalog/connectable` — enabled __production__ entries only; no credentials
- `POST /api/secure/provider-catalog/:id/authorize` — server loads entry, fills `client_id`/scopes/base URL, SMART discovery + PKCE; returns `authorize_url`, `state`, `code_verifier`, `login_wait_seconds`, `relay_poll_seconds`, `redirect_uri` (server-derived from relay config when omitted)
- `POST /api/secure/provider-catalog/:id/connect` — server fills credentials, polls relay for code by `state`, token exchange, patient id, `CreateSource`, background sync. Body: `{state, code_verifier, redirect_uri?, display?}` — __no__ client credentials. Requires product PP/ToS consent unless `consent_policy=skip`.
BYO `/source/authorize` + `/source/connect` ([#51](https://github.com/jwilleke/yourphr/issues/51)) remains for advanced/dev use; the __UI path is catalog__.
## Seeding
### Historical migration templates
`DefaultProviderCatalogEntries()` still supports early migrations: sandbox templates, empty credentials, disabled, idempotent by `Display`. __No real credential is committed__ (AGENTS.md hard rule).
### Live sandbox credentials (env)
At startup, `SeedSandboxProviders` upserts entries from `SandboxProviderSeeds()` when the corresponding env client id is set (or a literal open client id for SMART Health IT):
| Display (admin/sandbox) | Env (examples) |
|---|---|
| Medicare — Blue Button 2.0 (Sandbox) | `YOURPHR_SANDBOX_BLUEBUTTON_CLIENT_ID` / `_SECRET` |
| Epic (Sandbox) | `YOURPHR_SANDBOX_EPIC_CLIENT_ID` |
| Oracle Health / Cerner (Sandbox) | `YOURPHR_SANDBOX_ORACLE_CLIENT_ID` (+ authorize override) |
| athenahealth (Sandbox) | `YOURPHR_SANDBOX_ATHENA_CLIENT_ID` / `_SECRET` |
| SMART Health IT (Sandbox) | always seeded (literal public client id) |
All of these use `environment=sandbox`, so only a __test account__ sees them on `/sources`; a real account never does.
### Production Medicare ([#432](https://github.com/jwilleke/yourphr/issues/432))
__Template (no secrets):__ migration seeds a disabled production row:
| Field | Value |
|---|---|
| Display | `Medicare` |
| Environment | `production` |
| FHIR base | `https://api.bluebutton.cms.gov/v2/fhir` |
| Scopes | `models.BlueButtonSMARTScopes` |
| Enabled | `false` until credentials are set |
__Enable without code change:__
1. __Admin UI__ — Provider Catalog → edit __Medicare__ → paste production `client_id` / `client_secret` → enable, or
2. __Env__ — `YOURPHR_PROD_BLUEBUTTON_CLIENT_ID` + `YOURPHR_PROD_BLUEBUTTON_CLIENT_SECRET` (startup upserts and enables)
Register the instance __relay callback__ (`Admin → SMART OAuth Relay` or `GET /api/secure/source/relay-config` → `callback_url`) with the CMS production app.
Patient button label is __Medicare__. Full checklist: [`docs/EHR-vendors/cms-medicare/medicare-bluebutton.md`](../EHR-vendors/cms-medicare/medicare-bluebutton.md) (Production section).
## Patient connect UX (catalog path)
Default for medical sources (modular; see connection policy):
1. Account Profile: grant PP/ToS if required
2. `/sources`: pick provider (Medicare-class shows CMS attribution when listed)
3. Pre-connect informed modal (Cancel / Continue) unless `pre_connect_profile=none`
4. OAuth popup → relay → connect → Connected Sources on the same page
5. Connected Sources: __Disconnect__ (tokens only), __Remove data__, or __Disconnect & remove data__ (full teardown)
## Security
- `client_secret`: `json:"-"`, DB-encrypted, never logged; exchange is server-side via relay.
- Patient connect request never carries client credentials.
- FHIR base URL SSRF-checked ([#302](https://github.com/jwilleke/yourphr/issues/302)).
- Admin mutations gated on `currentUser.Role`.
- Production vs sandbox split prevents test sandboxes from appearing as patient sources ([#291](https://github.com/jwilleke/yourphr/issues/291)).
## Relationship to the upstream catalog
`CreateReconnectSource` may still call `sourceDefinitions.GetSourceDefinition` (stubbed `fasten-sources` definitions). The provider catalog is the __owned__ path for new connect. Migrating reconnect fully onto the catalog is follow-on under EPIC [#20](https://github.com/jwilleke/yourphr/issues/20) / [#288](https://github.com/jwilleke/yourphr/issues/288).