what the mobile app needs is now buildable against something real: GET /apps,
GET /key, GET /install/:app/:platform/:version, POST /installs, plus device and
build verbs. all under /api/kiosk, all behind a platform session.
the install URL is built per request and never stored, because it carries the
CALLING user's key - two people asking for the same build get different links,
which is the point: the artefact route is public and the key is what authorises
it.
deliberately NOT here: the four requests that arrive with no session. installd
fetching the manifest and the ipa, the device posting its udid, safari
downloading a mobileconfig. a plugin cannot serve an unauthenticated route
today, so those wait for the platform primitive and will land in api/public.ts
- a separate file so the two surfaces cannot be confused by whoever reads
either one.
so /install returns a correct URL pointing at a route that does not exist yet.
that is the shape the app should build against.
layout is a horizontal root with the app list first, and that is load-bearing:
on a phone WorkspaceRenderer collapses horizontal groups to children[0] and
PluginScreen passes no mobilePanelId, so first is the only panel a phone shows.
the install page is the one page that must work in mobile safari.
PUBLIC_URL is read from the environment rather than officer-url.mjs, which
exports only loopback URLs - ios is not on loopback, and it needs https with a
cert it already trusts or it declines silently.
ios provisioning profiles last a year and the app stops launching when one
expires - it does not degrade, it refuses to start. that date drives the
re-sign queue and the reminder, and per BUSINESS-MODEL.md the annual re-sign is
the product rather than an upsell.
free to add today, a migration after the first publish.
officer kiosk is the product name; OAS was a placeholder that mimicked expo's
application services and is gone.
the rename is total because nothing has shipped: appName, permission key, all
four table prefixes, every index and check constraint name, the exported drizzle
symbols, and the secret-store purpose. no rows exist anywhere, so there is no
migration and no compatibility window - which is exactly why doing it now cost
nothing and doing it after the first publish would have cost a manual migration
on every install.
the one remaining mention of OAS is dated history in the manifest, naming what
this was ported from.
the schema is deliberately over-specified. there are no migrations - install
generates and creates, swallowing 'already exists', so a column added in v2 is
silently absent on every machine that installed v1. email survived that only
because its table had never held a row; oas_builds holds real rows from the
first publish.
legal values are CHECK constraints rather than free text, per the house rule:
platform, signing, device kind and status. oneOf() uses sql.raw because an
interpolated value binds as a parameter and postgres refuses IN ($1, $2).
two decisions worth naming:
version is TEXT despite being numeric. it is an identifier, it appears in a
path, and 2026081801 is already at the edge of a 32-bit int. fixed width means
lexicographic sort is correct.
oas_user_keys stores the key twice - a hash to check against, and an encrypted
copy because the mobile app must READ the key back to build an install URL. the
platform's own api_keys needs no such thing because nothing re-displays them.
udid is uniquely indexed only WHERE NOT NULL: many rows sit pending without one,
but a captured identifier is one physical device and must not be claimable twice.