Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Parsec

Parsec is a scalable realtime messaging engine built on Centrifuge (OSS). It ships as a Go library (primary), a CLI binary, and a Twirp JSON RPC service — three surfaces over the same engine.

This book is the reference manual. Start with the quickstart, or skip to the section that matches your role:

Capability map

NeedRead
Try parsec in 30 secondsRunning with Docker
Wire a browser to parsecBrowser client
Test code that embeds parsecparsectest helpers
Open a channel, publish, subscribeQuickstart
Understand the namespace grammarChannel naming
Token-gate a channel with patternsACL scopes
Rotate signing keys without downtimeKey rotation
Accept IdP-issued bearersOIDC bridge
Survive sink outagesDLQ
Gate abusive callersRate limiting
Cluster across machinesDeployment
Span regionsMulti-region
Speak HTTP/3WebTransport
Compress chatty channelsDelta compression
Embed the operator UIAdmin UI
Scrape metricsObservability

What Parsec is

  • A broker process that holds long-lived client connections (WebSocket + optional WebTransport).
  • A channel manager with a strict public:/private: namespace convention and TTL-driven lifecycle.
  • A library you embed in Go services to publish, subscribe, and authenticate.
  • A CLI and a Twirp RPC surface — two ways to control the same engine from outside.

What Parsec is not

  • Not an everything-store. It does not own your business data; it ships events.
  • Not a durable work queue. Use one for jobs; use parsec for “you have a notification”.
  • Not a Centrifugo Pro replacement. Parsec uses the OSS library; we ship our own delta-compression toggle, admin UI, and presence primitives — only the subset we need.

Installation

How to get a working parsec binary on disk.

Prerequisites

  • Go 1.26.1 or newer. Parsec uses generics, structured logging, and the modern slog API; older toolchains will not build.
  • A POSIX shell. The bootstrap message uses standard environment variables; nothing exotic.
  • No CGO. The Makefile sets CGO_ENABLED=0 and treats any new C-toolchain dependency as a build failure. You do not need a C compiler.

Verify your toolchain:

go version
# go version go1.26.1 darwin/arm64

Install with go install

The fastest path. Drops the binary in $(go env GOBIN) (or $(go env GOPATH)/bin if GOBIN is unset):

go install github.com/frankbardon/parsec/cmd/parsec@latest

Confirm:

parsec --version
# parsec version dev

The dev string is the default ldflags version stamp. Tagged releases substitute the tag at build time.

Build from source

Clone the repo and use the Makefile. The resulting binary lands at bin/parsec:

git clone https://github.com/frankbardon/parsec.git
cd parsec
make build
./bin/parsec --version

make build runs:

go build -trimpath -ldflags="-s -w" -o bin/parsec ./cmd/parsec

-trimpath strips local build paths from the binary; -ldflags="-s -w" drops the DWARF debug info and symbol table to shrink the artifact. CGO is disabled by the Makefile.

Run the test and lint targets

The same Makefile drives the rest of the development loop:

make test       # go test ./...
make lint       # go vet + staticcheck
make cover      # coverage profile
make docs       # mdbook build (requires mdbook on PATH)

make lint shells out to staticcheck via go run; it does not need a separate install step.

What you should do next

Quick start

Boot the broker, open a public channel, publish, mint a private channel, and exchange a refresh token. Five minutes end-to-end.

1. Install

go install github.com/frankbardon/parsec/cmd/parsec@latest

Or build from source:

git clone https://github.com/frankbardon/parsec.git
cd parsec
make build   # binary lands at bin/parsec

2. Boot the server

The simplest invocation listens on :8000 with a persistent keyring under /var/lib/parsec. First boot mints a bootstrap mgmt token and prints it to stderr — capture it.

parsec serve --addr :8000 --state-dir /var/lib/parsec
# parsec serve: bootstrap mgmt token (expires 2026-05-23 18:04:11 UTC):
# eyJhbGciOiJIUzI1NiIs...

--state-dir makes the HMAC keyring file-backed so tokens survive restart. Omit it for fully ephemeral dev runs.

See serve for every flag.

3. Export your operator credentials

Most CLI commands hit the management RPC, so they need the bearer:

export PARSEC_TOKEN="eyJhbGciOiJIUzI1NiIs..."
export PARSEC_SERVER="http://localhost:8000"

Already use an IdP? Run parsec login oidc instead of pasting a bearer.

4. Open a public channel

parsec channels open public:webapp.system.status

Public channels have no token gate — anyone can subscribe. They still have a TTL (30 min inactivity by default; override with --ttl 1h).

5. Publish to it

echo '{"msg":"hello"}' | parsec publish public:webapp.system.status

The broker returns a publish offset and an epoch. Late subscribers can replay from any offset within the 5-minute history window.

6. Subscribe as a probe

parsec subscribe public:webapp.system.status

Server-Sent Events client for debugging. Publications appear as NDJSON on stdout; Ctrl-C to exit. Production clients use the WebSocket (/connection/websocket) or the optional WebTransport endpoint.

7. Mint a private channel

Private channels need an access token. channels create returns the access + refresh pair in one response:

parsec channels create private:webapp.user.42.downloads \
  --subject user-42 --ttl 30m

Access tokens default to 5 minutes; refresh tokens live up to the channel TTL (capped at 1 hour). The token bodies are JWTs you can pass to centrifuge-js directly. See private channels.

8. Exchange a refresh for a fresh access

parsec tokens refresh "<refresh-token>"

RefreshToken is public — the refresh token in the body authenticates the call. The new access token cannot outlive the refresh expiry.

Next steps

Running with Docker

Parsec ships a multi-arch (linux/amd64, linux/arm64) container image. The Dockerfile builds a fully static binary on golang:1.26.1-alpine and ships it on gcr.io/distroless/static-debian12 — the result is ~7 MB total, no shell, no package manager, no CVE surface beyond the binary itself.

Tagged releases publish to ghcr.io/frankbardon/parsec:

TagTracks
latestMost recent tagged release
vX.Y.ZSpecific semver release
vX.YLatest patch of the minor
vXLatest minor of the major
mainTip of main branch (unstable)
sha-<short>Specific commit

One-shot run

docker run --rm -p 8000:8000 ghcr.io/frankbardon/parsec:latest
# parsec serve: bootstrap mgmt token (expires 2026-05-23 18:04:11 UTC):
# eyJhbGciOiJIUzI1NiIs...

The bootstrap mgmt token is on stderr. Capture it from the container logs:

docker logs <container-id> 2>&1 | grep bootstrap

Persistent keyring (recommended):

docker run --rm -p 8000:8000 \
  -v parsec-state:/var/lib/parsec \
  ghcr.io/frankbardon/parsec:latest

The image’s default CMD is serve --addr :8000 --state-dir /var/lib/parsec. Override with any parsec subcommand:

docker run --rm ghcr.io/frankbardon/parsec:latest --version
docker run --rm ghcr.io/frankbardon/parsec:latest channels list \
  --server http://parsec.internal:8000 \
  --token "$PARSEC_TOKEN"

docker compose

The repo ships a docker-compose.yml that boots parsec + Redis together. It mounts examples/config/parsec.docker.yaml into the container so the broker, channel registry, keyring, DLQ, and rate limiter all run against Redis — the same multi-node code paths production uses.

git clone https://github.com/frankbardon/parsec.git
cd parsec
docker compose up --build

Once both services are healthy:

docker compose logs parsec | grep bootstrap
# capture the mgmt token, export PARSEC_TOKEN, then:
parsec channels list   # (or use the in-container CLI)

Drop the state volume to reset auth:

docker compose down -v

Building locally

docker build -t parsec:dev .
docker run --rm -p 8000:8000 parsec:dev

The build is cached on go.mod / go.sum, so iteration on Go source typically reuses the module-download layer.

To override the version stamp baked into the binary:

docker build --build-arg VERSION=v0.1.0 -t parsec:v0.1.0 .

Health checks

The image exposes a /healthz endpoint that returns 200 OK once the HTTP listener is bound. Wire it into your orchestrator:

livenessProbe:
  httpGet: { path: /healthz, port: 8000 }
  initialDelaySeconds: 2
  periodSeconds: 10

For Kubernetes deployments, also gate the bearer middleware by setting PARSEC_METRICS_TOKEN and scraping /metrics from your monitoring namespace.

Image surface

  • User: nonroot (uid 65532, gid 65532). Drops root from the container at start; required for restricted PSP/PSA environments.
  • Entrypoint: /usr/local/bin/parsec. The image has no shell — docker exec -it ... sh will fail. Use a sidecar for debugging.
  • Ports: 8000/tcp (HTTP / WebSocket / Twirp). WebTransport (HTTP/3) uses UDP on a separate listener; expose it as needed.
  • Volumes: /var/lib/parsec (keyring + any future state).
  • Labels: Standard OCI labels point at the source repo + license.

Security notes

  • Always set PARSEC_METRICS_TOKEN (env or YAML) when /metrics is reachable from outside the cluster. The default is unguarded so dev evaluation works out of the box.
  • Always set --state-dir on a persistent volume in production. An ephemeral keyring means every container restart issues a new bootstrap mgmt token and invalidates every outstanding access / refresh token.
  • The image is rebuilt on every tagged release. Pin the digest in production (ghcr.io/frankbardon/parsec@sha256:...) so an unrelated retag does not change what you deploy.

Browser client

Looking for a batteries-included wrapper? The JavaScript Client (parsec-client) page documents an opinionated @frankbardon/parsec-client package that adds token refresh, channel-name validation, manifest-driven transport selection, and coded error mapping on top of centrifuge-js. The page you are reading remains the bare-metal centrifuge-js walkthrough — useful when you want full control or are stitching parsec into an existing client.

This page walks through the full browser ↔ parsec round-trip:

  1. A Go server hosts parsec and an HTML page.
  2. The browser connects to the websocket transport using centrifuge-js.
  3. Public channels need no token; private channels present a JWT access token at connect time.

Parsec speaks the Centrifugo wire protocol unchanged, so the official JS client is a drop-in. Every example below uses centrifuge-js directly.

1. Public channel — no auth

The shortest demo. Boot a parsec instance, open a public channel, mount the websocket handler, serve an HTML page.

// examples/browser/main.go (excerpt)
p, _ := parsec.New(parsec.Options{StateDir: dir})
go p.Run(ctx)
for !p.Broker().Started() { time.Sleep(5*time.Millisecond) }

p.OpenPublic("public:demo.system.heartbeat", time.Hour)

mux := http.NewServeMux()
mux.Handle("/connection/websocket",
    centrifuge.NewWebsocketHandler(p.Broker().Node(), centrifuge.WebsocketConfig{}))
mux.Handle("/", http.FileServer(http.Dir("./public")))
http.ListenAndServe(":8000", mux)

The browser side is ~15 lines:

<script src="https://unpkg.com/centrifuge@5.4.0/dist/centrifuge.js"></script>
<script>
  const c = new Centrifuge("ws://" + location.host + "/connection/websocket");
  c.on("connected", () => console.log("connected"));

  const sub = c.newSubscription("public:demo.system.heartbeat");
  sub.on("publication", (ctx) => console.log("got:", ctx.data));
  sub.subscribe();

  c.connect();
</script>

Run the bundled example to see it live:

go run ./examples/browser
# open http://localhost:8000

See examples/browser/ for the full source.

2. Private channel — token-gated

Private channels require an access token. The flow:

  1. Server mints the token via parsec.CreatePrivate (or the Twirp CreatePrivate RPC).
  2. Server hands the token to the browser — typically as part of a session-bootstrap response or a dedicated GET /token endpoint gated by your existing app session.
  3. Browser presents the token to centrifuge via setToken(...) before connect.
  4. Parsec verifies + authorizes subscribe against the access token’s chs list and pattern scopes.

Server (Go)

// mintToken returns the access token for the current user.
func mintToken(p *parsec.Parsec, userID string) (string, error) {
    creds, err := p.CreatePrivate(userID,
        fmt.Sprintf("private:webapp.user.%s.downloads", userID),
        30*time.Minute)
    if err != nil {
        return "", err
    }
    return creds.AccessToken, nil
}

// HTTP handler: returns { token, channel } as JSON. Your app's session
// guard runs first — only authenticated users get a token.
func tokenHandler(p *parsec.Parsec) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        uid := sessionUserID(r) // your auth glue
        if uid == "" {
            http.Error(w, "unauthorized", http.StatusUnauthorized)
            return
        }
        tok, err := mintToken(p, uid)
        if err != nil {
            http.Error(w, err.Error(), http.StatusInternalServerError)
            return
        }
        json.NewEncoder(w).Encode(map[string]string{
            "token":   tok,
            "channel": fmt.Sprintf("private:webapp.user.%s.downloads", uid),
        })
    }
}

Mount it alongside the websocket handler:

mux.HandleFunc("/token", tokenHandler(p))
mux.Handle("/connection/websocket",
    centrifuge.NewWebsocketHandler(p.Broker().Node(), centrifuge.WebsocketConfig{}))

Browser

<script src="https://unpkg.com/centrifuge@5.4.0/dist/centrifuge.js"></script>
<script>
  async function main() {
    // Your app's auth cookie carries the user identity into /token.
    const res = await fetch("/token", { credentials: "include" });
    if (!res.ok) throw new Error("token mint failed");
    const { token, channel } = await res.json();

    const c = new Centrifuge("ws://" + location.host + "/connection/websocket", {
      token,
    });

    const sub = c.newSubscription(channel);
    sub.on("publication", (ctx) => console.log("got:", ctx.data));
    sub.on("error", (ctx) => console.error("sub error:", ctx));
    sub.subscribe();

    c.connect();
  }
  main().catch(console.error);
</script>

Refresh — keeping the connection alive past 5 minutes

Access tokens are short-lived (5 min default). When the server says the token is about to expire, centrifuge-js fires a refresh event — the client exchanges the refresh token for a fresh access token via parsec’s public RefreshToken RPC:

const c = new Centrifuge("ws://" + location.host + "/connection/websocket", {
  token,
  getToken: async (ctx) => {
    // Exchange the refresh token (held server-side) for a new access
    // token. The /refresh endpoint is your shim around the parsec
    // RefreshToken RPC.
    const res = await fetch("/refresh", { credentials: "include" });
    const { token } = await res.json();
    return token;
  },
});

On the server, /refresh calls p.RefreshAccess(refreshToken):

func refreshHandler(p *parsec.Parsec) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        refreshTok := sessionRefreshToken(r) // your storage
        out, err := p.RefreshAccess(refreshTok)
        if err != nil {
            http.Error(w, err.Error(), http.StatusUnauthorized)
            return
        }
        json.NewEncoder(w).Encode(map[string]string{"token": out.AccessToken})
    }
}

The refresh token can be stored in an HttpOnly cookie or a server-side session — never expose it to JavaScript directly.

3. WebTransport (HTTP/3)

If WebTransport is enabled, the centrifuge-js options take a webtransport: true toggle:

new Centrifuge([
  { transport: "webtransport", endpoint: "https://" + location.host + ":8443/connection/webtransport" },
  { transport: "websocket",    endpoint: "ws://" + location.host + "/connection/websocket" },
])

The client tries WebTransport first; on failure (network restrictions, TLS issues, older browsers) it falls back to WebSocket.

Troubleshooting

SymptomLikely causeFix
Connect succeeds, subscribe rejects with permission deniedAccess token missing chs for the channel, or its scopes deny the verbMint via CreatePrivate for that channel name; check scope grants.
Centrifuge logs “bad request” on connectToken expired between mint and useShorten the mint→connect window; use getToken for live refresh.
Page loads, websocket immediately closes with transport: token is invalidServer rotated keys; cached token signed by retired keyRe-mint the token; check parsec keys list.
404 on /connection/websocketHandler not mountedmux.Handle("/connection/websocket", centrifuge.NewWebsocketHandler(...))

For deeper protocol questions, see the upstream centrifuge-js docs. The parsec server speaks that protocol verbatim — anything that works against Centrifugo works against parsec.

JavaScript Client (@frankbardon/parsec-client)

@frankbardon/parsec-client is a thin composition wrapper around centrifuge-js that adds the Parsec conventions a real browser app needs: token refresh with rotation, channel-name validation, manifest-driven transport selection, coded errors that mirror errors/codes.go, and a (decode- only) scope inspector for UIs that need to display token grants.

The package lives in-tree at clients/js/ and is published to npm under @frankbardon/parsec-client. The Centrifugo wire protocol is unchanged — anything you can do with raw centrifuge-js (see Browser client) you can still do.

Install

npm install @frankbardon/parsec-client

centrifuge ships as a direct dependency — no extra install needed.

Quickstart

import { ParsecClient } from "@frankbardon/parsec-client";

const client = new ParsecClient({
  endpoint: "https://parsec.example.com",
  accessToken: initialAccess,
  refreshToken: initialRefresh,
  accessExp: initialAccessExp, // unix seconds; 0 forces immediate refresh
});

client.on("terminal", (err) => console.error("re-authenticate:", err.code));

await client.connect();

const sub = client.newSubscription("public:webapp.lobby.global");
sub.on("publication", ({ data }) => console.log(data));
await sub.subscribe();

await client.publish("public:webapp.lobby.global", { kind: "hello" });

Manifest auto-discovery

connect() GETs /manifest, intersects the advertised transports with the client’s preference list, and builds the endpoint array centrifuge-js expects. The default client preference is ["websocket", "http_stream"]; override with preferTransports and listen for manifestUnavailable to detect manifest fetch failures:

const client = new ParsecClient({
  endpoint: "https://parsec.example.com",
  preferTransports: ["webtransport", "websocket", "http_stream"],
});
client.on("manifestUnavailable", (err) => console.warn("manifest fetch failed", err));

To skip the manifest fetch entirely (e.g. tightly-locked-down deployments behind a CDN that does not expose /manifest):

new ParsecClient({ endpoint: "...", manifest: false });

Token refresh and rotation

The wrapper plumbs centrifuge-js’s getToken callback into a single-flight refresh state machine. A preemptive timer fires at accessExp − skew − jitter so refresh almost always happens before centrifuge has to ask. The refresh hits the Twirp RefreshToken RPC at /twirp/parsec.ParsecService/RefreshToken.

Failure policy:

ClassExamplesAction
TerminalPARSEC_AUTH_DENIED, PARSEC_AUTH_EXPIREDClear store, emit terminal, return null to centrifuge (disconnects). Host app re-authenticates.
RecoverableNetwork, PARSEC_BROKER_NOT_READY, PARSEC_RATE_LIMITEDThrow — centrifuge handles its own backoff and will retry.

The wrapper always persists the response’s refresh_token (even when rotated: false), and it persists BEFORE resolving the promise to centrifuge. Reusing a stale refresh after rotation triggers family-revoke — the wrapper avoids that path by design. See the Refresh-Token Rotation runbook for the server side.

Plugging in custom storage

MemoryTokenStore is the default. For session continuity across page reloads, implement the TokenStore interface against your storage of choice (cookies, IndexedDB, a session-storage adapter):

import type { TokenStore, TokenPair } from "@frankbardon/parsec-client";

class IndexedDbTokenStore implements TokenStore {
  async get(): Promise<TokenPair | null> { /* ... */ return null; }
  async set(pair: TokenPair): Promise<void> { /* ... */ }
  async clear(): Promise<void> { /* ... */ }
}

new ParsecClient({ endpoint: "...", tokenStore: new IndexedDbTokenStore() });

Coded errors

Every error thrown by the wrapper is a ParsecError carrying a PARSEC_* code that mirrors errors/codes.go. Branch on .code instead of string-matching messages:

import { ParsecError, ParsecErrorCode } from "@frankbardon/parsec-client";

try {
  await client.publish(channel, payload);
} catch (err) {
  if (err instanceof ParsecError && err.code === ParsecErrorCode.RateLimited) {
    // back off
  }
}

The Twirp wire mapping mirrors internal/rpcclient/errors.go: unauthenticated → PARSEC_AUTH_EXPIRED, permission_denied → PARSEC_AUTH_DENIED, resource_exhausted → PARSEC_RATE_LIMITED, etc.

Channel name validation

parseName and buildName mirror channels.ParseName in Go. Subscribe / publish call parseName before delegating, so malformed channels fail with PARSEC_CHANNEL_INVALID before centrifuge ever sees them. You can call the helpers directly to validate operator-typed input:

import { parseName, buildName, Visibility } from "@frankbardon/parsec-client/channels";

const name = buildName(Visibility.Private, "webapp", "user", userId, "inbox");
// → "private:webapp.user.<id>.inbox", validated end-to-end

Scope inspector — decode only

inspectScopes decodes the JWT payload and returns a typed view a UI can render. It does NOT verify the signature. The server is the only authority on token validity; treat the result as display data, not as an authorization decision.

import { inspectScopes } from "@frankbardon/parsec-client/scopes";

const view = inspectScopes(accessToken);
// view.channels, view.scopes (pattern/verbs/deny), view.expiresAt, view.expired

Codegen interop (parsec-gen --lang ts)

parsec-gen --lang ts emits pattern constants and per-aspect payload interfaces from your schema registry. Pair them with typedSubscribe for end-to-end types:

import { sessionsChannel, type SessionsCursor } from "./gen/index";

const sub = client.typedSubscribe<SessionsCursor>(sessionsChannel(userId));
sub.on("publication", ({ data }) => {
  // data: SessionsCursor
});
await sub.subscribe();

The T parameter is a phantom type — runtime is the same Subscription centrifuge-js returns. The type is a promise, not a guarantee: the schema registry is the contract enforcer. Wire your emitter, regenerate after schema changes, and the types will track.

Versioning

The npm package versions independently from the Go server. Each release declares a minimum Parsec server version. A server release that bumps descriptor.FormatVersion is a wire change and requires a matching client release.

Reference

ExportModulePurpose
ParsecClient@frankbardon/parsec-clientComposition wrapper
ParsecError, ParsecErrorCode@frankbardon/parsec-clientCoded errors
parseName, buildName, Visibility@frankbardon/parsec-client/channelsChannel grammar
inspectScopes, decodeJwtPayload@frankbardon/parsec-client/scopesUnverified JWT introspection
MemoryTokenStore, TokenStore@frankbardon/parsec-clientToken persistence boundary
fetchManifest, pickTransports@frankbardon/parsec-clientManifest + transport ranking

Channel Naming Convention

Channel names are the contract between publishers and subscribers. Parsec enforces a strict layout so every name announces its visibility, its owning application, and the domain object it speaks for.

Grammar

<visibility>:<app>.<domain>[.<id>][.<topic>]
  • visibilitypublic or private. Required. Maps to a Centrifuge namespace at boot. Anything else is rejected at validation time.
  • app — a short slug for the consuming application (e.g. webapp, agent, cron). Required. Lowercase ASCII letters, digits, and hyphens.
  • domain — the root domain object (session, user, download, analysis, alert, heartbeat, report, scoreboard). Required.
  • id — a stable identifier (UUID, session ID, user ID). Required for private; optional for public when the channel is broadcast-wide.
  • topic — optional sub-topic for finer-grained subscription (progress, complete, failed).

Components are joined with . after the : separator. The : is reserved for the visibility namespace and must not appear elsewhere.

Examples

Public

ChannelMeaning
public:webapp.system.statusSystem-wide status feed for the webapp.
public:scoreboard.counter.ai_usersCounter that anyone in the org can subscribe to.
public:agent.broadcast.maintenanceMaintenance announcements from the agent runtime.

Private

ChannelMeaning
private:webapp.session.{sid}.notificationsPer-session notification stream.
private:webapp.user.{uid}.downloadsPer-user async-download progress.
private:agent.analysis.{job}.progressPer-job analysis progress.

Why this matters

  • A grep for public: finds everything you can subscribe to without auth.
  • A grep for private: finds everything that needs a token.
  • The app.domain prefix tells you who owns it — kill that namespace and you cleanly remove a consumer.
  • The id segment makes per-tenant fanout cheap: subscribers do not need server-side filters.

Domain hygiene

Domain names are project-specific, but the foundation is fixed:

  • Keep domain to a small vocabulary per app. If you have more than ~12 domains, you probably want a new app slug.
  • Treat domain plurality consistently. We use the singular form (session, not sessions).
  • Reserve a system domain in every app for service-level events.

The full enforcement lives in channels/name.go and is exercised by the CLI and the Twirp surface alike.

Patterns

The same grammar — with two extra wildcards — describes the patterns that may appear in a token’s scopes claim:

  • * matches exactly one segment, in any position.
  • ** matches one-or-more trailing segments; last position only.

Patterns are bound to a concrete visibility (public or private); cross-visibility patterns like *:foo.bar are rejected at parse time. See Access Control Scopes for how scope patterns interact with the chs exact-match list and the verb permissions.

Public channels

Public channels are broadcast feeds: anyone can subscribe, no token required.

./bin/parsec channels open public:webapp.system.status
echo '{"msg":"deploy started"}' | ./bin/parsec publish public:webapp.system.status
./bin/parsec subscribe public:webapp.system.status

The visibility prefix public: is what unlocks the no-auth subscribe path. The grammar is documented in naming; anything not matching public:<app>.<domain>[.<id>][.<topic>] is rejected by channels.ParseName before the manager ever sees it.

Lifecycle

Public channels move between three states:

StateMeaningNew subscribersExisting subscribers
openActiveAcceptedReceive publications
closedInactivity TTL exceededRejectedDrain naturally (not kicked)
deletedRemoved by an operatorRejectedUnsubscribed

The transition from open to closed happens during the manager’s periodic sweep — every 30 seconds by default. A closed channel can be reopened with another channels open call, which resets last_active and emits a fresh opened event.

TTL semantics

Public channels have an inactivity TTL: any Publish (or explicit Touch) resets the clock. The default is 30 minutes; override with --ttl 2h on channels open. Two important properties:

  • Closed public channels are not removed. The record stays in the manager so an operator can reopen it without losing the name.
  • Closed public channels do not kick subscribers. They simply refuse new ones. This matches the “fade out” intent of a public feed — the broadcaster has stopped, but the people already listening can finish what they’re hearing.

See TTL and expiry for the full lifecycle diagram and how it differs from private.

History

Centrifuge keeps a per-channel history buffer so a late subscriber can catch up. The defaults: 100 publications, 5-minute retention. These are broker-side knobs configured at boot via broker.Options:

  • HistorySize — bound on retained publications per channel
  • PublicHistoryTTL — how long publications stay in history

Both default to safe single-node values. Embedders who need different windows set them through parsec.Options.BrokerOptions.

Delete

Public channels do not auto-delete (only private does). You explicitly remove one with:

./bin/parsec channels delete public:webapp.system.status

Delete is final. The manager flips the record to deleted, the broker unsubscribes every connected client, and the name becomes unusable — even reopening it returns PARSEC_CHANNEL_NOT_FOUND. Create a new channel under a different name (or wait for the manager state to be wiped on restart).

Typical use

  • System status feeds: public:<app>.system.status
  • Org-wide counters: public:scoreboard.counter.<metric>
  • Maintenance announcements: public:<app>.broadcast.maintenance

Anything that should reach an authenticated population belongs in a private channel instead.

Private channels

Private channels are token-gated, time-boxed feeds: a subscriber must present a valid access token tied to the channel name and subject.

./bin/parsec channels create private:webapp.user.42.downloads \
  --subject user-42 --ttl 30m
# {
#   "code": "OK",
#   "payload": {
#     "name": "private:webapp.user.42.downloads",
#     "access_token":  "eyJ...",
#     "refresh_token": "eyJ...",
#     "access_expires_unix":  1747935432,
#     "refresh_expires_unix": 1747937232
#   }
# }

The grammar (channels.ParseName) requires private channels to carry an id segment; private:webapp.user.downloads is invalid because there is no per-tenant scope. See naming for the full rule set.

What create returns

channels create mints a brand-new channel record AND issues the token pair in one round trip. The response carries:

  • access_token — short-lived (default 5 minutes; bounded by parsec.Options.AccessTokenTTL).
  • refresh_token — lives up to the channel TTL, capped at 1 hour. Use it to mint fresh access tokens until the channel expires.
  • access_expires_unix / refresh_expires_unix — absolute expiry timestamps.

The server does NOT keep the tokens. Lose them and you must recreate the channel. This is intentional: Parsec stores channel records, not secret material.

TTL cap

The hard ceiling is one hour. The validator that enforces it is in channels/manager.go:

const MaxPrivateTTL = 1 * time.Hour

Requesting more returns PARSEC_CHANNEL_TTL_EXCEEDS_MAX. Most deployments use 5–30 minutes; the upper bound is meant for long-running download or analysis jobs, not for ambient session channels.

Lifecycle

Private channels move through:

StateMeaningEffect on subscribers
openActive. Accepts subscribes with a valid access token.Receive publications.
deletedInactivity TTL exceeded OR explicit delete.Unsubscribed.

Note the absence of a closed state. Private channels do not “fade out” — when their TTL elapses they are reaped from the manager entirely. The reason: a closed-but-not-deleted private channel would leak the channel’s existence to anyone who asked, defeating the visibility prefix.

Refresh flow

Once an access token expires, the client exchanges its refresh token for a new access:

./bin/parsec tokens refresh "<refresh-token>"

RefreshToken is a public RPC — no bearer required — because it validates the refresh token in the body. The new access cannot outlive the refresh. When the refresh expires the channel is effectively unreachable; the next sweep reaps it.

Touch and Publish

Every successful Publish calls Manager.Touch under the hood, which resets LastActive. A heavily-used channel stays alive past its TTL by the publishing client merely doing its job. An idle channel ages out on its own.

If a client wants to keep a channel alive without sending data, the library exposes Manager.Touch(name) directly. The CLI does not — there is no operator use case.

Typical use

  • Per-user notifications: private:webapp.user.<uid>.<topic>
  • Per-job progress: private:agent.analysis.<job>.progress
  • Per-run failure streams: private:tools.run.<run_id>.failures

See also public channels and TTL and expiry for how the lifecycle differs.

Pattern-based grants

Tokens can carry a scopes claim — a set of channel-name pattern grants with per-verb permissions. A single token can authorize a whole namespace prefix like private:webapp.user.42.* instead of naming every channel individually. See Access Control Scopes for the full grammar and examples.

TTL and expiry

Every channel carries an inactivity TTL; the manager’s sweep loop is what enforces it.

# Open with an explicit TTL
./bin/parsec channels open public:webapp.system.status --ttl 1h

# Inspect TTL + last_active
./bin/parsec channels get public:webapp.system.status

States

Three states, one transition diagram:

                 +-------+
                 | open  |
                 +-------+
                /         \
   sweep (public,         sweep (private,
    TTL exceeded)          TTL exceeded)
              |             |
              v             v
         +--------+    +---------+
         | closed |    | deleted |
         +--------+    +---------+
              \          ^
        reopen \        / explicit Delete
                v      /
             +-------+
             | open  |  (closed -> open via OpenPublic)
             +-------+

The constants live in channels/manager.go:

const MaxPrivateTTL    = 1 * time.Hour
const DefaultPublicTTL = 30 * time.Minute

Default private TTL is 15 minutes — see CreatePrivate in the same file.

Sweep behavior

Manager.Sweep(now) runs once per SweepInterval (default 30s). For every channel in StateOpen:

  • If now.Sub(LastActive) < TTL — skip.
  • Else, if the name is public: — flip to closed, emit EventClosed. Existing subscribers stay attached and continue to receive publications until they disconnect on their own.
  • Else (private:) — remove the record entirely, emit EventDeleted. The broker bridge kicks every connected subscriber.

That asymmetry is intentional. Public channels are “broadcasts” — the broadcaster fading out should not look the same as a private per-tenant feed silently disappearing.

Touch

A channel resets its LastActive on three occasions:

  • OpenPublic on a closed channel — reopens and resets.
  • Publish — every publish path calls Touch on success.
  • An explicit Manager.Touch call from library code.

There is no CLI touch subcommand because operator workflows do not need one. If you want to keep a channel alive without sending data, either republish heartbeat messages or extend the TTL by calling channels open again with a larger --ttl.

Public vs private at a glance

BehaviorPublicPrivate
Default TTL30m15m
Max TTLunbounded1h
TTL exceeded →closed (record kept)deleted (record reaped)
Subscribers when expireddrain naturallykicked
Reopenableyes (channels open again)no — create fresh
Auto-deleteneveron TTL

See public channels and private channels for the surface-by-surface details, and the architecture overview for how the manager-event bridge translates sweep events into broker actions.

Events

The manager emits typed events on every transition:

KindWhenBridge effect
openedPublic re-open or private createNew subscribers accepted
closedPublic TTL exceededNo new arrivals; existing drain
deletedExplicit delete or private TTL exceededBroker unsubscribes everyone

Subscribers register via Manager.Subscribe(chan<- Event); the manager sends non-blocking, so subscribers should buffer. See broker internals for the wire side.

Access Control Scopes

Parsec tokens carry two grant sources:

  1. chs — an exact-match list of channel names. A token whose chs contains private:webapp.user.42.profile can subscribe to that channel and nothing else.
  2. scopes — a set of channel-name pattern grants. Each scope binds a pattern to a list of verbs (subscribe, publish, manage).

A token may carry one or both. The check at subscribe/publish time is the union: any path that authorizes is sufficient.

Why patterns

Pure chs requires the operator to know every channel a token may touch at issuance time. For multi-tenant SaaS workloads where a user owns a namespace prefix and channels are created on the fly inside that prefix, this means either pre-minting hundreds of tokens or re-issuing constantly.

Scopes let a single token say “this user owns the prefix private:webapp.user.42.” and survive every new channel created under that prefix until the token expires.

Grammar

A scope pattern is a channel name with two wildcard primitives layered on top:

<visibility>:<seg>(.<seg>)*[.*][.**]
  • <visibility> must be public or private. Cross-visibility patterns (*:foo.bar) are rejected at issuance with PARSEC_INVALID_ARGUMENT.
  • <seg> is a literal channel-name segment (lowercase ASCII, digits, -, _).
  • * matches exactly one segment, in any position.
  • ** matches one-or-more trailing segments; it is only legal as the last segment of the pattern.
  • The total segment count is capped at 8 — pathological tokens with **.**.**... are rejected.

Truth table

PatternChannelMatches?
private:foo.bar.7private:foo.bar.7yes
private:foo.bar.7private:foo.bar.8no
private:foo.bar.*private:foo.bar.7yes
private:foo.bar.*private:foo.bar.7.xno
private:foo.bar.**private:foo.bar.7yes
private:foo.bar.**private:foo.bar.7.x.yyes
private:foo.*public:foo.barno
*:foo.bar(any)rejected at parse

Verbs

Three verbs are recognized:

VerbWhere it applies
subscribethe broker’s subscribe authorizer. A chs exact-match entry authorizes every verb (including subscribe).
publishclient-side publish over the websocket. Server-side publishes via the management RPC remain gated by the mgmt bearer.
manageper-channel management RPCs when called with an access token instead of a mgmt token (delegated administration).

A scope must list at least one verb. The check at runtime requires the exact verb the surface is asking for — a subscribe-only scope cannot publish, even on a channel the pattern matches.

Example token

A scope-bearing access token’s payload looks like this:

{
  "sub": "user-42",
  "typ": "access",
  "chs": ["private:webapp.user.42.profile"],
  "scopes": [
    {
      "pat": "private:webapp.user.42.**",
      "v": ["subscribe", "publish"]
    }
  ],
  "iat": 1700000000,
  "exp": 1700000300
}

This token can:

  • subscribe to private:webapp.user.42.profile (Chs exact match — any verb)
  • subscribe to private:webapp.user.42.downloads (scope match + verb)
  • subscribe to private:webapp.user.42.session.notifications (** match)
  • publish to private:webapp.user.42.profile (scope match + verb)

It cannot subscribe to private:webapp.user.43.profile (scope does not match).

chs-only tokens

A token with only chs and no scopes is the simplest shape: the exact-match list is checked, and any name in the list authorizes every verb. A token with both chs and scopes widens the grant — the authorization check is the union.

Deny scopes

A scope may set deny: true to subtract from the grant set. The on-wire shape is identical to an allow scope plus the new field:

{
  "pat": "private:webapp.user.42.financials",
  "v":   ["subscribe"],
  "deny": true
}

The deny key is omitted from the JSON when false, so allow-only tokens stay compact on the wire.

Precedence: deny wins

Claims.Authorizes evaluates a single (channel, verb) request in this order:

  1. Deny pass. Walk every scope where deny: true. If the pattern matches the channel AND the verb is in the scope’s verb list, return deny immediately.
  2. chs pass. Walk the chs exact-match list. Any entry equal to the channel returns allow.
  3. Allow pass. Walk every scope where deny: false. If the pattern matches the channel AND the verb is in the verb list, return allow.
  4. Otherwise return deny.

A matching deny always wins — it overrides both chs exact matches and any overlapping allow scope. This mirrors AWS IAM, GCP IAM, and similar systems: deny is non-overridable, so operators can carve a hole in a permissive grant without having to enumerate every channel they do want to allow.

A deny scope without any overlapping allow is a defensive no-op: the channel already had no grant; the deny does not unlock anything.

The manifest exposes this with two fields:

{
  "deny_supported":   true,
  "scope_precedence": "deny-wins"
}

Worked example

The operator wants user-42 to subscribe to anywhere under their user prefix EXCEPT a sensitive financials channel.

parsec channels create private:webapp.user.42.profile \
  --subject user-42 \
  --scope "private:webapp.user.42.*" \
  --deny-scope "private:webapp.user.42.financials" \
  --verb subscribe

The minted access token’s scope set is:

[
  {"pat": "private:webapp.user.42.*", "v": ["subscribe"]},
  {"pat": "private:webapp.user.42.financials", "v": ["subscribe"], "deny": true}
]

At subscribe time:

ChannelAllow matches?Deny matches?Result
private:webapp.user.42.profileyesnoallow
private:webapp.user.42.downloadsyesnoallow
private:webapp.user.42.financialsyesyesdeny
private:webapp.user.43.profilenonodeny (no allow)

CLI flags

parsec channels create accepts two new flags alongside --scope and --verb:

  • --deny-scope <pattern> (repeatable) — adds a deny scope.
  • --deny-verb <verb> (repeatable) — verb list the deny scopes apply to. When omitted, the deny scopes inherit the --verb list (default subscribe).

Security note: deny patterns are visible in the token

The deny scope rides in the access token in clear (signed but not encrypted, like every other claim). An operator that mints a token with deny: private:tenant.x.secret-project is telling the holder of that token “secret-project exists.” Do not treat deny patterns as confidential — if the channel name itself is sensitive, the deny belongs at the broker level (a server-side ACL Parsec does not yet ship), not in the token.

The trade-off is intentional: keeping deny inside the token avoids a round-trip per subscribe and keeps the authorization decision local to the broker’s connection.

Issuance

Over the CLI:

parsec channels create private:webapp.user.42.profile \
  --subject user-42 \
  --scope "private:webapp.user.42.*" \
  --scope "private:webapp.user.42.session.*" \
  --verb subscribe \
  --verb publish

Each --scope adds a pattern; --verb flags apply to every scope on the command. If no --verb is given, subscribe is the default.

Over the Twirp RPC (CreatePrivate):

{
  "name": "private:webapp.user.42.profile",
  "subject": "user-42",
  "ttlSeconds": 900,
  "scopes": [
    {"pattern": "private:webapp.user.42.*", "verbs": ["subscribe"]}
  ]
}

Programmatic Go:

creds, err := svc.CreatePrivateChannel(ctx, "user-42",
    "private:webapp.user.42.profile", 15*time.Minute,
    []auth.Scope{
        {Pattern: "private:webapp.user.42.**", Verbs: []auth.Verb{auth.VerbSubscribe}},
    })

Performance

The pattern matcher is hand-rolled (no regex) and runs in well under 500ns per Matches call. Parsed patterns are cached on the Scope value the first time the matcher consults them — subsequent calls reuse the compiled segments without re-parsing. There are no string allocations in the hot path.

When scopes is empty, Claims.Authorizes short-circuits to the chs exact-match loop, so tokens without pattern grants pay zero overhead for the pattern matcher.

parsec serve

Boot the broker and the HTTP service (Twirp, websocket, SSE, healthz) on one address.

./bin/parsec serve --addr :8000 --state-dir /var/lib/parsec
# parsec serve: loaded keyring from /var/lib/parsec/keyring.json (active=k0)
# parsec serve: bootstrap mgmt token (expires 2026-05-23 18:04:11 UTC):
# eyJhbGciOiJIUzI1NiIs...

What it does

serve is the only long-running subcommand. It:

  1. Builds a *parsec.Parsec with the flag-driven Options.
  2. Loads (or bootstraps) the keyring at <state-dir>/keyring.json. Without --state-dir the ring is ephemeral and tokens die on restart.
  3. Mints a bootstrap mgmt token under the active key and prints it to stderr — copy it before the server scrolls it off.
  4. Starts the Centrifuge node, the channel-manager sweeper, the manager-to-broker event bridge, and (when persistent) the keyring-file watcher.
  5. Serves Twirp at /twirp/parsec.ParsecService/, the websocket at /connection/websocket, SSE at /sse, and the manifest at /manifest.

Stop with SIGINT / SIGTERM. The broker drains over a 5-second shutdown grace period before the process exits.

Operator-disabled auth

--no-auth removes the bearer middleware from the management RPC. The flag exists for local development against a throwaway broker; use it anywhere production-shaped and an attacker can mutate channels at will. Parsec logs a loud warning at boot to make accidents loud.

Persistent state

--state-dir controls the keyring file path. The directory is created with mode 0700; the file with mode 0600. Channel records are NOT persisted — only the HMAC ring is. See deployment for the full persistence stance and key rotation for the rotation runbook.

Reference

NAME:
   parsec serve - Boot the parsec broker and HTTP/Twirp service

USAGE:
   parsec serve [options]

OPTIONS:
   --addr string, -a string  Listen address (host:port) (default: ":8000")
   --state-dir string        Directory holding keyring.json (created with 0700 if missing). If unset, the keyring is ephemeral. [$PARSEC_STATE_DIR]
   --mgmt-subject string     Subject (sub claim) for the bootstrap mgmt token printed at boot (default: "operator") [$PARSEC_MGMT_SUBJECT]
   --mgmt-ttl duration       Lifetime of the bootstrap mgmt token printed at boot (default: 24h0m0s) [$PARSEC_MGMT_TTL]
   --keyring-poll duration   Interval between keyring.json mtime checks. 0 disables polling. (default: 5s) [$PARSEC_KEYRING_POLL]
   --no-auth                 Disable bearer auth on the management RPC. DANGEROUS — local development only. [$PARSEC_NO_AUTH]
   --help, -h                show help

GLOBAL OPTIONS:
   --json  Output the parsec manifest as a descriptor envelope

See also

parsec channels

Manage channels on a remote Parsec server.

export PARSEC_TOKEN=$(cat ~/.parsec/mgmt-token)
export PARSEC_SERVER=https://parsec.example.com

./bin/parsec channels list
./bin/parsec channels open public:webapp.system.status --ttl 1h
./bin/parsec channels create private:webapp.user.42.downloads --subject user-42 --ttl 30m
./bin/parsec channels get  private:webapp.user.42.downloads
./bin/parsec channels delete public:webapp.system.status

Every subcommand routes to the Twirp service at /twirp/parsec.ParsecService/. The CLI is a thin adapter — anything you can do here is reachable over Twirp.

Auth

The --server and --token flags (or PARSEC_SERVER / PARSEC_TOKEN) are required. Tokens are mgmt bearers minted at boot by parsec serve or rotated via the key-rotation runbook.

list

Returns every channel the manager currently knows about, including those in closed state. Deleted records do not appear.

NAME:
   parsec channels list - List all managed channels

USAGE:
   parsec channels list [options]

OPTIONS:
   --help, -h  show help

GLOBAL OPTIONS:
   --json  Output the parsec manifest as a descriptor envelope

open

Opens (or reopens) a public channel. Idempotent — calling it on an existing open channel resets LastActive and the TTL.

NAME:
   parsec channels open - Open (or re-open) a public channel

USAGE:
   parsec channels open [options] <channel-name>

OPTIONS:
   --ttl duration  Inactivity TTL before close (default: 30m0s)
   --help, -h      show help

GLOBAL OPTIONS:
   --json  Output the parsec manifest as a descriptor envelope

create

Mints a private channel and returns its one-shot access + refresh tokens. The TTL is hard-capped at 1 hour. See private channels for the lifecycle.

NAME:
   parsec channels create - Create a private channel and print its one-shot access + refresh tokens

USAGE:
   parsec channels create [options] <channel-name>

OPTIONS:
   --ttl duration               Inactivity TTL (max 1h) (default: 15m0s)
   --subject string, -s string  Subject (sub claim) embedded in the tokens; typically a user id
   --scope string (repeatable)  Channel-name pattern grant baked into the issued tokens (e.g. private:webapp.user.42.*)
   --verb string  (repeatable)  Verb granted on the listed scopes. Allowed: subscribe, publish, manage. Default: subscribe
   --help, -h                   show help

GLOBAL OPTIONS:
   --json  Output the parsec manifest as a descriptor envelope

Pattern grants (–scope / –verb)

Beyond the exact-match chs claim, the issued tokens may carry a set of pattern-based grants in the scopes claim. Each --scope adds a channel-name pattern; --verb flags apply to every pattern on the command. With no --verb, the default is subscribe.

./bin/parsec channels create private:webapp.user.42.profile \
  --subject user-42 \
  --scope "private:webapp.user.42.*" \
  --scope "private:webapp.user.42.session.*" \
  --verb subscribe --verb publish

See Access Control Scopes for the grammar, truth table, and verb semantics.

get

Single-channel snapshot. Returns PARSEC_CHANNEL_NOT_FOUND if the channel was never opened or has been deleted.

NAME:
   parsec channels get - Show a single channel's metadata

USAGE:
   parsec channels get [options] <channel-name>

OPTIONS:
   --help, -h  show help

GLOBAL OPTIONS:
   --json  Output the parsec manifest as a descriptor envelope

delete

Final. Removes the channel from the manager and unsubscribes every connected client. Private channels normally auto-delete on TTL; you rarely call delete for them. Public channels require an explicit delete (or a server restart, since channel state is in-memory).

NAME:
   parsec channels delete - Delete a channel (public channels must be deleted explicitly; private auto-expire)

USAGE:
   parsec channels delete [options] <channel-name>

OPTIONS:
   --help, -h  show help

GLOBAL OPTIONS:
   --json  Output the parsec manifest as a descriptor envelope

Reference

NAME:
   parsec channels - Manage channels on a remote parsec server

USAGE:
   parsec channels [command [command options]]

COMMANDS:
   list    List all managed channels
   open    Open (or re-open) a public channel
   create  Create a private channel and print its one-shot access + refresh tokens
   get     Show a single channel's metadata
   delete  Delete a channel (public channels must be deleted explicitly; private auto-expire)

OPTIONS:
   --server string  Parsec server base URL (default: "http://localhost:8000") [$PARSEC_SERVER]
   --token string   Mgmt bearer token for the management API [$PARSEC_TOKEN]
   --help, -h       show help

See also

parsec publish

Push a single message to a channel.

# Inline body
./bin/parsec publish public:webapp.system.status --data '{"msg":"hello"}'

# From a file
./bin/parsec publish public:webapp.system.status --file ./payload.json

# From stdin (pipe-friendly)
echo '{"msg":"hello"}' | ./bin/parsec publish public:webapp.system.status

What it does

publish calls the Publish RPC. The server validates the channel name through channels.ParseName, confirms the channel exists and is open, ships the data to Centrifuge, and touches LastActive so the sweeper does not close the channel out from under an active publisher.

Bodies are opaque bytes. Parsec does not impose a schema; the contract is between the publisher and the subscriber. JSON is conventional because every consumer language can parse it cheaply, but you can ship protobuf, msgpack, or raw text just as easily.

Source precedence

Exactly one source is honored, picked in this order:

  1. --data / -d — inline.
  2. --file / -f — read the named file.
  3. stdin — only when neither of the above is set.

Mixing sources is an operator error and the CLI returns PARSEC_INVALID_ARGUMENT.

What you get back

The CLI prints a descriptor envelope with the broker’s publish ack:

{
  "code": "OK",
  "payload": {
    "offset": 14,
    "epoch": "abc123"
  }
}

The offset is the per-channel monotonic publication index Centrifuge assigns. Late subscribers use (offset, epoch) to ask for a recovery stream — they catch up on the publications they missed.

Failure modes

CodeCause
PARSEC_CHANNEL_INVALIDName does not match the grammar.
PARSEC_CHANNEL_NOT_FOUNDChannel was never opened, or expired.
PARSEC_CHANNEL_CLOSEDPublic channel past its TTL — reopen first.
PARSEC_AUTH_DENIEDMissing or expired bearer.
PARSEC_BROKER_NOT_READYServer is still booting; retry shortly.

For private channels, the bearer is the operator mgmt token — not the end-user access token. End users publish via the websocket.

Reference

NAME:
   parsec publish - Publish a message to a channel

USAGE:
   parsec publish [options] <channel-name>

OPTIONS:
   --server string           (default: "http://localhost:8000") [$PARSEC_SERVER]
   --token string             [$PARSEC_TOKEN]
   --data string, -d string  Inline message body
   --file string, -f string  Read message body from file
   --help, -h                show help

GLOBAL OPTIONS:
   --json  Output the parsec manifest as a descriptor envelope

See also

parsec subscribe

Tail a parsec channel from the terminal. Implemented as a Server-Sent Events (SSE) client against the server’s /sse?channel=<name> endpoint. Intended as a debug probe, not a production transport — production clients use the WebSocket (/connection/websocket) or WebTransport (/connection/webtransport) endpoints.

Usage

parsec subscribe <channel-name>

Each publication appears on stdout as one NDJSON envelope:

{"channel":"public:webapp.system.status","offset":42,"data":{"msg":"hello"}}

Ctrl-C exits cleanly.

Flags

FlagEnvDefault
--serverPARSEC_SERVERhttp://localhost:8000
--tokenPARSEC_TOKEN(empty)
--from-offset0 (latest)

For private channels, set --token to an access token (NOT a mgmt bearer):

parsec subscribe private:webapp.user.42.downloads \
  --token "$(parsec channels create private:webapp.user.42.downloads --subject u42 | jq -r .payload.access_token)"

Why not for production?

SSE polls. The server holds a long-lived HTTP connection but the backpressure model and reconnection semantics are simpler than the WebSocket transport — fine for a debug probe, suboptimal for browser clients. Use centrifuge-js (or the parsec library directly inside a Go service) for real workloads.

parsec tokens

Token operations. The subcommands split along auth boundary:

  • refresh is public — the refresh token in the body authenticates the call. Use it to re-mint an access token without holding a mgmt bearer.
  • mgmt, revoke, and revoke-user require a valid mgmt bearer. mgmt is used during key rotation; revoke / revoke-user are the operator-facing front door to the RevocationStore.

Flags

FlagEnvDefault
--serverPARSEC_SERVERhttp://localhost:8000
--tokenPARSEC_TOKEN(empty — required for mgmt)

tokens refresh

Exchange a refresh token for a fresh access token. The new access token cannot outlive the refresh expiry.

parsec tokens refresh "<refresh-token>"

Output (descriptor envelope):

{
  "type": "parsec.token.refreshed",
  "payload": {
    "access_token": "eyJhbGciOi...",
    "expires_at":   "2026-05-22T18:14:11Z"
  }
}

tokens mgmt

Mint a new mgmt bearer signed by the active key. The intended use is mid-rotation: after parsec keys promote <new-kid>, the operator’s current bearer is still signed by the old key. Calling tokens mgmt issues a fresh bearer under the new key so the old one can safely retire.

parsec tokens mgmt --subject ops-bot --ttl 24h
FlagDefaultNotes
--subject, -soperatorSubject (sub claim)
--ttl24hClamped to [1h, 7d]

Output:

{
  "type": "parsec.token.mgmt",
  "payload": {
    "mgmt_token": "eyJhbGciOi...",
    "expires_at": "2026-05-23T17:08:42Z"
  }
}

The new bearer typically goes straight into PARSEC_TOKEN:

export PARSEC_TOKEN=$(parsec tokens mgmt | jq -r .payload.mgmt_token)

See key rotation for the full rotation runbook.

tokens revoke

Mark a single access token revoked by its jti claim (the same value the token broker returns as token_id in its issuance response). The subscribe authorizer consults the store on every private subscribe and denies revoked tokens with PARSEC_AUTH_DENIED.

parsec tokens revoke <token-id> --user <user-id> --reason "compromised"
FlagDefaultNotes
--user, -u(empty)Optional user-id recorded with the entry for audit
--reason, -r(empty)Optional free-text reason recorded with the entry for audit

Output:

{
  "type": "parsec.token.revoked",
  "payload": {
    "token_id": "abcd1234...",
    "user_id":  "user-42",
    "reason":   "compromised"
  }
}

The call returns PARSEC_INVALID_ARGUMENT when the server has no RevocationStore wired (parsec.Options.RevocationStore left nil). That’s a deliberate loud failure — silently no-op’ing here would mask a security gap.

tokens revoke-user

Blanket-revoke every token previously issued to a user. The store records the wall-clock cutoff; tokens minted after the call remain valid (use this when a user re-authenticates after a compromise — old sessions die, new ones live).

parsec tokens revoke-user <user-id> --reason "password reset"
FlagDefaultNotes
--reason, -r(empty)Optional free-text reason recorded with the entry for audit

See token broker for the operational runbook.

parsec keys

Manage HMAC signing keys. The keyring lives at <state-dir>/keyring.json (file-backed) or in Redis (multi-node). The CLI never edits the file directly except for export / import — every other subcommand routes through the mgmt RPC so the server is the single writer.

Flags

FlagEnvDefault
--serverPARSEC_SERVERhttp://localhost:8000
--tokenPARSEC_TOKEN(empty — required)

keys list

parsec keys list

Returns every key in the ring with its role (active, verify-only, retired), creation timestamp, and short fingerprint. Retired keys appear once in the result and then drop on the next snapshot.

keys generate

parsec keys generate

Mints a new 32-byte HMAC secret and joins it to the ring as verify-only. The new key does NOT sign tokens yet — promote does that. The kid is returned in the descriptor envelope.

keys promote

parsec keys promote <kid>

Makes <kid> the active signer. The previous active key demotes to verify-only, so tokens already issued under it keep verifying until they expire.

keys retire

parsec keys retire <kid>
parsec keys retire <kid> --notify https://parsec.eu-west.example.com:8000 \
                          --notify https://parsec.ap-south.example.com:8000

Stops the named key from verifying. Operators must wait the longest in-flight token TTL (default 24h for mgmt) before retiring a key, or existing tokens reject early.

--notify <peer-url> POSTs a ReloadKeys to each listed peer after the local retire succeeds. Peers must share the keyring (via the parsec-keys-sync daemon or a shared Redis) AND trust the bearer this CLI presents. Individual peer failures are surfaced in the envelope but never roll back the local retire — propagation is best-effort.

keys reload

parsec keys reload

Force the server to re-read its keyring file. Functionally identical to sending SIGHUP to the parsec process. Use when the mtime-poll watcher is disabled (--keyring-poll 0).

keys export

# from a file-backed ring
parsec keys export --state-dir /var/lib/parsec -o snapshot.json

# from a Redis-backed ring
parsec keys export --redis-addr redis://localhost:6379 \
                   --redis-key-prefix parsec-us-east -o snapshot.json

Emits the wire-stable auth.Snapshot JSON. The output of export is the input of import — operators in different regions shuttle the file to share signing keys without a live RPC route between them.

Source flags --state-dir and --redis-addr are mutually exclusive; exactly one must be set. Output goes to stdout unless --out is given.

keys import

parsec keys import --state-dir /var/lib/parsec snapshot.json
parsec keys import --redis-addr redis://localhost:6379 \
                   --redis-key-prefix parsec-eu-west snapshot.json

Reads a snapshot file and merges it into the local store. Existing non-retired kids in the local ring are preserved; matching kids are overwritten with the snapshot’s role. The active key in the snapshot becomes the active key locally; if multiple actives would result, the import aborts with a coded error so the operator can resolve the conflict.

After import, run parsec keys reload (or send SIGHUP) on every node so in-process verifiers pick up the new keys without a restart.

See key rotation for the full rotation runbook and multi-region for cross-region propagation.

parsec dlq

Inspect and operate on the sink dead-letter queue. Every parked item is a DLQItem carrying the failing sink name, the original message, the last error, and a retry count. The backend is sinks.MemoryDLQ (single-node) or sinks.RedisDLQ (Redis Streams, one stream per sink at parsec:dlq:<sink>).

Flags

FlagEnvDefault
--serverPARSEC_SERVERhttp://localhost:8000
--tokenPARSEC_TOKEN(empty — required)

dlq list

parsec dlq list <sink-name> --limit 50

Returns the newest items first. Each row carries:

FieldNotes
idOpaque DLQ identifier. Stable across replays.
sinkThe sink name (matches the Sinks registry key).
channelChannel the publication targeted.
recipientSink-specific recipient JSON (decoded via sinks.RecipientDecoder).
messageThe original parsec.Message.
last_errorMost recent error string.
attemptsNumber of retries before parking.
parked_atRFC3339 timestamp.

dlq count

parsec dlq count <sink-name>

Returns the total parked count for the named sink. Useful to wire into alerting — non-zero is the operator’s signal to investigate.

dlq discard

parsec dlq discard <id>

Permanently remove an item. The publication is lost; no further delivery attempt is made.

dlq replay

parsec dlq replay <id>

Re-enqueue the item through the original sink’s Retrier. The full retry policy runs again — if the underlying failure persists, the item returns to the DLQ with an incremented attempts count.

replay requires the sink to implement sinks.RecipientDecoder so the opaque recipient JSON can be rebuilt into the typed value. Built-in sinks (email / slack / webhook) all satisfy this; custom sinks must implement the interface or replay returns PARSEC_SINK_RECIPIENT_DECODE.

Operator runbook

See docs/src/ops/dlq.md for the failure-mode catalog, retention policy, and how to wire DLQ count into alerting.

parsec login / parsec logout

Authenticate against an OIDC identity provider via the device-code flow (RFC 8628). The resulting ID token is persisted at ~/.parsec/credentials (mode 0600) and picked up automatically by the CLI’s --token resolution — every subsequent parsec invocation presents it as the mgmt bearer.

The server side of the bridge is documented at OIDC bridge. The IdP must implement device_authorization_endpoint for this flow to work; Google, Okta, Keycloak, Auth0, and Azure AD all do.

login oidc

parsec login oidc \
  --issuer https://accounts.google.com \
  --client-id "<your-oauth-client-id>"
FlagEnvDefault
--issuerPARSEC_OIDC_ISSUER(required)
--client-idPARSEC_OIDC_CLIENT_ID(required)
--client-secretPARSEC_OIDC_CLIENT_SECRET(empty — public clients)
--scopeopenid email profile groups
--timeout5m

The flow:

  1. Parsec fetches the issuer’s /.well-known/openid-configuration and extracts device_authorization_endpoint + token_endpoint.
  2. POSTs to the device-auth endpoint with the client id; receives a user_code, a verification_uri, and a polling interval.
  3. Prints the URI + code to stdout. Open the URL in a browser, enter the code, and approve the request.
  4. Polls the token endpoint at the IdP-advertised interval until the operator approves, the device code expires, or --timeout elapses.
  5. Writes the resulting ID token to ~/.parsec/credentials.

The CLI verifies the token against the IdP’s JWKS as a sanity check. The server re-verifies on every RPC, so a tampered local file just results in 401s.

logout

parsec logout

Removes ~/.parsec/credentials. Subsequent CLI invocations require --token (or PARSEC_TOKEN) to authenticate again.

Combining with --token

The explicit --token flag and the PARSEC_TOKEN env var take precedence over the persisted credential. Use this when an operator needs to temporarily switch identities without logout-ing:

PARSEC_TOKEN="$break-glass-bearer" parsec keys retire <kid>

parsec-gen

parsec-gen reads a schema-registry snapshot and emits typed Go and TypeScript bindings for every registered channel pattern.

It is a separate binary from parsec; build it with go build ./cmd/parsec-gen or vendor the published release artifact.

Usage

# Go bindings from a running registry into ./gen/parsecgen.
parsec-gen --registry http://localhost:8000/parsec/schemas \
           --lang go --out ./gen/parsecgen \
           --package parsecgen

# Both languages from a committed snapshot file.
parsec-gen --registry-file ./schemas.json \
           --lang go,ts --out ./gen

Flags

FlagDefaultPurpose
--registry <url>required if no --registry-fileSchema-registry HTTP endpoint
--registry-file <path>required if no --registryJSON snapshot on disk
--out <dir>./genOutput directory (created if missing)
--lang <list>goComma-separated targets: go, ts
--package <name>parsecgenGo package name (ignored for non-Go)
--header <text>emptyOptional header prepended to every emitted file

--registry and --registry-file are mutually exclusive.

Output

LanguageFileFormat
go<out>/parsec_gen.gogofmt-formatted package
ts<out>/index.tsNo external imports

For each ChannelPattern the generator emits:

  • A <Name>Pattern constant carrying the raw registry pattern.
  • A <Name>Channel(...) helper (only if the pattern has placeholders) that reconstructs a concrete channel name from typed arguments. Trailing ** placeholders become rest ...string in Go and rest: string[] in TypeScript.
  • One <Name><Aspect> type per declared aspect. The type follows the declared payload_schema: object becomes a struct/interface, array becomes an alias, scalars become a typed alias.

Naming

Pattern segments contribute to the type name in PascalCase; placeholder segments are dropped from the name and surface only in helper signatures. Aspect names are PascalCased and appended.

PatternAspectGenerated type
sessions:{id}dataSessionsData
brands:meridian.reportssummaryBrandsMeridianReportsSummary
events.**rawEventsRaw

Common initialisms (id, url, api, http, …) are uppercased in their entirety so the output passes staticcheck ST1003.

Wiring with the client

The Go output drops straight into client.OnAspectTyped[T any]:

sub, _ := pc.Subscribe(channel)
client.OnAspectTyped[parsecgen.SessionsData](sub, "data",
    func(d parsecgen.SessionsData, env envelope.Envelope) {
        fmt.Println(d.Text)
    })

The TypeScript output drops straight into parsec-client’s typedSubscribe<T> ergonomic:

import { sessionsChannel, type SessionsData } from "./gen/index";

const sub = client.typedSubscribe<SessionsData>(sessionsChannel(userId));
sub.on("publication", ({ data }) => {
  // data: SessionsData
});
await sub.subscribe();

See JavaScript Client (parsec-client) for the full integration.

Regeneration

The generator is deterministic — patterns and aspects are emitted in lexicographic order. Re-running with the same registry yields a byte-identical file. Commit the output and re-run in CI to detect unintended schema drift.

Go API overview

Parsec’s primary deliverable is a Go library. The CLI and Twirp surfaces are translators on top of the same *parsec.Parsec value.

package main

import (
    "context"
    "log"
    "time"

    "github.com/frankbardon/parsec"
)

func main() {
    ctx, cancel := context.WithCancel(context.Background())
    defer cancel()

    p, err := parsec.New(parsec.Options{StateDir: "/var/lib/parsec"})
    if err != nil { log.Fatal(err) }
    go func() { _ = p.Run(ctx) }()

    ch, _ := p.OpenPublic("public:webapp.system.status", time.Hour)
    _, _ = p.Publish(ctx, ch.Name.String(), []byte(`{"msg":"hello"}`))
}

The package import path is github.com/frankbardon/parsec. Everything else (auth, broker, channels, sinks) lives one directory down.

Construction

parsec.New(opts) builds the instance but does not start anything. It assembles the keyring, the signer/verifier/issuer triad, the channel manager, and the broker — and wires them together so a closed channel rejects subscribes before the bridge runs. Every field on Options has a default; see parsec.New & Options for the per-field walkthrough.

Lifecycle

The instance is inert until you call Run(ctx):

go func() {
    if err := p.Run(ctx); err != nil {
        log.Printf("parsec exited: %v", err)
    }
}()

Run launches three goroutines and then blocks on the Centrifuge node inside the same call:

  • The sweep loop (Manager.RunSweeper) that ticks every SweepInterval.
  • The manager-to-broker event bridge that translates lifecycle events into broker actions (close = no kick; deleted = kick everyone).
  • The keyring file watcher (when StateDir is set) that hot-reloads the ring on mtime changes.

Cancel the context to shut everything down. The broker drains over a 5-second grace window before Run returns.

Surfaces and the embedded model

parsec.Parsec exposes the building blocks for an embedder to mount its own surfaces:

AccessorReturns
p.Broker()The Centrifuge wrapper — mount /connection/websocket from this.
p.Manager()The channel lifecycle manager — subscribe to events.
p.Sinks()The sink registry — register custom sinks.
p.Verifier()Token verifier — use for custom subscribe auth.
p.Issuer()Token issuer — mint access / refresh / mgmt tokens.
p.KeyRing()The HMAC ring — inspect, never mutate directly.

The standard server in internal/server/ shows how the CLI’s serve wires Twirp, websocket, SSE, and healthz onto these accessors. You can reproduce that wiring inside your own HTTP mux, or you can call parsec.Parsec methods directly from a non-HTTP context (e.g. a worker that just publishes).

Publishing

Two flavors:

// Always publishes; if nobody is listening, the message is buffered in
// Centrifuge's history but no out-of-band delivery happens.
res, err := p.Publish(ctx, "private:webapp.user.42.downloads",
    []byte(`{"status":"ready"}`))

// If presence == 0, falls back to the named sink. If presence > 0,
// publishes normally and returns delivered=false (since no sink ran).
delivered, err := p.PublishOrSink(ctx,
    "private:webapp.user.42.downloads",
    []byte(`{"status":"ready"}`),
    "email",
    email.Recipient{Address: "user@example.com"},
    parsec.Message{Subject: "Your download is ready", Body: "..."})

See custom sinks for writing your own.

Private channels

p.CreatePrivate(subject, name, ttl) mints a record AND issues the token pair in one call:

creds, err := p.CreatePrivate("user-42",
    "private:webapp.user.42.downloads",
    30 * time.Minute)
// creds.AccessToken, creds.RefreshToken, expiries

The library is the only place the tokens exist after this call. Don’t log them.

parsec.New and Options

parsec.New(Options{}) is the only constructor. Every field on Options is optional and falls back to a documented default.

p, err := parsec.New(parsec.Options{
    StateDir:           "/var/lib/parsec",
    AccessTokenTTL:     5 * time.Minute,
    MaxRefreshTokenTTL: time.Hour,
    SweepInterval:      30 * time.Second,
})

The full struct is in parsec.go. Each field below corresponds to one struct member, in declaration order.

FS afero.Fs

The filesystem used by anything in the library that touches disk — chiefly the keyring loader. Defaults to afero.NewOsFs(). Override in tests with afero.NewMemMapFs() to keep the keyring fully in memory. Library code never calls os.Open directly; every disk access goes through this handle.

Sinks *sinks.Registry

The out-of-band delivery registry. Defaults to an empty registry (sinks.NewRegistry()); register sinks before calling Run if you intend to use PublishOrSink. See custom sinks.

KeyRing *auth.KeyRing

The HMAC signing ring. If you provide one, parsec.New uses it as-is and ignores StateDir. Most embedders leave this nil and pass StateDir instead — fewer ways to get rotation wrong.

StateDir string

When set, makes the keyring file-backed at <StateDir>/keyring.json (mode 0600, parent 0700). Bootstrap is automatic: if the file doesn’t exist, the library generates an initial active key and writes it. Without this option AND without KeyRing, the library logs a loud warning and uses an ephemeral ring — tokens do not survive a restart.

KeyringPollInterval time.Duration

Mtime-poll interval for the keyring file watcher. Default 5 seconds. The watcher is what makes out-of-band keyring.json edits visible to a running server. Set to 0 to disable polling — you can still trigger a reload with SIGHUP or Parsec.ReloadKeys().

AccessTokenTTL time.Duration

Overrides the default access-token lifetime (5 minutes). The issuer clamps to [1m, 1h], so values outside that range are rejected at issuance time. Shorter access TTLs mean more refreshes — pick based on how aggressively you need revocation.

MaxRefreshTokenTTL time.Duration

Overrides the default refresh-token cap (1 hour). The actual refresh TTL is min(channel.TTL, MaxRefreshTokenTTL) — you cannot mint a refresh token that outlives its channel.

BrokerOptions broker.Options

Forwarded verbatim to broker.New. The fields that matter most:

  • HistorySize — per-channel history bound (default 100).
  • PublicHistoryTTL / PrivateHistoryTTL — history retention windows (both default 5m).
  • LogHandler — Centrifuge-level log sink.
  • SubscribeAuthorizerparsec.New REPLACES whatever you put here with one that combines Manager.IsOpen and the token-based authorizer. Set this on broker.Options only if you are wiring the broker without parsec.New.

SweepInterval time.Duration

How often the channel manager runs its expiry sweep. Default 30 seconds. Shorter intervals make TTL enforcement more responsive at the cost of cycles; longer intervals delay both closed (public) and deleted (private) transitions.

Logger *slog.Logger

Receives boot warnings, keyring reload events, and other operational messages. Defaults to slog.Default(). Inject your own logger if you want a structured output stream — every message carries field attributes (active_key_id, path, etc.) suitable for JSON sinks.

Default summary

FieldDefault
FSafero.NewOsFs()
Sinksempty sinks.Registry
KeyRingbootstrapped from StateDir (or ephemeral if unset)
StateDir“” (ephemeral keyring)
KeyringPollInterval5s
AccessTokenTTL5m
MaxRefreshTokenTTL1h
BrokerOptions.HistorySize100
BrokerOptions.{Public,Private}HistoryTTL5m
SweepInterval30s
Loggerslog.Default()

See also

Custom sinks

A “sink” is what Parsec delivers a message to when the realtime channel has nobody listening. The sinks.Sink interface is two methods; the registry is a map[string]Sink.

type Sink interface {
    Name() string
    Send(ctx context.Context, to Recipient, m Message) error
}

Ship a custom sink by implementing the interface, registering it on *parsec.Parsec, and naming it in your PublishOrSink calls.

The interface

sinks/sink.go:

type Message struct {
    Subject  string
    Body     string
    Metadata map[string]string
}

type Recipient any // typed per-sink

type Sink interface {
    Name() string
    Send(ctx context.Context, to Recipient, m Message) error
}

Recipient is any because each sink defines its own target shape:

  • Email sinks expect a struct with an address.
  • Slack sinks usually ignore the recipient (the webhook URL fixes the destination).
  • Webhook sinks may carry a per-call URL override.

Pick a typed Recipient struct in your sink package and do the type-assert inside Send. Reference: sinks/email, sinks/slack, sinks/webhook.

Implementation skeleton

package mysink

import (
    "context"

    "github.com/frankbardon/parsec/errors"
    "github.com/frankbardon/parsec/sinks"
)

type Config struct{ /* ... */ }

type Recipient struct {
    UserID string
}

type Sink struct{ cfg Config }

func New(cfg Config) (*Sink, error) {
    if cfg == (Config{}) {
        return nil, errors.New(errors.SinkConfig, "mysink requires a Config")
    }
    return &Sink{cfg: cfg}, nil
}

func (s *Sink) Name() string { return "mysink" }

func (s *Sink) Send(ctx context.Context, to sinks.Recipient, m sinks.Message) error {
    r, ok := to.(Recipient)
    if !ok {
        return errors.New(errors.InvalidArgument, "mysink expects mysink.Recipient")
    }
    if r.UserID == "" {
        return errors.New(errors.InvalidArgument, "mysink recipient UserID is empty")
    }
    // ...deliver...
    return nil
}

Two things to imitate from the reference sinks:

  • Validate the config in New; return errors.SinkConfig.
  • Wrap transport failures with errors.SinkUnavailable so the caller can branch on the code rather than parsing strings.

Registration

Pass a *sinks.Registry into parsec.Options or register on the live instance — both work.

reg := sinks.NewRegistry()
reg.Register(must(mysink.New(mysink.Config{...})))

p, _ := parsec.New(parsec.Options{Sinks: reg})

// Or, post-construction:
p.Sinks().Register(must(mysink.New(mysink.Config{...})))

Last-write-wins: registering a different sink under the same Name() silently replaces the previous one. Names should be stable string literals, not derived at runtime.

PublishOrSink semantics

The fallback rule:

delivered, err := p.PublishOrSink(ctx, name, data, "mysink", to, msg)
  1. Parse the channel name. Invalid → return the coded error.
  2. Ask the broker for the presence count (number of live subscribers).
  3. If > 0: publish to the broker, touch the channel, return delivered=false.
  4. If == 0: look up the sink by name, call Send through the retry-wrapped sink. Return delivered=true on success, on DLQ landing, or the sink’s error if the DLQ push itself failed.

delivered=true means “the sink path ran” — not “the recipient saw the message”. With phase-12 retry + DLQ wiring, a transient sink failure retries up to Options.SinkRetry.MaxAttempts with exponential backoff before being recorded in the DLQ.

Retry contract

Every sink in the registry is wrapped in a sinks.Retrier at construction time. The wrapper:

  • Calls Send once. On success, returns nil.
  • On a transient error (network blip, HTTP 5xx, 429, SMTP 4xx), waits BaseBackoff * 2^(attempt-2) (capped at MaxBackoff) with +/- JitterFraction jitter and retries.
  • On a terminal error (invalid recipient, HTTP 4xx other than 429, config error), aborts the loop immediately.
  • When the budget is exhausted, pushes the failed payload + the last error into the DLQ and returns nil to the caller. The failure is now recorded; the caller does not see the error.
  • The DLQ push itself can fail (e.g. Redis unreachable). In that case the caller gets a PARSEC_SINK_DLQ_OVERFLOW error.

Classification

Sinks signal whether an error is transient by wrapping it with sinks.Transient(err) or sinks.Terminal(err). Custom sinks should follow the same pattern. The default classifier (sinks.IsTransient) falls back to a heuristic for unwrapped errors:

  • context.DeadlineExceeded, context.Canceled → terminal
  • net.OpError, net.Error.Timeout(), *url.Error → transient
  • HTTP 5xx + 429 → transient (matched on error-message prefix)
  • SMTP 4xx → transient
  • Everything else → terminal

DLQ wiring

BackendActivation
Memory DLQDefault when Options.RedisClient == nil
Redis DLQDefault when Options.RedisClient != nil
Custom DLQSet Options.DLQ explicitly

A RecipientDecoder capability on the sink lets the Redis DLQ round-trip the typed Recipient back into Go on Replay. The reference email and webhook sinks implement it; slack does not need to because the channel is fixed by the webhook URL.

See ops/dlq.md for the operator-side runbook.

Manifest exposure

Registered sink names show up in the descriptor envelope under payload.sinks. The CLI’s --json flag exposes this. Clients can probe which delivery channels are configured without reading your boot code.

See also

Testing with parsectest

github.com/frankbardon/parsec/parsectest ships test helpers that mirror net/http/httptest: one-call constructors return a ready *parsec.Parsec, and teardown is registered via testing.TB.Cleanup.

The package lives in this repository — import "github.com/frankbardon/parsec/parsectest" and you have everything.

Quickstart

package mypkg_test

import (
    "context"
    "testing"
    "time"

    "github.com/frankbardon/parsec/parsectest"
)

func TestMyPublisher(t *testing.T) {
    p := parsectest.New(t) // ephemeral keyring, in-memory everything

    ch, err := p.OpenPublic("public:webapp.system.status", time.Minute)
    if err != nil {
        t.Fatal(err)
    }
    if _, err := p.Publish(context.Background(), ch.Name.String(), []byte(`{"k":"v"}`)); err != nil {
        t.Fatal(err)
    }
}

parsectest.New(t) returns an *Instance that embeds *parsec.Parsec — every library method is directly available. The broker is fully booted before the constructor returns; the first Publish is safe.

HTTP integration tests

NewServer mounts the parsec HTTP surface (Twirp + websocket + manifest

  • healthz) on a *httptest.Server:
func TestMyClient(t *testing.T) {
    inst := parsectest.NewServer(t)

    bearer := inst.MintMgmt(t, "ops", time.Hour)
    req, _ := http.NewRequest("GET", inst.BaseURL+"/manifest", nil)
    req.Header.Set("Authorization", "Bearer "+bearer)
    resp, _ := http.DefaultClient.Do(req)
    // ...
}

inst.BaseURL is the *httptest.Server.URL. The bearer middleware is wired through the parsec verifier, so tokens from MintMgmt or MintAccess authenticate normally.

Multi-node code paths (miniredis)

NewWithRedis starts an in-process miniredis and points parsec at it. The broker, channel registry, keyring, DLQ, and rate limiter all switch to their Redis backends — same code paths production runs, no docker required:

func TestClusterScenario(t *testing.T) {
    inst := parsectest.NewWithRedis(t)
    // inst now backs every subsystem with miniredis.
    // Spin up a second instance with the same WithRedis option to
    // simulate two nodes sharing state.
}

For tests that also want the HTTP surface:

inst := parsectest.NewServerWithRedis(t)

Minting tokens

MethodReturns
MintMgmt(t, subject, ttl)Mgmt bearer (Authorization: Bearer)
MintAccess(t, subject, channel, ttl)Access token (websocket connect / private subscribe)
MintRefresh(t, subject, channel, ttl)Refresh token for the RefreshToken RPC
MintPair(t, subject, channel, ttl)auth.PairResult with both halves + expiries

All helpers t.Fatal on issuance error — tests do not need to check.

Options

The constructors take variadic parsectest.Option values:

inst := parsectest.NewServer(t,
    parsectest.WithLogger(slog.Default()),
    parsectest.WithSink(myFakeSink),
    parsectest.WithRateLimits(ratelimit.RateLimits{
        Publish: ratelimit.Limit{Rate: 10, Per: time.Second},
    }),
)
OptionPurpose
WithLogger(*slog.Logger)Replace the default discard logger
WithStateDir(dir)Override the keyring location (rarely useful)
WithSink(sinks.Sink)Register a sink before retry/DLQ wrapping
WithRateLimits(rl)Attach per-bucket rate limits
WithRedis(client)Use a pre-built go-redis client (alternative to NewWithRedis)
WithOptions(fn)Last-resort hook — receives *parsec.Options for any setting not surfaced above

What parsectest.New does

  1. Builds parsec.Options with StateDir: t.TempDir() and a discard logger.
  2. Applies your options on top.
  3. Calls parsec.New and launches Run in a goroutine.
  4. Polls until Broker().Started() reports ready (5 s budget).
  5. Registers a t.Cleanup that cancels the run context, closes the httptest.Server if one was created, and waits up to 5 s for Run to exit.

The result is a fully-running parsec instance, with no manual teardown in your test body.

Schema Registry Broadcast

The schema registry exposes two surfaces: an HTTP endpoint at /parsec/schemas for snapshot fetches, and a parsec channel onto which every registry mutation is published in real time. Subscribers join the channel and hot-reload definitions as patterns are registered, updated, or deregistered — no polling.

This page covers the broadcast surface. The HTTP handler is documented in Architecture Overview.

Channel

The default broadcast channel is:

public:parsec.registry.schemas

Exposed as schema.ChangeChannel in the library. The channel is public, so any client carrying a subscribe authorizer that allows public channels can listen — typically every client.

Operators that want a different channel name pass it via schema.PublisherOptions.Channel. The wire shape is unchanged.

Wire shape

Each publication is a JSON-encoded schema.Change:

{
  "kind": "registered",
  "pattern": "sessions:{id}",
  "schema": {
    "pattern": "sessions:{id}",
    "version": 1,
    "aspects": { "data": { "name": "data", "payload_schema": { "type": "object" } } }
  },
  "at": "2026-06-05T20:14:30.123Z"
}

kind is one of registered, updated, or deregistered. The schema field is omitted on deregistered (the pattern is gone — the client deletes the cache entry).

Wiring

The schema.Publisher bridges any schema.Registry to any schema.PublishFunc. Wire it once at boot, alongside the HTTP handler:

reg := schema.NewMemoryRegistry()

pc, _ := parsec.New(parsec.Options{
    SchemaHandler: schema.Handler(reg),
    // ...
})

pub := schema.NewPublisher(reg, func(ctx context.Context, ch string, data []byte) error {
    _, err := pc.Publish(ctx, ch, data)
    return err
}, schema.PublisherOptions{
    EnsureChannel: func(ch string) error {
        _, err := pc.OpenPublic(ch, time.Hour)
        return err
    },
})

go func() { _ = pub.Run(ctx) }()

EnsureChannel is invoked exactly once at Run start. Use it to open the broadcast channel on the broker before the first publication — parsec rejects publishes to channels that have not been opened.

The Publisher is best-effort: marshal or publish errors are logged at WARN and the loop continues. Subscribers that need exactly-once semantics re-sync against the HTTP snapshot whenever they detect a gap.

Client behavior

A client library following the recommended pattern:

  1. Fetches GET /parsec/schemas on startup, populates a local cache.
  2. Subscribes to public:parsec.registry.schemas.
  3. For each Change envelope:
    • registered / updated — replace the cache entry.
    • deregistered — drop the cache entry; pending publications to the removed pattern fail-closed at the validation layer.
  4. On a transport reconnect, re-fetch the snapshot. The broadcast is not durable across long disconnects.

Cardinality

The broadcast channel is a single channel name, shared by every subscriber. The publisher fans out via the normal Centrifuge broker path — no per-subscriber state in the registry itself.

Twirp service

Parsec speaks Twirp v8 JSON over HTTP. The CLI is a thin wrapper around this RPC; anything you can do from parsec channels you can also do with a curl.

curl -X POST https://parsec.example.com/twirp/parsec.ParsecService/ListChannels \
  -H "Authorization: Bearer $PARSEC_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'

Wire format

  • Encoding: Twirp v8 JSON (not protobuf binary). Every request and response body is JSON.
  • Path prefix: /twirp/parsec.ParsecService/.
  • Method names follow the service.proto definition; the full RPC URL is the prefix plus the method name (e.g. /twirp/parsec.ParsecService/Publish).
  • Headers: Content-Type: application/json, Authorization: Bearer <mgmt-token> (for non-public methods).
  • Errors are coded — see Errors below.

Auth

Every method except Manifest and RefreshToken requires a valid mgmt bearer in the Authorization header. The bearer is verified by auth.Verifier against the live keyring; tokens signed by a retired key fail as if the signature were bad.

The public methods are:

var PublicMethods = map[string]bool{
    MethodManifest:     true,
    MethodRefreshToken: true,
}

Manifest is public because it is the discovery endpoint. RefreshToken is public because it validates its own (in-body) refresh token instead of a bearer.

Methods

The full method list is in rpc/service.proto and mirrored in rpc/types.go. Grouped by intent:

Discovery

MethodRequestResponse
ManifestEmptyJSONResponse (descriptor envelope)

Channels

MethodRequestResponse
OpenPublicOpenPublicRequestChannelResponse
CreatePrivateCreatePrivateRequestCredentials
ListChannelsEmptyListChannelsResponse
GetChannelChannelRefChannelResponse
DeleteChannelChannelRefEmpty

Tokens

MethodRequestResponse
RefreshTokenRefreshTokenRequestRefreshTokenResponse
IssueMgmtIssueMgmtRequestIssueMgmtResponse

Keys

MethodRequestResponse
ListKeysEmptyListKeysResponse
GenerateKeyEmptyKeySummary
PromoteKeyKeyRefEmpty
RetireKeyKeyRefEmpty
ReloadKeysEmptyEmpty

Publish / presence

MethodRequestResponse
PublishPublishRequestPublishResponse
PresenceChannelRefPresenceResponse

The request and response shapes are declared in rpc/types.go. The proto definition lives in rpc/service.proto; running make proto will (eventually) regenerate service.pb.go and service.twirp.go from the proto. Until that runs, the hand-rolled types are the contract.

Errors

Coded errors cross the boundary as Twirp errors. The mapping is one function — rpc/server.go:writeServiceError. The Twirp code carries the HTTP status; the response body carries the Parsec code as a meta.code entry. The full code list:

  • PARSEC_CHANNEL_INVALID
  • PARSEC_CHANNEL_NOT_FOUND
  • PARSEC_CHANNEL_EXISTS
  • PARSEC_CHANNEL_CLOSED
  • PARSEC_CHANNEL_TTL_EXCEEDS_MAX
  • PARSEC_AUTH_DENIED
  • PARSEC_AUTH_EXPIRED
  • PARSEC_BROKER_NOT_READY
  • PARSEC_BROKER_INTERNAL
  • PARSEC_SINK_UNAVAILABLE
  • PARSEC_SINK_CONFIG_INVALID
  • PARSEC_INVALID_ARGUMENT
  • PARSEC_INTERNAL

See Troubleshooting for what to do when they fire.

Client

The reference Go client is in rpc/client.go. The CLI uses it through internal/rpcclient/. Both are non-stable packages — if you build an external Go client, generate one from the proto with make proto once that target is wired.

See also

Email sink

A thin SMTP relay. Delivers the Message via net/smtp.SendMail.

import (
    "github.com/frankbardon/parsec"
    "github.com/frankbardon/parsec/sinks/email"
)

es, _ := email.New(email.Config{
    Addr:     "smtp.example.com:587",
    From:     "parsec@example.com",
    Username: "parsec",
    Password: os.Getenv("SMTP_PASSWORD"),
})

p, _ := parsec.New(parsec.Options{})
p.Sinks().Register(es)

Once registered, you reference it by name in PublishOrSink:

delivered, err := p.PublishOrSink(ctx,
    "private:webapp.user.42.downloads",
    []byte(`{"status":"ready"}`),
    "email",
    email.Recipient{Address: "user@example.com"},
    parsec.Message{
        Subject: "Your download is ready",
        Body:    "Tap the link in the app.",
    })

Configuration

email.Config:

FieldRequiredNotes
AddryesSMTP host:port — e.g. smtp.example.com:587.
FromyesRFC 5322 mailbox-list, used as the envelope sender.
UsernamenoOmit for relays without auth (e.g. local Postfix).
PasswordnoRequired when Username is set.

If Username is set the sink uses smtp.PlainAuth over the connection. If not, the sink connects unauthenticated. The host component is parsed from Addr and used as the PlainAuth host identity.

Recipient

email.Recipient:

type Recipient struct {
    Address string
}

Address is required and must be an RFC 5322 mailbox. Empty addresses return PARSEC_INVALID_ARGUMENT.

Body shape

The sink stitches together a minimal RFC 5322 message:

From: <cfg.From>
To: <recipient.Address>
Subject: <message.Subject>

<message.Body>

Nothing else. No HTML wrapping, no MIME alternative, no templating — the publisher is responsible for whatever content shape the recipient expects. If you need richer email (HTML bodies, attachments, custom headers from Metadata), wrap this sink with your own and pre-format the body before calling Send.

Failure semantics

ConditionReturned code
Missing Addr or From at constructionPARSEC_SINK_CONFIG_INVALID
Wrong recipient typePARSEC_INVALID_ARGUMENT
Empty recipient addressPARSEC_INVALID_ARGUMENT
smtp.SendMail returns errorPARSEC_SINK_UNAVAILABLE

The sink does NOT retry. Network blips return immediately. If you need durability, wrap the sink in your own retry layer or queue the message before calling PublishOrSink.

See also

Slack sink

Posts to a Slack incoming-webhook URL. One sink instance per destination channel; the webhook URL fixes which Slack channel the message lands in.

import (
    "github.com/frankbardon/parsec"
    "github.com/frankbardon/parsec/sinks/slack"
)

s, _ := slack.New(slack.Config{
    WebhookURL: os.Getenv("PARSEC_SLACK_WEBHOOK"),
})

p, _ := parsec.New(parsec.Options{})
p.Sinks().Register(s)

Then:

_, _ = p.PublishOrSink(ctx,
    "private:agent.analysis.42.failures",
    []byte(`{"job":42,"err":"timeout"}`),
    "slack",
    nil, // recipient is ignored
    parsec.Message{
        Subject: "Job 42 failed",
        Body:    "timeout while fetching artifacts",
    })

Configuration

slack.Config:

FieldRequiredNotes
WebhookURLyesThe full Slack incoming-webhook URL.
HTTPClientnoDefaults to http.DefaultClient. Override to inject timeouts / tracing.

Recipient

slack.Recipient is an empty struct — the channel is determined by the webhook URL itself. Callers may pass nil to Send; the sink ignores the value.

If you need to post to multiple Slack channels, register multiple sink instances under different names (e.g. "slack-alerts", "slack-deploys") and pick the right one per call.

Payload shape

The sink posts a JSON body like:

{
  "text": "*Job 42 failed*\ntimeout while fetching artifacts",
  "metadata": {"trace_id": "..."}
}

Subject becomes a bold prefix on text; an empty Subject posts the body alone. Metadata is attached verbatim so it appears in Slack webhook metadata where supported.

Failure semantics

ConditionReturned code
Missing WebhookURL at constructionPARSEC_SINK_CONFIG_INVALID
http.Client.Do returns an errorPARSEC_SINK_UNAVAILABLE
Slack returns a >= 300 statusPARSEC_SINK_UNAVAILABLE (carries the status line)

The sink does not retry. Slack rate-limits webhooks; downstream clients that hit 429 should back off — the sink does not.

Common gotchas

  • Slack webhooks are long-lived but per-channel. If you change the destination Slack channel, mint a new webhook and re-register.
  • Slack does not signal whether the human at the keyboard saw the message — Send returning nil means “Slack accepted the post”, not “the user saw it”. Real delivery confirmation needs the Slack Web API and a different sink.

See also

Webhook sink

Posts a JSON envelope to a configurable HTTP endpoint. Use this when the downstream consumer is another service rather than a person — internal scoreboards, downstream workers, audit sinks.

import (
    "github.com/frankbardon/parsec"
    "github.com/frankbardon/parsec/sinks/webhook"
)

w, _ := webhook.New(webhook.Config{
    URL: "https://hooks.internal.example.com/parsec",
    Headers: map[string]string{
        "X-Parsec-Source": "prod",
        "Authorization":   "Bearer " + os.Getenv("DOWNSTREAM_TOKEN"),
    },
})

p, _ := parsec.New(parsec.Options{})
p.Sinks().Register(w)

Then:

_, _ = p.PublishOrSink(ctx,
    "private:webapp.user.42.downloads",
    []byte(`{"status":"ready"}`),
    "webhook",
    webhook.Recipient{URL: ""}, // use the configured URL
    parsec.Message{
        Subject: "download.ready",
        Body:    `{"user_id":42}`,
        Metadata: map[string]string{"trace_id": "abc"},
    })

Configuration

webhook.Config:

FieldRequiredNotes
URLyesDefault POST target. Used when the recipient does not override it.
HeadersnoStatic headers added to every request. Useful for an auth header or a fixed X-Parsec-Source.
HTTPClientnoDefaults to http.DefaultClient. Inject your own for timeouts, tracing, retries.

Recipient

webhook.Recipient:

type Recipient struct {
    URL string
}

URL == "" (or passing nil / a non-webhook.Recipient value) means “use the configured URL”. A non-empty URL overrides the destination on a per-call basis — useful when one sink instance fans out to multiple downstream services that share a common header set.

Payload shape

The sink marshals a fixed envelope:

{
  "subject":  "download.ready",
  "body":     "{\"user_id\":42}",
  "metadata": {"trace_id": "abc"}
}

Fields with zero values are omitted. The wire Content-Type is application/json. Configured headers are set after the Content-Type, so a config that overrides Content-Type wins.

Failure semantics

ConditionReturned code
Missing URL at constructionPARSEC_SINK_CONFIG_INVALID
Marshalling the envelope failsPARSEC_INTERNAL (very rare)
http.Client.Do returns an errorPARSEC_SINK_UNAVAILABLE
Downstream returns >= 300 statusPARSEC_SINK_UNAVAILABLE (carries the status line)

The sink does NOT retry, NOT queue, and NOT batch. It’s a thin forwarder. If downstream is flaky, wrap it.

Patterns

  • One sink per downstream service is the easy default — name them "webhook-audit", "webhook-scoreboard", etc.
  • One sink + per-call URL override works when the destinations share headers (auth, tracing) but differ in path.
  • For at-least-once semantics, queue the message yourself (e.g. in Kafka or a database table) and have a worker call Send with a retry loop. Parsec sinks are best-effort, not durable.

See also

Architecture overview

Parsec is a library with three parallel surfaces (CLI, Twirp, websocket). Every surface translates an external call into a service.Service method, which in turn drives the *parsec.Parsec instance.

flowchart TD
    subgraph external [External clients]
        CLI[parsec CLI]
        HTTP[Twirp HTTP client]
        WS[Browser / centrifuge-js]
    end

    subgraph surfaces [Surfaces]
        SVC[service.Service]
        RPC[rpc + internal/server]
        Hub[centrifuge.Hub]
    end

    subgraph core [Library core]
        P[parsec.Parsec]
        B[broker.Broker]
        M[channels.Manager]
        S[sinks.Registry]
        A[auth: KeyRing + Signer + Verifier + Issuer]
    end

    CLI -- Twirp --> RPC
    HTTP -- HTTP --> RPC

    RPC --> SVC
    SVC --> P

    P --> B
    P --> M
    P --> S
    P --> A

    B -- mounts --> Hub
    WS -- websocket --> Hub
    M -- events --> P
    P -- bridge --> B

The library is the deliverable

parsec.Parsec is the unit under test. The CLI binary is one assembler in cmd/parsec/main.go that hands command parsing to urfave/cli/v3 and dispatches into internal/cli/. The Twirp surface is a sibling.

Every surface uses the same service.Service shim, which is the surface-agnostic API. The shim exists so the CLI and Twirp handlers can share their business logic — and so the library callers (embedders) can pretend the surfaces don’t exist.

Component map

PackageResponsibility
parsecThe library facade: New, Run, Publish, PublishOrSink, CreatePrivate, RefreshAccess, key mutation methods.
authHMAC signer / verifier / issuer; keyring; subscribe authorizer for the broker.
brokerA thin wrapper over centrifuge.Node. Owns publish, presence, and the subscribe-authorization hook.
channelsThe name grammar (ParseName) and the lifecycle manager (Manager) with TTL sweep.
sinksThe Sink interface and registry; ships email/slack/webhook reference implementations.
serviceSurface-agnostic verbs the CLI and Twirp servers call into.
rpcTwirp-JSON types, server, client, and bearer middleware.
descriptorManifest envelope (CLI --json, /manifest).
errorsThe PARSEC_* coded error system.
internal/cliCLI command bodies.
internal/serverHTTP mux: Twirp + websocket + SSE + healthz.
cmd/parsecmain.go — assembles the command tree, nothing else.

Request flow: a publish

  1. Operator runs parsec publish public:webapp.system.status -d '{...}'.
  2. internal/cli/publish.go parses flags and calls internal/rpcclient.Publish(...).
  3. The client posts to /twirp/parsec.ParsecService/Publish with a mgmt bearer.
  4. internal/server/ routes through the bearer middleware (auth.Verifier) and into rpc/server.go.
  5. The RPC server calls service.Service.Publish(...).
  6. service.Service calls parsec.Parsec.Publish(...).
  7. Parsec.Publish parses the name, asserts the channel is known to the manager, and calls broker.Broker.Publish.
  8. The broker hands off to centrifuge.Node.Publish, which fans the message out to every subscriber and records it in history.

Request flow: a subscribe

  1. Browser opens a websocket to /connection/websocket with an access token.
  2. The broker’s OnConnecting extracts the token and resolves the subject.
  3. On SUBSCRIBE, the broker invokes the SubscribeAuthorizer that parsec.New installed — which checks both that the channel is open (Manager.IsOpen) AND that the token is valid for that channel (auth.SubscribeAuthorizer).
  4. On success the subscription completes; on failure Centrifuge sends the typed error to the client.

Manager-to-broker bridge

The channel manager emits typed Event values on every lifecycle transition; parsec.Parsec.runEventBridge translates them:

EventBroker action
EventOpenedNothing — Manager.IsOpen already returns true.
EventClosed (public, TTL exceeded)Nothing — existing subscribers drain.
EventDeletedbroker.UnsubscribeAll(name) — kick every connected subscriber.

That asymmetry is the single most important rule in the codebase: closed != deleted. See TTL and expiry.

See also

Broker (Centrifuge)

broker.Broker is a thin wrapper around *centrifuge.Node. Its job is to keep Parsec’s other packages from depending directly on the Centrifuge OSS library and to install the two hooks Parsec actually cares about: connect and subscribe authorization.

br, err := broker.New(broker.Options{
    HistorySize:         100,
    PublicHistoryTTL:    5 * time.Minute,
    PrivateHistoryTTL:   5 * time.Minute,
    SubscribeAuthorizer: myAuthorizer, // optional
})
go br.Run(ctx)

What gets wrapped

*centrifuge.Node is the underlying broker. Parsec exposes a narrow surface on top:

MethodCentrifuge equivalentNotes
New(opts)centrifuge.New(cfg)Installs the connect + subscribe hooks.
Run(ctx)node.Run() + node.ShutdownBlocks until ctx is done; 5s shutdown grace.
Publish(ctx, ch, data)node.Publish(...)History size + TTL filled in from Options.
Presence(ctx, ch)node.Presence(...)Returns just the subscriber count.
UnsubscribeAll(ch)iterate hub.Connections()Used by the bridge on EventDeleted.
Node()n/aEscape hatch — the HTTP mount needs it to install the websocket handler.

Connect hook

OnConnecting is installed at construction time with a permissive default:

node.OnConnecting(func(ctx context.Context, _ centrifuge.ConnectEvent) (centrifuge.ConnectReply, error) {
    return centrifuge.ConnectReply{Credentials: &centrifuge.Credentials{UserID: ""}}, nil
})

Anonymous connect is intentional. The real authorization happens at SUBSCRIBE time, because that is when the channel name is known. An attacker who connects but never subscribes does nothing useful.

Embedders that need per-connection identity (e.g. browser SSO) can override this hook by calling broker.Node().OnConnecting(...) after broker.New.

Subscribe hook

The subscribe path is the security boundary. For every subscribe attempt:

  1. channels.ParseName(event.Channel) — invalid grammar → reject.
  2. SubscribeAuthorizer(ctx, userID, name, event) — runs the configured authorizer.

parsec.New replaces whatever SubscribeAuthorizer you put on broker.Options with one that does TWO checks:

if !manager.IsOpen(ch) {
    return errors.New(errors.ChannelClosed, "channel not open")
}
return tokenAuth(ctx, userID, ch, event)

So a closed channel rejects new subscribes BEFORE the token is even examined. The token check (auth.NewSubscribeAuthorizer(verifier)) allows any well-formed public channel and validates the embedded access token against private channels.

The default authorizer (when you skip parsec.New) is stricter still — it rejects every private channel outright:

func defaultSubscribeAuthorizer(_ context.Context, _ string, ch channels.Name, _ centrifuge.SubscribeEvent) error {
    if ch.IsPrivate() {
        return errors.New(errors.AuthDenied, "private channel requires a SubscribeAuthorizer")
    }
    return nil
}

Use that when you wire broker.New directly and have not yet built a token authorizer.

Publish path

Publish is a thin layer over node.Publish with history options chosen by visibility:

node.Publish(ch.String(), data,
    centrifuge.WithHistory(opts.HistorySize, b.historyTTLFor(ch)))

Public and private channels can be tuned independently via PublicHistoryTTL and PrivateHistoryTTL. Defaults are 5 minutes each.

When the broker is “not ready”

Publish checks an internal started flag and returns PARSEC_BROKER_NOT_READY if Run has not yet executed node.Run(). Callers should retry on this code — it usually means the server is still booting.

Deeper Centrifuge internals

Parsec relies on Centrifuge for the actual transport layer (websocket, SSE, hub fanout, history). The OSS library’s design is well-documented upstream:

  • The Centrifuge documentation: https://centrifugal.dev/docs/server/library_intro
  • The Go API reference: https://pkg.go.dev/github.com/centrifugal/centrifuge

Parsec deliberately does NOT re-document those internals. The contract is “anything the OSS library can do is reachable via Broker.Node()”; the wrapper exists to keep the rest of Parsec from coupling to Centrifuge symbols directly.

See also

Channel manager

channels.Manager owns the lifecycle of every channel the broker has heard of. Its job is to track (state, ttl, last_active) and emit events when a transition happens.

m := channels.NewManager()
events := make(chan channels.Event, 64)
m.Subscribe(events)

go m.RunSweeper(ctx, 30*time.Second)

The broker side of the wire effects (kick subscribers on delete, drain on close) is driven by these events through the manager-to-broker bridge in parsec.Parsec.runEventBridge.

State

The channel record:

type Channel struct {
    Name       Name
    State      State        // open | closed | deleted
    TTL        time.Duration
    CreatedAt  time.Time
    LastActive time.Time
}

Three lifecycle states:

StateMeaning
openAccepting subscribes; publish path is live.
closedPublic only. Past TTL; no new subscribes but record is kept.
deletedRemoved from the manager. Subscribers must be kicked.

The constants live in channels/manager.go:

const MaxPrivateTTL    = 1 * time.Hour
const DefaultPublicTTL = 30 * time.Minute

Transitions

OpenPublic        ──► state=open                 EventOpened (on new or reopen)
CreatePrivate     ──► state=open                 EventOpened
Sweep (public,    ──► state=closed               EventClosed (no kick)
       TTL hit)
Sweep (private,   ──► record removed             EventDeleted (kick)
       TTL hit)
Delete            ──► state=deleted              EventDeleted (kick)
Touch / Publish   ──► last_active = now          (no event)

The state=open → state=open re-open on OpenPublic for an already open channel does NOT emit a fresh opened event — the contract is “only on the closed→open edge”.

Sweep behavior

Manager.Sweep(now time.Time) int runs one pass over every channel:

  • Skip non-open channels (closed or already-deleted records).
  • Skip channels with now.Sub(LastActive) < TTL.
  • For private channels past TTL: delete(m.channels, key) + emit EventDeleted.
  • For public channels past TTL: flip to StateClosed + emit EventClosed.

The sweep is called by RunSweeper on a ticker, but tests usually call Sweep(t) directly with a fixed clock — see channels/manager_test.go.

Event subscription

Consumers register with:

events := make(chan channels.Event, 16)
m.Subscribe(events)

Buffer the channel. The manager does non-blocking sends — a slow consumer drops events. Two consumers are common in a live process:

  • The broker bridge (parsec.Parsec.runEventBridge) translates events into wire actions.
  • Test code or telemetry hooks listen to the same stream for visibility.

Events are emitted from a goroutine that holds no manager lock; you do not need to worry about re-entering the manager from a subscriber.

Listing

Manager.List() returns a snapshot copy of every record. Deleted records are excluded from Get but included in List so a tool can see the tombstone (the state field tells you which is which).

Thread-safety

Manager is safe for concurrent use. Internally it holds two mutexes — one for the channel map, one for the subscribers slice — plus a per-channel mutex-free copy-on-read pattern for Get / List. The hot paths (IsOpen, Touch, Publish-side Touch) take the read lock and the writer lock briefly, respectively.

Test clock

Manager.SetClock(func() time.Time) swaps the internal time source. Tests use this to deterministically drive TTL expiry without sleeping:

m.SetClock(func() time.Time { return fixedTime })
m.Sweep(fixedTime.Add(time.Hour))

See channels/manager_test.go for the canonical pattern.

See also

Deployment

How to run parsec serve in something resembling production. The single-node story is the default; the multi-node story attaches a Redis backend.

parsec serve \
  --addr :8000 \
  --state-dir /var/lib/parsec \
  --keyring-poll 5s

Topology: single node

The simplest deployment is one Parsec process with an in-memory channel registry and a file-backed keyring. Centrifuge holds tens of thousands of concurrent websockets per core — single-node carries a lot of load.

Topology: clustered

Point Options.RedisClient (or the YAML redis.addr field) at a shared Redis and the broker, channel registry, keyring, DLQ, and rate limiter all switch to their Redis-backed implementations. Multiple Parsec nodes then share the same truth about open channels, keys, and sink failures. See key rotation for the rotation walkthrough.

Required state

PathWhatSurvives restart?
<state-dir>/keyring.jsonHMAC signing keysYes
In-memory channel mapOpen channels, private recordsNo
In-memory subscriber setLive websocket connectionsNo

The manifest reports "persistence": "in-memory" so clients can discover the stance without reading docs. The contract: a restart wipes every channel, every subscriber must reconnect, every private channel must be re-created with fresh tokens.

If that is a problem for your use case, Parsec is the wrong primitive — use a real durable queue.

--state-dir

Always pass it in production. Without --state-dir, the keyring is ephemeral and every restart mints a new bootstrap token under a brand new active key. Existing browser clients can no longer refresh and must re-authenticate. With --state-dir, the ring survives the restart and active sessions tolerate a quick bounce.

The directory is created with mode 0700; the keyring file with mode 0600. Both are owned by the running uid. Mount your state volume so the uid is consistent across restarts.

Environment variables

The CLI reads these at boot. The library does not — it takes typed values via Options.

VariableDefaultNotes
PARSEC_STATE_DIR“”Same as --state-dir.
PARSEC_MGMT_SUBJECToperatorSubject claim on the bootstrap mgmt token.
PARSEC_MGMT_TTL24hBootstrap mgmt TTL.
PARSEC_KEYRING_POLL5smtime-poll interval. 0 disables polling.
PARSEC_NO_AUTHunsetDangerous; disables the bearer middleware. Dev only.
PARSEC_SERVERhttp://localhost:8000Default --server for the client subcommands.
PARSEC_TOKEN“”Mgmt bearer for the client subcommands.

Capturing the bootstrap token

parsec serve prints the bootstrap mgmt token to STDERR exactly once. A typical systemd unit captures it like this:

[Service]
ExecStart=/usr/local/bin/parsec serve --addr :8000 --state-dir /var/lib/parsec
StandardError=append:/var/log/parsec/boot.log

Then grep the log:

grep "bootstrap mgmt token" /var/log/parsec/boot.log

If you persist the keyring, the same token continues to work across restarts — fetch it once, store it in your secret manager, move on.

Health and observability

EndpointPurpose
/healthzLiveness probe. Returns 200 once node.Run has succeeded.
/manifestDescriptor envelope — surfaces, sinks, version.

Centrifuge’s own metrics are exposed under broker.Node().Metrics(...); mount them on your own /metrics handler if you want Prometheus.

Upgrades

A restart kills channel state. The supported upgrade procedure:

  1. Drain or accept the disruption (browsers reconnect; private channels need fresh tokens).
  2. systemctl restart parsec (or your orchestrator’s equivalent).
  3. The new process loads the same keyring.json; active operator bearers continue to verify.

If you cannot tolerate the disconnect window, queue a maintenance banner on public:<app>.broadcast.maintenance and run during a quiet period.

See also

Configuration file

parsec serve --config <path> loads operator settings from a YAML file so multi-node deployments and version-controlled rollouts have a single artifact to track. The flag is also reachable via the PARSEC_CONFIG environment variable.

Precedence

CLI flag > env var > config file > built-in default

Any flag the operator passes explicitly wins over the file. Anything the file doesn’t set falls through to defaults baked into the binary. There is no merging of structured fields — each scalar is settled at one layer.

Format

YAML. Reasons it’s the default:

  • Familiar to operators coming from Kubernetes, GitHub Actions, Docker Compose, and most other Go ecosystem libraries
  • Anchors / aliases supported if you need them (rarely)
  • Comments survive
  • Parsed by gopkg.in/yaml.v3 — the de facto Go YAML decoder

Strings support ${ENV_VAR} interpolation so secrets can be sourced from the environment without inlining them into the file:

observability:
  metrics_bearer_token: "${PARSEC_METRICS_TOKEN}"

Unknown keys are rejected at load time — a typo in a key name fails boot with a clear error rather than silently ignoring the setting.

Reference

See examples/config/parsec.yaml in the repo for a fully-populated configuration with comments explaining each field. The schema follows the parsec.Options surface; every CLI flag has a matching file field.

Sections

SectionPurpose
serverListen address, debug toggles
authKeyring location, bootstrap mgmt token settings
redisCross-node Redis coherence (channels + keyring + DLQ + rate limits)
managerChannel manager tunables (sweep interval)
sink_retryGlobal + per-sink retry policy
rate_limits.*Publish / subscribe / token-issue buckets
observabilityMetrics, tracing, access-log trusted proxies

Mutual exclusions

The loader rejects impossible combinations at startup:

  • A rate_limits.<bucket> with rate > 0 must also set per

Caught at boot, not at first request.

Boot diagnostics

When --config is set, parsec serve logs parsec serve: loaded config path=<path> on startup, and the manifest exposes a config_source field with the same value so clients can confirm which file is active:

curl -s -H "Authorization: Bearer $PARSEC_TOKEN" \
  http://localhost:8000/twirp/parsec.ParsecService/Manifest \
  | jq '.payload.config_source'

Single-source-of-truth pattern

In production, prefer:

  1. Commit a parsec.yaml per environment (config/staging.yaml, config/prod.yaml)
  2. Reference secrets via ${ENV_VAR} interpolation
  3. Run with parsec serve --config config/prod.yaml
  4. Override anything urgent via CLI flag — the file stays the long-running baseline; flags handle the incident-response case

See also

  • examples/config/parsec.yaml — reference file
  • Deployment — single-node and multi-node patterns
  • Rate limiting — bucket semantics
  • Observability — metrics, tracing, log fields

Key rotation runbook

This document walks the full procedure for rotating Parsec’s HMAC signing key without dropping a single live subscriber or refresh-flow client.

The contract: tokens minted under any non-retired key continue to verify. Promoting a new key does not invalidate prior tokens. Retiring a key does — only after you have waited for tokens signed by that key to expire naturally.

Preconditions

  • Parsec is running with --state-dir <dir>. Without it the keyring is ephemeral and rotation does nothing useful.
  • You have a valid mgmt bearer token. Export it as PARSEC_TOKEN.
  • You know the longest TTL in use:
    • Access tokens: 5m by default
    • Refresh tokens: ≤ channel TTL (max 1h)
    • Mgmt tokens: ≤ 7d (24h default)

The wait between Promote and Retire is the maximum of these — for a deployment with default TTLs that is 24 hours.

Procedure

export PARSEC_SERVER="https://parsec.internal:8000"
export PARSEC_TOKEN="<current mgmt bearer>"

# 1. Inspect current state. There should be exactly one active key.
parsec keys list

# 2. Generate a new key. It joins the ring as verify-only — it does not
#    sign anything yet. You can do this at any time.
NEW_KID=$(parsec keys generate | jq -r '.payload.id')
echo "new key: $NEW_KID"

# 3. Promote the new key. From this moment on every newly-minted token
#    embeds the new kid. Tokens already in flight continue to verify
#    because the previous active key is now verify-only.
parsec keys promote "$NEW_KID"

# 4. Mint a fresh mgmt bearer signed by the NEW key. Use this bearer
#    going forward — your existing bearer is still valid but will die
#    when you retire the old key in step 6.
NEW_BEARER=$(parsec tokens mgmt --ttl 24h | jq -r '.payload.mgmt_token')
export PARSEC_TOKEN="$NEW_BEARER"

# 5. Wait. The wait must cover the longest TTL of tokens that may have
#    been signed by the previous active key. With Parsec defaults this
#    is 24 hours; lower TTL deployments may need less.
sleep 86400  # not literally — schedule this, monitor in the meantime

# 6. Retire the old key. From this moment on tokens claiming the old
#    kid fail with PARSEC_AUTH_DENIED.
OLD_KID=$(parsec keys list | jq -r '.payload.keys[] | select(.role=="verify-only") | .id')
parsec keys retire "$OLD_KID"

# 7. Confirm the ring is clean.
parsec keys list

SIGHUP / manual reload

If you edit keyring.json out-of-band (do not do this normally — go through the CLI), the running server will pick it up either on the next mtime-poll tick (every 5s by default) or when you signal it:

kill -HUP $(pgrep parsec)
# OR
parsec keys reload

Both achieve the same swap.

Multi-node deployments

Point every Parsec node at a shared Redis (Options.RedisClient or the YAML redis.addr field). The Redis-backed KeyRingStore publishes a version event on every rotation, and every node subscribes — parsec keys generate/promote/retire on any one node propagates to the rest of the cluster within milliseconds without operator intervention. The file-backed keyring + NFS pattern still works for deployments without Redis; in that case disable the mtime poller on all but one node (--keyring-poll 0) and SIGHUP the others after each rotation.

What if I have to break glass?

If a key has been compromised and you cannot wait for tokens to expire:

# 1. Generate + promote a new key.
NEW_KID=$(parsec keys generate | jq -r '.payload.id')
parsec keys promote "$NEW_KID"
NEW_BEARER=$(parsec tokens mgmt | jq -r '.payload.mgmt_token')
export PARSEC_TOKEN="$NEW_BEARER"

# 2. Retire the compromised key immediately. Every token signed by it
#    fails verification; live subscribers using a compromised access
#    token are kicked at next protocol heartbeat.
parsec keys retire <compromised-kid>

This is destructive: every in-flight token signed by that key dies. Browser clients reconnect via the refresh flow only if their refresh token was signed by a DIFFERENT key — otherwise the user is logged out and must re-authenticate through your application’s normal flow.

What never works

  • You cannot retire the active key. Promote another key first.
  • You cannot resurrect a retired key. The disk snapshot drops retired entries; the in-memory ring drops them on next reload. Mint a new one if you need one.
  • You cannot use a key the ring does not know about. Tokens with a kid claim that doesn’t resolve to a live key fail as if the signature were bad. Parsec doesn’t tell the attacker which case.

Refresh-Token Rotation

Parsec rotates refresh tokens on every redemption. Each refresh carries a JTI (per-token unique ID) and a FID (rotation-family ID). Successful redemption mints a fresh access and a fresh refresh that shares the old FID and carries a new JTI; the old refresh is marked redeemed in the rotation store and cannot be used again.

A second redemption of the same JTI is treated as a leak: the entire family is revoked, so every refresh in the rotation chain — past, present, and future — fails until the recorded expiry passes.

This page covers the rotation contract, the backing store, the operator-facing knobs, and what to do when a family is revoked.

Why rotation matters

A long-lived refresh token is a credential. If it leaks (shoulder-surf, disk image, log spill) the attacker can mint access tokens until the refresh expires — typically minutes to an hour. Without rotation there is no way to detect that the leak happened: both the legitimate client and the attacker hold the same valid token.

With rotation the legitimate client’s next refresh attempt presents the already-redeemed JTI, the server detects the reuse, and Parsec revokes the family. Sessions break loudly — but only for compromised chains, and the operator gets a metric and an access-log line that name the incident.

Wire shape

The RefreshTokenResponse carries the rotated refresh:

{
  "access_token": "...",
  "access_expires_unix": 1735300000,
  "refresh_token": "...",
  "refresh_expires_unix": 1735303600,
  "rotated": true
}

Legacy tokens (no JTI on the input refresh) yield a response without refresh_token / refresh_expires_unix and with rotated: false. A modern client always uses the returned refresh on the next redemption.

Rotation store

Two records per chain, scoped to the refresh’s natural TTL:

RecordPurpose
jtiMarked on successful redemption. A second SET attempt with the same JTI fails — that is the reuse signal.
fidMarked on detected reuse. While present, every JTI in the family is rejected without even consulting its own record.

Two backends:

  • MemoryRefreshStore — single-node; entries live in a map and age out on a periodic pruner (default 5 minutes; tune via parsec.Options.MemoryRefreshPruneInterval).

  • RedisRefreshStore — multi-node; SETNX with a TTL equal to the refresh’s remaining lifetime. Key layout:

    <prefix>:refresh:jti:<jti>  → "1"  TTL = exp - now
    <prefix>:refresh:fid:<fid>  → "1"  TTL = exp - now
    

parsec.New builds the right backend automatically: Redis when RedisClient is configured, in-memory otherwise. Operators can inject a custom backend by setting Options.RefreshStore.

Operator-facing knobs

OptionDefaultEffect
RefreshStorenil → autoExplicit backend
MemoryRefreshPruneInterval5mHow often the in-memory backend reclaims expired records
MaxRefreshTokenTTL1hUpper bound on every refresh, original or rotated

AccessTokenTTL still bounds the access half (default 5m, clamped to [1m, 1h]). Rotation does not extend the access TTL beyond the refresh expiry.

What to do when a family is revoked

parsec_refresh_rotations_total{result="reused"} is the loud signal — every increment is a confirmed reuse. The corresponding client will hit PARSEC_AUTH_DENIED on its next refresh and need a fresh credential pair from CreatePrivate.

Triage:

  1. Find the access log line for the failed redemption (bearer_subject + request_id). The sub claim names the compromised session.
  2. Inspect the channel granted to that session — assume the attacker subscribed for the duration the refresh was valid.
  3. Issue a new pair via CreatePrivate if the legitimate client is still online; otherwise let the channel TTL expire naturally.

Family revocation only affects the one rotation chain. Other users and other refresh families for the same subject continue to work.

Backward compatibility

Refresh tokens minted by older Parsec versions have no JTI / FID. The rotation gate detects this and short-circuits to the legacy path: mint a fresh access only, never touch the store, no refresh_token in the response. Clients upgrading to the rotation surface should re-CreatePrivate once to obtain a JTI-bearing refresh; until they do, sessions still work but the leak-detection guarantee does not apply.

Metrics

  • parsec_refresh_rotations_total{result="rotated"} — successful rotation.
  • parsec_refresh_rotations_total{result="reused"} — reuse detected; family revoked.
  • parsec_refresh_rotations_total{result="family_revoked"} — caller presented a refresh whose family was already revoked.
  • parsec_refresh_rotations_total{result="legacy"} — pre-rotation token; no store interaction.

Cardinality budget: result is one of four constants. No subject or channel labels.

Asymmetric Signing (RS256 / EdDSA / ES256 / ES384)

Parsec’s default signer is HS256 — a single 32-byte HMAC secret per key. Symmetric is fast, small on the wire, and easy to rotate when the verifier sits in the same process. It does NOT fit deployments where verifying tokens needs to happen somewhere parsec is not (a CDN edge, a separate API gateway, an external partner). Sharing the HMAC secret to those parties would give them the ability to mint tokens too.

This page covers Parsec’s RS256, EdDSA, and ECDSA (ES256, ES384) support: how to add an asymmetric key, how to publish the public material via the JWKS endpoint, and the trade-offs.

When to choose which alg

AlgUse when
HS256All verifiers are Parsec processes you control. Smallest tokens, simplest rotation. (Default.)
EdDSAYou need a public key for external verifiers and you control the consumer libraries. Smaller keys (32-byte private seed), faster signing than RS256.
ES256NIST-curve interop required (FIPS-leaning environments, hardware modules that don’t speak Ed25519). 64-byte signatures, P-256 keys, fast verification.
ES384Same as ES256 but with a P-384 curve when a 192-bit security level is required by policy. 96-byte signatures.
RS256You need broad ecosystem interop (most enterprise SSO / JWKS libraries assume RSA). Largest keys (2048+ bit modulus), larger signatures, slower signing.

You can mix algorithms in one ring — Parsec dispatches per key. A common deployment runs HS256 for internal mgmt tokens and Ed25519 (or ES256) for client tokens consumed by an edge verifier.

Adding an asymmetric key

# Default (back-compat) is still HS256 — same as before.
parsec keys generate

# Ed25519 (joins as verify-only):
parsec keys generate --alg eddsa

# RS256 with the default 2048-bit modulus:
parsec keys generate --alg rs256

# ECDSA on P-256 / P-384:
parsec keys generate --alg es256
parsec keys generate --alg es384

Promote to active when you are ready to start signing with it:

parsec keys list                 # find the new key id
parsec keys promote <new-kid>

The previously-active key transitions to verify-only so existing tokens stay valid until they expire. Retire the old key after the longest token TTL elapses (same procedure as key rotation).

JWKS endpoint

Once any non-retired key in the ring is asymmetric, Parsec serves a JWKS document (RFC 7517) at:

GET /parsec/jwks.json

The handler returns one JWK per asymmetric key. HMAC keys are never exposed — they are shared secrets, not verifying material.

A minimal Ed25519 JWKS entry looks like:

{
  "kty": "OKP",
  "kid": "k-9a3f1c0b1e22",
  "alg": "EdDSA",
  "use": "sig",
  "crv": "Ed25519",
  "x": "<base64url public key>"
}

ECDSA JWKS entries carry kty=EC with both coordinates of the public point. The x and y fields are fixed-width per curve (32 bytes for P-256, 48 bytes for P-384) before base64url encoding:

{
  "kty": "EC",
  "kid": "k-1f3b8a04c011",
  "alg": "ES256",
  "use": "sig",
  "crv": "P-256",
  "x": "<base64url X coordinate, 32 bytes>",
  "y": "<base64url Y coordinate, 32 bytes>"
}

The response is cacheable (Cache-Control: public, max-age=300). External verifiers should respect that header and re-fetch after the TTL or on a kid lookup miss.

/parsec/jwks.json is unauthenticated by design — verifying parties must be able to reach it without a Parsec credential. Mount it behind a CDN with appropriate firewalling if reachability needs to be controlled.

When the ring contains only HMAC keys, the route returns 404. There is no entry to expose, so probing yields no information.

Wire shape — what changes in the token

Every JWT carries alg in its header. Parsec writes one of HS256, RS256, EdDSA, ES256, or ES384. The verifier checks the header alg against the algorithm of the key it points to (kid); a mismatch is rejected with PARSEC_AUTH_DENIED to defeat key-confusion attacks. There is no algorithm negotiation — the kid uniquely determines the signing alg.

ECDSA signatures are written in the JOSE fixed-width form (RFC 7515 §3.4): the raw r||s integers padded to the curve’s coordinate size (32 bytes for P-256, 48 bytes for P-384), NOT the ASN.1/DER wrapper that ECDSA APIs typically emit. The verifier rejects any signature whose length isn’t exactly 2 * coord_size so a DER-shaped signature cannot be smuggled in. Other libraries that produce DER need to re-encode to fixed-width when interoperating.

Persistence

The keyring snapshot format version bumps from 1 to 2 to carry the per-key alg and private_pem (PKCS#8 for RS256, EdDSA, and both ECDSA curves). Loaders accept both versions:

  • v1 (or v2 with alg="") is treated as HS256, falling back to the secret_hex field. Legacy on-disk rings load without operator intervention.
  • v2 with alg=RS256 / alg=EdDSA / alg=ES256 / alg=ES384 decodes private_pem and reconstructs the typed key. For ECDSA the loader cross-checks that the encoded curve matches the declared alg (P-256 ↔ ES256, P-384 ↔ ES384) — a mismatch refuses to load.

File mode + parent dir mode are unchanged (0600 / 0700). PKCS#8 encoding means the on-disk blob carries the algorithm internally, so the loader cross-checks the declared alg against the parsed key type — a mismatch refuses to load the ring rather than guess.

What is not yet supported

  • Algorithm-per-token-type (e.g. mgmt = HS256, access = EdDSA). The active key dictates the alg for every issuance.
  • ECDSA on P-521 (ES512). JOSE defines it, but parsec pins the supported curves to P-256 and P-384; larger curves bring variable-length JOSE signatures and no compelling operator benefit.
  • JWKS rotation hints (the alg field is per-key; we do not currently emit x5c or chain metadata).

Metrics and audit

parsec_token_verifications_total{type, result} is unchanged. The metric does not break down by alg — adding it would create a five-cell label set per token type for limited operator benefit. The access log records the kid that verified each request; correlate against parsec keys list to see which alg signed it.

OIDC Bridge for Management Authentication

Parsec’s management RPC accepts two kinds of bearer tokens:

  1. HMAC mgmt tokens — minted by parsec tokens mgmt (or IssueMgmt) and signed by the active key in the parsec keyring.
  2. OIDC ID tokens — issued by a corporate IdP (Okta, Auth0, Google Workspace, Keycloak) and verified against the issuer’s JWKS endpoint at request time.

OIDC is opt-in. When auth.oidc.issuer is empty (the default), the only accepted mgmt tokens are HMAC-signed parsec tokens.

Why OIDC?

Parsec HMAC tokens are well-suited to service-to-service automation: short-lived, key-rotation-aware, and minted by the parsec instance itself. Human operators are different — corporate SSO is the expected entry point, and provisioning a per-operator HMAC token just to call the management RPC adds operational friction.

Mounting OIDC alongside HMAC means operators authenticate with the same identity their IT department already manages, while automation keeps using the existing token issuer.

Architecture

                  +-----------------+
  bearer token -> | bearer middleware| --> twirp handler
                  +-----------------+
                          |
                          v
                  +-----------------+
                  | CompositeVerifier|
                  +-----------------+
                    /            \
                   v              v
            +--------+      +-----------+
            |  HMAC  |      |   OIDC    |
            +--------+      +-----------+
            (keyring)       (issuer JWKS)

The bearer middleware tries HMAC first. On any failure (malformed, expired, signature mismatch, unknown kid), it falls through to the OIDC verifier, which:

  1. Re-fetches the IdP’s JWKS if the cached keys are stale.
  2. Verifies the token’s signature against the JWKS.
  3. Enforces exp, iat, aud, and iss.
  4. Translates the payload into a synthetic auth.Claims:
    • sub <- the claim named by subject_claim (defaults to sub)
    • typ <- "mgmt"
    • scopes <- derived from the IdP groups via auth.oidc.grants

The synthetic claims object has the same shape as an HMAC-issued mgmt token, so downstream code (the rate limiter, scope authorizer, access log) does not need an OIDC-specific code path.

Configuration

OIDC is configured via the YAML config file under auth.oidc:

auth:
  oidc:
    issuer: https://accounts.google.com
    audience: parsec-prod
    # Optional claim mapping. Defaults: sub -> subject; groups -> scopes.
    subject_claim: email
    scopes_claim: groups
    # Map IdP group names to parsec verbs / channel patterns.
    grants:
      - if_group: parsec-admins
        scope: "*:**"
        verbs: [subscribe, publish, manage]
      - if_group: parsec-readers
        scope: "public:**"
        verbs: [subscribe]
KeyPurposeDefault
issuerOpenID issuer URL. Empty disables OIDC.(none)
audienceExpected aud claim — the client identifier registered with the IdP. Required when issuer is set.(none)
subject_claimWhich claim becomes Claims.Sub. Common: email, preferred_username, sub.sub
scopes_claimWhich claim carries the operator’s group memberships.groups
grants[].if_groupGroup name to match against the token’s scopes_claim. Case-sensitive.(required)
grants[].scopeparsec scope pattern to grant (channel grammar + * / **).(required)
grants[].verbsSubset of [subscribe, publish, manage].(required)

auth.oidc.issuer accepts the same ${ENV_VAR} interpolation as every other string field in the config.

Setup walkthroughs

The examples below show the placeholder IdP-side settings needed to make parsec a registered client. Adjust ${PARSEC_HOSTNAME} and group names to your deployment.

Google Workspace

  1. In Google Cloud Console, create an OAuth 2.0 client of type “Web application” or “Desktop” (for device-code flow).

  2. Set the client ID as the parsec audience.

  3. Add the https://accounts.google.com/.well-known/openid-configuration issuer to parsec’s config:

    auth:
      oidc:
        issuer: https://accounts.google.com
        audience: ${GOOGLE_CLIENT_ID}
        subject_claim: email
        # Google does not ship group claims by default. Pair this
        # with the Cloud Identity API or push groups through a
        # custom claim during sign-in.
        scopes_claim: groups
        grants:
          - if_group: parsec-admins@example.com
            scope: "*:**"
            verbs: [subscribe, publish, manage]
    
  4. On the operator’s laptop:

    parsec login oidc \
      --issuer https://accounts.google.com \
      --client-id "$GOOGLE_CLIENT_ID"
    

    The CLI prints a verification URL and a user code. Open the URL, sign in, paste the code — the CLI receives the ID token and writes it to ~/.parsec/credentials.

Okta

  1. In Okta, create an OIDC application of type “Native” (device grant supported).

  2. Enable “Device Authorization Grant” under the General tab.

  3. Pin a custom claim that includes the user’s group memberships (e.g. groups <- getFilteredGroups(...)).

    auth:
      oidc:
        issuer: https://${OKTA_DOMAIN}/oauth2/default
        audience: ${OKTA_CLIENT_ID}
        subject_claim: email
        scopes_claim: groups
        grants:
          - if_group: parsec-admins
            scope: "*:**"
            verbs: [subscribe, publish, manage]
          - if_group: parsec-readers
            scope: "public:**"
            verbs: [subscribe]
    
  4. Operator login:

    parsec login oidc \
      --issuer https://${OKTA_DOMAIN}/oauth2/default \
      --client-id "$OKTA_CLIENT_ID"
    

Keycloak

  1. In the realm of your choice, create a client with “Standard Flow” + “OAuth 2.0 Device Authorization Grant” enabled.

  2. Set the client’s access type to “public” (or “confidential” with a client secret — pass --client-secret to parsec login oidc in that case).

  3. Map the user’s groups onto a claim named groups.

    auth:
      oidc:
        issuer: https://${KEYCLOAK_HOST}/realms/${REALM}
        audience: ${KEYCLOAK_CLIENT_ID}
        subject_claim: preferred_username
        scopes_claim: groups
        grants:
          - if_group: /parsec-admins
            scope: "*:**"
            verbs: [subscribe, publish, manage]
    
  4. Operator login:

    parsec login oidc \
      --issuer https://${KEYCLOAK_HOST}/realms/${REALM} \
      --client-id "$KEYCLOAK_CLIENT_ID" \
      --client-secret "$KEYCLOAK_CLIENT_SECRET"   # optional
    

CLI usage

After parsec login oidc completes, the persisted ID token at ~/.parsec/credentials is picked up by subsequent CLI invocations that read PARSEC_TOKEN from --token. To inspect the file:

cat ~/.parsec/credentials

To clear it (sign out):

parsec logout

The CLI does NOT refresh expired ID tokens automatically; when the token expires, run parsec login oidc again.

Verifying the bridge is live

The manifest exposes two fields the operator can query:

curl -s http://localhost:8000/manifest | jq '.payload | {auth_oidc_enabled, auth_oidc_issuer}'

auth_oidc_enabled: true and a populated auth_oidc_issuer confirm parsec discovered the issuer’s JWKS at boot.

Security notes

  • Parsec verifies the token via the IdP’s JWKS only — there is no RFC 7662 token introspection. If the operator’s IdP revokes the token mid-session, parsec will keep accepting it until exp. Operators with strict revocation requirements should configure short exp lifetimes on the IdP side.
  • The OIDC verifier is constructed at boot. The JWKS is re-fetched by the underlying go-oidc library when the cached keys can’t verify a token (key rotation is handled transparently).
  • An operator whose groups don’t match any grant still authenticates but receives an empty scope set — they can read the manifest but not subscribe / publish / manage. Configure a default-deny grant if you want stricter behavior.
  • A deployment without auth.oidc.issuer accepts HMAC tokens only — the OIDC verifier is not constructed and the bearer middleware skips the JWKS path entirely.

Cross-references

Token Broker

The token broker is the policy point for issuing user-facing connection tokens. A backend service authenticates the end user, asks the broker for a token scoped to a list of channels, the broker consults an Authorizer, and emits a JWT the browser uses to connect via WebSocket or HTTP-streaming.

The broker is opt-in: deployments wire Options.TokenBrokerHandler when they want delegated issuance and revocation. The library facade (parsec.New) does not require it — manifest-direct embeddings can mint tokens via auth.Issuer directly.

Routes

When Options.TokenBrokerHandler = broker.Handler() is set, the following routes mount under /parsec:

PathBodyReturns
POST /parsec/tokenIssueRequest { channels, ttl }IssueResponse { token, token_id, expires_at, channels_granted, channels_denied }
POST /parsec/token/delegateDelegateRequest { on_behalf_of, channels, lifetime_seconds }IssueResponse
POST /parsec/revokeRevokeRequest { token_id?, user_id?, reason? }{ "ok": true }

Every route requires Authorization: Bearer <user-token>. The bearer is passed verbatim to the broker’s Authenticator — Parsec does no signature verification on the user-auth bearer.

Delegated issuance

A trusted backend service can mint a token “on behalf of” a different user by POSTing to /parsec/token/delegate. The service’s own bearer is the requester; on_behalf_of names the target user.

Configure Options.DelegateAuthorizer to gate which services may delegate to which users:

b, _ := tokenbroker.New(tokenbroker.Options{
    Issuer:        issuer,
    Authorizer:    authorizer,
    Authenticator: authn,
    DelegateAuthorizer: func(ctx context.Context, svc, target tokenbroker.UserID) error {
        if svc == "svc-notifier" && strings.HasPrefix(string(target), "user-") {
            return nil
        }
        return tokenbroker.ErrUnauthenticated
    },
})

When DelegateAuthorizer is nil, the broker accepts any delegation the Authenticator admits — fine for single-tenant deployments, risky in multi-tenant ones.

The audit log captures both the delegator and the on-behalf-of identity in every entry so investigations can trace who minted what.

Revocation

Token revocation requires two pieces:

  1. A RevocationStore that survives across nodes.
  2. Options.RevocationStore on parsec.New so the subscribe authorizer consults the store before allowing a private subscribe.

Stores

StoreUse case
tokenbroker.NewMemoryRevocations()Single-node dev/test. Entries age out after MaxTTL (default 24h); start the pruner with store.StartPruner(ctx, interval) to reclaim map memory.
tokenbroker.NewRedisRevocations(client)Multi-node production. Revocation entries SET with EX MaxTTL. Tune with WithMaxTTL(d). Keys live under <prefix>:tb:revoked:* and <prefix>:tb:user_revoked_at:*.
store := tokenbroker.NewRedisRevocations(redisClient).
    WithKeyPrefix("parsec").
    WithMaxTTL(36 * time.Hour) // cover the longest mgmt TTL + a margin

b, _ := tokenbroker.New(tokenbroker.Options{
    Issuer:        issuer,
    Authorizer:    authorizer,
    Authenticator: authn,
    Revocations:   store,
})

p, _ := parsec.New(parsec.Options{
    KeyRing:            ring,
    TokenBrokerHandler: b.Handler(),
    RevocationStore:    store, // SAME store — broker and subscribe path share it
})

When Options.RevocationStore is left nil, the broker can still mark tokens revoked in its own copy, but the subscribe authorizer never consults that copy — connections accepted before the revoke continue to subscribe. The manifest exposes revocation_store_enabled: false to flag this gap to operators.

Scopes

  • Single-token revoke (token_id): the named token cannot be used to subscribe again. Re-subscribe attempts return PARSEC_AUTH_DENIED.
  • User blanket revoke (user_id): every token issued to the user before the revoke moment is rejected. Tokens minted after the revoke remain valid — operators who want a full lock-out also need to stop minting new tokens for the user upstream.

The subscribe authorizer reads the token’s jti (added to every access token automatically) and sub claims. Tokens minted before this PR landed do not carry a jti and cannot be single-token revoked; those tokens still age out naturally at their exp claim and the user-blanket revoke still applies.

Manifest exposure

The public Manifest now surfaces both states so SDKs and dashboards can discover broker availability without sniffing routes:

{
  "kind": "parsec.manifest",
  "payload": {
    "token_broker_enabled": true,
    "revocation_store_enabled": true
  }
}

Audit log

Pass an AuditLogger to capture every successful issuance:

b, _ := tokenbroker.New(tokenbroker.Options{
    // ... Issuer / Authorizer / Authenticator
    AuditLog: tokenbroker.AuditLoggerFunc(func(ctx context.Context, e tokenbroker.AuditEntry) {
        slog.Info("token issued",
            "token_id", e.TokenID,
            "user", e.IssuedTo,
            "delegator", e.Delegator,
            "channels", e.Channels,
            "expires_at", e.ExpiresAt,
        )
    }),
})

Audit entries are best-effort observation — they fire after the JWT is signed and the revocation store is updated. A panicking logger will not undo an issuance.

Operator-side CLI

The Twirp service exposes two operator-driven revoke RPCs gated by the mgmt bearer. The CLI is the canonical front door:

# Single token (the access token's jti claim, which is the same value
# the broker returns in IssueResponse.token_id).
$ parsec tokens revoke <token-id> --user <user-id> --reason "compromised"

# Blanket-revoke every token issued to a user before now.
$ parsec tokens revoke-user <user-id> --reason "password reset"

PARSEC_TOKEN (or --token) supplies the mgmt bearer the same way every other parsec subcommand consumes one. The CLI is just a thin wrapper around the RPC; backends that need to call from code should hit RevokeToken / RevokeUser directly via the generated Twirp client.

PARSEC_INVALID_ARGUMENT is returned when Options.RevocationStore is nil — the operator-facing path refuses to silently no-op so a missing wiring is discovered loudly. The user-facing /parsec/revoke HTTP route remains the right surface for end-user-driven flows (e.g. a “sign me out everywhere” button) because it consumes the user’s own bearer via the broker’s Authenticator.

Operational runbook — revoking a compromised token

  1. Reach for the issuance audit log to find the token_id.
  2. parsec tokens revoke <id> --user <user-id> --reason "compromised" (or the raw curl -X POST -H "Authorization: Bearer $MGMT" -d '{"token_id":"<id>","reason":"compromised"}' https://parsec.example.com/twirp/parsec.ParsecService/RevokeToken Twirp call).
  3. Verify the revocation is visible by reissuing a fresh token for the same user — the new token’s token_id will differ and the old one will be rejected on its next subscribe.
  4. If the compromise scope is broader than one token, blanket-revoke the user with parsec tokens revoke-user <id> --reason "...".

Request-Hash Cache

The cache package gives Parsec applications a request-hash cache for sharing computed results across users and sessions. The cache stores envelope.Envelope values (not raw bytes), so a hit looks identical to a fresh computation from the subscriber’s point of view.

Two implementations ship — pick the one that fits the deployment:

BackendUse case
cache.NewMemoryCache(maxEntries)Single-node dev/test or per-process hot path. LRU + per-entry TTL; bounded by maxEntries with a background sweeper.
cache.NewRedisCache(client, prefix)Multi-node production. Shared keyspace under <prefix> (default parsec:cache); per-entry TTL via Redis EX.
cache.NewNoopCache()Explicit opt-out. Every Get misses; writes drop. Useful when Options.RedisClient is set but you don’t want the auto-built Redis cache.

Wiring

import (
    "github.com/frankbardon/parsec"
    "github.com/frankbardon/parsec/cache"
)

p, err := parsec.New(parsec.Options{
    KeyRing: ring,
    // Either pass an explicit cache:
    Cache:       cache.NewMemoryCache(8192),
    // ...or set RedisClient and parsec.New auto-builds a RedisCache:
    // RedisClient: redisClient,
})

Access the wired cache via p.Cache(). When no cache is configured, p.Cache() returns nil — embedders that consume the cache opportunistically should treat that as “caching disabled.”

The manifest exposes both states so SDKs and dashboards can pick the right behavior without sniffing internals:

{
  "kind": "parsec.manifest",
  "payload": {
    "cache_enabled": true,
    "cache_backend": "redis"
  }
}

cache_backend is one of memory, redis, noop, custom, or empty (cache disabled).

Tenant scoping

The cache surface ships paired tenant-scoped methods for every operation — GetForTenant, PutForTenant, DeleteForTenant. Keys are namespaced with <tenantID>: so cross-tenant leakage is impossible by construction when call sites consistently use the tenant-scoped API.

cached, ok, _ := p.Cache().GetForTenant(ctx, tenantID, key)
if !ok {
    env := computeExpensiveResult(ctx, ...)
    _ = p.Cache().PutForTenant(ctx, tenantID, key, env, 5*time.Minute)
    cached = env
}

Metrics

Every cache operation increments the bundled Prometheus collectors:

MetricTypeLabels
parsec_cache_operations_totalcounterop (get/put/delete), result (hit/miss/success/failure)
parsec_cache_size_entriesgaugebackend (memory/redis/noop/custom)

The size_entries gauge is sampled at every Stats() call. Redis caches report their local view (this process’s hits/misses); operators who want a global view should consult Redis’ own INFO memory.

Telemetry adapter

The aggregated /parsec/metrics JSON view picks up cache hit rate via the telemetry.CacheSource. The adapter wires it in one line:

import "github.com/frankbardon/parsec/telemetry"

agg := telemetry.New(
    channelSrc,
    envSrc,
    tokenSrc,
    telemetry.NewCacheSourceFromCache(p.Cache()), // nil-safe
)
opts.TelemetryHandler = agg.Handler()

When p.Cache() is nil, NewCacheSourceFromCache returns nil and the aggregator’s other sources keep working.

Adding a new backend

A custom backend must:

  1. Implement the full cache.Cache interface (Get/Put/Delete plus the three tenant-scoped twins plus Stats).
  2. Return a cache.Stats struct with Hits/Misses/Puts/Evictions/ SizeEntries so the telemetry adapter and the metrics wrapper read stable numbers.
  3. Optionally implement cache.BackendReporter if the backend should identify itself in the manifest as something other than custom.

Pass it to parsec.Options.Cache and the rest of the wiring — manifest exposure, metrics, telemetry — works without further changes.

Observability

Every Parsec deployment exposes three streams: Prometheus metrics at /metrics, OpenTelemetry traces to an OTLP collector, and structured access logs through slog.

The defaults are operator-friendly: metrics are always on; tracing is off until you set an endpoint; the access log uses the Logger you pass into parsec.Options (or slog.Default() if none).

Metrics

Scraping /metrics

The route is mounted on the same HTTP mux as everything else. Set Options.MetricsBearerToken to require Authorization: Bearer <token> on every scrape; leave it empty to run unguarded (firewall the route at the network layer in that case).

curl -H "Authorization: Bearer $METRICS_TOKEN" \
     http://parsec.internal:8000/metrics

The registry includes Go runtime + process collectors by default. To share a registry with the rest of your service, pass Options.MetricsRegistry.

Metric reference

MetricTypeLabelsSource
parsec_publishes_totalcounterchannel_visibility, resultevery Publish call
parsec_publish_duration_secondshistogramchannel_visibilityevery Publish call
parsec_subscribers_activegaugechannel_visibilitybroker subscribe / unsubscribe hooks
parsec_channels_activegaugestate, visibilitysweeper, after every tick
parsec_token_verifications_totalcountertype, resultevery Verifier.Verify
parsec_sink_attempts_totalcountersink, resultevery sink Send
parsec_sink_duration_secondshistogramsinkevery sink Send
parsec_dlq_sizegaugesinkperiodic scrape (sweep interval)
parsec_key_rotations_totalcounteractionGenerateKey / PromoteKey / RetireKey
parsec_rpc_requests_totalcountermethod, statusHTTP middleware (last URL segment)
parsec_rpc_duration_secondshistogrammethodHTTP middleware

result values are success or failure. channel_visibility is public or private (or unknown for the rare race where a name has not been parsed). action for key_rotations_total is one of generated, promoted, retired.

Cardinality contract

Channel names, subject IDs, and bearer tokens are never labels. The label set is bounded by:

  • channel_visibility ∈ {public, private, unknown}
  • result ∈ {success, failure}
  • type ∈ {access, refresh, mgmt, unknown}
  • sink — bounded by the registered sink registry (3 in the default build)
  • state ∈ {open, closed, deleted}
  • method — the last URL segment of a /twirp/parsec.ParsecService/ path; anything outside the prefix is collapsed to the literal non_rpc

If you add a new subsystem with internal state, add a metric or document why none is needed (see CLAUDE.md Update Demand).

Tracing

Set Options.OTLPEndpoint to enable the OTLP HTTP exporter:

parsec.Options{
    OTLPEndpoint: "otel-collector.observability:4318",
}

When the endpoint is empty (the default), internal/tracing.NewTracer returns a no-op tracer that performs no allocations. There is no runtime cost to leaving tracing off.

The service name is baked in as parsec. The exporter uses insecure transport — terminate TLS at your collector or run a sidecar.

Each RPC request gets a server span; sub-spans for token verification, broker publish, and sink delivery are emitted by the corresponding subsystems. Trace context propagates via standard W3C headers — Parsec is a participant, not an originator.

Access logs

The HTTP surface emits one slog INFO line per request:

{
  "time": "2026-05-22T14:03:11.123Z",
  "level": "INFO",
  "msg": "http_request",
  "method": "POST",
  "path": "/twirp/parsec.ParsecService/Publish",
  "status": 200,
  "duration_ms": 7,
  "remote_addr": "203.0.113.42",
  "request_id": "f9e3c0d1...",
  "bearer_subject": "ops-alice",
  "trace_id": "9c5f1a..."
}

Failed token verifications emit a WARN line with a PARSEC_AUTH_* code; the token contents are never logged:

{
  "level": "WARN",
  "msg": "auth_failure",
  "code": "PARSEC_AUTH_EXPIRED",
  "request_id": "f9e3c0d1..."
}

Trusting X-Forwarded-For

Options.TrustedProxies is the list of CIDRs Parsec will honor an X-Forwarded-For chain from. If the immediate TCP peer is not in the list, XFF is ignored and the TCP peer is logged. This prevents any unauthenticated client from spoofing their IP by setting a header.

_, lb, _ := net.ParseCIDR("10.0.0.0/8")
opts := parsec.Options{
    TrustedProxies: []net.IPNet{*lb},
}

Request IDs

The middleware honors X-Request-ID when the client provides one and generates a fresh 24-character hex value otherwise. The chosen ID is echoed back in the response so clients can correlate.

Grafana dashboard

A starter dashboard JSON lives at examples/grafana/parsec-overview.json. It includes panels for:

  • publish rate by visibility
  • sink success ratio
  • active channel inventory
  • token verification failures
  • key rotation activity

Import via the Grafana UI (Dashboards → Import → Upload JSON).

Telemetry alerts and Prometheus exposition

The aggregated /parsec/metrics JSON view can also surface declarative alert firings and a parallel Prometheus text exposition for external scraping. See Telemetry Alerts & Prometheus Exposition for the rule shape, metric reference, and AlertManager wiring.

Telemetry Alerts & Prometheus Exposition

The telemetry package aggregates per-source dashboard metrics into a single Snapshot served as JSON at /parsec/metrics. This page covers two extensions that operators commonly want on top:

  1. Declarative alert rules that fire when an aggregate value crosses a threshold.
  2. Prometheus text exposition of the same Snapshot so an external Prometheus + AlertManager pair can scrape it alongside the process-level /metrics registry.

The existing JSON view at /parsec/metrics is unchanged — alerts attach to the same Aggregator and surface in Snapshot.Alerts.

Alert rules

An AlertRule is a name, severity, description, and a Condition closure that receives the freshly computed Snapshot and returns true when the rule should fire.

import "github.com/frankbardon/parsec/telemetry"

agg := telemetry.New(channelSrc, envSrc, tokenSrc, cacheSrc)
agg, err := agg.WithAlerts([]telemetry.AlertRule{
    {
        Name:        "cache_cold",
        Severity:    telemetry.SeverityWarning,
        Description: "Cache hit rate dropped below 80%",
        Condition: func(s telemetry.Snapshot) bool {
            return s.Cache.HitRatePct < 80
        },
    },
    {
        Name:        "history_pressure",
        Severity:    telemetry.SeverityCritical,
        Description: "History buffers above 90% utilization",
        Condition: func(s telemetry.Snapshot) bool {
            return s.History.BufferUtilizationPct > 90
        },
    },
})
if err != nil {
    log.Fatalf("alert rules: %v", err)
}

opts := parsec.Options{TelemetryHandler: agg.Handler()}

WithAlerts runs ValidateAlertRules first — empty names, unknown severities, nil Condition closures, and duplicate names abort the boot (matching the rest of parsec’s “fail loud on misconfiguration” posture).

Severities

SeverityMeaning
infoThe condition is unusual but does not require action.
warningInvestigate; the system is still serving traffic.
criticalImmediate action — user impact imminent or active.

JSON output

Snapshot.Alerts is omitted from the JSON when empty and rendered as an array of firing rules when not:

{
  "channels": {"total_active": 12},
  "tokens":   {"active_count": 4810},
  "cache":    {"hit_rate_pct": 73.2},
  "alerts": [
    {
      "name": "cache_cold",
      "severity": "warning",
      "description": "Cache hit rate dropped below 80%"
    }
  ],
  "at": "2026-06-06T19:00:00Z"
}

Prometheus exposition

Aggregator.PrometheusHandler() returns an http.Handler that re-aggregates on every scrape and writes the Snapshot in Prometheus text exposition format (version 0.0.4). Mount it next to the JSON handler:

mux := http.NewServeMux()
mux.Handle("/parsec/metrics", agg.Handler())                  // JSON
mux.Handle("/parsec/telemetry/metrics", agg.PrometheusHandler()) // Prometheus

Operators can also wire it as Options.TelemetryHandler directly when they only want the Prometheus view.

Metric reference

MetricTypeLabels
parsec_telemetry_channels_activegauge
parsec_telemetry_channels_by_patterngaugepattern
parsec_telemetry_envelope_rate_per_secondgauge
parsec_telemetry_envelopes_by_aspectgaugeaspect
parsec_telemetry_presence_total_usersgauge
parsec_telemetry_presence_total_agentsgauge
parsec_telemetry_presence_average_per_channelgauge
parsec_telemetry_history_bufferedgauge
parsec_telemetry_history_buffer_utilization_pctgauge
parsec_telemetry_tokens_issued_last_hourgauge
parsec_telemetry_tokens_revoked_last_hourgauge
parsec_telemetry_tokens_activegauge
parsec_telemetry_cache_hit_rate_pctgauge
parsec_telemetry_cache_size_bytesgauge
parsec_telemetry_cache_size_entriesgauge
parsec_telemetry_cache_evictions_last_hourgauge
parsec_telemetry_alerts_firinggaugealert, severity

parsec_telemetry_alerts_firing only emits samples for rules that are currently firing (value 1); a rule that no longer fires simply disappears from the scrape. Operators who want a “0 when not firing” exposition shape should add their own AlertManager rule that uses absent_over_time.

Cardinality discipline

Only bounded labels escape the handler:

  • pattern — the configured channel grammar pattern. The embedder controls this set when constructing the ChannelLister.
  • aspect — the envelope kind reported by EnvelopeCounters.Observe. Constrain it at the call site (data / cursor / heartbeat / etc.).
  • alert — the AlertRule.Name. Operators own the rule list.
  • severity — one of info, warning, critical.

Channel names, subject IDs, and bearer tokens are NEVER labels — matching the existing /metrics cardinality contract.

AlertManager wiring

A starter prometheus.yml snippet:

scrape_configs:
  - job_name: parsec-telemetry
    static_configs:
      - targets: ["parsec.internal:8000"]
    metrics_path: /parsec/telemetry/metrics

rule_files:
  - parsec_telemetry_rules.yml

A matching parsec_telemetry_rules.yml:

groups:
  - name: parsec-telemetry
    rules:
      - alert: ParsecCacheCold
        expr: parsec_telemetry_alerts_firing{alert="cache_cold"} == 1
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "Parsec cache hit rate below threshold"
      - alert: ParsecHistoryPressure
        expr: parsec_telemetry_alerts_firing{alert="history_pressure"} == 1
        for: 2m
        labels:
          severity: critical

Because the alert metric already encodes the rule semantics, the Prometheus rule simply checks for the gauge’s presence. Operators who want richer alerting can run additional expr rules over the underlying gauges (parsec_telemetry_cache_hit_rate_pct < 80) instead of the firing gauge.

Sink dead-letter queue (DLQ)

Sinks transparently retry transient failures with exponential backoff + jitter. Anything that survives the retry budget — or is classified as terminal from the first call — lands in the DLQ. An operator inspects, replays, or discards items from there.

Backends

BackendWhen it activatesPersistence
memoryOptions.RedisClient == nilProcess-local; lost on restart
redisOptions.RedisClient != nil (or --redis-addr)Redis Streams, one stream per sink (parsec:dlq:<sink>)

The backend in use is reported in the dlq_backend field of parsec --json.

Retry policy

Default config (overridable via Options.SinkRetry or Options.PerSinkRetry):

FieldDefault
MaxAttempts5
BaseBackoff1s
MaxBackoff30s
JitterFraction0.2

Backoff per attempt is BaseBackoff * 2^(attempt-2) capped at MaxBackoff, multiplied by uniform(1 - JitterFraction, 1 + JitterFraction) to break thundering herds.

Set MaxAttempts = 1 to disable retries (terminal-or-DLQ on first error).

CLI

parsec dlq list <sink>            # newest first
parsec dlq count <sink>           # XLEN
parsec dlq discard <id>           # XDEL
parsec dlq replay <id>            # re-run through the original sink

Every command honors --server and --token (or PARSEC_SERVER / PARSEC_TOKEN).

Inspecting items

$ parsec dlq list email --limit 10
{
  "format_version": "1.0",
  "kind": "parsec.dlq.list",
  "payload": {
    "sink": "email",
    "items": [
      {
        "id": "1741310221012-0",
        "sink": "email",
        "at": "2026-05-22T18:30:21Z",
        "subject": "Order shipped",
        "body": "Your package is on the way.",
        "attempts": 5,
        "last_error": "smtp send: 451 4.4.5 Temporary error",
        "recipient": {"address": "alice@example.com"}
      }
    ]
  }
}

Replaying

Replay reruns the stored item through the original sink. If the sink is wrapped by a Retrier (the default), the replay itself retries up to MaxAttempts. If it fails again it lands back in the DLQ as a new item — the original entry stays in place so you can compare attempt counts.

$ parsec dlq replay 1741310221012-0
{ "kind": "parsec.dlq.replayed", "payload": {"id": "1741310221012-0"} }

Discarding

discard is final. Use it when the payload is irrelevant or the failure was diagnosed and the item is unrecoverable.

$ parsec dlq discard 1741310221012-0

RPC

MethodRequestResponse
DlqList{sink, limit}{items[]}
DlqCount{sink}{count}
DlqDiscard{id}Empty
DlqReplay{id}Empty

All methods require a mgmt bearer.

Stream growth & retention

The Redis backend uses XADD MAXLEN ~ 10000 by default — old entries are trimmed approximately when the stream grows past the cap. Override via RedisDLQ.WithMaxLen(n) for tighter or looser retention.

Error taxonomy

CodeMeaning
PARSEC_SINK_UNAVAILABLETransient sink failure (retry will run)
PARSEC_SINK_DLQ_OVERFLOWRetries exhausted and DLQ push itself failed
PARSEC_SINK_DLQ_NOT_FOUNDReplay/Discard referenced an unknown id
PARSEC_SINK_CONFIG_INVALIDSink misconfigured at registration time

PublishOrSink returns err == nil when the DLQ accepted the failed payload — the failure has been recorded. It returns an error only when the DLQ itself failed (overflow / Redis unreachable).

Rate Limiting

Parsec gates three abusable ingress points with configurable budgets:

BucketWhat it gatesDefault key
publishThe Publish RPCmgmt-bearer subject
subscribeCentrifuge subscribe attemptsuserID, when authenticated
token-issueThe RefreshToken RPCremote IP

When a bucket is exhausted the caller receives a PARSEC_RATE_LIMITED coded error, rendered as Twirp’s resource_exhausted and HTTP 429 with an advisory Reset window. Single-node mode uses an in-process token bucket; multi-node mode (when Options.RedisClient is set) shares the budget across nodes via a Redis sorted-set sliding window driven by a single-round-trip Lua script.

Quick start (CLI)

parsec serve \
  --publish-rate 100/s --publish-burst 200 \
  --subscribe-rate 20/s \
  --token-issue-rate 5/s

The shorthand format is <integer>/<window>. The window accepts the unit aliases s, m, h, plus any duration time.ParseDuration understands (500ms, 2m30s). An empty rate string disables that bucket.

Environment-variable equivalents (for container deployments):

PARSEC_PUBLISH_RATE=100/s
PARSEC_PUBLISH_BURST=200
PARSEC_SUBSCRIBE_RATE=20/s
PARSEC_TOKEN_ISSUE_RATE=5/s

Library configuration

import "github.com/frankbardon/parsec/ratelimit"

p, _ := parsec.New(parsec.Options{
    RateLimits: ratelimit.RateLimits{
        Publish:    ratelimit.Limit{Rate: 100, Per: time.Second, Burst: 200},
        Subscribe:  ratelimit.Limit{Rate: 20,  Per: time.Second},
        TokenIssue: ratelimit.Limit{Rate: 5,   Per: time.Second},
    },
})

Pass Options.Limiter to plug in a custom backend (e.g. a token-bucket shared with another service). When Limiter is nil and any bucket has Rate > 0, Parsec constructs:

  • ratelimit.RedisLimiter when Options.RedisClient is set
  • ratelimit.MemoryLimiter otherwise

Cross-node behaviour

Two Parsec nodes pointing at the same Redis (and sharing Options.RedisKeyPrefix) consume one global budget. The Lua script is EVALSHA’d on first use and reloaded automatically on NOSCRIPT. Keys take the form <prefix>:rl:<bucket>:<subject> and inherit PEXPIRE(window + 10s) so abandoned buckets free themselves.

Per-token override

A mgmt token may carry an rl claim that overrides the operator default for that subject. Mint such a token with Issuer.IssuePairWithRateLimit:

override := ratelimit.Limit{Rate: 10, Per: time.Second}
pair, _ := p.Issuer().IssuePairWithRateLimit(sub, channel, ttl, override)

Server-side, the override is automatically picked up from the bearer’s claims on every gated call. The default is still applied to subjects without an override.

Overrides only narrow the budget — they cannot widen it past the operator-configured ceiling for an unauthenticated path (the token-issue bucket keys off IP, not the subject).

Per-channel publish ceilings

Operators can tighten (or loosen) the publish bucket for individual channels or families of channels by mapping a channel-name pattern to a Limit. The library option is PerChannelPublishLimits:

parsec.New(parsec.Options{
    RateLimits: ratelimit.RateLimits{
        Publish: ratelimit.Limit{Rate: 100, Per: time.Second, Burst: 200},
    },
    PerChannelPublishLimits: map[string]ratelimit.Limit{
        // Internal metrics fanout — drown the broker without
        // affecting other publishers.
        "public:hot.metrics.**": {Rate: 500, Per: time.Second},
        // Sensitive admin broadcasts — clamp to a handful per minute.
        "private:admin.alerts.**": {Rate: 5, Per: time.Minute},
    },
})

The pattern grammar is the channel-name grammar from channels.ParsePattern (* matches one segment, trailing ** matches any remaining segments). Invalid patterns abort parsec.New with PARSEC_INVALID_ARGUMENT so a typo never silently disables a rule.

Rule selection: the most specific matching rule wins (literal segments score 4, single-star segments 1, trailing ** 0; ties broken by pattern string ascending). The selected rule’s Limit overrides the default Publish budget for the call, and the bucket key is namespaced publish-channel:<pattern>:<subject> so two rules on disjoint channels never share a budget. Channels that match no rule fall through to the default Publish bucket (publish:<subject>).

Per-token overrides still apply on top: auth.Claims.RateLimitOverride beats both the rule and the default. Use this when a single tenant needs a tighter or looser ceiling on top of a global rule.

Per-channel subscribe ceilings

Subscribe-side limits use the same shape, configured via PerChannelSubscribeLimits:

parsec.New(parsec.Options{
    RateLimits: ratelimit.RateLimits{
        Subscribe: ratelimit.Limit{Rate: 50, Per: time.Second},
    },
    PerChannelSubscribeLimits: map[string]ratelimit.Limit{
        // Tenant-scoped channel — one subscribe per second per user.
        "private:webapp.tenant.**":    {Rate: 1, Per: time.Second},
        // High-frequency presence channel — relax for embed widgets.
        "public:status.heartbeat.**":  {Rate: 200, Per: time.Second},
    },
})

Rule selection matches the publish path (most-specific wins; literal

single-star > trailing **; ties broken by pattern string). The bucket key is namespaced subscribe-channel:<pattern>:<userID>; channels matching no rule fall through to subscribe:<userID> with the global Subscribe budget. Anonymous subscribes (userID empty because the transport never authenticated the connection) are not charged — operators wanting per-IP gating for unauthenticated traffic should enforce it at L7. The subscribe gate runs before token verification so a flood of bad-token attempts cannot exhaust CPU on HMAC verifies.

Per-token overrides are not consulted on the subscribe path: the access token’s subject is the rate-limit key, and a token cannot loosen the operator-configured budget for its own subject. To grant a single user a tighter (or looser) subscribe budget than the rule allows, encode the tenant in the channel name and add a more-specific rule.

Manifest discovery

The Manifest RPC exposes the default budgets:

{
  "rate_limits": {
    "publish":     {"rate": 100, "per": "1s", "burst": 200},
    "subscribe":   {"rate": 20,  "per": "1s", "burst": 20},
    "token_issue": {"rate": 5,   "per": "1s", "burst": 5}
  }
}

Per-channel rules are appended under per_channel_publish and per_channel_subscribe (each omitted when none configured):

{
  "rate_limits": {
    "publish":     {"rate": 100, "per": "1s", "burst": 200},
    "subscribe":   {"rate": 20,  "per": "1s", "burst": 20},
    "token_issue": {"rate": 5,   "per": "1s", "burst": 5},
    "per_channel_publish": [
      {"pattern": "public:hot.metrics.**", "limit": {"rate": 500, "per": "1s", "burst": 500}}
    ],
    "per_channel_subscribe": [
      {"pattern": "private:webapp.tenant.**", "limit": {"rate": 1, "per": "1s", "burst": 1}}
    ]
  }
}

Per-token overrides are private to the issued token and never surface in the manifest.

Metrics

The parsec_rate_limit_decisions_total{bucket,result} counter increments on every decision. bucket is publish, subscribe, or token-issue; result is allowed or denied. Alert on rate(parsec_rate_limit_decisions_total{result="denied"}[1m]) > 0 to catch abusive callers.

Tuning

  • Burst > Rate. Configure burst ~2x rate for bursty human-driven workloads; keep them equal for machine workloads where rate violations indicate misconfiguration.
  • Per-IP token-issue. The refresh endpoint has no mgmt bearer, so the IP is the only stable key. If Parsec sits behind a load balancer, put a complementary L7 rate limit on the LB and keep the parsec budget loose — the LB sees the real client IP, parsec sees only the LB.
  • Single-node fallback. MemoryLimiter is fine for dev and small deployments. Distinct keys live in a shared map; call Sweep periodically (or rely on the implicit drop-on-touch) if your key cardinality is large.

Failure modes

  • Redis unreachable. A failed Allow returns the underlying redis error, which the RPC layer translates to PARSEC_INTERNAL (HTTP 500). The publish/subscribe paths fail closed — operators should monitor parsec_rate_limit_decisions_total divergence from request totals to detect this case.
  • Mixed-version clusters. EVALSHA / NOSCRIPT fallback handles rolling upgrades automatically.

WebTransport (HTTP/3)

Parsec ships an optional HTTP/3 WebTransport listener alongside the default WebSocket transport. Both transports terminate at the same centrifuge.Node, so auth, channel state, and subscribe authorization behave identically — only the wire layer differs.

Use WebTransport when you want:

  • Lower head-of-line blocking under packet loss (QUIC streams vs. one TCP connection).
  • Faster reconnects across mobile network changes (connection migration).
  • A drop-in path for browsers on networks that block long-lived WebSocket upgrades.

WebTransport requires TLS — there is no plaintext mode. Browsers refuse to upgrade an HTTP/3 transport without a valid certificate (or a known SPKI hash for local development).

Enabling the listener

Library

p, err := parsec.New(parsec.Options{
    StateDir: "/var/lib/parsec",
    WebTransport: parsec.WebTransportOptions{
        Addr:        ":8443",                              // UDP listen
        TLSCertFile: "/etc/parsec/tls/server.crt",
        TLSKeyFile:  "/etc/parsec/tls/server.key",
        AllowedOrigins: []string{
            "https://app.example.com",
        },
    },
})

AllowedOrigins is the WT-level CORS gate. An empty slice means “allow all” — appropriate for local dev only.

YAML

server:
  addr: ":8000"
  webtransport:
    addr: ":8443"
    tls_cert_file: /etc/parsec/tls/server.crt
    tls_key_file: /etc/parsec/tls/server.key
    allowed_origins:
      - https://app.example.com

CLI

parsec serve \
  --addr :8000 \
  --webtransport-addr :8443 \
  --webtransport-cert /etc/parsec/tls/server.crt \
  --webtransport-key  /etc/parsec/tls/server.key

The Twirp listener stays on :8000. The WT listener binds UDP on :8443. Both share the same Parsec instance.

Path and protocol

WT clients connect to https://<host>:<wt-port>/connection/webtransport. The handler upgrades the request to a WebTransport session and accepts a single bidirectional stream that carries length-prefixed framed centrifuge protocol messages — the same payload shape used over WebSocket.

The manifest exposes the available transports:

{
  "transports": ["websocket", "webtransport"]
}

Browser clients that detect webtransport in the manifest can prefer HTTP/3 and fall back to WebSocket on unsupported networks.

Operating notes

  • WebTransport listens on UDP — open the firewall for the chosen port (default UDP 8443) in addition to the TCP port serving Twirp / WebSocket.
  • Some L7 load balancers do not yet proxy HTTP/3. Terminate TLS at the parsec process (or at a QUIC-aware proxy) until your edge supports it.
  • Certificate rotation is identical to any HTTP/3 service: write a new cert pair and restart the process. There is no hot-reload yet.
  • The WT transport uses the same access tokens as WebSocket. Clients open a bidi stream with the access token as the first frame; subscribe authorization runs through auth.SubscribeAuthorizer, so deny-wins scope evaluation behaves identically.

Observability

Per-transport counters are emitted on the standard collectors:

parsec_transport_connections_total{transport="webtransport", result="ok"}
parsec_transport_connections_total{transport="webtransport", result="rejected"}

Access logs include transport=webtransport for the upgrade request. The underlying QUIC handshake is not logged — operators wanting that detail should enable quic-go’s tracer in a future release.

Disabling

Omit WebTransportOptions.Addr (or the YAML server.webtransport block) and the entire HTTP/3 stack is excluded — no UDP listener, no extra goroutines. The manifest then advertises "transports": ["websocket"] only.

Limitations

  • No automatic certificate rotation (process restart required).
  • Centrifuge OSS does not ship a WebTransport handler upstream; parsec implements wtTransport directly against centrifuge.Transport. Behavior matches the WebSocket transport but the implementation is parsec-local — file issues here, not against the upstream project.
  • quic-go requires Go 1.22+ (parsec targets 1.26.1) and ALSO requires a kernel with sufficient UDP socket buffer space on Linux — the net.core.rmem_max sysctl may need bumping for high-throughput deployments.

HTTP-Streaming Transport

Parsec mounts a bidirectional HTTP-streaming endpoint at /connection/http_stream alongside the WebSocket transport. Both transports terminate at the same centrifuge.Node, so auth, channel state, and subscribe authorization behave identically — only the wire layer differs.

Use HTTP-streaming when:

  • A client network blocks WebSocket upgrades (corporate proxies, certain mobile carriers) but still allows long-lived HTTP/1.1 or HTTP/2 POST responses.
  • You want an emulation transport for environments where WebSocket libraries aren’t available (e.g. minimal HTTP-only clients, serverless runtimes).
  • You need a parallel fallback to WebSocket without standing up HTTP/3 + TLS for WebTransport.

Not the same as /sse. The /sse endpoint is a tiny polling-backed Server-Sent Events probe used by parsec subscribe — it is not a production transport. /connection/http_stream is a full bidirectional centrifuge transport that production SDKs use.

Wire format

DirectionShape
Client → serverA single POST body carrying the connect command (newline-delimited JSON, or octet-protobuf when Content-Type: application/octet-stream).
Server → clientStreaming response body; newline-delimited JSON frames (or framed protobuf for the binary protocol) until the client disconnects or the server closes.

The handler sets the standard streaming response headers so reverse proxies don’t buffer:

Cache-Control: private, no-cache, no-store, must-revalidate, max-age=0
X-Accel-Buffering: no
Pragma: no-cache
Expire: 0

CORS preflight (OPTIONS) returns 204 No Content with Access-Control-Allow-Methods: POST, OPTIONS so browser clients can POST cross-origin.

Connecting from centrifuge-js

import { Centrifuge } from 'centrifuge';

const client = new Centrifuge({
  transports: [
    { transport: 'websocket', endpoint: 'wss://parsec.example.com/connection/websocket' },
    { transport: 'http_stream', endpoint: 'https://parsec.example.com/connection/http_stream' },
  ],
  // Token comes from your auth flow — see docs/src/library/options.md.
  token: accessToken,
});

client.on('connected', (ctx) => console.log('transport:', ctx.transport));
client.connect();

The SDK falls back through the transports list in order: WebSocket first, then HTTP-streaming when the upgrade is refused.

Manifest exposure

The transports field of the public Manifest advertises http_stream so clients can discover availability:

{
  "kind": "parsec.manifest",
  "format_version": "1",
  "payload": {
    "transports": ["websocket", "http_stream"]
  }
}

When WebTransport.Addr is also set, the list becomes ["websocket", "http_stream", "webtransport"].

Tuning

The handler accepts a centrifuge.HTTPStreamConfig (see the centrifuge module). Parsec uses the zero value today — defaults are fine for almost every deployment:

  • MaxRequestBodySize defaults to 64 KiB.
  • PingPongConfig defaults to the node-wide ping/pong settings.

If you need to tighten the request-body cap, open an issue — we’ll plumb the knob through parsec.Options.

Observability

HTTP-streaming connections appear in:

  • /metrics — the existing centrifuge collectors (centrifuge_num_clients, centrifuge_transport_messages_*) count http-stream sessions the same way they count WebSocket sessions.
  • Access log — every POST to /connection/http_stream lands one structured INFO line with method=POST, path=/connection/http_stream, duration_ms (the full stream lifetime), bearer_subject (when the connect command carried a token).

Delta compression

Parsec exposes Centrifuge’s per-channel fossil-delta encoding as an opt-in flag. When enabled, the broker ships only the diff between the previous publication and the current one — the wire payload shrinks dramatically for high-frequency channels with small mutations (scoreboards, ticker feeds, partial state snapshots).

When to enable

Enable delta when all of the following hold:

  • The channel publishes frequently (multiple times per second).
  • Successive payloads share most of their bytes (incremental counters, small JSON patches, append-only logs).
  • Subscribers can tolerate a one-message-out-of-order tail being recomputed from the prior payload.

Do not enable delta for:

  • Sparse, infrequent publications (no compression headroom; the encoder overhead is pure cost).
  • Mostly-unique payloads (deltas approach the full payload size — slower than no encoding at all).
  • Channels where individual messages must remain self-contained on the wire (audit feeds, signed payloads).

Library API

ch, _ := p.OpenPublic("public:scoreboard.counter.global", time.Hour)
_ = p.Manager().SetDelta(ch.Name, true)

SetDelta(name, true) flips the channel’s DeltaEnabled flag in the store. The broker reads the flag on every publish and chooses between the plain and delta wire format. Toggling is hot — no reconnect required.

Channel.DeltaEnabled is exposed on parsec channels get and through the Twirp GetChannel RPC so operators can audit the state.

CLI

parsec channels open public:scoreboard.counter.global --delta
parsec channels create private:agent.analysis.{job}.progress --delta --subject job-42

For an already-open channel:

parsec channels set-delta public:scoreboard.counter.global --on
parsec channels set-delta public:scoreboard.counter.global --off

How it works

Parsec sets centrifuge’s WithDelta(true) publish option and configures the Node with AllowedDeltaTypes: []DeltaType{DeltaTypeFossil}. The encoder is the fossil-delta algorithm shipped in centrifuge OSS — parsec adds the channel-level toggle and persistence; the wire encoding is upstream.

Subscribers negotiate delta support during the connect handshake. A client that does NOT advertise fossil support receives the full payload — the broker degrades gracefully, never producing a delta the client cannot decode.

Manifest exposure

{
  "delta_compression": "supported"
}

The field reports parsec capability, not per-channel state. Operators inspecting individual channels see the flag on Channel.DeltaEnabled.

Multi-node coherence

When the Redis broker is active, the per-channel delta flag rides on the channel registry HASH (parsec:ch:<name>delta=1). Every node reads the flag before publishing, so a delta channel stays delta across the whole cluster without re-issuing tokens.

The SetDelta event also rides the channel event bus so cached Channel snapshots stay in sync — clients of Manager.Subscribe see a updated event whenever the flag flips.

Observability

parsec_publications_total{visibility="public", delta="true"}
parsec_publications_total{visibility="public", delta="false"}

The label is bounded to true / false so cardinality stays flat.

Caveats

  • Delta is per-channel, not per-subscriber. A channel either ships deltas to clients that support fossil OR it does not.
  • The encoder holds the previous payload in memory per channel — for very wide payloads (>1 MB) the memory overhead can matter; profile before enabling.
  • History playback still ships full payloads — deltas are a transport optimization, not a storage format. Late subscribers receive the ground truth.

Multi-Region Deployments

Parsec is a single-region service by default. The minimum tooling needed to run N regions and keep the auth surface coherent ships in the box — every region runs its own self-contained Parsec cluster (broker, manager, DLQ, rate limiter, Redis) and clients connect to their nearest one. Tokens issued in us-east must verify in eu-west; a key retired in one region must stop accepting tokens in every other region within a second.

Full active-active broker replication (cross-region channel history, conflict resolution, CRDT-Redis) is deferred to v0.4+. This page covers what you can do today.

When NOT to use multi-region

Single-region is almost always simpler. Use it when:

  • Your client base is regional (e.g. a US-only app).
  • Cross-region latency for the auth surface is acceptable. A US-based client doing one token issue per session can tolerate a 200ms round-trip to a single Parsec cluster.
  • You do not yet have a regional Redis fleet. Parsec multi-region assumes each region runs its own Redis (and that you operate one).

Add a second region when:

  • p95 connect/subscribe latency is dominated by the trans-oceanic hop. Clients on three continents will feel it.
  • You have a regulatory requirement to keep customer traffic local (e.g. EU data residency for EU users).
  • You already run a multi-region service mesh and want Parsec to live next to your existing surfaces.

Topology

                           ┌────────────────┐
                           │   Operator     │
                           │   (you)        │
                           └───────┬────────┘
                                   │  parsec keys retire k-old
                                   │  parsec keys export | scp …
                                   │
              ┌────────────────────┼────────────────────┐
              ▼                    ▼                    ▼
        ┌──────────┐         ┌──────────┐         ┌──────────┐
        │ us-east  │         │ eu-west  │         │ ap-south │
        │ Parsec   │         │ Parsec   │         │ Parsec   │
        │   ↕      │         │   ↕      │         │   ↕      │
        │ Redis    │         │ Redis    │         │ Redis    │
        └──────────┘         └──────────┘         └──────────┘
              ▲                    ▲                    ▲
              │                    │                    │
              └─── US clients      │                    │
                                EU clients         APAC clients

Each region is independent for channels, presence, history, sinks, and DLQ. The only cross-region coherence point is the keyring — every region must verify the same kid set so a token minted in us-east verifies in eu-west.

Configuration

Region tag

Options.Region (or region: in the YAML config) labels every Prometheus metric and every access-log line with the operator’s region name. Empty value (single-region default) attaches no label — the scrape and log shape stays lean for single-region deployments.

region: us-east
peers:
  - https://parsec.eu-west.example.com:8000
  - https://parsec.ap-south.example.com:8000

peers: is informational — Parsec does not call into peers automatically. The list surfaces in the manifest so operators (and tooling like parsec-keys-sync) can discover topology.

Prometheus example query (after configuring two regions):

sum by (region) (rate(parsec_publishes_total[1m]))

Backward compatibility

Existing single-region deployments need no changes. The new fields default to empty / nil, the manifest omits them (omitempty), and the metrics scrape stays unlabeled.

Sharing keyrings between regions

Three patterns are supported. Pick based on your network topology.

Pattern 1 — Push via the CLI

The operator generates and promotes a key in region A, exports the ring, and imports it into every peer.

# Region A (the source)
parsec keys generate
parsec keys promote k-abcdef123456
parsec keys export --state-dir /var/lib/parsec > /tmp/keyring.json

# For every peer region
scp /tmp/keyring.json eu-west:/tmp/
ssh eu-west 'parsec keys import --state-dir /var/lib/parsec < /tmp/keyring.json'

For Redis-backed deployments:

parsec keys export --redis-addr us-east-redis:6379 > /tmp/keyring.json
parsec keys import --redis-addr eu-west-redis:6379 < /tmp/keyring.json

keys import calls KeyRingStore.Save which (for Redis) bumps the version key and publishes the rotation event, so every parsec node in the target region reloads through its existing watch goroutine.

Collision safety

keys import refuses to overwrite an existing kid whose secret differs:

PARSEC_INVALID_ARGUMENT: key id "k-abcdef" present locally with a
different secret; pass --force to overwrite

This protects against the “two regions independently generated the same kid” pathological case. Pass --force only when you intend to overwrite — for example, when migrating from independently-bootstrapped regions to a shared ring.

Pattern 2 — Push via the notify hook

When you retire a key, propagate the retirement to every peer in one command:

parsec keys retire k-old \
  --notify https://parsec.eu-west.example.com:8000 \
  --notify https://parsec.ap-south.example.com:8000

The CLI does the local retire first, then POSTs to each peer’s /twirp/parsec.ParsecService/ReloadKeys with the operator’s current bearer (so peers must trust the same kid via a shared keyring — usually via Pattern 1 first, or via a shared OIDC issuer from Phase 21).

Individual peer failures log a warning but do not roll back the local retire — propagation is best-effort. The envelope output includes a notified array with per-peer status so you can spot which region drifted.

Pattern 3 — Pull via parsec-keys-sync

Run the standalone parsec-keys-sync daemon next to each region’s Redis. It subscribes to the source region’s <prefix>:keyring:events pub/sub channel and re-publishes every payload to every target region’s Redis.

parsec-keys-sync \
  --from us-east-redis:6379 \
  --to eu-west-redis:6379 \
  --to ap-south-redis:6379 \
  --key-prefix parsec

The daemon is stateless and idempotent — re-publishing an already- applied version is a no-op on the target side (the target’s parsec nodes load the snapshot and see no change). Restart the daemon with the same flags after any crash; there’s nothing to recover.

A common deployment is one daemon per source region, each forwarding to every other region. With three regions you run three daemons, each with two --to targets.

Operating notes

  • The daemon is a separate binary at cmd/parsec-keys-sync/. Build it from the parsec repo: go build -o bin/parsec-keys-sync ./cmd/parsec-keys-sync.
  • Logs every forwarded event at INFO. Forwarding failures log at WARN and the daemon keeps running — a flapping target does not affect the others.
  • SIGTERM / SIGINT cause a graceful shutdown (cancel context, unwind the subscriber, exit 0).
  • The daemon needs network access to all Redis instances. Place it accordingly in your VPC peering / firewall rules.

Push vs Pull — picking a pattern

QuestionUse
Do operators have a shell on every region?Push
Do you want a single command to propagate retires?Push
Are regional Redis instances on a shared L7 mesh?Pull
Do you want automated continuous mirroring?Pull
Can you tolerate operator-driven manual propagation?Push

The two patterns are complementary. A common combination: bootstrap with Pattern 1 (single export+import on day 1), then run Pattern 3 continuously so rotations propagate automatically.

Latency considerations

  • Key rotation propagates at the speed of your slowest link. Pattern 2 (notify) is HTTP RPC over your existing load balancers; expect 50ms to 300ms depending on geography. Pattern 3 (keys-sync) is a Redis publish; same-DC publishes complete in microseconds, trans-oceanic publishes in 100-200ms.
  • Token verification is local — the verifying region never reaches across to the issuing region. As long as both regions have the same kid in their ring (via export/import or keys-sync), a token minted in us-east verifies in eu-west with zero cross-region latency.
  • Connect / subscribe / publish are entirely region-local. There is no broker replication; a publish to public:foo.bar in us-east is not seen by subscribers in eu-west. Clients that need cross- region fanout should subscribe to the same logical channel on every region they care about, or wait for v0.4 active-active replication.

Failure modes

ScenarioBehavior
One region’s Parsec is downClients re-route to the next closest region (operator’s load balancer / DNS). Tokens still verify there.
Peer region unreachable during --notifyThe local retire still succeeds. The unreachable region keeps verifying the old kid until reachable + reloaded.
parsec-keys-sync crashesNo data loss — restart with the same flags. Rotations missed during downtime can be triggered manually with parsec keys reload on the target region.
Two regions promote different keysThe last import wins. Prefer Pattern 1 (operator-driven) for promotion; promotion is a deliberate operator action, not a rotation event.

Out of scope

  • Active-active broker replication (channel state, history, presence). Deferred to v0.4+.
  • DNS-based region routing — handle with your existing load balancer or CDN (GeoDNS, AWS Global Accelerator, Cloudflare Workers, etc.).
  • Region failover automation — handle with your existing orchestrator (Kubernetes Service mesh, Consul, etc.).
  • Cross-region rate-limit coordination. Each region’s Redis hosts its own rate-limit state; a publisher hitting two regions could in theory exceed the configured rate. For most deployments this is acceptable — the per-region ceiling is still enforced.

Admin UI

Parsec ships an embedded single-page application at /admin for operator-grade diagnosis. This is not an end-user surface. The UI is intentionally minimal — vanilla HTML, JavaScript and CSS, no build pipeline, no frameworks, served from embed.FS.

The admin UI is off by default. Operators opt in per deployment. When disabled the entire /admin/* tree returns 404, so no bytes from the asset bundle reach the response graph.

Enabling

YAML:

server:
  admin_ui:
    enabled: true

CLI:

parsec serve --admin-ui

Env:

PARSEC_ADMIN_UI=true parsec serve

The CLI flag overrides the YAML setting when explicitly passed.

Confirming it’s on

Hit /manifest; look for admin_ui_enabled: true:

curl -s http://localhost:8000/manifest | jq '.payload.admin_ui_enabled'

Pages

PathPurpose
/admin/Landing page + raw manifest dump
/admin/login.htmlOperator pastes a mgmt bearer; stored in sessionStorage
/admin/channels.htmlList channels; open public; create private; delete
/admin/keys.htmlList keys; generate; promote; retire
/admin/dlq.htmlSink-scoped DLQ inspection; replay or discard items
/admin/limits.htmlRead-only view of configured rate-limit budgets

Auth model

The UI is a thin wrapper over the Twirp surface. Every action invokes the same POST /twirp/parsec.ParsecService/<Method> route a curl operator would call, with the mgmt bearer attached as Authorization: Bearer <token>. The bearer lives in sessionStorage which the browser clears when the tab closes — so leaving the UI open is identical to leaving a shell with PARSEC_TOKEN exported.

The login form itself is plain static HTML; the Twirp calls enforce the bearer, not the asset routes. A 401 from any RPC redirects the page back to login.html and clears the stored token.

Layout sketch

+--------------------------------------------------------------+
| parsec admin                                                 |
+--------------------------------------------------------------+
| Channels  Keys  DLQ  Limits                       sign out   |
+--------------------------------------------------------------+
| Operator-grade diagnostic UI. Pick a section above.          |
|                                                              |
| {                                                            |
|   "format_version": "1.0",                                   |
|   "kind": "parsec.manifest",                                 |
|   "payload": {                                               |
|     "service": "parsec",                                     |
|     "active_key_id": "k-...",                                |
|     "admin_ui_enabled": true,                                |
|     ...                                                      |
|   }                                                          |
| }                                                            |
+--------------------------------------------------------------+

Limits

  • No WebSocket subscriber view — the UI uses the Twirp surface only.
  • No multi-user roles inside the UI. The mgmt bearer IS the role. Want different views for different operators? Mint mgmt tokens with pattern scopes and let the bearer’s grants decide what each call succeeds at.
  • No SSO from the UI. Use the OIDC bridge to obtain a mgmt bearer outside the UI and paste it in.
  • No history. Refresh-survival of state is not a goal — the UI is a diagnostic, not a console.

When to use it

  • A pager fires; you want to see channel state without curl + jq.
  • You’re rotating keys and want to confirm the new key landed.
  • You suspect a sink is backed up; you want a quick DLQ peek.
  • You’re showing parsec to a colleague and screenshots beat shell.

When not to use it:

  • Building a UI for application users. Use the websocket transport
    • the issued credentials. The admin UI is operator-only.
  • Automating any workflow. Use the CLI or RPC directly.
  • Production deployments that don’t want the bytes on the wire. Leave server.admin_ui.enabled at its default false.

Troubleshooting

Common error codes and what to do about them.

# When in doubt, bump the log level and re-run the failing command.
parsec serve --addr :8000 --state-dir /var/lib/parsec
# Use the LOG_LEVEL env var (the slog default handler honors it) for
# debug-level structured output.
LOG_LEVEL=debug parsec serve --addr :8000

Every error that crosses a package boundary carries a PARSEC_* code. The codes are stable; the messages are not. Match on the code.

PARSEC_AUTH_DENIED

The mgmt bearer is missing, malformed, or signed by a key the ring no longer trusts. Causes, in order of likelihood:

  1. You forgot --token / PARSEC_TOKEN. Set it and retry.
  2. The token’s kid resolves to a retired key. Mint a fresh bearer: parsec tokens mgmt.
  3. The token’s alg or typ claim was tampered with. Parsec only accepts HS256 / JWT with a kid.
  4. The keyring was rotated and the old key retired. See key rotation.

Note: a closed channel returns PARSEC_CHANNEL_CLOSED, NOT PARSEC_AUTH_DENIED. If you see denied on a subscribe, the token is the problem.

PARSEC_AUTH_EXPIRED

The token’s exp claim is in the past. Mint a new one. For access tokens, use the refresh flow:

parsec tokens refresh "<refresh-token>"

For mgmt tokens, mint a fresh bearer under the active key:

parsec tokens mgmt --ttl 24h

PARSEC_CHANNEL_INVALID

The name does not match the grammar enforced by channels.ParseName. See naming. Frequent gotchas:

  • Missing public: / private: prefix.
  • Uppercase or non-ASCII components.
  • A : inside the body (the colon is reserved for the visibility prefix).
  • Private channel without an id segment.

PARSEC_CHANNEL_NOT_FOUND

The channel either:

  • Was never opened.
  • Was a private channel whose TTL elapsed and was reaped on the next sweep (default sweep interval: 30s).
  • Was deleted explicitly.
  • Lived in a process that has since restarted (channel state is in-memory).

Reopen public channels with channels open; private channels need to be re-created and a fresh token pair handed out.

PARSEC_CHANNEL_CLOSED

Public channel whose inactivity TTL elapsed. The record is still there; new subscribes are refused. Reopen:

parsec channels open public:<name> --ttl 1h

This resets LastActive and emits a fresh opened event.

PARSEC_CHANNEL_EXISTS

You called channels create on a private name that already exists. Private channels do NOT support reopen — the API only goes one way. Pick a different id segment or wait for the existing channel to expire.

PARSEC_CHANNEL_TTL_EXCEEDS_MAX

You requested a private channel with ttl > 1h. The cap is in channels.MaxPrivateTTL. Lower the TTL and retry. If you actually need a long-lived feed, consider whether it should be public.

PARSEC_BROKER_NOT_READY

The Centrifuge node has not finished node.Run() yet. Almost always a boot race — the server received an RPC before it finished initializing the broker. Retry after a short backoff; this usually clears in under a second.

PARSEC_BROKER_INTERNAL

The OSS Centrifuge library returned an error from Publish, Presence, or Run. Check the server log; the underlying message is preserved in the wrapped error chain.

PARSEC_SINK_UNAVAILABLE

A sink’s Send failed at the transport layer. Common causes:

  • SMTP server refused the relay (email sink).
  • Slack webhook returned a 4xx/5xx.
  • Webhook target was unreachable.

Parsec does NOT retry. Wrap the sink in your own retry layer or queue the message upstream of PublishOrSink.

PARSEC_SINK_CONFIG_INVALID

A sink rejected its constructor config. Re-read the sink’s documentation:

  • Email — requires Addr and From.
  • Slack — requires WebhookURL.
  • Webhook — requires URL.

PARSEC_INVALID_ARGUMENT

Generic input validation failure. The accompanying message is the useful part — read the server log or the response body.

PARSEC_INTERNAL

Unhandled, unexpected error path. File a bug with the surrounding log context. These should be rare; treat them as actionable.

Debug tips

  • Run parsec channels list to confirm what the server thinks exists.
  • Run parsec channels get <name> to inspect a single record’s state and last_active.
  • Use parsec --json (or curl /manifest) to verify the server version, configured sinks, and persistence stance.
  • Watch the server log with the slog default handler on debug; key events (keyring reload, bootstrap mgmt token) are emitted as structured events.

See also

Development setup

How to get a working Parsec checkout and run the development loop.

git clone https://github.com/frankbardon/parsec.git
cd parsec
make build
make test

Prerequisites

  • Go 1.26.1 or newer. The go.mod declares the toolchain; an older compiler will refuse to build.
  • A POSIX shell (Linux or macOS).
  • make. The repo uses GNU make for the development loop.
  • mdbook (optional) — only required for make docs.
  • No CGO, no protoc by default. The proto target needs protoc, protoc-gen-go, and protoc-gen-twirp only when you regenerate the RPC bindings.

Verify your Go install:

go version
# go version go1.26.1 darwin/arm64

Clone

git clone https://github.com/frankbardon/parsec.git
cd parsec

The module path is github.com/frankbardon/parsec. The repo follows the standard Go layout — cmd/ for binaries, internal/ for non-API packages, every other top-level directory is a public library package.

Build

make build
# bin/parsec

This runs go build -trimpath -ldflags="-s -w" -o bin/parsec ./cmd/parsec with CGO_ENABLED=0. The Makefile owns the build flags; the goal is a reproducible, statically linkable artifact.

Confirm the binary:

./bin/parsec --version
# parsec version dev

Test

make test
# go test ./...

The full suite is fast (under a minute on a modern laptop). Per-package tests are conventional Go tests; the channels manager and parsec end-to-end suites use injected clocks rather than time.Sleep so the suite stays deterministic.

For coverage:

make cover
# go test -coverprofile=coverage.out ./...
# go tool cover -func=coverage.out

Lint

make lint
# go vet ./...
# go run honnef.co/go/tools/cmd/staticcheck@latest ./...

make lint chains go vet and staticcheck. The staticcheck call is via go run so you don’t need to install it separately. All new code is expected to pass both. If staticcheck flags a lint you believe is wrong, add a //lint:ignore with a brief rationale rather than silently muting the rule.

Format

make fmt
# go fmt ./...

Imports are managed by gofmt; do not hand-sort them.

Documentation

make docs
# mdbook build docs

The mdBook source lives under docs/src/. Adding a new page means adding a file under docs/src/<section>/ AND adding a line to docs/src/SUMMARY.md. The summary is the table of contents — pages that exist on disk but are not in the summary are invisible.

For live preview while writing:

make docs-serve
# mdbook serve docs --open

Protobuf (rarely)

make proto
# protoc + protoc-gen-go + protoc-gen-twirp

Only needed when the wire shape in rpc/service.proto changes. The hand-rolled rpc/types.go is the contract until then.

Submitting changes

  • Branch from main.
  • One logical change per PR. Keep diffs small.
  • Run make lint test docs before opening the PR. The CI runs the same targets.
  • Update the relevant doc page in the same PR — see “The Update Demand” in CLAUDE.md for the full mapping.

See also

Style guide

How to write Go that fits the rest of the codebase.

// Package channels owns the public/private channel namespace and the
// lifecycle manager. Every other Parsec surface defers to ParseName for
// validation.
package channels

Two reflexes summarize most of the rules: keep functions short, and let the type system carry the contract.

Files

  • Short. Most files in the repo are under 200 lines. If a file is growing past 300, split it by responsibility.
  • Package-doc on every package, on the file named for the package (channels/name.go carries the // Package channels... doc).
  • One package per directory. No subpackages by hand.

Comments

  • Comment what is not obvious. Do not echo the function signature in English.
  • Comment every exported symbol with a sentence that starts with its name, per gofmt convention.
  • Do not narrate the implementation inside a function. If you feel the need to, the function is probably too long.

A bad comment:

// Touch resets the LastActive timestamp on the channel.
func (m *Manager) Touch(name Name) error {
    // ...
}

A good comment:

// Touch marks the channel as recently active so the tick loop will not
// expire it on the next sweep.
func (m *Manager) Touch(name Name) error {
    // ...
}

The first one is the signature in English; the second carries the intent.

Errors

  • All errors that cross a package boundary are *errors.Error from github.com/frankbardon/parsec/errors. Never errors.New(...) from the stdlib at a boundary.
  • Codes are PARSEC_DOMAIN_CATEGORY strings. Pick the right one from errors/codes.go; if none fits, add one in that file in the same PR and update the RPC mapping in rpc/server.go:writeServiceError.
  • Wrap with errors.Wrap(code, msg, cause) when you have an underlying error; otherwise errors.New(code, msg).

Internal helpers can use any error type — but the moment the error is returned from an exported function, it must be coded.

Channel names

There is exactly one validator: channels.ParseName. Every surface calls it. Do not reimplement parsing logic anywhere else. If you need a new visibility prefix or a new component rule, the change lands in channels/name.go and gains tests in channels/name_test.go.

Library-first

parsec.Parsec is the deliverable. Everything else (CLI, RPC) is a translator.

  • cmd/parsec/main.go parses flags. Nothing else.
  • internal/cli/* builds command structures. The bodies dispatch to service.Service or *parsec.Parsec. No business logic.
  • If a CLI subcommand grows logic that the library cannot also call, the CLI is wrong. Pull the logic into a library package and let the CLI re-use it.

Filesystem access

Library code uses afero.Fs (opts.FS). Never os.Open / os.ReadFile inside library packages. The CLI is the one allowed exception (e.g. publish --file).

Descriptor envelopes

Every JSON output (CLI --json, /manifest) wraps its payload in descriptor.Envelope. The format version bumps on any wire-shape change.

Tests

  • Table-driven where it helps. channels/name_test.go is the model.
  • Inject the clock for any time-dependent test. Manager.SetClock is there for this; do not call time.Sleep.
  • Tests live next to the code, in _test.go files. The naming pattern is TestThing_subcase. Avoid TestX followed by 30 unrelated scenarios.

Imports

  • Group: stdlib, third-party, project-local. gofmt enforces.
  • No dot imports.
  • No blank imports outside main packages.

Naming

  • Package names are lower-case, one word.
  • Exported types are TitleCase. Exported constants are also TitleCase (no MAX_FOO shouting).
  • Receivers are short — a single letter that matches the type (m *Manager, b *Broker, p *Parsec). The receiver name is consistent across every method on the type.

Anti-patterns to refuse

These show up in PRs and should be pushed back on:

  • A new internal/ package that duplicates service/ logic.
  • A CLI command that calls broker.* or channels.* directly. Go through service.Service.
  • A new channel-validation helper outside channels/.
  • A change to a public surface (CLI, RPC, library) without an update to the corresponding docs page in the same PR.

See also

  • Development setup.
  • The CLAUDE.md at the repo root — the operational contract this guide derives from.