fix(display): define the display–server messages once, in src/shared
Cause of the production "Offline / Not offline ready" roster.
The display sends reportedContributionId: null for every native-image slide.
The heartbeat route's optionalString accepted only a missing field, so it refused null with a 400.
Every heartbeat from such a screen was rejected silently.
lastSeenAt moved only when a page opened its viewer session, so the roster said Offline 90 s later while the screen was fine.
offlineReady and cachedAssetCount were never recorded.
Reproduced in a real browser against platform v0.1.1 + Signage v0.1.1 (Ekklesia draft PR #56): 27 of 27 heartbeats in one display's 5.5-minute run were 400.
Why the tests missed it: each side was tested only against its own idea of the other. The display tests stubbed the heartbeat route with {}, and the viewer tests called the service directly with hand-written objects.
Commit 1, the failing test first
tests/browser/display-contract.test.ts answers the real display controller's requests with the real viewer route handlers, over the in-memory store.
The routes gain an optional store factory for this; production still uses the module database.
On main: POST /viewers/:viewerId/heartbeat → 400 SIGNAGE_INVALID_REQUEST, and the viewer's reported selection stays null.
Commit 2, the fix
src/shared/display-protocol.ts defines every display ↔ server message, and both halves import it:
Message
Server
Display
POST viewers/session
parses the request, types the response
parses the response (was a cast)
POST viewers/:id/heartbeat
parses the request, types the response
builds the request as the shared type
GET channels/:id/display-state
its state type extends the wire type, so drift is a compile error
reads the wire type (was its own copy)
realtime display-state
the publication is typed
reads the group revisions it needs
Rules for every message:
A nullable field accepts null explicitly.
An absent field means "not reported", so the receiver keeps what it had.
An unknown field is ignored, so a newer display is never refused by an older server.
A refusal is Signage's sanitized SIGNAGE_INVALID_REQUEST naming the field.
One behaviour change: an absentreported* field used to clear the stored value, and now keeps it. The display always sends every field, so no real display is affected.
Test stubs: they described server responses more loosely than the server sends them. They now match the real responses (viewerName, stale, full media records) and are typed as the protocol.
Verification
Signage has no CI. Run locally in an Ekklesia main (= v0.1.1) workspace:
pnpm --filter @ekklesia/signage test: 220 passed, including 20 new protocol tests and the 2 contract tests;
pnpm --filter @ekklesia/signage typecheck: clean;
pnpm --filter @ekklesia/signage lint: clean.
Not in this PR
Retry and recovery for the display's own requests (the next PR) and blob-URL release (the PR after). Showing rejected reports in the roster is a later PR.
## fix(display): define the display–server messages once, in `src/shared`
**Cause of the production "Offline / Not offline ready" roster.**
- The display sends `reportedContributionId: null` for every native-image slide.
- The heartbeat route's `optionalString` accepted only a *missing* field, so it refused `null` with a 400.
- Every heartbeat from such a screen was rejected silently.
- `lastSeenAt` moved only when a page opened its viewer session, so the roster said Offline 90 s later while the screen was fine.
- `offlineReady` and `cachedAssetCount` were never recorded.
Reproduced in a real browser against platform v0.1.1 + Signage v0.1.1 (Ekklesia draft PR #56): 27 of 27 heartbeats in one display's 5.5-minute run were `400`.
**Why the tests missed it:** each side was tested only against its own idea of the other. The display tests stubbed the heartbeat route with `{}`, and the viewer tests called the service directly with hand-written objects.
### Commit 1, the failing test first
`tests/browser/display-contract.test.ts` answers the **real** display controller's requests with the **real** viewer route handlers, over the in-memory store.
- The routes gain an optional store factory for this; production still uses the module database.
- On `main`: `POST /viewers/:viewerId/heartbeat` → `400 SIGNAGE_INVALID_REQUEST`, and the viewer's reported selection stays `null`.
### Commit 2, the fix
`src/shared/display-protocol.ts` defines every display ↔ server message, and both halves import it:
| Message | Server | Display |
|---|---|---|
| `POST viewers/session` | parses the request, types the response | **parses** the response (was a cast) |
| `POST viewers/:id/heartbeat` | parses the request, types the response | builds the request as the shared type |
| `GET channels/:id/display-state` | its state type **extends** the wire type, so drift is a compile error | reads the wire type (was its own copy) |
| realtime `display-state` | the publication is typed | reads the group revisions it needs |
Rules for every message:
- A nullable field accepts `null` explicitly.
- An absent field means "not reported", so the receiver keeps what it had.
- An unknown field is ignored, so a newer display is never refused by an older server.
A refusal is Signage's sanitized `SIGNAGE_INVALID_REQUEST` naming the field.
**One behaviour change:** an *absent* `reported*` field used to clear the stored value, and now keeps it. The display always sends every field, so no real display is affected.
**Test stubs:** they described server responses more loosely than the server sends them. They now match the real responses (`viewerName`, `stale`, full media records) and are typed as the protocol.
### Verification
Signage has no CI. Run locally in an Ekklesia `main` (= v0.1.1) workspace:
- `pnpm --filter @ekklesia/signage test`: 220 passed, including 20 new protocol tests and the 2 contract tests;
- `pnpm --filter @ekklesia/signage typecheck`: clean;
- `pnpm --filter @ekklesia/signage lint`: clean.
### Not in this PR
Retry and recovery for the display's own requests (the next PR) and blob-URL release (the PR after). Showing rejected reports in the roster is a later PR.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Every display test answered the controller with a hand-written stub, and
every viewer test called the service with a hand-written object, so
neither side was ever tested against the other. In production every
heartbeat was refused with a 400.
This suite answers the real controller's requests with the real viewer
route handlers over the in-memory store. The routes gain an optional
store factory for that; production still uses the module database.
Fails today: POST /viewers/:viewerId/heartbeat -> 400
SIGNAGE_INVALID_REQUEST, because the display sends
reportedContributionId: null and the route accepts only a missing field.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The display controller and the viewer routes each described the
heartbeat their own way and disagreed about one thing: the display sent
null for "nothing to report", and the route accepted only a missing
field. Every heartbeat from a screen showing a native image was refused
with a 400. The roster therefore marked working screens Offline 90 s
after they opened, and never recorded offlineReady or cachedAssetCount.
src/shared/display-protocol.ts now defines every message between a
display and the server:
- viewer session request and response;
- heartbeat request and response;
- channel state;
- the realtime display payload.
Both halves import it. Its rules: a nullable field accepts null
explicitly, an absent field means "not reported", and an unknown field
is ignored, so a newer display is never refused by an older server.
- The routes parse with the shared parsers, and a refusal names the
field with Signage's sanitized code.
- The server's channel state extends the wire type, so a field the
display relies on cannot drift without a compile error.
- The display builds its heartbeat as the shared type, and reads its
registration with the shared parser instead of casting it.
- Test stubs that described server responses more loosely than the
server sends them (no viewerName, no stale, partial media) now match
the real responses and are typed as the protocol.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
fix(display): define the display–server messages once, in
src/sharedCause of the production "Offline / Not offline ready" roster.
reportedContributionId: nullfor every native-image slide.optionalStringaccepted only a missing field, so it refusednullwith a 400.lastSeenAtmoved only when a page opened its viewer session, so the roster said Offline 90 s later while the screen was fine.offlineReadyandcachedAssetCountwere never recorded.Reproduced in a real browser against platform v0.1.1 + Signage v0.1.1 (Ekklesia draft PR #56): 27 of 27 heartbeats in one display's 5.5-minute run were
400.Why the tests missed it: each side was tested only against its own idea of the other. The display tests stubbed the heartbeat route with
{}, and the viewer tests called the service directly with hand-written objects.Commit 1, the failing test first
tests/browser/display-contract.test.tsanswers the real display controller's requests with the real viewer route handlers, over the in-memory store.main:POST /viewers/:viewerId/heartbeat→400 SIGNAGE_INVALID_REQUEST, and the viewer's reported selection staysnull.Commit 2, the fix
src/shared/display-protocol.tsdefines every display ↔ server message, and both halves import it:POST viewers/sessionPOST viewers/:id/heartbeatGET channels/:id/display-statedisplay-stateRules for every message:
nullexplicitly.A refusal is Signage's sanitized
SIGNAGE_INVALID_REQUESTnaming the field.One behaviour change: an absent
reported*field used to clear the stored value, and now keeps it. The display always sends every field, so no real display is affected.Test stubs: they described server responses more loosely than the server sends them. They now match the real responses (
viewerName,stale, full media records) and are typed as the protocol.Verification
Signage has no CI. Run locally in an Ekklesia
main(= v0.1.1) workspace:pnpm --filter @ekklesia/signage test: 220 passed, including 20 new protocol tests and the 2 contract tests;pnpm --filter @ekklesia/signage typecheck: clean;pnpm --filter @ekklesia/signage lint: clean.Not in this PR
Retry and recovery for the display's own requests (the next PR) and blob-URL release (the PR after). Showing rejected reports in the roster is a later PR.
🤖 Generated with Claude Code
ekklesia-bot referenced this pull request2026-10-05 23:37:47 +00:00
776540ff78tof1b7d67aa5