Imported from wildflowerhealthio/Wildflower (
slices/apps/AGENTS.md). Install upstream withnpx skills add wildflowerhealthio/Wildflower --skill apps. Copyright stays with the author.
AGENTS.md — slices/apps
App registry, hosting-tier escalation protocol, and the HTTP API for launching FHIR apps. Read the self-hosted-apps README before touching anything under self-hosted-apps/.
Traps
self-hosted-apps/vendored builds are gitignored and unpinned. The third-party app builds it serves are absent from a fresh clone and from CI; they are read live from an app-data directory at runtime. Don't assume they exist, and don't add code that requires them at build or test time. At host startup they're auto-synced into app-data (sync_vendored_self_hosted_appsinapps-rust/src/seed.rs): dev overwrite-mirrors the source tree every run, release copies-if-missing from the bundled resources — both no-op when the builds are absent.- First-party apps never build into
self-hosted-apps/. Medications, Web Trace and Importer build into their ownapps/<app>/distand launch as cloud rows from the published site (their<app>-devrows are cloud rows on the vite dev port, with no fallback content). The only vendored build ispatient-browser/.tauri.conf.jsonstill maps the whole directory intobundle.resources, becausetauri-buildfails the compile on a missing resource path andpatient-browser/is gitignored — see the README's "What ships inbundle.resources". - The serving root is
<app-data>/self-hosted-apps/(renamed frominstalled-apps/). The host creates it at startup if absent; there is no longer any runtime rename of a pre-existinginstalled-apps/dir.setup_self_hosted_app/SelfHostedAppContextare the current names inself-hosted-apps-rust. self_hosted_app_configurationsis no longer read-only. Self-hosted apps are their own root resource: the owner-gatedPOST /self-hosted-appscreate endpoint ismultipart/form-data(name, optionalsubtitle, uploadedbundle) — it inserts anapp_registrationsregistration + itsself_hosted_app_configurationspayload (name-derived slug used verbatim — a clash is rejected400 InvalidName, not suffixed — and an allocated port);PUT /self-hosted-apps/{id}edits the launch path;DELETE /apps/{id}removes non-seeded ones. The migration-seeded rows carryseeded = 1and stay delete/edit-protected (409 AppNotEditable); the per-kind detail shape'sisRemovableis the client-facing removability contract.- Storage is one shared registration + one per-kind configuration (see Polymorphic Rows — apps' approach, one option among a few, contrasted there with gatekeeper grants' table-per-kind): an authoritative
app_registrationstable (the global id space, the shared catalogue fields, and the homescreen placementposition/on_homescreen) plus three per-kind configuration tables (system_app_configurations/cloud_app_configurations/self_hosted_app_configurations), real FKs configuration→registration withON DELETE CASCADE. Thekindcolumn names which configuration holds a registration's payload; a whole app is a(AppRegistration, …Configuration)pair. System apps are ordinary seeded rows (their launch template insystem_app_configurations.url). See Apps Explanation §"Data model". - Domain shape: the store and domain deal only in
AppRegistration(the shared row, diesel-mapped and theGET /appswire item) and a per-kind…AppConfiguration(payload only, plain data). A whole app is a(registration, configuration)pair; there is no "combined app" type anywhere — the launch and delete seams take the pair directly. The write methods take(registration, configuration)and persist only the editable subset (the actions synthesize the pair and gate);find_appreturns the registration + theAppConfigurationunion (the config of runtime-unknown kind), which the launch handler dispatches on andresolve_launch(a free fn inhttp/routes/apps/launch.rs) resolves. Per-kind config behaviour is the narrowCommonAppConfigtrait (const KIND+is_removable);is_smartis registration-derived. The per-kind editor wire shapes (CloudAppDetailetc.) live inhttp/wire_representations.rs, builtFrom<(&AppRegistration, &…Configuration)>. - Route table:
GET /apps→ uniformAppRegistration[](kind-tagged, includes hidden);PUT /home-screen→ atomic reorder +onHomescreen(the store'sreplace_placements);GET/POST /apps/{id}→ launch;DELETE /apps/{id}→ unified delete (204); per-kind detail/create/replace live on root resources/cloud-apps,/self-hosted-apps(multipart create),/system-apps(read-only) — a per-kind path given an id of another kind is a404(the kind mismatch can't be expressed). - Wire keys: the registration serializes
onHomescreen(the placement flag),isSmart(derived from the host-onlyclient_id), and each editable detail carriesisRemovable.positionnever leaves the host (theGET /appsarray order is the display order). - Ports-and-adapters store (mirrors collector/tunnel): the
AppsStoreport is a domain trait speaking primitive persistence (absence/non-permutation asOption, a delete miss asbool, a cloud insert that wrote nothing as the granular typedCloudInsertError, only the opaqueAppsError::Infrastructureraised). The self-hosted insert is the one exception — it has no granular signal and maps its own outcomes ontoAppsErrordirectly (taken slug →400 InvalidName, port exhaustion →500). ItsSQLiteadapterSqliteAppsStore(diesel over the sharedpersistence_rust::DieselPool) checks a connection out per call and delegates to thepub(super)query bodies, which are split by kind/context mirroringdomain/actions/—db/cloud_apps.rs/db/self_hosted_apps.rs/db/system_apps.rs(each owns itstable!+ row struct + mutators),db/app_registration.rs(the sharedapp_registrationstable, itsAppKindColumn, and the registration-wide queries + placement),db/all_kinds_apps.rs(find_app_on/delete, importing the per-kind tables), anddb/shared.rs(the oneAppUrlColumntwo kinds share);db/test_support.rsholds the shared#[cfg(test)]builders. The uniform list is a join-freeapp_registrationsread, a detail is the registration + one typed configuration read. The slice's semantics —NotFound/NotEditable(incl. the delete removability gate) /InvalidHomeScreen, the cloud id-collision mapping, and write-side field validation — live in the scope-gated capabilities (domain/capabilities.rs), one capability per operation, each owning its store logic (synthesizing the(registration, configuration)a create/replace persists, gating on kind + seeded, and mapping the store's primitive signals ontoAppsError); the HTTP handlers acquire aScoped<…Cap>and never touch the store directly. Thedomain/actions/folder holds the reusable pieces the capabilities are built from — the shared write-side validators (validate_cloud_fields,slugify,validate_launch_path) and theCloudAppPayloadinput struct the HTTP layer builds — all unit-tested against an in-memoryFakeAppsStore(intest_fake.rs), which the capability tests reuse; the cross-kind(registration, configuration)read is inlined (find_app+NotFound) in each read capability and the launch route, so there is no sharedget_apphelper. Pure store-support logic the DB layer calls lives in the domain too: the lowest-free-port allocator (lowest_free_port) and the home-screen permutation check (is_exact_registry_permutation), fed the taken sets the store reads in-transaction (the self-hosted slug is the caller-builtregistration.idused verbatim — a clash is rejected400 InvalidName, no allocator). The per-kind config row structs (diesel-mapped) and theirFromimpls onto the domain configs live in each kind'sdb/<kind>_apps.rsfile, beside that kind'stable!and queries. Migrations are embedded diesel migrations (apps-rust/migrations/) run under this slice's namespace ("apps"), so its0001never collides with another diesel slice's0001; schema (0001_app_registrations) and the default-registry seed (0002_seed_default_apps) are separate migrations so they version independently. - Each self-hosted app is served from its own loopback origin — per-origin isolation is the security boundary; don't collapse apps onto a shared origin.
- The committed
templates/<app-id>/<serve-path>.hbstree is embedded viainclude_dir!on the Rust side and rendered per request with Handlebars (theapiOriginvariable resolves per request provenance) — template/config changes need the Rust build to pick them up. - The slice has a committed OpenAPI snapshot (
apps-rust/openapi/apps.openapi.json); regenerate a stale one per the OpenAPI Spec Drift How-To.
References
- self-hosted-apps README — vendoring model, per-origin serving, committed templates
