# Browser and Programmatic Authentication Context Split

Status: historical monolith migration proposal for
[#1867](https://github.com/pingcap-inc/NexusCRM/issues/1867), jointly closing
the substrate shared by
[#1865](https://github.com/pingcap-inc/NexusCRM/issues/1865) and
[#1123](https://github.com/pingcap-inc/NexusCRM/issues/1123). The monolith route entry and its source-pinned inventory have since been retired.
This document retains the original security design and historical consumer map;
it is not a current serving inventory. See
[the current authoring/retirement boundary](metadata-authoring-current-paths.md)
and the independently serving Runtime/Gateway owners before changing admission.

Historical baselines used by this design:

- NexusCRM `9a3dbbbf5044847cbdec05a8fedb9f7e852fb123`;
- `nexuscrm-api-host` `v0.24.0`
  (`f481f1387be151fb727ca8ff4a5b8c98ba709f0b`);
- backend app-pack source lock
  `2425c00c77e968b0769d6bd247b59831a17de894`;
- frontend app-pack source lock
  `cdc6b66cdcd6c218f8c9ae73f1dde9fe8af49099`.

The evidence below describes those historical revisions. It must not be used
to infer that a current process mounts the former monolith routes.

## 1. Decision

NexusCRM will separate ambient first-party browser authority from portable
programmatic API authority.

1. A first-party browser login will establish an opaque, server-side session
   through a `Secure`, `HttpOnly`, `SameSite=Strict`, `__Host-` cookie. Page
   JavaScript will receive no NexusCRM access token or refresh token.
2. The browser cookie will be accepted only on routes whose owning registration
   explicitly permits browser-session authority. Dialect routes declare this in
   their L1 manifest; app-blind browser-session host routes declare it in their
   L0 route registration; product `RuntimeRouteSurfaces` declare it through the
   same neutral registrar. It will never authenticate raw REST, SOQL/query,
   Composite, Tooling, MCP, websocket upgrade, or another programmatic route by
   default.
3. Programmatic callers will continue to use `Authorization: Bearer` or PAT
   credentials. A programmatic credential must identify its credential class,
   Connected App where applicable, and a non-empty effective scope. Empty scope
   is not broad API authority.
4. A public UI API remains callable with a correctly scoped programmatic Bearer.
   A browser-capable route is therefore dual-admission, not browser-exclusive.
5. `_System_Session` remains the single session source of truth. The existing
   session-control proof is evolved into the browser credential; no parallel
   browser-session table or second session registry is introduced.
6. L0 owns credential mechanisms, generic route-policy enforcement, and the
   policy declarations on app-blind host routes it authors. L1 owns declarations
   on product/dialect and product runtime routes. L0 must not recognize
   Salesforce path strings to make the decision.

The target is a BFF-style security boundary, not a generic token-forwarding
proxy. The production-default relative backend URL already gives NexusCRM a
same-origin host/resource-server topology. A separately hosted first-party
frontend must expose the host under the same schemeful site, preferably through
a same-origin gateway, before cookie-only sessions are enabled. The host
authenticates that browser session and invokes existing neutral contracts
directly. It does not mint a hidden full-scope bearer and forward it through raw
public endpoints on the browser's behalf.

## 2. What has already landed, and what remains open

PRs #3700, #3703, and #3706 added a useful but partial mitigation:

- an independently generated proof is stored in the
  `__Host-nexuscrm-session-control` HttpOnly cookie;
- only the proof hash is persisted in `_System_Session.ControlProofHash`;
- admitted first-party browser requests can use the proof to recover or renew the
  paired Bearer;
- a superseded Bearer without the proof cannot recover the current generation;
- the host SDK owns request classification and strips caller-supplied host
  credential headers before installing its own.

These facts reduce the useful lifetime of a stolen superseded Bearer. They do
not satisfy #1865:

- `frontend/src/host/api/client.ts` still reads and writes `AUTH_TOKEN` in
  `localStorage`;
- the host still adds that token as `Authorization: Bearer` for backend
  requests;
- `RequireAuth` still requires an Authorization header before it considers the
  session-control proof;
- `ScopeEnforcer` still treats an empty scope as unrestricted;
- every route in one dialect version still receives the same base data-API
  middleware stack;
- product `RuntimeRouteSurfaces` can mount authenticated routes directly from
  raw middleware slices, outside the dialect manifest;
- the frontend defaults to relative same-origin requests but explicitly
  supports a configured cross-origin backend, while session-control activation
  currently validates the configured frontend origin without proving the
  frontend and public API share a schemeful site;
- the first-party password login still sends the browser-visible
  `nexuscrm-ui-internal` client secret.

The landed proof mechanism is therefore a migration anchor, not a second
authority and not evidence that the final boundary exists.

## 3. Historical ownership and consumer map

The former source-pinned JSON inventory and its test were removed with the dead
monolith route entry. The table below records the original proposal, not the
current production registration set. Runtime admission and process dependency
closure are verified at their current owners.

| Fact | Current author/owner | Current read path | Target owner/read path |
|---|---|---|---|
| Browser Bearer | OAuth/login handler and `AuthService` | frontend `APIClient` -> `localStorage` -> Authorization | not delivered; browser proof cookie -> api-host admission -> `AuthService` |
| Browser proof | `AuthService` | cookie plus Bearer -> `ValidateSessionWithControl` | same cookie -> proof-hash session lookup -> one durable validation path |
| Session identity snapshot | signed session JWT plus durable session reconciliation | `RequireAuth` -> `AuthService` | server-held session projection through the same `AuthService` validation owner |
| Product route grammar | app-pack dialect manifest and product `RuntimeRouteSurfaces` | api-host binder or raw `RuntimeRouteSurfaceContext.Router` | unchanged L1 author; both registration seams use one neutral credential-policy contract |
| App-blind browser-session routes | backend OAuth/direct Gin registration | route-specific middleware assembled by the host | L0 host route author declares browser establishment/only policy through the same registrar; no path classifier |
| Route credential enforcement | one dialect base stack plus caller-composed runtime/host stacks | dialect binder and direct Gin registrations | one api-host policy provider; every authenticated registration selects a declared policy before handlers mount |
| OAuth scope policy | L0 mechanism plus product-owned policy metadata | `ScopeEnforcer` | programmatic lane only; empty programmatic scope fails closed |
| `ApiEnabled` | system permission catalog | all data-API routes | programmatic lane only; browser lane uses ordinary object/FLS/RLS and route gates |
| Connected App rate identity | accepted scoped request context | process-local app limiter | programmatic lane, durable/shared limiter keyed by Connected App |

There must remain one durable session validation owner. Cookie authentication
must not implement a reduced validation path that omits session revocation,
expiry, auth epoch, user state, Connected App state, or durable grant
reconciliation.

## 4. Standards and mature-system basis

The design follows these external authorities:

- The IETF browser-based applications guidance orders BFF ahead of a
  token-mediating backend and a browser OAuth client. In the BFF pattern,
  access and refresh tokens remain server-side and the browser holds a cookie
  session.
- OAuth 2.0 Security Best Current Practice (RFC 9700) requires modern
  authorization-code protections and treats token replay and privilege
  restriction as controls separate from PKCE. PKCE does not make a token in
  `localStorage` safe after issuance.
- OWASP Session Management advises against storing session identifiers in
  `localStorage` and recommends restrictive cookie attributes. OWASP CSRF
  guidance requires a deliberate CSRF defense for ambient cookies.
- Salesforce Lightning uses UI API as a semantic UI data surface. UI API
  applies CRUD, field-level security, and sharing rather than requiring the
  browser to construct arbitrary raw data queries for every screen.

References are in section 17.

## 5. Goals and non-goals

### 5.1 Goals

- Page JavaScript cannot read or export a portable NexusCRM browser credential.
- A browser login alone cannot authenticate a raw programmatic API call.
- A user may use the UI without `ApiEnabled`.
- Existing external clients retain Bearer access to compatible public UI API
  and programmatic surfaces when their Connected App scope permits it.
- Empty scope has one fail-closed meaning at every programmatic API admission
  point.
- Revocation, audit, rate limits, and credential classification work across
  multiple host instances.
- Cookie-only browser serving is activated only when the configured frontend
  and public backend are HTTPS members of the same schemeful site (with the
  existing loopback-development exception), or are the same origin.
- Every migration step is additive or has an explicit bounded compatibility
  window and backout.

### 5.2 Non-goals

- Preventing an in-origin XSS from invoking UI operations the victim is allowed
  to perform. HttpOnly prevents credential extraction; it cannot prevent all
  same-origin confused-deputy actions. CSP and XSS prevention remain required.
- Replacing OAuth for external clients.
- Making a browser-shipped client secret confidential.
- Hiding Salesforce-compatible public API shapes behind an L0-only substitute.
- Implementing a generic reverse proxy that forwards unrestricted credentials.
- Moving product route names or endpoint inventories into L0.
- Supporting ambient first-party browser sessions across unrelated registrable
  domains with `SameSite=None`. Existing cross-site direct API/CORS deployments
  remain valid for programmatic credentials, but must add a same-site gateway or
  domain before migrating the first-party UI.

## 6. Authentication-context taxonomy

| Context | Browser-readable credential | Transport | Permitted surface | Scope/app rules |
|---|---|---|---|---|
| First-party browser session | none | ambient HttpOnly cookie plus admitted first-party request evidence | routes declared `BrowserOrProgrammatic` | no `ApiEnabled`; never interpreted as empty-scope API authority |
| External public client | short-lived Bearer in that client's custody | Authorization header | routes covered by effective scope | public Connected App, PKCE, approval/consent policy |
| Confidential integration | Bearer | Authorization header | routes covered by effective scope | confidential Connected App and explicit grant |
| PAT | PAT Bearer | Authorization header | routes covered by explicit PAT scope | non-empty scope and explicit credential class |
| Service/workload identity | service credential | non-browser authenticated transport | explicitly composed service surface | workload audience/capability; never browser fallback |

`SessionClass` is an admission input, not sufficient authority by itself. A
session claiming `platform_browser` does not gain browser-route access unless
the proof cookie, first-party request admission, durable session, and route
policy all agree.

## 7. Neutral route credential policy

One api-host-owned neutral enum applies to every authenticated or browser-
session-establishing HTTP route, not only dialect manifests. Names may be
adjusted to fit the api-host package, but its semantics are fixed here:

```go
type CredentialPolicy uint8

const (
    // Zero value for compatibility. It never gains ambient-cookie access.
    CredentialProgrammaticOnly CredentialPolicy = iota

    // Either browser session or scoped programmatic admission may succeed.
    CredentialBrowserOrProgrammatic

    // An existing browser session is required; Authorization is rejected.
    CredentialBrowserOnly

    // No existing session is required, but first-party browser admission is.
    // Only the browser-session establishment handler may issue the Cookie.
    CredentialBrowserEstablishment

    // First-party admission is required. Revoke when proof is valid, then
    // clear the Cookie without treating invalid proof as a principal.
    CredentialBrowserTermination
)
```

Rules:

1. Unknown values fail mount at boot.
2. The zero value preserves current dialect manifests and fails closed for
   browser authority. A new direct host/runtime registration must nevertheless
   pass a policy explicitly to the registrar; it cannot rely on an omitted
   struct field.
3. `CredentialBrowserOrProgrammatic` does not waive route permission gates,
   CRUD/FLS/RLS, write admission, audit, or CSRF.
4. A scoped Bearer can still call a dual route through the programmatic lane.
5. Browser credentials can never call a zero-value route.
6. L1 declares the value for product/dialect routes; L0 declares it for the
   app-blind browser-session routes L0 authors. L0 enforcement never examines
   product path prefixes.
7. `CredentialBrowserOnly` rejects Authorization even when a valid Cookie is
   present. `CredentialBrowserEstablishment` accepts no caller authentication
   credential and requires the stricter first-party establishment admission.
   `CredentialBrowserTermination` also rejects Authorization, never grants a
   principal from invalid proof, and always expires the Cookie after admission.
8. Internal neutral dispatch must identify its authority explicitly or re-enter
   the programmatic default; it must never inherit ambient browser authority by
   accident.

### 7.1 One policy, three declaration adapters

The policy has one semantic owner in api-host and three registration adapters:

1. **Dialect manifest.** `dialect.Route.CredentialPolicy` is consumed by the
   binder. L1 authors the value.
2. **Product runtime surface.** `RuntimeRouteSurfaceContext` exposes a neutral
   authenticated-route registrar or policy-to-stack resolver. A runtime surface
   must pass a policy for every authenticated route it mounts. The context no
   longer makes an unlabelled `DataAPIMiddleware` slice the only way to mount a
   browser-capable route.
3. **App-blind host route.** NexusCRM auth/session registration calls the same
   api-host registrar with `BrowserEstablishment`, `BrowserOnly`, or
   `BrowserTermination`. The host owns that declaration because it owns the
   generic session route; it does not gain authority from a path allowlist.

Unauthenticated public, webhook-HMAC, SCIM-purpose-token, and service transport
routes keep their existing explicit authentication owners. They do not silently
enter a browser policy lane. The route inventory test classifies every mounted
authenticated surface and fails when a browser-capable route bypasses the
policy registrar.

Cookie authentication is never installed as global Router middleware. A direct
Gin route that has not migrated to the registrar therefore remains unable to
consume the browser Cookie; at worst it stays Bearer-only and the UI migration
test fails. This makes an omitted declaration unavailable, not accidentally
ambient. `RuntimeRouteSurfaceContext` stops exporting an unlabelled browser-
capable middleware stack once its consumers migrate; custom non-browser auth
surfaces may retain their explicit owner without gaining Cookie admission.

Dialect route replacements inherit the target manifest route's policy. A
runtime replacement may replace the handler but cannot widen the declared
credential policy; an attempted mismatch fails composition at boot.

During the existing shared-dialect-to-api-host transition, the additive field
must exist in both neutral route structs and be mapped by
`apiHostRouteFromShared`. Both zero values have identical semantics. This is a
compatibility bridge, not two policy sources.

## 8. Per-request admission

The binder currently installs one base stack for the complete dialect version,
while product runtime and host routes assemble middleware independently. The
api-host policy provider becomes the single stack-selection owner for all three
adapters in section 7.1. Dialect binding asks it per manifest route; runtime and
host registrars require an explicit policy before mounting the handler.

### 8.1 Programmatic-only stack

1. reject ambient browser-cookie authentication;
2. require Authorization credential;
3. validate durable session/token and credential class;
4. require a non-empty effective programmatic scope;
5. enforce route scope;
6. bind Connected App/PAT identity for audit and rate limiting;
7. apply write admission where appropriate;
8. require `ApiEnabled`;
9. load permissions and apply route gate;
10. invoke the handler.

PAT policy may omit Connected App identity only when its explicit PAT credential
class and rate/audit subject are present. It must not fall into the historical
"direct login" branch.

### 8.2 Browser-or-programmatic stack

The credential selector first rejects malformed or ambiguous input, then takes
exactly one lane.

Browser lane:

1. require session-control intent and the existing static-origin admission;
2. require exactly one structurally valid proof cookie;
3. resolve and validate the durable browser session through `AuthService`;
4. apply the existing global CSRF middleware and unsafe-method origin/fetch
   evidence;
5. apply browser/user/IP rate policy and write admission;
6. load ordinary permissions; do not require `ApiEnabled`;
7. apply the route gate and normal CRUD/FLS/RLS/sharing checks;
8. invoke the handler.

Programmatic lane:

- run the complete programmatic-only stack above, including `ApiEnabled`.

Final-state requests carrying both a browser proof and Authorization credential
fail closed. The pre-cutover proof-plus-Bearer pair is supported only by an
explicit, measured compatibility phase and is removed before the final browser
lane is declared complete.

### 8.3 Browser-only and establishment stacks

`CredentialBrowserOnly` uses the browser lane above and rejects every
Authorization header before authentication. It is used for current-session,
and other app-blind personal session operations that require a valid principal.

`CredentialBrowserEstablishment` requires the session-control intent, exact
configured frontend Origin, accepted fetch metadata, and no Authorization or
pre-existing credential chosen by the caller. The handler may authenticate a
password or consume an already validated SSO transaction, rotate any prior
browser session, and issue the proof Cookie. It cannot invoke a programmatic
scope lane or return a Bearer.

`CredentialBrowserTermination` requires the same first-party admission and
rejects Authorization. A valid proof is revoked through the one durable session
owner. Missing, expired, malformed, or already-revoked proof grants no principal
but still produces the non-secret expired Cookie response, so a stale browser
can recover without an authentication oracle.

## 9. Browser-session credential and durable state

The existing raw session-control proof is suitable browser credential material:
it has independent cryptographic entropy, only its SHA-256 verifier is stored,
and the cookie already has the required restrictive attributes.

The final lookup path changes from:

```text
Bearer -> JWT session ID -> durable session -> compare proof hash
```

to:

```text
proof cookie -> hash -> indexed durable session -> authoritative session validation
```

Required invariants:

- `ControlProofHash` is unique among active/non-null browser sessions and has a
  supported indexed lookup. The migration must detect collisions before adding
  the constraint and fail closed rather than choosing a row.
- the raw proof is never logged, captured, returned in JSON, copied into a JWT,
  or stored durably;
- the proof rotates on login, privilege elevation, password/security changes,
  and any session-bound identity change that already requires revocation;
- logout atomically revokes the durable session and clears the cookie;
- expiry, user disablement, auth epoch, revocation, and currently persisted
  grant/app facts are checked on every authoritative validation path;
- no process-local session map becomes an authority. Any future cache must be
  bounded by a durable revocation/epoch design and measured separately;
- a browser session retains a server-side identity projection compatible with
  the existing session-bound formula context. The implementation may reuse the
  server-held signed projection already associated with `_System_Session`, but
  it must never send that bearer to the browser.

Schema readiness is enabled before cookie-only authentication. An old binary
must remain able to ignore the additive index/column state during the staged
window.

## 10. Browser login, SSO, and logout surfaces

The first-party browser does not use a confidential OAuth client.

A dedicated app-blind browser-session route family will own:

- password-session establishment when password login is enabled;
- SSO completion into a browser session;
- current-session projection;
- logout/revocation.

These are L0-authored host routes, so their registration is the policy source:
password/SSO completion uses `CredentialBrowserEstablishment`, current session
uses `CredentialBrowserOnly`, and logout uses
`CredentialBrowserTermination`. They mount through the api-host registrar
described in section 7.1, not through a path exception and not through the L1
manifest. Exact route names and neutral response envelopes are frozen in the
implementing contract PR. They must not be placed under the Salesforce data
dialect, and the file header must mark them as NexusCRM browser-session routes
rather than Salesforce REST compatibility routes.

Successful establishment returns user/session presentation data and the Cookie,
but no access token, refresh token, client secret, or replacement bearer header.
It calls the existing authentication and session owners directly; it does not
loop through the public password grant.

Logout revokes the matching active session before clearing the Cookie. An
expired, malformed, or already-revoked browser credential still receives the
same expired `Set-Cookie` cleanup response after first-party admission, without
being treated as an authenticated principal or leaking validation detail.

SSO initiation and completion retain the existing exact redirect policy and
add a one-time, expiring state/nonce binding to the browser login transaction.
Neither an authorization response nor a redirect URL may carry the resulting
NexusCRM session credential. Successful authentication rotates any pre-login
proof so an attacker cannot fix the browser onto a credential chosen earlier.

The existing OAuth token endpoint remains stable for programmatic compatibility.
The browser migrates away from it before the built-in UI secret and password
grant usage are removed. Public authorization-code clients continue to use PKCE
under the existing OAuth design.

### 10.1 Deployment precondition for `SameSite=Strict`

Current frontend configuration deliberately permits an explicit cross-origin
backend URL. Cross-origin is not necessarily cross-site: HTTPS
`app.example.com` and HTTPS `api.example.com` are different origins but share a
schemeful site, while `app.example.com` and `api.example.net` do not. A Strict
Cookie works in the former topology when credentialed CORS is exact; it is
withheld in the latter.

Cookie-only browser mode therefore has a mechanical deployment gate:

1. canonicalize `FRONTEND_URL` and the public API origin. A non-empty
   `API_BASE_URL` supplies the latter; an empty value declares the existing
   relative/same-origin mode and uses the frontend origin. Do not infer public
   authority from an arbitrary request Host;
2. accept the production configuration only when both are HTTPS and either the
   origins are equal or their schemeful sites are equal;
3. derive the registrable domain with a maintained public-suffix implementation,
   never string suffix matching;
4. for loopback development, require the same loopback hostname and scheme;
   different ports are permitted, and the existing Vite same-origin proxy is
   preferred;
5. when origins differ, require exact `Access-Control-Allow-Origin` for the one
   configured frontend, `Access-Control-Allow-Credentials: true`, and frontend
   `credentials: include`; wildcard credentialed CORS is invalid;
6. keep the Cookie `SameSite=Strict`; this design does not silently downgrade to
   `SameSite=None`;
7. when a trusted same-origin gateway terminates TLS, validate its configured
   public origin through the existing trusted-proxy boundary rather than trusting
   unvalidated forwarded headers.

During migration, a deployment that fails this gate keeps cookie-only browser
mode disabled and emits an operator-visible readiness reason. It cannot enter
the canary. Before legacy browser Bearer removal, every production UI deployment
must either move the backend behind the frontend origin, use a same-site API
domain, or add a same-site gateway. In final browser-enabled mode, an invalid
topology is a startup/readiness failure rather than a fallback to JS-readable
Bearer storage.

Cross-site direct CORS remains supported for external programmatic clients with
explicit Authorization credentials. It is not a first-party ambient browser
session topology.

## 11. Scope semantics

The future rule is contextual and unambiguous:

- browser session plus empty OAuth scope: browser-route authority only;
- programmatic Bearer/PAT plus empty scope: invalid for protected API routes;
- explicit `api`, `full`, `api.read`, or metadata-defined scope: only its
  declared programmatic coverage;
- absent credential classification: invalid for protected programmatic routes.

This avoids replacing one ambiguity with another. A blanket early change from
"empty means full" to "empty means deny" would break the existing browser UI
before its replacement exists, so the enforcement change lands after browser
consumers migrate and in its own compatibility-reviewed PR.

Existing unscoped programmatic sessions require inventory, bounded expiry or
revocation, operator-visible metrics, and a migration runbook. They do not earn
a permanent legacy bypass.

## 12. UI surface migration

At the current frontend app-pack lock, the UI endpoint inventory contains both
semantic `/ui-api/*` routes and raw `/sobjects`, `/query`, `/queryAll`,
`/composite`, `/tooling`, file, action, analytics, and process routes. Therefore
browser access cannot be cut off before consumers move.

The current backend also has two product route registration seams. Most routes
come from the app-pack dialect manifest, but product composition mounts
`RuntimeRouteSurfaces` directly and hands them `DataAPIMiddleware`. The migration
inventory covers both seams. The related-list candidate editor read is now owned
by the independent L1 AppService and has no direct backend registration. A
browser-capable runtime surface must move to the neutral policy registrar; it
cannot be declared only in the dialect inventory while the live direct route
keeps the old stack.

L1 owns this migration because it owns both the Salesforce-compatible route
grammar and the CRM frontend source. Each functional PR changes one consumer
slice and, when needed, adds the corresponding Salesforce v63 UI API surface.
It leaves the public raw endpoint unchanged for external clients.

Suggested independent slices:

1. object info, describe, layouts, and picklists;
2. record read;
3. record create;
4. record update;
5. record delete, clone, and defaults;
6. list metadata and list records;
7. lookup, search, suggestions, and MRU;
8. related-list metadata and records;
9. files and streaming/range behavior;
10. actions, approvals, and flows;
11. reports, dashboards, and analytics;
12. setup/tooling UI workflows;
13. websocket and notifications.

Every slice must cite the Salesforce v63 source for a changed public shape,
reuse the existing typed neutral contract where it exists, preserve canonical
error envelopes, and add the surface row required by
`salesforce-v63-api-contract.md`.

An inventory guard eventually proves that first-party browser source has no raw
programmatic route dependency. The guard is L1-authored input consumed by the
existing source-lock/conformance path; L0 must not maintain a copied list of CRM
endpoint strings.

## 13. Audit, rate limiting, and observability

Every protected request records a credential class selected by the admission
owner, not inferred later from a missing field:

- `browser_session`;
- `oauth_connected_app`;
- `personal_access_token`;
- `service_identity`.

Programmatic audit records include the Connected App or PAT identifier, scope,
route template, user/service principal, and decision. Browser audit records
include the durable session identifier or non-secret digest, principal, route
template, and decision. Raw secrets and tokens are never labels or log fields.

The current app limiter is process-local and skips requests without the
Connected App context marker. It is not sufficient evidence for a multi-node
Connected App boundary. The programmatic lane must fail closed when required
rate/audit identity is absent, and the production limiter must use a shared
authority or an equivalently proven distributed design. That change is a
standalone security PR, not hidden in route migration.

Required rollout metrics include:

- browser-session establishment success/failure by non-secret reason;
- browser proof validation failure and ambiguous-credential rejection;
- browser attempt against programmatic-only route;
- legacy browser Bearer requests remaining, by route template;
- unscoped programmatic rejection;
- Connected App/PAT audit-binding failure;
- CSRF/origin admission rejection;
- auth latency and durable session lookup latency.

## 14. Threat model and rebuttals

| Threat/claim | Decision and evidence |
|---|---|
| XSS reads `localStorage` and replays the Bearer elsewhere | Remove the browser Bearer entirely; HttpOnly proof is not readable by page JS. |
| "PKCE fixes this" | Rejected. PKCE protects authorization-code exchange, not post-issuance credential extraction. |
| "HttpOnly fixes all XSS" | Rejected. Same-origin malicious code may still invoke allowed UI operations; semantic route minimization and CSP remain necessary. |
| "Put the same full API token in a Cookie" | Rejected. That preserves excessive authority and introduces ambient credential/CSRF exposure to raw APIs. |
| "Allow Cookie on every `/services/data` route" | Rejected. It violates least privilege, keeps #1123 coupled to `ApiEnabled`, and makes raw APIs ambient. |
| "Check `/ui-api` path in L0" | Rejected. The path is L1 product grammar. L1 declares neutral policy; L0 enforces it. |
| "Make UI API browser-only" | Rejected. Public Salesforce-compatible UI API callers may legitimately use scoped OAuth Bearers. |
| "Flip empty scope first" | Rejected. It breaks current UI consumers before replacement and obscures migration failures. |
| Stolen Cookie is replayed from another origin | `__Host-`, Secure, SameSite Strict, static-origin admission, intent header, fetch metadata, Origin checks, expiry, and revocation constrain browser delivery and admission. Host compromise remains outside this control. |
| First-party frontend and API are on unrelated sites | Cookie mode fails its topology gate. Operators add a same-site gateway/domain; the system does not weaken the Cookie to `SameSite=None` or fall back after final cutover. |
| CSRF triggers unsafe UI actions | Existing global CSRF plus stricter session-control admission apply; Bearer exemptions do not exempt the cookie-only lane. |
| Duplicate proof hash selects the wrong session | Unique/indexed verifier lookup; migration detects duplicates and runtime treats zero or multiple rows as invalid. |
| Route forgotten during L1 authoring | Dialect zero-value is `ProgrammaticOnly`; browser access fails closed and inventory/conformance tests expose the omission. |
| Direct host/runtime route bypasses the dialect policy | Every authenticated direct registration requires the same policy registrar; host routes have an L0 declaration and product runtime routes have an L1 declaration. Inventory fails on unclassified browser-capable surfaces. |
| Multi-instance rollout mixes old and new hosts | Additive schema first, versioned feature capability, sticky-free durable validation, staged metrics, then legacy removal. |

## 15. Ordered implementation and PR contract

No PR in this sequence depends on unmerged code from a later PR. Published
repository boundaries use release tags/pins; local `replace` directives are not
merge evidence.

| Order | Repository | One PR fact | Runtime behavior |
|---|---|---|---|
| 1 | NexusCRM | this design and acceptance contract | none |
| 2 | NexusCRM | historical monolith registration inventory; retired with the unused route entry | none |
| 3 | app-packs | machine-checked frontend route/credential inventory | none |
| 4 | api-host | additive `CredentialPolicy`, authenticated-route registrar, and per-route stack provider; dialect zero value uses old stack | none |
| 5 | api-host | mount, direct-registration, replacement, and dispatch fail-closed conformance, then release tag | none |
| 6 | NexusCRM | shared compatibility field, adapter mapping, api-host pin, and explicit current-policy registration for every host/runtime surface | none |
| 7 | app-packs | annotate existing UI API dialect and runtime routes; publish accepted source revision | none |
| 8 | NexusCRM | advance app-pack source locks and policy snapshots | none |
| 9 | api-host | schemeful-site topology and exact credentialed-CORS validation contract, then release | none |
| 10 | NexusCRM | topology readiness adapter and api-host pin | dormant |
| 11 | controlplane | proof-hash lookup index/readiness migration and verify, then release | none |
| 12 | NexusCRM | controlplane pin plus proof-only durable lookup in the one auth owner | dormant |
| 13 | api-host | generic cookie authenticator/credential selector, then release | dormant |
| 14 | NexusCRM | api-host pin and explicitly registered browser-session endpoints | feature off |
| 15 | frontend host | cookie-only session transport and presentation | feature off |
| 16+ | app-packs | one UI functional migration slice per PR | legacy path still available |
| next | NexusCRM | canary browser-session enablement and metrics | bounded cohort |
| next | NexusCRM | browser cookie rejected from programmatic-only routes | enforced |
| next | NexusCRM | stop returning/storing/injecting first-party Browser Bearer | enforced |
| next | controlplane/frontend | remove built-in browser secret and old bootstrap assumption | enforced |
| next | api-host/NexusCRM | empty programmatic scope fail closed | enforced |
| next | NexusCRM | explicit PAT scope/class, audit binding, distributed app rate limit | enforced |
| final | NexusCRM/L2 | revoke legacy sessions, remove flags/compatibility, final conformance | final state |

Repository publication order for an external L0 change is:

```text
additive external-repo PR -> release tag -> NexusCRM pin-only consumer PR
-> behavior activation PR -> compatibility removal PR
```

App-pack order is:

```text
L0 contract merged -> L1 route/consumer PR -> accepted app-pack revision
-> NexusCRM source-lock PR -> activation
```

Security binding changes remain standalone as required by
`docs/architecture/repo-split.md`.

## 16. Verification, rollout, and backout

### 16.1 Required conformance matrix

| Credential/request | Browser-enabled UI route | Programmatic-only raw route |
|---|---:|---:|
| valid browser cookie, admitted origin | allow without `ApiEnabled` | reject |
| browser cookie, cross-site/missing intent | reject | reject |
| browser cookie plus Bearer, final state | reject ambiguous | reject ambiguous |
| scoped Connected App Bearer with `ApiEnabled` | allow if scope covers | allow if scope covers |
| scoped Bearer without `ApiEnabled` | reject | reject |
| unscoped programmatic Bearer | reject | reject |
| explicitly scoped PAT | allow if policy permits | allow if policy permits |
| no credential | reject | reject |

Additional mandatory proof:

- every authenticated dialect, `RuntimeRouteSurface`, and app-blind host route
  is classified by the neutral registrar; route replacements preserve policy;
- browser establishment routes accept admitted first-party requests only, and
  current-session accepts Cookie-only browser sessions; logout rejects Bearer,
  revokes valid proof, and clears invalid/stale proof without granting identity;
- a browser login response contains no access/refresh token or replacement
  credential header;
- browser storage contains no NexusCRM auth token;
- logout/revoke/user-disable/auth-epoch change invalidates requests on every
  process;
- greenfield setup, upgrade from every accepted schema revision, startup
  readiness, and rollback-schema compatibility pass;
- external OAuth/PAT conformance and Salesforce v63 wire shapes remain stable;
- browser UI journeys pass for a user without `ApiEnabled`;
- raw API attempts using only the browser cookie fail;
- XSS simulation can invoke an allowed same-origin UI operation but cannot read
  or export a reusable credential; documentation reports this residual risk
  honestly;
- auth latency and DB-pool load remain within an approved measured budget.

### 16.2 Rollout gates

1. Inventory metrics run before enforcement and identify every legacy browser
   Bearer route.
2. Cookie-only establishment remains disabled until schema readiness, every
   host binary supports it, and the frontend/API topology passes the schemeful-
   site plus exact credentialed-CORS gate.
3. Canary enablement requires zero raw-route dependency for that frontend
   revision.
4. Legacy Browser Bearer issuance is disabled only after the canary proves
   login, refresh-equivalent continuity, logout, SSO, and UI journeys.
5. Empty-scope programmatic rejection is enabled after legacy credentials are
   inventoried and migrated/revoked.
6. Compatibility code and feature flags have named removal PRs and cannot
   become permanent read paths.
7. Final browser-enabled production readiness fails closed on a cross-site
   frontend/API topology; it never restores legacy Bearer issuance implicitly.

### 16.3 Backout

Before legacy issuance is removed, a canary may return to the previous request
mode without reverting additive schema. Operators must record that this
temporarily restores the known #1865 exposure and bound the exception.

After final cutover, re-enabling JS-readable Bearer storage is not an acceptable
normal backout. Roll back to the previous secure cookie-capable release, keep
the additive schema, and disable only the failing UI slice. Session revocation
and proof rotation are forward-only security actions.

Every behavior PR supplies:

- its exact enable/disable control;
- startup and mixed-version compatibility;
- metrics that prove whether backout is safe;
- data/schema reversibility classification;
- the issue/PR that removes its temporary compatibility path.

## 17. References

External primary guidance:

- [IETF OAuth 2.0 for Browser-Based Applications](https://datatracker.ietf.org/doc/draft-ietf-oauth-browser-based-apps/26/)
- [RFC 9700: Best Current Practice for OAuth 2.0 Security](https://www.rfc-editor.org/rfc/rfc9700.html)
- [OWASP Session Management Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html)
- [OWASP Cross-Site Request Forgery Prevention Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html)
- [Spring Authorization Server: SPA with PKCE and BFF recommendation](https://docs.spring.io/spring-authorization-server/reference/guides/how-to-pkce.html)
- [Salesforce Lightning Data Service and UI API](https://developer.salesforce.com/docs/platform/lwc/guide/data-ui-api)
- [Salesforce Session Settings](https://help.salesforce.com/s/articleView?id=sf.admin_sessions.htm&language=en_US)

Repository authorities:

- [`oauth-pkce-external-apps.md`](oauth-pkce-external-apps.md)
- [`salesforce-v63-api-contract.md`](salesforce-v63-api-contract.md)
- [`l0-l1-standard.md`](l0-l1-standard.md)
- [`../architecture/repo-split.md`](../architecture/repo-split.md)
- [`../../CLAUDE.md`](../../CLAUDE.md)

## 18. Closure criteria for #1123, #1865, and #1867

The design umbrella may close when this contract is accepted and child work is
tracked. #1123 and #1865 do not close on design alone.

#1123 closes when a browser-session user without `ApiEnabled` can complete the
supported UI journeys while external data API calls still require
`ApiEnabled`.

#1865 closes only when all of the following are true:

- page JavaScript receives and stores no portable NexusCRM browser Bearer;
- browser-session authority cannot drive programmatic-only routes;
- dialect, product runtime, and app-blind host routes all consume the same
  explicit credential-policy enforcement contract;
- empty programmatic scope fails closed;
- the first-party browser ships no client secret;
- Connected App/PAT programmatic calls have explicit scope, audit identity, and
  rate-limit identity;
- revocation and rollout conformance pass in a multi-instance deployment;
- every browser-enabled deployment passes the same-site topology/readiness
  contract without `SameSite=None` fallback;
- the transitional proof-plus-Bearer path and its feature flags are removed.
