Repository navigation
Signed content packages and device-authenticated content sync for boards - #18
Conversation
Implements the backend side of the MusallahBoard on-device architecture v2 (agent/docs/architecture.md, sections 4, 5, 9.1-9.3): - ContentSigningService loads the Ed25519 content key (musallahboard.content-signing.private-key / MUSALLAHBOARD_CONTENT_SIGNING_KEY, base64 32-byte seed) and optional previous public keys. Without a key the app still starts: signed endpoints answer 503, enrollment returns no keys, one WARN. - OfflineBundleService now builds signed content packages: mbu.json (format 2, type content, sequence = createdAt millis, files with sha256/bytes, content block) signed over "musallahboard-mbu-v2\n" + the exact stored bytes, mbu.sig, payloads and media. Media the board already holds (haveMedia) is listed but left out, and a bounded metadata cache avoids re-downloading it from R2. Media content types are restricted to the ones the agent accepts. - Admin offline-bundle download returns the signed .mbu (application/vnd.musallahboard.mbu, musallahboard-content-<id8>-<firstDay>.mbu). - DeviceRequestAuthenticator implements device HTTP auth (X-MB-* headers, musallahboard-http-v1 message over the raw body hash, 5-minute skew). - New POST /api/agent/content-bundle (device auth) and GET /api/agent/signing-keys; enrollment returns contentSigningKeys. openapi.yaml updated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VUxfdFFUkL7SmAEUvUzspy
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: ccbc8b7d59
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| createdAt.toString(), | ||
| createdAt.toEpochMilli(), |
There was a problem hiding this comment.
Keep content package sequences strictly increasing
Truncating createdAt to seconds and then deriving sequence from it means any two packages built for the same device within one second receive the same anti-rollback value. If the board installs the first package and content changes before a second sync in that second, an agent that requires a newer sequence will reject the updated signed package; a clock adjustment backward has the same failure mode. Use a durable/per-device monotonic sequence (or at least preserve milliseconds while guaranteeing monotonicity) instead of the truncated wall-clock value.
Useful? React with 👍 / 👎.
Boards are local-first: they install signed per-day content packages and
never fetch the live payload, so GET /api/musallah/payload goes, along with
what only it used:
- BoardPayloadAssembler.assemble(UUID), its lenient per-producer path and
its DeviceRepository/OpenWeatherService dependencies
- BoardContext.of(Device, ZoneId) and the wholeDay flag; every context is
now a whole day
- the "active right now" poster path: PosterFrameProducer's live branch,
PosterService.getActivePosterFramesForAudience and
PosterRepository.findActivePostersForAudienceAt
Weather cannot live in a package built days ahead, so boards fetch it from
the new GET /api/agent/weather, device-authenticated like the content
bundle (signature over the empty body). It returns
{"weather": <OpenWeatherService.getCurrentWeather()> or null, "fetchedAt"}
and never 5xx because weather is unavailable.
openapi.yaml drops the payload path and adds the weather path. The
MusallahBoardPayload schema stays as a component (registered in
OpenApiConfig) since it is still the payloads/<date>.json format.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUxfdFFUkL7SmAEUvUzspy
…dies
Device-authenticated agent endpoints now read like any other controller:
public void contentBundle(@AuthenticatedDevice Device device,
@Valid @RequestBody(required = false) ContentBundleRequest body, ...)
- DeviceBodyCachingFilter reads /api/agent/ request bodies (at most 1 MiB)
so the signature can be checked over the exact bytes and Spring can
still bind @RequestBody afterwards
- DeviceAuthInterceptor verifies the signature before any argument is
resolved, so an unauthenticated request is always a 401, never a 400
- AuthenticatedDeviceArgumentResolver hands the verified Device to the
controller, mirroring CurrentUserArgumentResolver
- ContentBundleRequest validates itself with bean validation
- An OperationCustomizer documents the X-MB-* headers and the 401 once
for every @AuthenticatedDevice endpoint
- Tests: unsigned malformed body is 401; oversized body is 413
The wire protocol is unchanged; agents need no update.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUxfdFFUkL7SmAEUvUzspy
The JDK has no API for "public key of this Ed25519 seed", so the signing service fed the seed to a key pair generator through a fake SecureRandom and then signed and verified a probe message at startup to prove the trick worked. That read like test scaffolding in production code. BouncyCastle does it directly: Ed25519PrivateKeyParameters(seed) .generatePublicKey(), and Ed25519Signer for signing. The fake RNG, the startup self-test and the hand-built PKCS#8/X.509 DER prefixes are gone. Keys, key ids and signatures are unchanged (the cross-language test vector still passes). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VUxfdFFUkL7SmAEUvUzspy
… docs - DeviceBodyCachingFilter matches the path within the application, like the interceptor's /api/agent/** pattern, so a context path or X-Forwarded-Prefix cannot leave device requests without a cached body - Content package sequence keeps the build time's milliseconds, so two builds in the same second are ordered (createdAt stays whole seconds) - ContentBundleRequest reuses OfflineBundleService.MAX_DAYS, and both DEFAULT_DAYS constants say why they differ (7 for sync, 14 for visits) - DeviceRequestAuthenticator and AgentContentController javadocs describe the @AuthenticatedDevice flow instead of the old hand-parsed body Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VUxfdFFUkL7SmAEUvUzspy
…nal keys Nothing on the board side has been deployed, so: - HeartbeatFrame no longer accepts the old displayedFrameId name - The "legacy REFRESH substring" test is gone; no kiosk parses that way - contentSigningKeys is a required field of the enrollment response (an empty list without a key) instead of optional for older agents Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VUxfdFFUkL7SmAEUvUzspy
Backend half of the MusallahBoard local-first architecture (contract:
docs/architecture.mdin LensBridge/agent, sections 4, 5 and 9). Kept small and contained; includes the earlier offline bundle export commit, which this branch builds on.Changes
ContentSigningService: Ed25519 content key fromMUSALLAHBOARD_CONTENT_SIGNING_KEY(base64 32-byte seed); optionalprevious-public-keysfor rotation. Without a key the app still starts and the signed endpoints return 503; a malformed key fails startup on purpose..mbu(mbu.json+mbu.sig),application/vnd.musallahboard.mbu.POST /api/agent/content-bundle: device-authenticated (Ed25519 request signature over method, path, device, timestamp and body hash; 5 min skew), returns a package that omits media the board already has (haveMedia).GET /api/agent/signing-keys(public) andcontentSigningKeyson the enrollment response, so boards pin the content key.MediaMetadataCache: a sync where the board has every image downloads nothing from R2.openapi.yaml,application.properties.example,docker-compose.ymlupdated.Testing
mvn test: 599 tests, 0 failures. Cross-language test vector matches the Go agent.Notes for review
openapi.yamlwas edited by hand, not regenerated.🤖 Generated with Claude Code
https://claude.ai/code/session_01VUxfdFFUkL7SmAEUvUzspy
Generated by Claude Code