From 7be680492a1d5f965a4a8913a00f0f8066ddfac1 Mon Sep 17 00:00:00 2001 From: Trellis Contributor Date: Thu, 22 Oct 2026 19:20:22 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20machine-readable=20protocol=20changelog?= =?UTF-8?q?=20=E2=80=93=20retry,=20auth,=20docs=20(#352)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add ProtocolChangelogService.reload() with rollback guarantee: if the new file fails validation the previous in-memory document is preserved and the error is re-thrown, so a bad deploy cannot erase a working changelog. - Expand protocol-changelog.service.spec.ts with four retry/reload tests (successful reload, rollback on invalid file, rollback on missing file, recovery after a bad initial load) and four authorization-path tests (@Public metadata present, guard bypass without token, empty-query 200, impact filter applied unauthenticated). - Update changelog/protocol-changes.json: the machine-readable changelog entry now references both issue #126 and #352, and the migration notes document the reload() rollback guarantee. - Add docs/protocol-changelog.md: full operational runbook covering normal path, failure path, retry/reload path, authorization path, all semantic validator rules, edge cases (bad deploy, clock skew, CHANGELOG_PATH override, rollback, contract preservation) and how to run the tests. Closes #352 --- changelog/protocol-changes.json | 8 +- docs/protocol-changelog.md | 307 ++++++++++++------ .../protocol-changelog.service.spec.ts | 129 +++++++- src/changelog/protocol-changelog.service.ts | 22 ++ 4 files changed, 362 insertions(+), 104 deletions(-) diff --git a/changelog/protocol-changes.json b/changelog/protocol-changes.json index 0dfcdedb..c2187237 100644 --- a/changelog/protocol-changes.json +++ b/changelog/protocol-changes.json @@ -80,13 +80,13 @@ "status": "unreleased", "impact": "compatible", "title": "Machine-readable protocol changelog", - "summary": "Adds GET /changelog/protocol backed by changelog/protocol-changes.json, validated against changelog/schema/protocol-changes.schema.json in CI and by npm run changelog:validate.", + "summary": "Adds GET /changelog/protocol backed by changelog/protocol-changes.json, validated against changelog/schema/protocol-changes.schema.json in CI and by npm run changelog:validate. Covers normal, failure, retry and authorization paths with automated tests and an operational runbook.", "protocolSurfaces": [ { "kind": "http", "method": "GET", "path": "/changelog/protocol", - "description": "Protocol-facing changes with impact, migration and version filters." + "description": "Protocol-facing changes with impact, migration and version filters. Unauthenticated (@Public); no token required." }, { "kind": "cli", @@ -103,9 +103,9 @@ "required": false, "automated": true, "steps": [], - "notes": "No migration. Consumers that need upgrade warnings can poll the endpoint from the version they run." + "notes": "No migration. Consumers that need upgrade warnings can poll the endpoint from the version they run. The reload() API allows hot-swapping the changelog without a restart; on failure the previous document is preserved." }, - "relatedIssues": [126], + "relatedIssues": [126, 352], "docs": ["docs/protocol-changelog.md"] }, { diff --git a/docs/protocol-changelog.md b/docs/protocol-changelog.md index 5124efbd..8e01409e 100644 --- a/docs/protocol-changelog.md +++ b/docs/protocol-changelog.md @@ -1,130 +1,239 @@ # Machine-readable protocol changelog -Issue: [#126](https://github.com/TRELLIS-STELLAR/Trellis-API/issues/126) +> Tracks every change to an HTTP route, WebSocket/GraphQL surface, configuration key, or +> operator command so callers can discover what an upgrade does to them without reading prose. -`CHANGELOG.md` is written for people reading a release note. This is the -machine-readable counterpart: every change to a route, a WebSocket or GraphQL -surface, a configuration key or an operator command, with its impact on existing -callers and the migration that applies. +Issue: [#352](https://github.com/TRELLIS-STELLAR/Trellis-API/issues/352) / +[#126](https://github.com/TRELLIS-STELLAR/Trellis-API/issues/126) -## Files +--- -| Path | Purpose | -| --- | --- | -| `changelog/protocol-changes.json` | The changelog itself. | -| `changelog/schema/protocol-changes.schema.json` | JSON Schema (draft 2020-12) for the file. | -| `changelog/__fixtures__/` | Deliberately invalid files used by the tests. | -| `src/changelog/` | Validator, read model and HTTP endpoint. | -| `scripts/validate-protocol-changelog.ts` | Command-line validation. | +## Quick reference -## Using it +| Concern | Where | +|---|---| +| Changelog file | `changelog/protocol-changes.json` | +| JSON Schema | `changelog/schema/protocol-changes.schema.json` | +| Validator script | `npm run changelog:validate` | +| Runtime endpoint | `GET /changelog/protocol` | +| Service | `src/changelog/protocol-changelog.service.ts` | +| Controller | `src/changelog/changelog.controller.ts` | +| Types | `src/changelog/protocol-changelog.types.ts` | -```bash -curl "$API/changelog/protocol" +--- -# What would an upgrade from the version I run do to me? -curl "$API/changelog/protocol?since=0.1.0" +## Endpoint -# Only the entries that could break me. -curl "$API/changelog/protocol?impact=breaking&impact=deprecation" -``` +### `GET /changelog/protocol` + +No authentication required (`@Public()`). The `StrategyAuthGuard` reads +`IS_PUBLIC_KEY` from the controller class and returns `true` without checking +a token. + +**Query parameters** -The response adds what a caller needs to decide: +| Parameter | Type | Description | +|---|---|---| +| `since` | semver string | Return only entries with `version > since`. Callers pin the version they run and learn what an upgrade would do. | +| `impact` | enum[] | One or more of `breaking`, `deprecation`, `compatible`, `fix`, `security`. | +| `status` | `released` \| `unreleased` | Filter by release state. | -```json +**Response shape** + +```jsonc { "schemaVersion": "1.0.0", "project": "trellis-api", + "generatedAt": "2026-09-27T12:00:00.000Z", "latestVersion": "0.2.0", - "highestImpact": "compatible", - "migrationRequired": false, + "highestImpact": "compatible", // worst impact in the returned set + "migrationRequired": false, // true when any entry forces caller action "affectedPaths": ["/changelog/protocol", "/health/dependencies"], - "entries": [ ... ] + "entries": [ /* ProtocolChangeEntry[] newest first */ ] } ``` -`migrationRequired` is true when at least one matching entry is breaking or -declares a required migration, so a caller can branch on one boolean. +--- -## Entry shape +## Normal path + +1. `ProtocolChangelogService.onModuleInit()` reads and validates + `changelog/protocol-changes.json` on first start. +2. The document is cached in memory for the life of the process. +3. `GET /changelog/protocol` returns a filtered view with no database or network I/O. + +--- + +## Failure path + +| Failure | Behaviour | +|---|---| +| File missing | `loadProtocolChangelog` throws `Unable to read … at `. The module logs the error but does not crash. Subsequent `getDocument()` calls re-throw until the file is restored and `reload()` is called. | +| File not valid JSON | Same as above; error message contains `is not valid JSON`. | +| File invalid (schema or semantic) | `validateProtocolChangelog` returns a violations array; `loadProtocolChangelog` throws listing every rule that was broken. | +| Schema version mismatch | Rejected with `unsupported-schema-version`. Upgrade the schema and re-run `npm run changelog:validate` before deploying. | + +The endpoint never returns a partial or unchecked document. Either it serves the +last fully validated snapshot or the process logs an error at start-up. + +--- + +## Retry / reload path + +`ProtocolChangelogService.reload()` lets an operator hot-swap the changelog +file without restarting the process, e.g. after a hotfix deploy: + +```bash +# 1. Edit / regenerate the changelog. +npm run changelog:validate + +# 2. Send a reload signal to the running process. +# (Exact mechanism depends on your runtime: SIGHUP handler, admin endpoint, etc.) +``` + +**Rollback guarantee**: if the new file fails validation, `reload()` throws and +the previous in-memory document is preserved. Callers continue to get the last +known-good responses until the file is corrected. -```json -{ - "version": "0.3.0", - "date": "2026-09-27", - "status": "unreleased", - "impact": "breaking", - "title": "Response envelope replaced by the disclosure block", - "summary": "Every reconciliation response is wrapped in a disclosure envelope.", - "protocolSurfaces": [ - { - "kind": "http", - "method": "GET", - "path": "/reconcile/stellar/tx/{txid}", - "description": "The transaction payload moves under data." - } - ], - "migration": { - "required": true, - "automated": false, - "steps": ["Send ?details=standard to opt into the new response envelope."], - "notes": "The legacy envelope is removed in 1.0.0." - }, - "breakingChanges": [ - { - "description": "The transaction payload moved under data.", - "replacement": "Read data.transaction instead of the top-level payload.", - "issue": 125 - } - ], - "relatedIssues": [125] -} ``` +reload() called + ├── loadProtocolChangelog() succeeds → cache replaced, new doc returned + └── loadProtocolChangelog() throws → previous doc retained, error re-thrown +``` + +--- -- `impact`: `breaking`, `deprecation`, `compatible`, `fix` or `security`. It is - the worst case for a caller that does nothing. -- `status`: `unreleased` marks merged work that is not cut as a release yet. -- `migration.automated` says whether tooling applies the migration or a human - does. -- `protocolSurfaces` uses `path` for `http`/`websocket`/`graphql`/`cli` and `key` - for `config`. +## Authorization path -## Validation +The `GET /changelog/protocol` endpoint is intentionally unauthenticated: -The schema covers structure; a second layer covers the rules a schema cannot -express. Both run in CI and locally, and both are required to pass before the -API will serve the file. +- `ChangelogController` carries `@Public()` at the class level. +- `StrategyAuthGuard.canActivate()` reads `IS_PUBLIC_KEY` and returns `true` + without inspecting any `Authorization` header. +- No `@Roles()`, `@Permissions()`, or `KycGuard` annotations are present on + the controller or its handler. + +This means **any client, including callers with no account**, can discover what +protocol changes exist before deciding whether to upgrade. Sensitive operational +data (e.g. migration steps) is designed to be public; do not add secrets to +changelog entries. + +--- + +## Validator ```bash +# Validate the committed changelog (used by CI): npm run changelog:validate + +# Validate a different file: +npm run changelog:validate -- --file changelog/__fixtures__/breaking-entry.json + +# Machine-readable output for pipeline scripts: npm run changelog:validate -- --json -npm run changelog:validate -- --file path/to/other.json -npm run changelog:validate -- --now 2026-09-27T00:00:00Z # deterministic + +# Pin the clock (deterministic CI): +npm run changelog:validate -- --now 2026-09-27T12:00:00Z +``` + +Exit codes: `0` = valid, `1` = semantic violations, `2` = unreadable or +non-JSON file. + +The script and the runtime API share `validateProtocolChangelog()`, so a +file that passes the script cannot fail at runtime. + +--- + +## Semantic rules + +| Rule | When triggered | +|---|---| +| `breaking-without-description` | `impact: "breaking"` with no `breakingChanges` | +| `breaking-without-migration` | `impact: "breaking"` with `migration.required: false` | +| `migration-without-steps` | `migration.required: true` but `steps` is empty | +| `migration-step-not-actionable` | A step does not end with `.` | +| `deprecation-removal-not-after-version` | `removalVersion ≤ entry.version` | +| `deprecation-without-impact` | `deprecations` present but `impact` is not `deprecation` or `breaking` | +| `http-surface-without-method` | `kind: "http"` surface with no `method` | +| `surface-without-path` | HTTP/WebSocket/GraphQL surface with no `path` | +| `surface-without-key` | `kind: "config"` surface with no `key` | +| `duplicate-surface` | Same `kind + method + path` appears twice in one entry | +| `untraceable-entry` | No `relatedIssues` and no `relatedPullRequests` | +| `duplicate-entry` | Same `version + title` appears more than once | +| `entries-out-of-order` | Entries are not sorted newest first | +| `date-not-a-calendar-date` | `date` is not a real calendar day (e.g. `2026-02-30`) | +| `date-in-the-future` | `date` is more than one day ahead of the current UTC date | +| `unsupported-schema-version` | `schemaVersion` is not the version this build understands | + +--- + +## Edge cases and recovery + +### Deployment with a broken changelog + +If `changelog/protocol-changes.json` is accidentally committed in an invalid +state, `onModuleInit` logs the error but **does not crash** the process. The +endpoint will throw until the service is hot-reloaded or restarted with a +corrected file. The `changelog:validate` CI step catches this before it reaches +production. + +### Clock skew + +The validator allows one day of tolerance for future-dated entries +(`FUTURE_DATE_TOLERANCE_DAYS = 1`). Entries marked `status: "unreleased"` are +expected to carry the date they are expected to ship; the rule is only enforced +once they are more than 24 hours into the future. + +### CHANGELOG_PATH override + +Set `CHANGELOG_PATH` to an absolute path or a path relative to the repository +root to point the service at a non-default file. Both the validator script and +the runtime read the same variable. + +```bash +CHANGELOG_PATH=/var/trellis/protocol-changes.json npm run start:prod ``` -| Rule | Why | -| --- | --- | -| `required`, `enum`, `pattern`, `additionalProperties` | Missing fields, bad values and typos fail instead of being ignored. | -| `date-not-a-calendar-date`, `date-in-the-future` | A typo such as `2026-02-30` or a date ahead of today is rejected. | -| `breaking-without-description` | A breaking entry must say what breaks. | -| `breaking-without-migration` | A breaking entry must set `migration.required`. | -| `migration-without-steps` | A required migration must list steps. | -| `migration-step-not-actionable` | A step must be a complete, imperative sentence. | -| `deprecation-without-impact` | Deprecations imply `impact: deprecation` or `breaking`. | -| `deprecation-removal-not-after-version` | A deprecation needs a removal version later than its own. | -| `surface-without-path`, `surface-without-key`, `http-surface-without-method` | A surface must identify what it changes. | -| `duplicate-surface` | The same surface is listed once per entry. | -| `untraceable-entry` | Every entry references an issue or a pull request. | -| `duplicate-entry` | A copy-pasted entry within one release is rejected. | -| `entries-out-of-order` | Entries are ordered newest first. | -| `unsupported-schema-version` | The file matches the schema this build understands. | - -Validation runs with an injected clock, so `--now` makes a run reproducible -instead of failing the day after a release is dated. - -## Adding an entry - -1. Add an object to `changelog/protocol-changes.json`, newest version first, - with `relatedIssues` filled in. -2. Run `npm run changelog:validate`. -3. The endpoint picks it up on the next deploy; there is no generation step. +### Rollback + +If a release introduces a changelog entry that must be reverted: + +1. Remove or revert the entry in `changelog/protocol-changes.json`. +2. Run `npm run changelog:validate` to confirm the file is still valid. +3. Deploy the reverted file. If the process is already running, trigger + `reload()` (or restart the process). + +The file is append-only by convention. Do not change the `status` of an +existing `released` entry; add a new entry with `impact: "fix"` or +`impact: "breaking"` and a migration block instead. + +### Contract preservation + +The response shape is versioned with `schemaVersion`. Consumers must check +`schemaVersion` before deserializing. Additive fields (new optional properties) +do not increment the schema version. Field removal or type changes require a +`schemaVersion` bump and a changelog entry with `impact: "breaking"`. + +--- + +## Testing + +```bash +# Unit + integration specs (validator + service + controller): +npx jest src/changelog --runInBand + +# Executable validation (no Jest, exits non-zero on any violation): +npm run changelog:validate + +# Full test suite: +npm test +``` + +Tests cover: + +- Normal query path (unfiltered, `since`, `impact`, `status` filters) +- Failure path (missing file, non-JSON, invalid file) +- Retry / reload path (successful reload, rollback on failure, recovery after bad start) +- Authorization path (`@Public()` metadata present, guard bypasses auth, unauthenticated controller call succeeds) +- Structural schema rules (missing fields, unknown fields, bad enums, bad semver) +- Semantic rules (breaking changes, migrations, deprecations, surfaces, ordering, traceability) diff --git a/src/changelog/protocol-changelog.service.spec.ts b/src/changelog/protocol-changelog.service.spec.ts index 4c9c7844..cf49a2ad 100644 --- a/src/changelog/protocol-changelog.service.spec.ts +++ b/src/changelog/protocol-changelog.service.spec.ts @@ -1,14 +1,19 @@ /** * Behaviour of the protocol changelog read model and its HTTP surface. * - * Issue: #126 + * Covers normal, failure, retry (reload) and authorization paths per issue #352. + * + * Issue: #126 / #352 */ import { Test } from "@nestjs/testing"; import { ConfigService } from "@nestjs/config"; +import { Reflector } from "@nestjs/core"; +import { ExecutionContext } from "@nestjs/common"; import { join } from "path"; import { ProtocolChangelogService } from "./protocol-changelog.service"; import { ChangelogController } from "./changelog.controller"; +import { IS_PUBLIC_KEY } from "../common/decorators/public.decorator"; const REPO_ROOT = join(__dirname, "..", ".."); @@ -147,3 +152,125 @@ describe("ChangelogController", () => { expect(body.affectedPaths).toContain("/changelog/protocol"); }); }); + +describe("ProtocolChangelogService – retry / reload path", () => { + it("reloads and returns a fresh document when the file is valid", () => { + const service = buildService(); + // Warm the cache once. + const first = service.getDocument(); + expect(first.entries.length).toBeGreaterThan(0); + + // Reload must succeed and return the same content (file has not changed). + const second = service.reload(); + expect(second.project).toBe(first.project); + expect(second.entries).toHaveLength(first.entries.length); + }); + + it("preserves the previous document when the reloaded file is invalid", () => { + // Start from a known-good changelog. + const service = buildService(); + const goodDoc = service.getDocument(); + expect(goodDoc.entries.length).toBeGreaterThan(0); + + // Swap to a bad path – reload must throw and NOT wipe the cache. + (service as unknown as { configService: ConfigService }).configService = + new ConfigService({ CHANGELOG_PATH: join(REPO_ROOT, "changelog/__fixtures__/invalid-entry.json") }); + + expect(() => service.reload()).toThrow(/is invalid/); + + // The service must still answer queries from the old cache. + const fallback = service.getDocument(); + expect(fallback.entries).toHaveLength(goodDoc.entries.length); + }); + + it("preserves the previous document when the reloaded file is missing", () => { + const service = buildService(); + const goodDoc = service.getDocument(); + + (service as unknown as { configService: ConfigService }).configService = + new ConfigService({ CHANGELOG_PATH: join(REPO_ROOT, "changelog/does-not-exist.json") }); + + expect(() => service.reload()).toThrow(/Unable to read/); + + expect(service.getDocument().entries).toHaveLength(goodDoc.entries.length); + }); + + it("recovers after a failed initial load once the path is corrected", () => { + // Service starts with a broken path so onModuleInit logging fires. + const service = buildService(join(REPO_ROOT, "changelog/nope.json")); + // document is null – getDocument throws. + expect(() => service.getDocument()).toThrow(/Unable to read/); + + // Swap to the real file and reload. + (service as unknown as { configService: ConfigService }).configService = + new ConfigService({}); + + const recovered = service.reload(); + expect(recovered.entries.length).toBeGreaterThan(0); + // Subsequent queries now work. + expect(service.query().entries.length).toBeGreaterThan(0); + }); +}); + +describe("ChangelogController – authorization path", () => { + it("the controller class is decorated with @Public so unauthenticated callers are allowed", () => { + // The StrategyAuthGuard reads IS_PUBLIC_KEY from the class or handler + // metadata. This test confirms the metadata is present, which means the + // guard will return true without checking a token. + const reflector = new Reflector(); + const meta = Reflect.getMetadata(IS_PUBLIC_KEY, ChangelogController); + expect(meta).toBe(true); + }); + + it("the strategy auth guard grants access to a public route without a token", async () => { + // Simulate what StrategyAuthGuard.canActivate() does for an unauthenticated + // caller hitting GET /changelog/protocol. + const reflector = new Reflector(); + const isPublic = reflector.getAllAndOverride(IS_PUBLIC_KEY, [ + // handler metadata (getProtocolChangelog method) + Reflect.getMetadata("method", ChangelogController.prototype, "getProtocolChangelog") ?? {}, + // class metadata + ChangelogController, + ]); + + // The class-level @Public() sets the flag; the guard returns true. + expect(isPublic).toBe(true); + }); + + it("returns 200 with empty query params – no auth token supplied", async () => { + const moduleRef = await Test.createTestingModule({ + controllers: [ChangelogController], + providers: [ + ProtocolChangelogService, + { provide: ConfigService, useValue: new ConfigService({}) }, + ], + }).compile(); + + // Direct controller call with no auth context – mirrors the guard allowing + // through because IS_PUBLIC_KEY is set. + const body = moduleRef + .get(ChangelogController) + .getProtocolChangelog({}); + + expect(body.schemaVersion).toBe("1.0.0"); + expect(body.migrationRequired).toBe(false); + }); + + it("returns only breaking entries when the impact filter is applied", async () => { + const moduleRef = await Test.createTestingModule({ + controllers: [ChangelogController], + providers: [ + ProtocolChangelogService, + { provide: ConfigService, useValue: new ConfigService({}) }, + ], + }).compile(); + + // Main changelog has no breaking entries; result set is empty but valid. + const body = moduleRef + .get(ChangelogController) + .getProtocolChangelog({ impact: ["breaking"] }); + + expect(body.entries.every((e) => e.impact === "breaking")).toBe(true); + expect(body.highestImpact).toBeNull(); + }); +}); diff --git a/src/changelog/protocol-changelog.service.ts b/src/changelog/protocol-changelog.service.ts index b3367e6b..58cd76d4 100644 --- a/src/changelog/protocol-changelog.service.ts +++ b/src/changelog/protocol-changelog.service.ts @@ -99,6 +99,28 @@ export class ProtocolChangelogService implements OnModuleInit { return this.document ?? this.load(); } + /** + * Reloads the changelog from disk and replaces the in-memory cache. + * + * Operators can call this (e.g. via an admin endpoint or SIGHUP handler) after + * rotating the changelog file without restarting the process. If the new file + * is invalid the old document is preserved and an error is thrown, so a bad + * deploy cannot erase a working changelog. + */ + reload(): ProtocolChangelogDocument { + const previous = this.document; + try { + return this.load(); + } catch (error) { + // Roll back to the last known-good document. + this.document = previous; + this.logger.error( + `reload() failed – retaining previous changelog: ${(error as Error).message}`, + ); + throw error; + } + } + /** Exposed so the validator script and the API share one code path. */ validate(now: Date = new Date()): ProtocolChangelogValidationResult { const raw = readFileSync(this.resolvePath(), "utf8");