Skip to content

Signed content packages and device-authenticated content sync for boards - #18

Merged
IbraTech04 merged 6 commits into
masterfrom
claude/awesome-turing-bdgpfd
Sep 25, 2026
Merged

IbraTech04 merged 6 commits into
masterfrom
claude/awesome-turing-bdgpfd

Conversation

@IbraTech04

Copy link
Copy Markdown
Collaborator

Backend half of the MusallahBoard local-first architecture (contract: docs/architecture.md in 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 from MUSALLAHBOARD_CONTENT_SIGNING_KEY (base64 32-byte seed); optional previous-public-keys for rotation. Without a key the app still starts and the signed endpoints return 503; a malformed key fails startup on purpose.
  • Offline bundle export now produces the signed v2 .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) and contentSigningKeys on 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.yml updated.

Testing

  • mvn test: 599 tests, 0 failures. Cross-language test vector matches the Go agent.
  • A package built by this code verifies and installs with the Go agent (full and delta).

Notes for review

  • A poster with no image still fails the whole package with 502 (v1 behaviour), which for online boards means sync keeps failing until it is fixed; the board keeps showing its last content.
  • openapi.yaml was edited by hand, not regenerated.

🤖 Generated with Claude Code

https://claude.ai/code/session_01VUxfdFFUkL7SmAEUvUzspy


Generated by Claude Code

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

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +215 to +216
createdAt.toString(),
createdAt.toEpochMilli(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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
@IbraTech04
IbraTech04 merged commit 74868f4 into master Sep 25, 2026
1 check failed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants