Consumer gotchas
The short list of things that bite when building an app on the published su.onno:* libraries — each of these has cost a real debugging session. Verified against v1.11.1. Companion pages: CONFIGURATION.md for every property, HEADLESS_READ_API.md for the wire contract, ARCHITECTURE.md for how the pieces fit.
Keep this current. When a gotcha below is fixed or its behaviour changes, update this page in the same PR. See Keeping docs in sync.
Auth & session
onno.auth.public-pathsREPLACES the built-in defaults — it does not append. Set it without re-listing the defaults and login itself starts returning 401. The current default list (repeat all of it when overriding):/error,/api/theme,/api/config,/api/branding,/api/auth/login,/api/auth/me,/api/auth/csrf,/api/divkit/login,/api/desktop/ready,/api/desktop/manifest. Same trap does not apply toonno.auth.csrf-ignored-pathssemantics, but its default is just[/api/auth/login]— SSO callback paths (e.g./api/auth/telegram/**) must be added to both lists.- A blank
onno.auth.session.remember-me.keyfails startup on purpose. Earlier versions silently rotated the key per boot, which invalidated every session cookie on redeploy. Now the app refuses to start with a clear message; for dev,onno.auth.session.remember-me.allow-ephemeral-key: trueopts into a fixed, non-secret built-in key (cookies survive restarts, but never use it in prod). Remember-me is on by default, validity 14d; idle session timeout isonno.auth.session.timeout(default 8h, sliding). - Demo login buttons live under
onno.ui.login.demo-accounts(list of{label, username, password}) — underonno.ui, notonno.auth. They render on the password step of the login screen.
UI chrome & localization
onno.ui.messagesis a fixed key namespace (~190 keys); unknown keys are silently ignored. The authoritative list issu.onno.ui.UiMessages.DEFAULTS— grep it before a localization pass. Resolution is three layers: English defaults →onno.ui.localebundle (ruships complete; a consumer app can addonno/messages/messages-<locale>.propertieson the classpath, which wins) → explicit per-keyonno.ui.messages.*overrides.- Entity/attribute
nameis the API contract — keep it ASCII/URL-safe. It is the REST path segment and the write-path field key. Localize throughtitle/displayName/label(...)/@EnumLabel, never by renamingname(renaming it breaks every client). The old presence/SSE 403 on non-ASCII route segments is fixed, but the naming rule stands.
Model & lifecycle
- There is no
LocalTime(norInstant/OffsetDateTime/ZonedDateTime). Schema generation throws at boot with the supported list:String,int/Integer,long/Long,boolean/Boolean,double/Double,float/Float,BigDecimal,UUID,LocalDate,LocalDateTime, enums,Ref<T>. - No dependency injection inside
rules(),beforeWrite,beforePost,handlePosting,afterPost. In-transaction hooks run on plain reflectively-built instances —@Autowiredfields are null. Anything needing Spring beans belongs in an@EventListeneronDocumentPostedEvent/DocumentUnpostedEvent/EntityChangedEvent.
Wire contract
- Logical entity JSON is the default; storage JSON is an explicit compatibility mode. Catalog/document reads and writes use
id,description,posted, attributefieldNames, and sidecars such ascustomerDisplay. Add?representation=storageonly for a legacy read client. Writes also accept storage aliases during migration, but conflicting logical/storage values are400; updates remain partial. Full tables are in HEADLESS_READ_API.md. - An enum value is its deterministic UUID, never the constant name. The UUID is derived from
FQCN.CONSTANT, so it is stable across databases; the write path rejects"NEW"where it expects the UUID. Display strings/colors come from@EnumLabeland ride theFieldDisplay/FieldColorsidecars ({col}_display/{col}_colorin storage compatibility mode). - Temporal reads and writes are ISO-8601 wall-clock values.
LocalDatereads/writes asyyyy-MM-dd;LocalDateTimereads/writes as offset-freeyyyy-MM-ddTHH:mm[:ss[.fraction]]. SinceLocalDateTimehas no zone, an accepted transport offset (Z,+03:00) is ignored without shifting the local fields:2026-06-04T10:00+03:00persists as2026-06-04T10:00. Since v1.11.1 the read API normalizes PostgreSQL/JDBC timestamp representations and the bundled form normalizes loaded values before resubmitting them. Default logical reads can round-trip writable values without renaming keys; display/ref/color companions are read-only and ignored by partial writes.
SPA & static assets
- The SPA fallback swallows unknown paths — including your static assets. Anything not found under
classpath:/static/ui/returnsindex.htmlwith HTTP 200text/html; only{onno.ui.path}/plugins/**is exempt. A kiosk page or extra asset directory needs its own@GetMappingcontroller. Corollary for API work: if a call returns HTML, you hit a wrong path or aren't authenticated — not a working endpoint.
Live updates (SSE)
/api/eventsemits only NAMED events —EventSource.onmessagereceives nothing. Subscribe withaddEventListenerper event name. Current names:created,updated,deleted,posted,unposted,changed(entity changes; payload{type, entityType, entityName, id, naturalKey, timestamp}), plusready(carriesbootId/devMode),reload,presence,notification, and audience-scopedtasks-changed. Keepalives are SSE comments.- The stream is lossy by design. There is no
Last-Event-ID/since replay; on reconnect (client retries every 3s) refetch the surfaces you care about. Events are role-filtered per subscriber. In a browser, the SPA already multiplexes one connection per origin (Web Locks leader + BroadcastChannel) — don't open a second one per tab.
Custom widgets
- Host UI primitives are exposed to widgets (contract v2+):
Button,Badge,Input,Label,Textarea,Checkbox,Switch,Segmented,DatePicker,Card,Popover,Select— import them from@onno/widget-sdkinstead of rebuilding lookalikes. - Tailwind in widgets works, with two caveats. The
su.onno.widgetsGradle plugin runs Tailwind over widget sources (utilities-only, preflight off, host tokens), but it scans onlysrc/main/widgets, and runtime-concatenated class names (`text-${c}`) are invisible to it — use literal class strings, or inlinestylewithhsl(var(--primary)). - Plugin CSS must load before the host stylesheet. Each widget artifact emits a coordinate-specific
*-widgets.css; these are unscoped Tailwind utilities pass; its selectors tie with the host's on specificity, so document order decides every conflict. Appended after the host sheet, a plugin's bare utility (.flex-col) silently beats the host's responsive variant of the same property (sm:flex-row) on any host element carrying both classes — this once collapsed the desktop date-range popover (presets rail + calendar) into a stacked column.injectPluginStylestherefore inserts plugin<link>s before the first host style; keep that invariant if you touch style injection, and never append third-party utility CSS to the end of<head>.
Forms
- The New form prefills from query params. Append write-path field names to the New route:
/ui/documents/Reservations/new?room=<uuid>&startsAt=2026-07-16T19:00.Ref/enum values are UUID strings, temporals ISO, everything else verbatim;viewport/theme/profileare reserved; unknown keys and bad values are skipped silently. Prefill applies afterOnFillingHandlerand field initializers. This is the way to seed aRefdefault (aRefcan't be a literal field initializer).