A local-first event pipeline for independent agents, built on Jazz.
thought-stream spec web-auth.md
23 kB

Public web and inspector authentication #

Private Co chat boundary #

The chat UI uses the existing Stream light/dark neutral palette and purple accents. It opens directly into conversation controls without onboarding headlines, promotional subtitles, or repeated privacy footers. Error messages distinguish authentication/verification failures from uncertain creation and must not encourage blind retry of an ambiguous operation.

An authenticated GET navigation to exactly /chat or /chat/ with no query may carry Sec-Fetch-Site: cross-site following OAuth or an external link, provided its fetch mode is navigate and destination is document. This exception serves only the fixed shell. Cross-site API calls, asset requests, iframe loads, and mutations remain rejected; owner authentication and POST Origin/CSRF checks are unchanged.

/chat/, its exact JS/CSS assets, and /chat/api routes require the existing allowlisted OAuth owner session, including reads. Basic never grants chat access. Mutations are JSON POSTs requiring the exact configured HTTPS Origin and a unique session CSRF header. Requests reject unknown fields, arbitrary conversation/runtime ids, oversized bodies, and unsupported methods. All responses are no-store, no-referrer, anti-frame, nosniff; chat CSP permits only self-hosted scripts/styles, no remote images, and no forms. Authentication precedes registry access and private-object existence checks. API errors are fixed classifications, never SDK exception text. Fixed process rate limits and one globally owned active turn bound new dispatch. An unknown outcome may still be executing remotely; the app does not claim a global runtime-concurrency guarantee from its own controller map.

Chat runs inside the existing singleton authenticated proxy, under its existing store lock, not in the Jazz inspector. Its separate encrypted registry uses the existing owner-only authentication directory and encryption key, with fsync before dispatch. Letta credentials remain server-only and are not forwarded from browser headers. Activation requires explicit THOUGHTSTREAM_CO_CHAT_ENABLED=true, OAuth configuration, LETTA_API_KEY, exact LETTA_BASE_URL=https://api.letta.com, and operator-configured THOUGHTSTREAM_CO_AGENT_ID and THOUGHTSTREAM_CO_COMPUTER_DEVICE_ID. Verify those existing resources before provisioning. SDK Cloud transport selects that stable device id explicitly; no automatic Cloud sandbox or local worker fallback is permitted. The registry is bound to the configured agent and refuses reopening under a different agent. Browser requests cannot change either binding; changing the private deployment configuration requires operator authority.

The edge must forward only /chat/ GET/HEAD and /chat/api GET/POST to the authenticated proxy, without request-body/query logging. This is an additional browser mutation capability, separate from Review/course writes, with no Jazz, connector, or dispatcher handle. A compromised owner session can invoke Co's existing unrestricted tools; this is the operator-authorized tradeoff, not read-only OAuth authority. Co's existing agent instructions still govern actions/privacy and shared memory.

Activation and rollback #

Build the selected release with pnpm build. With the explicitly supplied API credential in the operator process environment, pnpm configure:inspector-co-chat creates only ~/.config/thoughtstream/credentials/inspector-co-chat.env (0600), refuses overwrite, and neither starts inference nor restarts a service. It must not be run with a consumer/connector environment file. The proxy template loads this optional file and retains its existing OAuth store lock and sandbox; the new registry is co-web-registry.enc.json in that store directory.

Install only the proxy release paths/unit and the new thoughtstream_co_chat nginx rate-limit zone plus /chat/api/ location from the checked source template. If deploying directly from a worktree, set both WorkingDirectory and ExecStart to that release, and leave inspector/consumer/listener paths unchanged. Coordinate root nginx validation/reload through the parent operator. Restart only the authenticated proxy after its configuration/build is ready. Verify effective PID/start time, code path, unauthenticated/Basic chat rejection, exact-Origin/CSRF rejection, and owner OAuth login landing at /chat/. Creation occurs only on an owner New chat request; source tests and an empty registry do not prove inference. Await a natural owner message for an exact registered web/request/run/terminal receipt; do not fabricate a production canary or read another channel's transcript.

Rollback disables only the Co chat flag or removes that optional credential file and restores the previous proxy code path. Preserve the encrypted registry and all idempotency receipts. Shutdown flushes unfinished admissions as unknown before releasing the store lock; late initialization may not send after shutdown. Do not restart the multi-channel listener or any Stream connector as part of this release.

Assets #

Protected assets are private Jazz events, source identities, manifests, traces, runtime paths and metadata, OAuth state and DPoP keys, access and refresh tokens, the confidential-client private key, browser sessions, and the Basic break-glass credential when installed.

Public assets are the four reviewed Markdown pages, one exact validated Around WOFF2 embedded into their generated HTML, OAuth client metadata, and the public half of the client JWKS.

Trust boundaries #

  1. Nginx terminates HTTPS, redirects www.thought.stream to the canonical origin before application routing, and forwards only GET, HEAD, the two required OAuth POSTs, and one exact bounded Review-decision POST to a loopback web proxy.
  2. The web proxy serves an exact public route allowlist. It has no public generic file handler and no public upstream fallback.
  3. Only the authenticated /inspector/ route family can reach the loopback inspector. Authorization, cookie, forwarding, and hop-by-hop headers are removed first.
  4. The official ATProto OAuth client crosses the network to discovered authorization/resource servers with its hardened resolver, PKCE, PAR, DPoP, and nonce handling.
  5. OAuth secrets and browser sessions are encrypted in an owner-only store outside Git and outside the thought stream runtime root.

Route matrix #

Route Methods Authority Upstream access
/, /docs, /docs/architecture, /docs/security GET, HEAD public reviewed files; / contains the direct OAuth login form none
/oauth/client-metadata.json, /oauth/jwks.json GET, HEAD public OAuth discovery none
/oauth/login GET, HEAD, POST public flow initiation authorization server only through SDK
/oauth/callback GET one-time browser-bound state token endpoint only through SDK
/oauth/logout GET, HEAD, POST valid OAuth browser session; CSRF on POST exact local generation deletion only
/inspector/, /inspector/* except the decision route GET, HEAD allowlisted OAuth DID or enabled Basic fallback loopback inspector
/inspector/api/reviews/:item/decisions POST allowlisted OAuth browser session, session CSRF, and configured proxy-to-inspector Review capability one fixed append-only Review decision
/inspector/api/courses/post-training/questions POST allowlisted OAuth browser session, session CSRF, and configured proxy-to-inspector course-chat capability one fixed private course question
/inspector/api/workbench/documents GET, HEAD, POST reads: allowlisted OAuth DID or enabled Basic fallback; POST: allowlisted OAuth browser session, session CSRF, and the Review capability list working documents; create one working document from one origin event
/inspector/api/workbench/documents/:id, /inspector/api/workbench/candidates GET, HEAD allowlisted OAuth DID or enabled Basic fallback one working-document projection; bounded selectable context
/inspector/api/workbench/documents/:id/versions POST allowlisted OAuth browser session, session CSRF, and the Review capability one operator edit against one exact base version (409 when stale)
/inspector/api/workbench/documents/:id/selections POST allowlisted OAuth browser session, session CSRF, and the Review capability one exact context selection and snapshot
/inspector/api/workbench/documents/:id/proposals POST allowlisted OAuth browser session, session CSRF, and the Review capability one runner proposal request over the current head
/inspector/api/workbench/proposals/:id/decisions POST allowlisted OAuth browser session, session CSRF, and the Review capability one append-only accept/reject decision with judgment (accept fails closed on a stale base)
every other route none none none

Threats and controls #

Public-to-private route confusion #

Encoded traversal, unknown paths, former root /api paths, and unsupported methods terminate in the public proxy. They never become arbitrary filesystem paths and never fall through to the inspector. /inspector redirects to /inspector/ only after authentication so the inspector's relative api/... requests remain inside the private prefix.

Public pages and inspector data responses use an inert script-src 'none' policy. Documentation pages permit only the embedded validated WOFF2 as a data: font and self-origin form submission; they do not permit data images or scripts. The root landing page additionally permits HTTPS form navigation because its fixed self-origin login POST returns a trusted cross-origin authorization redirect. Authenticated inspector HTML receives a separate route-scoped policy that permits its audited inline loader, same-origin snapshot/media requests, and one same-origin no-cache service worker while forbidding third-party images, forms, and framing. The proxy selects these policies from trusted route and loopback response metadata; public routes never inherit the inspector policy.

Credential forwarding and response smuggling #

Only a small request-header allowlist reaches the inspector. Authorization, cookie, X-Forwarded-*, and proxy headers are discarded. Upstream cookies and authentication challenges are discarded. Hop-by-hop headers are never copied.

Login CSRF, callback injection, and replay #

Every login creates a random application state in a separate expiring flow store and binds it to an HttpOnly, Secure, SameSite=Lax cookie. That application state is passed to the SDK's authorize call. The SDK independently generates the OAuth protocol state, stores it with PKCE/DPoP material, and sends that distinct value through the authorization request. On callback, thought stream requires exactly one bounded protocol-state query field and one unique application-state cookie, then delegates protocol-state validation and one-time consumption to the SDK. Only the application state returned by the SDK is compared with the cookie and consumed from the flow store. Protocol and application state must not be conflated.

The login page and its redirect response permit HTTPS form navigation because the atproto profile requires the client to redirect the browser from its local POST to the dynamically discovered Authorization Server after PAR. A successful login initiation returns 303 See Other, making the cross-origin follow-up an explicit GET instead of relying on user-agent-specific 302 rewriting of POST. This exception is route-scoped to /oauth/login; other public pages retain self-only form destinations. The browser policy does not choose the destination: the official SDK's hardened identity, resource-server, and authorization-server discovery returns the redirect URL, and HTTP authorization endpoints remain forbidden.

Missing or duplicate cookies fail before SDK callback. Mismatched or expired application state is detected after the SDK has consumed protocol state; attempt-scoped credentials are then deleted locally without promotion. Application cleanup sends no remote revocation because a provider may define revocation broadly enough to invalidate a newer grant for the same DID; the SDK residual below still applies. Protocol-state replay is rejected by the SDK store. Callback handling is serialized inside the single process so the SDK's separate state-store get and del calls cannot race each other. These failures create no browser session.

Wrong-account authorization #

The callback compares the returned DID to one configured DID using constant-time byte comparison. Credentials for any other authenticated DID are deleted from local staging without promotion or an application-initiated remote revocation. The expected handle is a login hint, not the authorization decision.

Token theft and browser-session theft #

OAuth state, DPoP keys, access/refresh tokens, and browser sessions are AES-256-GCM encrypted at rest under a separately injected key. Files and directories are owner-only and replaced atomically. The confidential ES256 client key is injected separately. Browser cookies hold random opaque ids, never a DID or OAuth token, and are short-lived, Secure, HttpOnly, SameSite=Lax, and host-only.

A stolen browser cookie is still a bearer credential until expiry. The service permits one current browser session, invalidates older browser cookies when a new callback settles, restores the server-side OAuth session only when its persisted per-DID generation matches the browser session, and uses a bounded lifetime. Expiry, DID mismatch, restore failure, and rejected callback settlement delete only their matching local generation. Explicit CSRF-checked logout deletes the verified generation locally and clears the browser cookie. Application logout deliberately sends no remote revocation because provider-wide semantics could invalidate a newer concurrently promoted grant. This does not replace host/browser security.

Logout CSRF #

Logout is a POST with a random token stored only in the server-side browser session and rendered only to an authenticated browser. Missing, duplicate, or mismatched tokens fail without revocation or disclosure.

The same session token protects the exact Review-decision JSON route. The token is returned only from an OAuth-authenticated private session endpoint and is sent in a dedicated request header. Basic authorization never receives it and remains read-only. A successful CSRF check does not reach Jazz directly: the proxy signs the exact method, normalized route, body digest, timestamp, and one-time nonce under a separately injected capability. The inspector verifies that envelope before accepting the fixed Review decision schema. Browser authentication, CSRF, loopback capability, and event validation are distinct gates.

The workbench document routes reuse the Review capability rather than adding a key: they are the same class of bounded, operator-authored append-only writes, and the inspector's workbench handler independently validates every body against strict schemas and the trusted workflow's stale-base and decision-identity checks. Query strings, non-JSON bodies, bodies over 98 KB, Basic credentials, and missing or mismatched CSRF tokens are rejected in the proxy before any upstream contact. Reads of the workbench projections remain ordinary authenticated inspector reads. Edge activation is a separate deployment step: the checked nginx template forwards POST only for the exact Review-decision and course-question locations, so the workbench POST locations must be added there (with the same rate limit and no body/query logging) before browser writes work through the public origin. The loopback proxy and inspector already enforce the full gate without that change.

The course-question route uses the same browser-session CSRF token and a separate course-chat loopback capability. The separate key prevents Review authority from silently expanding into model-triggering authority. The proxy signs the exact bounded question request, and the inspector derives lesson context from repository source before event insertion. Basic remains read-only on both routes.

SSRF and hostile OAuth metadata #

Authorization-server, resource-server, DID, and handle discovery are delegated to the official Node OAuth client and its hardened fetch/resolver stack. thought stream does not implement permissive metadata fetching or accept operator-supplied authorization endpoints. HTTP is disabled for production metadata.

Resource exhaustion and disconnects #

Every encrypted application and SDK store has independent entry-count and serialized-plaintext byte limits. Oversized writes fail atomically and retain the prior document. Encrypted envelope size is checked before read/decrypt. Login and callback are limited independently at both nginx and process layers, with bounded process limiter maps. Browser disconnect aborts SDK authorization discovery/PAR through the SDK-supported authorize(..., { signal }) path.

The installed SDK callback API has no AbortSignal option. thought stream therefore runs the entire callback settlement path against one authoritative watchdog: SDK exchange, application-state consumption, generation promotion, browser-session persistence, and cleanup. Timeout synchronously marks both the application attempt and staging attempt non-promotable before releasing the global serializer. Every persistent write rechecks authority immediately before atomic rename, and late completion is removed locally through detached cleanup.

Each promoted DID session receives a monotonically increasing local generation. Browser authority names that generation. SDK restore runs inside an exact-generation capability: get sees only that generation, refresh set and failure delete can mutate only that generation, stale completion becomes a no-op, and a post-restore persisted-generation check runs before browser authority returns. Cleanup deletes only an exact matching generation.

Expired staging remains quarantined until its SDK promise settles so a late SDK session-store set can be absorbed locally instead of taking that particular store-failure path. The staged record is then dropped without an application-initiated remote revocation. This is not a provider-side guarantee: the unmodified SDK may still revoke remotely after issuer, exchange, or other session-store failure, and provider revocation may be grant-wide. Preventing that would require transport interception, a fork, or killable process isolation. The enforceable guarantee is local: stale attempts cannot promote, restore, overwrite, or delete a newer generation.

A callback that never resolves retains an SDK promise and an inert staging tombstone. Retained attempts are capped at eight. At capacity, new callbacks fail closed with 503, Retry-After, and an explicit operator-recycle-required response. The flow cookie and application/protocol state are preserved so the same callback can be retried after recovery. Capacity recovers when attempts settle and are dropped or when the singleton proxy process restarts.

Query-string custody #

The canonical nginx access-log format records $uri, never $request or $request_uri. Callback access logging is disabled and its location error log is discarded so nginx cannot serialize the request line. The Node proxy emits no per-request URL logging and catches OAuth errors without printing SDK exceptions. OAuth callback query strings, codes, issuer parameters, and protocol state must never enter access logs, service output, incidents, or durable thought stream events.

Single-process encrypted-store contract #

The encrypted stores are whole-document, in-process serialized stores rather than multi-writer databases. This is a single process storage contract: exactly one OAuth-capable proxy process may own the fixed ~/.local/share/thoughtstream-inspector-auth directory. Custom OAuth store paths are rejected because the systemd sandbox grants write access only to that path. The application acquires an owner-only PID/token lock in that directory before opening any SDK or application store, refuses a live owner, reclaims only a parseable dead-PID lock, and releases its lock on graceful shutdown. The systemd unit is a singleton service. Horizontal replicas, templated instances, manual parallel starts, and shared network filesystems are unsupported; scale-out requires replacing this store implementation.

Availability, Basic break-glass, and retirement #

OAuth configuration is optional at startup. Basic fallback is separately configured and disabled by default. Only PROXY_BASIC_FALLBACK_ENABLED=1 or true plus a valid username and password enables it; absent and false values disable it, and malformed values fail startup. When enabled, a valid Basic credential authorizes only /inspector/ reads and bypasses OAuth restoration. It is never forwarded upstream, never receives Review session state, and grants no OAuth, PDS, write, or public-route authority. When disabled, the proxy does not require or decode the Basic credential and does not advertise WWW-Authenticate: Basic. Startup fails if both OAuth and Basic fallback are disabled.

Basic must remain available during an activation only when the operator explicitly installs its separate credential file. Retirement waits for external metadata/JWKS fetch, a real authorization redirect, allowlisted callback, private read, logout/local generation deletion, and one bounded Basic rollback read. After those receipts, the operator removes the optional Basic credential file and restarts only the proxy. Negative probes must show identical generic rejection for an old Basic header and no credentials, no Basic challenge, and continued OAuth admission. A test result or active service status is not that receipt.

Residual risks #

  • The proxy and inspector run on the same host; host compromise defeats this boundary.
  • The official OAuth SDK and its dependency graph remain trusted code.
  • OAuth browser sessions reveal highly private data if stolen and, when the separate Review capability is configured, can append one bounded decision under CSRF. They still grant no generic mutation authority.
  • When course chat is configured, a stolen OAuth browser session can also append bounded course questions that consume the tutor's declared inference budget. Rate limits, source/declaration binding, and independent accounting limit that path; the session still grants no model-selection, tool, channel, file, or publication authority.
  • Public documentation requires editorial review; a route-safe renderer cannot prevent a human from committing sensitive prose to an allowlisted public file.
  • An explicitly enabled Basic fallback is a high-value bearer credential. It must remain HTTPS-only, independently rate-limited at the edge, and limited to the activation or rollback window.
  • Callback token exchange cannot currently be canceled through the official SDK API after it begins; the SDK version exposes abort only for authorization discovery/PAR. The watchdog removes authority and queue blockage, not the underlying unresolved SDK promise.