Skip to main content

Infrastructure setup

Practical notes for standing up each real (non-noop) adapter implementation. See Environment variables for the exact variables each one reads.

ClickHouse (store)

Schema migrations live in schemas/clickhouse/0001_init_events.{up,down}.sql. Apply with the ClickHouse client:

clickhouse-client --multiquery < schemas/clickhouse/0001_init_events.up.sql

What that migration sets up, verified from the file directly:

  • A cns.events table — ENGINE = ReplacingMergeTree(ingest_time), ORDER BY (tenant_id, subject_id, session_id, seq, event_id), PARTITION BY (tenant_id, toYYYYMM(ingest_time)). subject_id in the physical order key is the actor (the activity-timeline key — see Events & the envelope); query-time ordering for a feed read is a separate (ingest_time, session_id, seq) sort the store adapter issues, not this physical key.
  • Per-tenant retention as a dictionary-backed TTL: a cns.tenant_retention table (upserted by SetTTL) feeds a cns.tenant_retention_dict dictionary, which the events table's TTL expression calls via dictGet — this is what lets one tenant's retention change without an ALTER TABLE per tenant. A tenant with no explicit SetTTL call falls back to 400 days.
  • Dedup is belt-and-braces: the store adapter's own short-TTL in-memory seen-set rejects an obviously-duplicate Append before it ever reaches ClickHouse; the ReplacingMergeTree(ingest_time) engine is the backstop for whatever gets through anyway (a verbatim re-Append of the same event_id collapses to one row once merged — queries read FINAL so this doesn't depend on background merge timing).

:::caution Drop order matters on teardown The down-migration must drop events before the dictionary/source table — the events table's TTL expression depends on the dictionary via dictGet, and at least one real ClickHouse version (24.8.14, tested during development) aborts a naively-ordered drop with Code: 630 (HAVE_DEPENDENT_OBJECTS). Use the shipped 0001_init_events.down.sql rather than hand-rolling teardown SQL. :::

Point cns-server at it with CNS_CLICKHOUSE_ENDPOINT (+ _DATABASE/_TABLE/ _USERNAME/_PASSWORD).

Centrifugo (realtime)

cns-server's Centrifugo adapter is a server-to-Centrifugo publish client (built on gocent, Centrifugo's HTTP server API) — configure CNS_CENTRIFUGO_ADDR and CNS_CENTRIFUGO_API_KEY and CNS can publish to Centrifugo channels named cns:<tenant_id>:subject:<subject_id> / cns:<tenant_id>:group:<group_id>.

:::caution What this does not set up Configuring these two variables makes GET /v1/stream pass its second authorization check (see Realtime & streaming) and enables AuthorizeSubscribe for subject/group channels — it does not, by itself, stand up a working end-user browser connection directly to Centrifugo (what the TS SDK's CNSClient.subscribe() targets). That path's wire protocol — Centrifugo connection/subscribe-token issuance bound to a subject/group — is an explicitly unresolved open item in the design. Running Centrifugo itself (its own config, TLS, scaling) is out of this repo's scope — see Centrifugo's own documentation. :::

AWS SSM (secrets)

Path convention (mirrors envctl's shape deliberately, for a mechanical future swap):

/cns/<env>/svc/<app>/sign_priv SecureString — per-app ed25519 private key
/cns/<env>/common/pubkeys/<app> String — per-app ed25519 public key

IAM is where "the ingestor can't forge tokens" is actually enforced — this is a deployment/IAM concern, not something the Go interface can enforce on its own:

  • Each app's own IAM role should read only its own svc/<app>/*, plus common/pubkeys/*.
  • The cns-server process's own role should read common/pubkeys/* only — never any sign_priv path. CNS verifies signatures; it never mints render-tokens or service-JWTs itself.

Rotation is version-pinned, not kid-based: a token carries app plus the SSM parameter's integer version, and the verifier resolves the matching public key by (app, version) — see Apps & credentials. Set CNS_SECRETS_IMPL=ssm (the default) once the roles above are in place.

For local development without any AWS account at all, CNS_SECRETS_IMPL=memory swaps in an in-process ed25519 keystore, SSM-path-faithful in shape, that makes zero AWS calls — this is what the dev fixture uses, precisely because the ssm adapter's AWS SDK would otherwise resolve ambient credentials (~/.aws, IMDS) even with AWS_* variables unset.

Identity (SSO-JWT)

Configure a signing-key source (CNS_SSO_JWT_HMAC_SECRET or CNS_SSO_JWT_JWKS_URL) plus issuer/audience. The adapter is IdP-shape-agnostic — see Wiring a non-native IdP for a worked example beyond a platform-native SSO, and Environment variables for every claim-name override.