Skip to main content

Trust & render-tokens

CNS treats trust as an explicit, first-class field — not an assumption baked into "where the event came from."

Provenance: two tiers, always server-derived

provenance: "backend-verified" | "client-asserted"
  • backend-verified — audit-grade. The event was emitted as a side effect of a real server action: a mutation, a served-render that returned a signed token later redeemed, or a confirmed third-party webhook.
  • client-asserted — telemetry-grade. A bare frontend claim: identity-bound (actor is always the verified JWT subject) but otherwise unverifiable.

A client can never set or elevate its own provenance — it's always derived server-side from how the event was actually anchored.

Two threats, three anchor tiers

The model separates who might forge an event:

  • Threat A — a third party forges events as the user (e.g. a malicious site with ambient credentials). Defeated by transport: Bearer-JWT-only auth, no cookies (see Authentication).
  • Threat B — the user forges their own low-trust events. This is the harder case, and it's resolved by asking, for every event: does any server anywhere observe this?
AnchorExampleResulting provenance
Server-ownedOur own mutation, or our own render that issued a tokenbackend-verified
Third-party-anchoredEmbedded video/content, confirmed via the provider's webhook/heartbeatbackend-verified iff confirmed S2S
Irreducibly client-onlyNo server anywhere ever observed itclient-asserted, permanently

The third row is a hard limit, not a gap to engineer around: no client-side crypto can lift a client-only event to audit-grade. Any signing key shipped to a browser is in the adversary's hands too — a frontend-signed claim is just a user-signed claim with extra steps. If an event must be audit-grade, it needs an actual server anchor: a mutation, a render-token, a provider webhook, or an explicit acknowledge action the backend processes. A passive "viewed" fired from the browser can never be legal proof on its own.

The render-token: turning a passive view into a server anchor

This is the mechanism that lets a client-only-looking event (e.g. "the user viewed this document") earn a real anchor:

  1. An app backend serves protected content and mints a short-lived signed token locally — bound to (subject, doc_id, iat, exp, nonce), signed with the app's own ed25519 private key. No network round-trip to CNS to do this.
  2. The token is delivered to the frontend via whichever carrier fits: an SSR <meta name="notify-proof"> tag, an injected window.__notifyProof__ global, a JSON _proof field, or an X-Notify-Proof response header (Cache-Control: no-store, CORS-exposed).
  3. The frontend calls markViewed(), which reads whichever carrier has the token — it never asks the CNS event server for one. The witness is the backend that served the content, not the event server.
  4. CNS's ingestor verifies the token's signature, freshness, and single-use nonce (idempotency — a token can escalate exactly one event), then stamps the event backend-verified.
// Backend (spec §3 "the witness"):
import { serveWithProof } from "@amphoze/cns-server";
const { json } = await serveWithProof(
{ app: "docs-svc", tenant: "acme", subject: subjectId, docId, keyVersion, privateKey },
{ json: responseBody },
);

// Frontend (never calls the event server for a token):
await client.markViewed(docId); // auto-reads _proof / header / <meta> / window global

For a browser calling a database directly (no custom backend — e.g. Supabase + row-level security), the witness role moves to a Supabase Edge Function that mints and embeds the token instead: @amphoze/cns-edge's mintAndHeaders / mintAndJSON / mintAndMeta.

Claim shape (confirmed matching, Go ↔ TypeScript)

alg: EdDSA (ed25519)
app — emitting app id
tenant — tenant id
sub — subject_id the token is bound to
doc_id — the document/resource witnessed
nonce — single-use idempotency key
key_ver — SSM parameter integer version of the signing key ("version-pin" instead of a `kid`)
iat / exp — issued-at / expiry (short TTL — covers key-rotation overlap)

This shape was flagged as an open discrepancy earlier in the project (the TS SDK proposed it before the Go side had a Tenant field); both sides now match field-for-field — see AGENTS.md's 2026-09-16 entries if you want the paper trail.

Standalone verification

POST /v1/render-tokens/verify checks a token's signature/freshness/nonce without submitting an event — useful for a frontend confirming freshness before emitting, or for ops/debugging. The primary redemption path is still inline inside POST /v1/events: a client-asserted event carrying a render-token in metadata is verified and escalated during normal ingestion. Full request/response shapes: API Reference.

Residual hardening for events that stay client-asserted

Even without a server anchor, CNS bounds (never proves) a client-asserted event: identity-locked to the JWT subject, replay-windowed, protected by normal TLS integrity. On native mobile, Play Integrity / App Attest raise the cost of forgery (not proof of authenticity). The per-session seq/heartbeat stream doubles as a plausibility check — a 10-minute video "watched" in two seconds is anomalous and can be flagged, but flagging is not the same as proving it didn't happen.