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 (actoris 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?
| Anchor | Example | Resulting provenance |
|---|---|---|
| Server-owned | Our own mutation, or our own render that issued a token | backend-verified |
| Third-party-anchored | Embedded video/content, confirmed via the provider's webhook/heartbeat | backend-verified iff confirmed S2S |
| Irreducibly client-only | No server anywhere ever observed it | client-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:
- 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. - The token is delivered to the frontend via whichever carrier fits: an SSR
<meta name="notify-proof">tag, an injectedwindow.__notifyProof__global, a JSON_prooffield, or anX-Notify-Proofresponse header (Cache-Control: no-store, CORS-exposed). - 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. - 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.