Login
Architecture decisions
Login

API payloads are explicit JSON models

POST /api/messages now deserializes into CreateMessageRequest instead of a generic hash. This keeps the route machine-readable with consistent 422 JSON failures when body fields are missing or malformed, and keeps endpoint logic compact.

Parsing and message creation are delegated to MessageCreationService, so route handlers remain small and focused on HTTP concerns while domain validation continues in TimelineService. The service normalizes parse/shape failures to compact, stable error strings so API clients can rely on consistent machine-readable 422 responses.

Keep runtime dependencies minimal

The project currently uses Kemal as its only direct shard dependency. Additional UI/database shards were removed because they were not used by the current API-first implementation. This keeps setup simple for peer-node deployments.

V1 trust and input defaults are fixed

To remove ambiguity from v1 delivery planning, two operational defaults are now fixed:

USB keyboard input is enabled for badge-local authoring screens only. Web interaction remains web-interface driven and does not model direct keyboard attachment as an app-level input mode. The badge interaction model remains browse-first by default, which keeps v1 composition practical without requiring an on-screen keyboard.

V1 trust defaults are exposed as API resources

PolicyService centralizes fixed v1 trust defaults in one composable service object. Kemal exposes this via a machine-readable JSON endpoint:

This keeps route handlers concise while making operational defaults discoverable to clients without duplicating constants across UI code.

Meshtastic envelopes are canonical for peer relay

MeshtasticEnvelopeService projects timeline messages into compact envelopes with explicit dedupe keys, trust level, and relay-hop metadata. The envelope model enforces v1 guardrails for payload/body size and compact metadata lengths so peer transport always uses Meshtastic-friendly representations.

Peer relay baseline uses explicit queue + retry state

PeerTransportService owns outbound relay queue state, exponential retry backoff, and inbound peer envelope ingestion. PeerRelayService keeps HTTP parsing concerns separate from transport state transitions. Kemal exposes this baseline via JSON endpoints for enqueueing relay jobs, inspecting queue state, marking delivery outcomes, and accepting inbound envelopes.

Local persistence starts with JSON snapshots

To keep dependencies minimal while meeting v1 durability needs, local state now persists as JSON snapshots written under a configurable data directory (SOCIAL_BADGE_DATA_DIR, default data/). TimelineService saves identity, messages, and dedupe keys, while PeerTransportService persists outbound relay jobs. The JSON snapshots can be replaced by a SQLite-backed store later without changing the public service interfaces.

Web typesetting is canonical; badge consumes rendered image artifacts

For the MSG_320x240 message window, Typst rendering is the canonical typesetting reference. The browser composer calls POST /api/preview/render to render the message window through the same Typst layout package used by fixture tests, while the badge LVGL path consumes deterministic 320x240 image artifacts from that contract.

Authoring preview supports Typst-style placement blocks

The web composer recognizes URLs and Typst-style directives (#font(...), #place(...)[...], #qr(...), #event(...), #contact(...)) as layout controls. Typst directives are non-printing in the body layout so control syntax does not consume message viewport lines. Artifact placement defaults to right-float and can be overridden per block (for example #place(bottom + right)[#qr(\"...\")]). Font selection is message-defined via short IDs so rendering inputs remain self-contained in the message payload. Rendering uses a hard single-page 320x240 limit with no pagination fallback; overflow must be corrected at author time.

Typst package is the fixture renderer for bit-exact layout tests

typst/social-badge/layout.typ defines the deterministic 320x240 message window typesetting contract used for fixture rendering. scripts/check_typst_layouts.cr compiles cases from testdata/typst/layout_cases.json directly from raw message content and verifies SHA-256 hashes for bit-exact regression checks. This keeps layout validation machine-checkable while the browser preview evolves.

Typst QR generation is local and real (no synthetic pattern)

To avoid placeholder QR glyphs, the Typst layout package now imports a vendored local copy of the tiaoma plugin (typst/vendor/tiaoma) and generates real QR code symbols in #qr(...) calls. This keeps fixture rendering offline and repeatable while producing standards-compliant QR output.

Hardware trial readiness is exposed as a machine-readable checklist

HardwareTrialService captures a Meshtastic-focused hardware validation checklist and keeps LoRaWAN network-server work explicitly out of v1 scope for this repository. Kemal exposes this through:

The endpoint lets badge and web tooling consume a stable JSON checklist for real radio testing without hard-coding protocol assumptions in UI clients.

Meshtastic adapter boundary now supports practical payload handoff

MeshtasticAdapterService introduces a dedicated encode/decode boundary for compact Meshtastic payloads with an enforced 233-byte payload budget. The adapter keeps wire keys short and maps trust levels to compact integer codes so payloads stay relay-friendly over constrained radio links.

PeerTransportService now exports queued relay jobs as base64 payloads and accepts base64 payload ingestion for receive flows. Kemal exposes this through JSON endpoints:

This keeps route handlers thin while providing a practical bridge contract for radio or gateway processes that move encoded Meshtastic frames.

Linux Wio SX1262 bridge contract supports Meshtastic and Supercon-style frames

LinuxWioSx1262DriverService adds a dedicated boundary for Linux-side Wio SX1262 radio processes. Outbound relay jobs can now be exported as machine-readable Meshtastic text-app frames (payload_b64, portnum, destination metadata) without exposing queue internals to driver scripts.

Inbound driver frames accept multiple payload key shapes commonly used by Meshtastic wrappers (payload_b64, payload, nested packet.decoded.payload). If canonical envelope decode fails, the service attempts a Supercon-compatible fallback that treats decoded content as plain text and maps it into constrained Meshtastic envelopes with deterministic IDs.

Kemal exposes this through JSON endpoints:

This keeps route handlers thin, maintains compact on-air message defaults, and allows Linux radio bridges to interoperate with Supercon Badge messaging styles.

First-pass badge UI uses an LVGL Applet lifecycle

The badge UI now ships as SocialBadge::BadgeApplet < Lvgl::Applet with explicit setup(screen), loop(screen, message), and cleanup(screen) hooks instead of serving UI state over HTTP.

This aligns with LVGL runtime ownership expectations (single UI loop, explicit teardown) while keeping message presentation compact and Meshtastic-friendly for on-device triage flows.

The web and badge UIs run concurrently from the same src/main.cr process. When no local display/input stack is available, LVGL_BACKEND=headless is used so the badge applet loop can still execute alongside Kemal.

Local identity/storage rescoped from Discourse+OIDC to Fossil forum/user management

The v1 plan originally rooted account identity in an external Discourse instance via OIDC and planned an eventually-SQLite local store for posts/messages. Both are rescoped to use a Fossil repository's own mechanisms instead: the repository user table as the local identity authority, and Fossil forum posts/threads as the local, sync-replicated store for the timeline. Full rationale, the verified Fossil schema/artifact format this relies on, and the phased implementation plan are in docs/fossil-forum-activitypub-integration.md.

This pass adds TrustLevel::FossilLoginVerified and FossilIdentityService (src/social_badge/fossil_identity_service.cr), which maps a Fossil user row (login/cap/info) onto the existing Identity model. TrustLevel keeps FullOidcVerified for now rather than removing it, since nothing in this repo depended on OIDC actually being implemented and the trust-level shape (direct-verified / peer-attested / unverified) is unchanged by the rescope. Forum-backed storage (read and write) and the peer-sync leg are scoped as follow-up phases in the new doc, not implemented in this pass — the write path in particular requires confirming Fossil's /forumnew/ /forumpost form fields against a live server rather than guessing them, since there is no fossil forum CLI/JSON command to build on.

Read-side Fossil forum store: FossilForumReaderService + card parser

FossilForumCardParser (src/social_badge/fossil_forum_card_parser.cr) decodes the plain-text control-artifact format fossil artifact <uuid> returns for forum posts into a new FossilForumPost model (src/social_badge/fossil_forum_models.cr), and FossilForumReaderService (src/social_badge/fossil_forum_reader_service.cr) drives it end-to-end: query the repository's event/forumpost/blob tables for forum-post UUIDs in chronological order, then decode each artifact. Verified against this repository's own live forum, including a real reply post (G/I cards) and a real space-escaped title (\s) — see docs/fossil-forum-activitypub-integration.md for the exact artifacts.

FossilForumPost is deliberately a new type, not Message: forum bodies aren't bounded to Message's 280-byte Meshtastic-friendly limit, and constructing a Message from arbitrary forum content would raise for any post over that size (this repo's own forum posts already exceed it).

Safety finding worth flagging for anyone touching this again: the sqlite3 shard's ?mode=ro connection-string parameter does not actually block writes against this schema. This was discovered by an INSERT through a mode=ro connection succeeding and briefly writing a bogus row into this repository's own event table (caught and removed immediately after). The mechanism that does reliably reject writes is PRAGMA query_only = ON; issued right after opening the connection, which FossilForumReaderService always does before running any query. Do not use ?mode=ro alone for anything that touches a live repository.

Write-side Fossil forum store: FossilForumWriterService

FossilForumWriterService (src/social_badge/fossil_forum_writer_service.cr) delivers the write half of the Fossil forum store: it drives Fossil's plain HTML forum web forms (there is no CLI/JSON write path) through the verified flow POST /login → GET /forumnew or /forumedit?fpid=<root>&reply → POST /forume1//forume2 (preview) → POST /forume1//forume2 (submit), confirmed end-to-end against a disposable local fossil server copy of this repository (never the live one).

The one non-obvious requirement, worth calling out because it cost real debugging time: every editor POST needs a Referer header. Without one, Fossil doesn't error — it silently re-renders the same edit form with the submitted values echoed back, which looks exactly like a rejected submission (bad csrf, missing capability, bad field name) rather than a missing header. Ruled out in order before landing on this: csrf staleness, missing Forum-Write/Forum-Trusted capability, fossil server --repolist vs. single-repo mode, User-Agent/bot filtering. This exact flow, Referer requirement included, is independently documented as already "cracked" in this host's own ChatCGI::ForumWriter implementation (/root/ollama/agent-docs/html/ChatCGI/ForumWriter.html) — found after this repo's own service had already been built and verified against a disposable test repository, and confirmed to match it exactly. See docs/fossil-forum-activitypub-integration.md for the full request/response detail.

The service takes an injectable HTTP transport (FossilForumWriterService::Transport) so spec/fossil_forum_writer_service_spec.cr asserts the exact request sequence — including that Referer is present on every editor POST — with a fake transport, no live fossil server needed in CI.

RSA-SHA256 for HTTP Signatures binds libcrypto directly

HttpSignatureService called OpenSSL::PKey::RSA and OpenSSL::Digest#digest. Neither is in Crystal's standard library (OpenSSL::PKey comes from a separate shard that was never added to shard.yml), so src/main.cr did not compile from the time the signature work landed, and the spec was excluded as a supposed "host OpenSSL gap". SocialBadge::RsaSha256 (src/social_badge/rsa_sha256.cr) instead binds the few EVP calls needed (PEM_read_bio_PUBKEY/PrivateKey, EVP_DigestVerify*, EVP_DigestSign*) against the libcrypto the standard library already links, reusing its LibCrypto types. These calls have the same signatures in OpenSSL 1.1+, OpenSSL 3 and LibreSSL, and no new shard is needed. sign is there for the specs and for signing outbound deliveries later. The spec uses fixed, test-only keys in spec/fixtures/http_signature/, because the standard library can't generate RSA keys.

This is independent of the web front-end, which is planned to move from Kemal to Fossil/OCX through CGI. ActivityPub needs signature verification either way.

The headless check looks for LVGL's test symbols in the running process

lvgl-crystal's headless backend decides whether LVGL's test symbols exist by loading liblvgl.so from a path fixed when the app is compiled, inside the build tree. An installed copy of the app, such as the BeagleBadge build, has no build tree, so the check failed and the app exited before its web server started.

src/social_badge/lvgl_headless_symbols.cr replaces that check: the app links liblvgl.so directly, so it looks the symbols up in the running process with dlopen(NULL). This overrides a method of the shard, so it is debt. It goes away when lvgl-crystal makes the same change upstream.