# Hub/Fleet experience contract v2

This is a proposed additive read model. Existing `/v1/*` endpoints remain valid
during migration.

## Ownership

| Data | Authority |
|---|---|
| License, request, release, deployment health | Fleet |
| Logo, category, product copy, screenshots | `rc-branding` |
| Customer app launch URL and environment | Customer/deployment configuration, registered with Fleet |
| Customer theme | Published organization theme/brand service |
| Pipeline execution | Customer-side deployer |
| Deployment-run metadata | Fleet; updated by Hub/deployer callbacks |

Fleet may materialize branding fields for availability and caching, but it is
not their authoring source.

## Authentication boundaries

The customer user's browser does not call Fleet with the Hub service token.
There are two independent boundaries:

1. **Browser -> customer Hub:** Microsoft Entra ID establishes a signed,
   HTTP-only Hub session containing the minimum identity and role claims.
2. **Customer Hub -> Fleet:** the existing customer-scoped bearer token reads
   operational data for that customer only.

Fleet operators use a third boundary: RapidCloud portal SSO establishes a
separate Fleet session after the portal assertion and operator allowlist are
validated.

### Hub browser routes

| Route | Responsibility |
|---|---|
| `GET /access?next=` | Customer-branded entry and authentication result states |
| `GET /auth/login?next=&switch=` | Create state/nonce and redirect to Microsoft Entra |
| `GET /auth/callback` | Validate state, exchange code, validate ID token, create session |
| `GET /auth/logout` | End only the Hub session and return to `/access?logged_out=1` |
| `GET /api/session` | Return display name, email, role, organization, and expiry; never a token |

Set `ENTRA_LOGIN_PAGE=/access` so callback failures return a stable error code
to the branded screen instead of JSON. Supported presentation codes are
`cancelled`, `invalid`, `failed`, `forbidden`, and `technical`. Error query
parameters contain no token, provider description, email address, or stack
detail. A generated support reference is safe to display and correlate with
server logs.

`next` is accepted only when it is a relative path in the server-side Hub route
allowlist. The destination is stored in a short-lived HTTP-only cookie and is
cleared after success or failure. Session, state, and destination cookies use
`Secure`, `HttpOnly`, and `SameSite=Lax`; state/destination expire after ten
minutes. Session expiry is finite and shown by `/api/session` without exposing
the signed cookie.

### Fleet browser routes

| Route | Responsibility |
|---|---|
| `GET /login` | RapidCloud SSO entry with collapsed break-glass access |
| `POST /sso` | Validate portal assertion, audience/issuer/expiry, and operator allowlist |
| `POST /login` | Rate-limited emergency password exchange with CSRF protection and audit |
| `POST /logout` | End the Fleet session and return to the signed-out state |

Browser HTML requests never receive `WWW-Authenticate: Basic`. Basic remains a
compatibility mechanism for non-browser automation until scoped machine
credentials replace it. Fleet sessions have an independent signing key, carry
operator identity and expiry, and are not derived from the emergency password.
Every emergency attempt records outcome, actor or attempted username, time, and
source security context without logging the password.

## `GET /v2/home`

One customer-scoped response for the Hub home screen. Hub-to-Fleet auth remains
the existing per-customer bearer token; the browser's Entra session is not
forwarded to Fleet.

```json
{
  "contract_version": 2,
  "generated_at": "2026-08-31T18:30:00Z",
  "customer": {
    "slug": "shn",
    "name": "Slachtofferhulp Nederland",
    "theme_slug": "shn",
    "support": {"label": "RapidCloud support", "url": "https://..."}
  },
  "service_status": {
    "level": "maintenance_scheduled",
    "title": "Gepland onderhoud op 3 september",
    "starts_at": "2026-09-03T18:00:00Z",
    "ends_at": "2026-09-03T19:00:00Z"
  },
  "apps": [
    {
      "slug": "kennisbank-bff",
      "name": "Kennisbank",
      "short_description": "Vind betrouwbare antwoorden in goedgekeurde bronnen.",
      "category": "Kennis & AI",
      "icon": {"kind": "url", "value": "https://branding.../logo"},
      "launch_url": "https://kennisbank.customer.example",
      "access": {"license": "active", "request": null, "can_open": true},
      "deployment": {
        "environment": "prd",
        "health": "ok",
        "last_seen_at": "2026-08-31T18:27:00Z",
        "current_version": "2026.08.12-98f7e6d",
        "current_digest": "sha256:...",
        "latest_version": "2026.08.29-a1b2c3d",
        "update_state": "available"
      },
      "links": {
        "detail": "/apps/kennisbank-bff",
        "documentation": "/docs?app=kennisbank-bff"
      },
      "permissions": {
        "request": false,
        "update": true,
        "manage_notifications": true
      }
    }
  ],
  "sync": {"fresh": true, "last_success_at": "2026-08-31T18:30:00Z"}
}
```

Rules:

- `launch_url` is required before an app can present `can_open: true`.
- App launch URLs and the full response are included in the Hub's last-good
  cache.
- Missing heartbeats never remove an app; they change `health`.
- `latest_version` is the newest compatible approved release for this customer,
  not simply the newest global release.

## `GET /v2/apps/{app_slug}`

Returns the detailed app view:

```json
{
  "app": {"...": "same summary fields as /v2/home"},
  "environments": [],
  "available_update": {
    "version": "2026.08.29-a1b2c3d",
    "released_at": "2026-08-29T09:00:00Z",
    "release_notes_html": "<h2>...</h2>",
    "compatibility": {
      "status": "pass",
      "checks": [
        {"key": "deployer", "label": "Deployer version", "status": "pass"},
        {"key": "configuration", "label": "Configuration", "status": "pass"}
      ]
    },
    "gates": {"parity": "pass", "integration": "pass", "provenance_url": "https://..."},
    "rollback_version": "2026.08.12-98f7e6d"
  },
  "recent_runs": [],
  "documents": []
}
```

Rendered HTML must be produced from sanitized Markdown. The original Markdown
can also be returned for download or audit.

## Deployment runs

The current heartbeat proves final state but cannot describe an update in
progress. Add an operational record that contains metadata only.

### `POST /v2/deployment-runs`

Called by Hub immediately before dispatching the customer pipeline.

```json
{
  "app": "kennisbank-bff",
  "environment": "prd",
  "from_version": "2026.08.12-98f7e6d",
  "target_version": "2026.08.29-a1b2c3d",
  "requested_by": "admin@customer.example",
  "action": "update"
}
```

Response:

```json
{
  "id": "run_uuid",
  "state": "validating",
  "callback_token": "shown-once scoped token",
  "created_at": "2026-08-31T18:35:00Z"
}
```

The callback token is scoped to that run and stored in the customer pipeline.
It is not an Azure credential.

### `PATCH /v2/deployment-runs/{id}`

Called by the customer-side deployer.

```json
{
  "state": "deploying",
  "step": "container_app_revision",
  "message": "New revision created",
  "pipeline_url": "https://customer-pipeline.example/runs/123",
  "at": "2026-08-31T18:38:00Z"
}
```

Allowed states are `validating`, `ready`, `awaiting_confirmation`, `deploying`,
`verifying`, `succeeded`, `failed`, `rolling_back`, and `rolled_back`.

### `GET /v2/deployment-runs?app=&environment=&state=`

Returns the Hub activity timeline and Fleet operations queue. Events contain no
configuration values, secrets, user content, prompts, documents, or end-user
identifiers beyond the administrator who requested the action.

## Fleet operator read models

### `GET /admin/v2/command-center`

```json
{
  "summary": {
    "production_exceptions": 3,
    "drift": 1,
    "failed_runs": 1,
    "open_requests": 4,
    "expiring_licenses": 2
  },
  "exceptions": [],
  "recent_runs": [],
  "open_requests": []
}
```

Every summary key maps to a stable filter on its corresponding collection
endpoint.

### `GET /admin/v2/customers/{customer_slug}`

Returns the customer header, contacts, app/license summary, deployment summary,
Hub token metadata, notification coverage, and recent audit events. Token values
are never returned.

## Schema additions

Suggested additive tables/columns:

```text
customer_app_config
  customer_id, app_id, environment, launch_url, display_name_override,
  support_url, created_at, updated_at

deployment_runs
  id, customer_id, app_id, environment, action, from_version, target_version,
  state, step, requested_by, pipeline_url, error_code, created_at, started_at,
  finished_at

deployment_run_events
  id, run_id, at, state, step, message

hub_tokens additions
  label, last_used_at, expires_at
```

Brand presentation data can initially be cached in Fleet with `branding_version`
and `branding_synced_at`; it should not become editable in two places.

## Error and degraded-mode behavior

- Responses include `contract_version` and `generated_at`.
- Hub displays last-good data when Fleet is unreachable and labels it with the
  exact last-success time.
- Opening an application remains enabled from cached configuration.
- Requests, updates, and settings are disabled when their source data is stale.
- A partial upstream failure should mark the relevant section stale instead of
  replacing the entire Hub with an empty state.

## Compatibility sequence

1. Add schema and `/v2/home` while retaining v1.
2. Populate launch URLs during customer onboarding.
3. Enrich app presentation from `rc-branding` with a cache/fallback.
4. Move Hub home to v2.
5. Add deployment runs and pipeline callbacks.
6. Move Fleet screens to the operator read models.
7. Remove v1 only after all deployed customer Hubs have upgraded.
