diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e95fb44..30a29b5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,10 +19,10 @@ jobs: steps: - name: Check out repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Set up Go - uses: actions/setup-go@v6 + uses: actions/setup-go@v7 with: go-version-file: go.mod cache: true @@ -105,7 +105,7 @@ jobs: run: make test-functional - name: Upload coverage to Codecov - uses: codecov/codecov-action@v6 + uses: codecov/codecov-action@v7 with: files: ./coverage.out flags: unittests @@ -124,7 +124,7 @@ jobs: steps: - name: Check out repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v4 @@ -172,12 +172,12 @@ jobs: steps: - name: Check out repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: fetch-depth: 0 - name: Set up Go - uses: actions/setup-go@v6 + uses: actions/setup-go@v7 with: go-version-file: go.mod cache: true @@ -240,7 +240,7 @@ jobs: steps: - name: Check out repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Set up QEMU uses: docker/setup-qemu-action@v4 @@ -334,7 +334,7 @@ jobs: - name: Install Cosign if: startsWith(github.ref, 'refs/tags/v') - uses: sigstore/cosign-installer@v3 + uses: sigstore/cosign-installer@v4 with: # Match the pin in release.yml so both signing surfaces # (docker image here, checksums.txt in release.yml) use the diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml index 895240b..58d9fa2 100644 --- a/.github/workflows/fuzz.yml +++ b/.github/workflows/fuzz.yml @@ -40,10 +40,10 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Set up Go - uses: actions/setup-go@v6 + uses: actions/setup-go@v7 with: go-version-file: go.mod cache: true diff --git a/.github/workflows/mutation.yml b/.github/workflows/mutation.yml index 92383ce..aee0828 100644 --- a/.github/workflows/mutation.yml +++ b/.github/workflows/mutation.yml @@ -23,10 +23,10 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Set up Go - uses: actions/setup-go@v6 + uses: actions/setup-go@v7 with: go-version-file: go.mod cache: true @@ -45,7 +45,7 @@ jobs: - name: Cache gremlins binary id: cache_gremlins - uses: actions/cache@v5 + uses: actions/cache@v6 with: path: ~/go/bin/gremlins key: gremlins-${{ runner.os }}-${{ steps.gremlins_version.outputs.version }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bfaf01c..f4b48fe 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -62,12 +62,12 @@ jobs: timeout-minutes: 20 steps: - name: Check out repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: fetch-depth: 0 - name: Set up Go - uses: actions/setup-go@v6 + uses: actions/setup-go@v7 with: go-version-file: go.mod cache: true @@ -87,7 +87,7 @@ jobs: uses: anchore/sbom-action/download-syft@v0 - name: Install Cosign for checksum signing - uses: sigstore/cosign-installer@v3 + uses: sigstore/cosign-installer@v4 with: # Pin cosign itself, not just the installer action. The rest # of the supply-chain tooling pins tightly (golangci-lint diff --git a/.github/workflows/website.yml b/.github/workflows/website.yml index 11ee859..e5cc063 100644 --- a/.github/workflows/website.yml +++ b/.github/workflows/website.yml @@ -37,7 +37,7 @@ jobs: timeout-minutes: 15 steps: - name: Check out repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Set up Ruby uses: ruby/setup-ruby@v1 @@ -49,7 +49,7 @@ jobs: # 22, not 20: the pinned Wrangler below requires Node >= 22. Keep in step with # website/Dockerfile so `make website-build` still mirrors this build. - name: Set up Node - uses: actions/setup-node@v4 + uses: actions/setup-node@v7 with: node-version: '22' cache: npm diff --git a/AGENTS.md b/AGENTS.md index 4168447..5cc353e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,6 +33,23 @@ to replace it. sequence after a NATS reconnect or consumer loss; a client whose writes stop completing is disconnected at `write_timeout` and resumes via `Last-Event-ID`. +- `shared_subscriptions`: connections caught up with the live stream share + one consumer per topic set, and move to their own when they fall behind. +- A stream watch: stream-info reads, on every stream request and every 10 s, + notice a JetStream stream recreated or restored under open SSE streams. + Those streams end with a `reset` event whose id, `0`, makes EventSource + replay the new stream (`stream_recreated`, `stream_rewound`). +- NATS liveness: pings every `nats_ping_interval` (20 s) declare a server + that stopped answering stale within a minute. Until then, a stream-info + read that times out makes NUTS ping, and while the ping goes unanswered, + requests are told to retry at once. +- Event shaping: `event_type` names events (`message`, `topic`, or + `header `), `payload_format raw` sends the NATS payload itself, and + `event_id_format sequence_time` makes ids `-`, so that + a cursor from a replaced stream is noticed. +- Retry hints: `sse_retry` sends `retry:` with each handshake, and + `transient_retry` sets the average of the jittered delay NUTS asks for + after transient failures. - NATS authentication (credentials file, token, user/password) and TLS / mTLS to the NATS server. - Optional first-party subscriber JWT auth (`subscriber_jwt_key`, @@ -44,10 +61,13 @@ to replace it. - Per-frame write deadlines (`write_timeout`, 30 s by default). `dispatch_timeout` is deprecated and has no effect. - Probes: `live_path` (`/livez`), `ready_path` (`/readyz`), and the legacy - `health_path` (`/healthz`). + `health_path` (`/healthz`); `health_details` adds the NATS server and the + stream's details to the readiness JSON. - Hub discovery via `Link: ; rel="nuts"` when `hub_url` is set. - Prometheus metrics (`nuts_*`) registered via `promauto`, surfaced through Caddy's `/metrics` handler. +- A dependency-free JavaScript helper for replay-aware pages, + [example/nuts-client.js](example/nuts-client.js). A current and complete directive list with defaults, JSON field names, validation rules, and operational notes is in @@ -82,7 +102,7 @@ Tests live alongside the source: | [serve_test.go](serve_test.go) | Request parsing, replay planning, formatting, and stream helper unit tests without live NATS. | | [serve_integration_test.go](serve_integration_test.go) | `ServeHTTP` end to end on an embedded JetStream server: streaming, replay cursors, write failures, probes, hub discovery, heartbeats, topic prefixes, NATS restarts. | | [hardening_test.go](hardening_test.go) | Request hardening: CORS, connection, topic and event-size limits, probe paths, replay caps and windows, oversized cursors. | -| [consumer_test.go](consumer_test.go) | Feed unit tests (ordering, oversize drops, backpressure, failure and stop paths) with a fake iterator; recovery of a deleted consumer. | +| [consumer_test.go](consumer_test.go) | Feed unit tests (ordering, oversize drops, backpressure, failure and stop paths) with a fake iterator; recovery of a deleted consumer; the stream watch (generations, recreation and rewind detection, the poll's continuity check) and the `reset` frame. | | [shared_test.go](shared_test.go) | Shared-subscription mechanics (ring, gap-free joins, fall-behind, lifecycle) and end-to-end behaviour; the delivery contract tests also run in shared mode. | | [helpers_test.go](helpers_test.go) | Helper edge cases: topic and cookie validation, JSON, URL redaction, SSE writes and deadlines, NATS error classification. | | [metrics_test.go](metrics_test.go) | Each metric moves with the event it counts; registration on Caddy's registry. | @@ -92,7 +112,7 @@ Tests live alongside the source: | [caddy_integration_test.go](caddy_integration_test.go) | Caddy-in-the-loop tests via `caddy.Load` (access logs, HTTP metrics, the metrics endpoint). | | [performance_test.go](performance_test.go) | `TestPerformance_*` confidence tests and benchmarks. | | [fuzz_test.go](fuzz_test.go) | Fuzz targets holding the topic, filter and cookie validators to their contracts in both directions, and the subject matchers to a reference; the nightly Fuzz workflow runs every target. | -| [docs_test.go](docs_test.go) | Keeps metric names in README, website, docs and ops files in step with `metrics.go`, and the nightly fuzz matrix in step with the `Fuzz` functions. | +| [docs_test.go](docs_test.go) | Keeps metric names in README, website, docs and ops files in step with `metrics.go`, the defaults documented in `docs/CONFIGURATION.md` in step with `Provision`, and the nightly fuzz matrix in step with the `Fuzz` functions. | | [testutil_test.go](testutil_test.go) | Helpers shared by more than one test file, by theme: embedded NATS servers (and restarts on the same port), handler set-up, response writers, SSE stream readers and assertions, fakes for the stream path, metric and log readers, subscriber JWTs. A helper used by one file lives in that file. | | [functional_test/](functional_test/) | [Godog](https://github.com/cucumber/godog) BDD tests against a real Docker Compose stack. | | [features/](features/) | Gherkin `.feature` files driving the Godog suite. | @@ -265,17 +285,27 @@ that gap before review starts, instead of leaking it into review load. ## CI and release surfaces -CI is GitHub Actions ([.github/workflows/ci.yml](.github/workflows/ci.yml), -[.github/workflows/release.yml](.github/workflows/release.yml), -[.github/workflows/website.yml](.github/workflows/website.yml)). +CI is GitHub Actions: + +| Workflow | Runs on | +| --- | --- | +| [ci.yml](.github/workflows/ci.yml) | Every pull request and every push to `main`/`master` or a `v*` tag. | +| [release.yml](.github/workflows/release.yml) | `v*` tags. | +| [website.yml](.github/workflows/website.yml) | `v*` tags, and `workflow_dispatch` for a manual redeploy. | +| [fuzz.yml](.github/workflows/fuzz.yml) | Nightly at 04:00 UTC: every fuzz target. | +| [mutation.yml](.github/workflows/mutation.yml) | Weekly on Sunday at 03:00 UTC: the full gremlins run. | + +Actions are pinned by major version (`@v7`), and Dependabot proposes the +bumps. PRs run, in this order: 1. `gofmt`, `go mod tidy` diff check, golangci-lint, unit tests with coverage, - focused race tests, `go vet`, the example JavaScript client's tests, - `govulncheck`. + the whole unit package under `-race`, `go vet`, the example JavaScript + client's tests (`make test-js`), `govulncheck`. 2. Functional test matrix (`nats:2.10-alpine`, `nats:2.12-alpine`, - `nats:2.14-alpine`, `nats:2.15-alpine`) and a 3× functional stress pass. + `nats:2.14-alpine`, `nats:2.15-alpine`), a 3× functional stress pass, and + one functional pass under `-race`. 3. Coverage upload to Codecov. 4. Production Docker image build + `caddy adapt` validation + Trivy scan + SBOM (SPDX JSON) artifact. @@ -283,17 +313,24 @@ PRs run, in this order: change ([.goreleaser.yml](.goreleaser.yml), workflows, `Makefile`, `Dockerfile`). -Pushes to `main`/`master` and `v*` tags additionally: +Pushes to `main`/`master` additionally build the multi-arch (`linux/amd64`, +`linux/arm64`) image, scan it with Trivy before and after the push, and push +it as `idcttech/nuts:latest` with provenance and SBOM attestations. + +`v*` tags additionally: + +1. Build, scan and push the image the same way as `idcttech/nuts:` + (a tag never moves `latest`), then sign it keylessly via Cosign + GitHub + OIDC. +2. Once CI has passed on the tagged commit, GoReleaser publishes the GitHub + release: archives, their SBOMs, and `checksums.txt` signed with Cosign + (`checksums.txt.sig`, `checksums.txt.pem`). +3. Build the [website/](website/) Jekyll site and deploy it to Cloudflare at + `https://idct.tech/nuts`. Docs changes merged to `main` go live with the + next tag. -1. Build and push the multi-arch (`linux/amd64`, `linux/arm64`) - `idcttech/nuts` image with provenance and SBOM attestations. -2. On tag: scan the pushed image by digest with Trivy and sign all tags - keyless via Cosign + GitHub OIDC. -3. Update the Docker Hub description from [DOCKERHUB_README.md](DOCKERHUB_README.md). -4. GoReleaser publishes archives and SBOMs for the tag. -5. Build the [website/](website/) Jekyll site and deploy it to Cloudflare at - `https://idct.tech/nuts` ([website.yml](.github/workflows/website.yml); also - runnable on demand via `workflow_dispatch`). +No workflow updates the Docker Hub description; its text is in +[DOCKERHUB_README.md](DOCKERHUB_README.md). Release surfaces and verification commands are documented in [docs/RELEASE.md](docs/RELEASE.md). Operator-facing release notes follow the @@ -306,7 +343,8 @@ The published artefact set: - `idcttech/nuts:` Docker image (multi-arch, non-root uid 10001, Cosign-signed, SBOM-attested for tag releases). `:latest` is mutable — pin to a concrete tag in production. -- GitHub release archives with SHA-256 checksums and SBOMs. +- GitHub release archives with SBOMs, and SHA-256 checksums signed with + Cosign. - Source build via `go build ./cmd/caddy` or `xcaddy build --with github.com/ideaconnect/nuts`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 76a30a2..cb129f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,46 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.6.0] - 2026-09-28 + +A development release on the 0.x line. SSE streams on a JetStream stream that +was recreated or restored no longer go silent, and requests no longer wait on +a NATS server that stopped answering. New optional directives name events, +send raw payloads, put the message's time in event IDs, add readiness details +and set the retry hints, and `example/nuts-client.js` gives pages +replay-aware subscriptions. Nothing in an existing configuration has to +change. + +### Upgrading + +**Operator impact.** Upgrade urgency: **high** if you recreate or restore +JetStream streams while NUTS serves them. Open SSE streams skipped the +replaced stream's messages until it passed their old position, while +heartbeats kept them looking healthy (#133). **Medium** otherwise. + +- **Config changes:** none required. Every new directive is optional, and + its default keeps 0.5.0's behaviour. Two changes you may notice: + - NUTS pings the NATS server every 20 seconds (`nats_ping_interval`) + instead of every two minutes, so a server that stops answering is + declared stale within a minute (`disconnected from NATS`, + `stale connection`). + - SSE streams on a replaced stream end with a `reset` event, and + EventSource replays the new stream from its start. Bound those replays + with `replay_max_messages` or `replay_window`; the + `NutsStreamReplacedUnderClients` rule in `ops/prometheus-alerts.yml` + alerts on them. +- **Deployment checks:** each handler reads its stream's info every 10 + seconds. The readiness JSON names servers, clusters and limits only with + `health_details`, which public probe paths should leave off. +- **Rollback:** back to 0.5.0 is safe unless `event_id_format + sequence_time` was on. 0.5.0 does not parse `-` + cursors: it answers such a `?last-id=` with `400` and ignores such a + `Last-Event-ID`, so those clients start from new messages. + +**Compatibility.** Caddy 2.11.x (built with v2.11.4), Go 1.26.8, +nats-server 2.10 or newer (2.14.7 or newer for multi-topic subscriptions), +as in 0.5.0. + ### Added - `example/nuts-client.js`, a dependency-free ES module wrapping EventSource for replay-aware pages (#146). It keeps the cursor of every diff --git a/README.md b/README.md index b196bcc..49baddc 100644 --- a/README.md +++ b/README.md @@ -24,18 +24,25 @@ A Caddy Server module that bridges NATS.io JetStream messages to Server-Sent Eve - **[Message Replay](#message-replay-with-last-id-or-last-event-id)**: Clients can reconnect and replay messages from a specific ID using `?last-id=` or the standard `Last-Event-ID` header. Replay can be bounded by `replay_max_messages` or `replay_window` when configured. - **Multiple Topics**: Subscribe to multiple NATS subjects simultaneously - **Automatic Reconnection**: Built-in NATS reconnection handling; after a reconnect each stream resumes after the last message it delivered, without gaps +- **[NATS Liveness](#nats_ping_interval)**: NUTS pings the NATS server every 20 seconds, so a server that stops answering is found within a minute; in the meantime, once one request's read times out, new requests are told to retry at once +- **[Recreated Streams](#message-format)**: When the JetStream stream is recreated or restored under open streams, clients get a `reset` event and replay the new stream from its start - **[CORS Support](#cors-and-allowed_origins)**: Configurable cross-origin resource sharing - **Heartbeat**: Keep-alive mechanism to prevent connection timeouts - **[Backpressure, Not Drops](#slow-clients-and-replay)**: Each stream pulls from JetStream only as fast as its client reads, so bursts and long replays wait in the stream instead of being dropped or disconnecting the client. A client that stops reading is disconnected by `write_timeout` and resumes from its last event ID. Oversized events can still be rejected by `max_event_size`. +- **[Shared Subscriptions](#shared_subscriptions)**: Optionally, connections caught up with the live stream share one JetStream consumer per topic set - **[NATS Authentication](#with-nats-authentication)**: Credentials file, token, or user/password auth for the NUTS-to-NATS connection - **NATS TLS / mTLS**: Optional `nats_tls_ca`, `nats_tls_cert`, `nats_tls_key` directives for an encrypted and mutually authenticated NATS connection - **[Subscriber JWT Authorization](#subscriber-authentication-and-topic-authorization)**: Optional HMAC-signed JWT auth with per-topic `subscribe` claims, accepted from `Authorization: Bearer` or a configurable cookie - **[Connection Caps](#max_connections)**: `max_connections` bounds concurrent SSE streams; rejected clients receive `429 Too Many Requests` with `Retry-After`, and browser `EventSource` clients are told to [retry](#transient-failures-and-eventsource) - **[Per-frame Write Bounds](#write_timeout)**: `write_timeout` (default 30 s) bounds every SSE write, so a client that stopped reading cannot tie up a handler indefinitely - **Topic Prefixing**: Optional prefix for all NATS subscriptions +- **[Event Names](#event_type) and [Raw Payloads](#payload_format)**: Name SSE events after their topic or a message header, and send the NATS payload itself instead of the JSON envelope +- **[Timed Event IDs](#event_id_format)**: `event_id_format sequence_time` adds each message's stored time to its id, so a client returning with a cursor from a replaced stream replays the new one +- **[Retry Hints](#retry-hints-sse_retry-and-transient_retry)**: `sse_retry` sets EventSource's reconnection delay, and `transient_retry` the delay NUTS asks for after transient failures - **[Prometheus Metrics](#prometheus-metrics)**: Built-in `nuts_*` counters and gauges (active connections, messages delivered, slow-client disconnects, replay stats) -- **[Liveness And Readiness Checks](#liveness-and-readiness-checks)**: `/livez`, `/readyz`, and legacy `/healthz` probe endpoints +- **[Liveness And Readiness Checks](#liveness-and-readiness-checks)**: `/livez`, `/readyz`, and legacy `/healthz` probe endpoints; `health_details` adds the NATS server and stream details - **[Hub Discovery](#hub-discovery)**: Optional `Link` header with `rel="nuts"` for automatic hub detection +- **[JavaScript Helper](#replay-aware-javascript-helper)**: `example/nuts-client.js`, a dependency-free module that keeps the cursor across page reloads ## Table of Contents @@ -52,10 +59,19 @@ A Caddy Server module that bridges NATS.io JetStream messages to Server-Sent Eve - [Configuration](#configuration) - [Caddyfile Syntax](#caddyfile-syntax) - [Path-shorthand and `route`](#path-shorthand-and-route) + - [`topic_prefix`](#topic_prefix) + - [Zero and negative values](#zero-and-negative-values) - [`max_event_size`](#max_event_size) - [`max_connections`](#max_connections) - [`write_timeout`](#write_timeout) + - [`nats_ping_interval`](#nats_ping_interval) + - [JetStream consumers](#jetstream-consumers) + - [`shared_subscriptions`](#shared_subscriptions) + - [Consumer limits on nats-server 2.15](#consumer-limits-on-nats-server-215) - [`replay_max_messages` and `replay_window`](#replay_max_messages-and-replay_window) + - [`event_type`](#event_type) + - [`payload_format`](#payload_format) + - [`event_id_format`](#event_id_format) - [CORS and `allowed_origins`](#cors-and-allowed_origins) - [Subscriber authentication and topic authorization](#subscriber-authentication-and-topic-authorization) - [Liveness And Readiness Checks](#liveness-and-readiness-checks) @@ -65,6 +81,8 @@ A Caddy Server module that bridges NATS.io JetStream messages to Server-Sent Eve - [Client Usage](#client-usage) - [JavaScript EventSource](#javascript-eventsource) - [Replay-aware JavaScript helper](#replay-aware-javascript-helper) + - [Transient failures and EventSource](#transient-failures-and-eventsource) + - [Retry hints: `sse_retry` and `transient_retry`](#retry-hints-sse_retry-and-transient_retry) - [Slow Clients And Replay](#slow-clients-and-replay) - [Message Replay with `last-id` or `Last-Event-ID`](#message-replay-with-last-id-or-last-event-id) - [Message Format](#message-format) @@ -1410,7 +1428,8 @@ Its `id` is the stream position the subscription starts after: the stream's last sequence for a request without a cursor, or the requested cursor for a replay. Treat it like any other event ID. It is omitted when the stream starts in a fallback replay mode, in which case the client keeps its previous -cursor. +cursor. With [`sse_retry`](#retry-hints-sse_retry-and-transient_retry), the +frame starts with a `retry:` line. When the JetStream stream is deleted and created again, or restored from a backup, under an open stream, NUTS ends the stream with a `reset` event: @@ -1426,9 +1445,10 @@ id: 0 The new or restored stream numbers the messages after its start or its backup's end anew, so the event sets the client's last event ID to `0`, and EventSource replays the stream from its start after the `retry` delay. The -`reason` is `stream_recreated` or `stream_rewound`. A page that keeps the last event ID across -page reloads must take it from `reset` events too, as the -[JavaScript example](#javascript-eventsource) does. See +`reason` is `stream_recreated` or `stream_rewound`. A page that keeps the +last event ID across page reloads must take it from `reset` events too, as +the [JavaScript example](#javascript-eventsource) and the +[helper](#replay-aware-javascript-helper) do. See [TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md#streams-close-after-the-stream-was-recreated-or-restored). #### Response headers @@ -1458,7 +1478,8 @@ rather than published by an application. NUTS does not forward them: They are counted as `nuts_messages_dropped_total{reason="control_message"}`. Their sequence numbers never appear as event IDs, so IDs can skip them. -Apart from these two headers, NUTS does not look at message headers. +Apart from these two headers and the one [`event_type header`](#event_type) +names, NUTS does not look at message headers. ## Example Scenarios @@ -1603,6 +1624,15 @@ Feature: SSE Streaming with JetStream And the event should have an ID ``` +#### JavaScript Client + +The example client's tests use Node.js's built-in test runner (Node.js 20 or +later, no packages): + +```bash +make test-js +``` + #### All Tests ```bash @@ -1692,6 +1722,7 @@ make build # Build the Caddy binary make test # Run all tests (unit + functional) make test-unit # Run unit tests with embedded NATS make test-functional # Run BDD tests with Docker +make test-js # Test the example JavaScript client with Node.js make mutate-tools # Install the pinned gremlins binary make mutate # Run mutation testing on the whole module make mutate-pkg PKG=… # Run mutation testing scoped to one file/dir diff --git a/docs/PLAN.md b/docs/PLAN.md index 2702fb5..d6decd8 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -608,24 +608,45 @@ These are carried over from the v0.4 burn-list (P2 product polish). resume after the old last sequence and stay silent until the new stream passes it, skipping everything before (reproduced in both subscription modes). Done: stream-info reads, on requests and every 10 s, end a stream - generation when the creation time changes (`stream_recreated`; the last - frame resets the client's cursor to 0) or the last sequence is behind on - two reads at least 5 s apart (`stream_rewound`; the cursor falls back to - the retained replay). + generation when the creation time changes (`stream_recreated`) or the last + sequence is behind on two reads at least 5 s apart (`stream_rewound`). + Both end with a `reset` event whose id, 0, makes EventSource replay the + stream ([#147], [#137]); a poll also checks the stored time of the message + at the sequence it last saw, for a rewound stream that caught up ([#137]). - [x] P1 ([#134]): Notice a silently dead NATS server sooner. NUTS kept nats.go's two-minute ping interval, so a server that stopped answering took minutes to declare stale, and meanwhile each new request waited about 7–10 s for JetStream timeouts before it was told to retry (seen in the example stack with a paused NATS container). Done: `nats_ping_interval`, 20 s by default with two pings outstanding, so the outage is noticed within a minute. -- [ ] P2: Optional event-type mapping from topic or metadata. -- [ ] P2: Optional payload envelope customization for raw payload-only events. -- [ ] P2: Expose the NATS server version and stream metadata in health or - debug output, gated appropriately. -- [ ] P2: Configurable retry hints in SSE output. [#105] and Phase 4 deliver - part of this. -- [ ] P2: A sample JavaScript client helper for replay-aware subscriptions, - paired with [#102] and [#105]. +- [x] P1 ([#138]): A client away while its stream was replaced comes back + with a cursor from the old stream. Done: `event_id_format sequence_time` + adds the message's stored time to each id, and a cursor whose time does + not match replays the stream from its start. +- [x] P1 ([#139]): Requests waited about 7 s each on a NATS server that + stopped answering, until it was declared stale. Done: a stream-info read + that times out makes NUTS ping the server, and while the ping goes + unanswered, requests are told to retry at once. +- [x] ([#141]): `make mutate-pkg` failed scoped runs on gremlins' mutant + coverage. Done: it gates on efficacy. +- [ ] ([#140]): Uncalled vulnerable modules in Caddy's dependency tree. chi is + bumped; grpc and cel-go wait on upstream releases. +- [x] P2 ([#142]): Optional event-type mapping from topic or metadata. Done: + `event_type topic` and `event_type header `. +- [x] P2 ([#143]): Optional payload envelope customization for raw + payload-only events. Done: `payload_format raw`. +- [x] P2 ([#144]): Expose the NATS server version and stream metadata in + health or debug output, gated appropriately. Done: `health_details`, off + by default. +- [x] P2 ([#145]): Configurable retry hints in SSE output. Done: `sse_retry` + and `transient_retry`. +- [x] P2 ([#146]): A sample JavaScript client helper for replay-aware + subscriptions, paired with [#102] and [#105]. Done: + `example/nuts-client.js`, tested by `make test-js`. +- [ ] Optional gap signal (D4e, from [#107]): opt-in by decision; no client + has asked for it. + +All of the above shipped in v0.6.0 except the open items. ## Exit criteria @@ -656,7 +677,7 @@ These are carried over from the v0.4 burn-list (P2 product polish). (`TestDeliveryContract_EventSourceReconnectWithURLCursorMakesProgress`); - [x] a recreated stream resumes correctly for a client that reconnects (`TestDeliveryContract_CursorFromARecreatedStreamStillDelivers`); open - streams across a recreation are a known limitation, see the backlog; + streams across a recreation end with a `reset` since [#133]; - [x] a consumer deleted mid-stream is recovered (`TestHandler_ConsumerDeletedMidStream_RecreatesAndResumes`). - [x] The functional matrix is green on every supported server (per D3), @@ -763,3 +784,14 @@ is split between Phases 1 and 5. [#131]: https://github.com/ideaconnect/nuts/issues/131 [#133]: https://github.com/ideaconnect/nuts/issues/133 [#134]: https://github.com/ideaconnect/nuts/issues/134 +[#137]: https://github.com/ideaconnect/nuts/issues/137 +[#138]: https://github.com/ideaconnect/nuts/issues/138 +[#139]: https://github.com/ideaconnect/nuts/issues/139 +[#140]: https://github.com/ideaconnect/nuts/issues/140 +[#141]: https://github.com/ideaconnect/nuts/issues/141 +[#142]: https://github.com/ideaconnect/nuts/issues/142 +[#143]: https://github.com/ideaconnect/nuts/issues/143 +[#144]: https://github.com/ideaconnect/nuts/issues/144 +[#145]: https://github.com/ideaconnect/nuts/issues/145 +[#146]: https://github.com/ideaconnect/nuts/issues/146 +[#147]: https://github.com/ideaconnect/nuts/issues/147 diff --git a/docs/RELEASE.md b/docs/RELEASE.md index a23e3c2..203deb5 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -60,9 +60,22 @@ cosign verify idcttech/nuts: \ --certificate-oidc-issuer https://token.actions.githubusercontent.com ``` -GitHub release archives are covered by SHA-256 checksums and release SBOMs. -Checksum signing can be added later with Cosign `sign-blob` if downstream -consumers require detached signatures for archive artifacts. +GitHub release archives are covered by release SBOMs and by SHA-256 checksums +in `checksums.txt`, which the release workflow signs keylessly with Cosign +`sign-blob` (`checksums.txt.sig`, `checksums.txt.pem`). Verify the checksums' +signature, then an archive against them: + +```bash +cosign verify-blob checksums.txt \ + --signature checksums.txt.sig \ + --certificate checksums.txt.pem \ + --certificate-identity-regexp 'https://github.com/ideaconnect/nuts/.github/workflows/release.yml@refs/tags/v.*' \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com +sha256sum --ignore-missing -c checksums.txt +``` + +Both workflows pin Cosign itself (`cosign-release: v2.6.3`): Cosign v3's +`sign-blob` writes a bundle instead, which changes these files. ## Dependency Updates