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:
- identity trust downgrade interval defaults to 30 days without renewal;
- revocation propagation TTL defaults to 7 days over peer channels.
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:
GET /api/policy/trust
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:
GET /api/meshtastic/hardware_trial
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:
GET /api/peer/outbound_queue/:id/payloadPOST /api/peer/inbox_payload
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:
GET /api/peer/outbound_queue/:id/linux_wio_sx1262_txPOST /api/peer/inbox_linux_wio_sx1262
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.