The app icon is flattened onto black because iOS rejects an alpha channel, and
copying that straight over made every dock tile an opaque black square. The tile
wants the frame's own rounded edge with the corners cut away — which is what the
icon this replaces already had, and what tile: bare means.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The dock tile takes assets/icon.png when the plugin ships one and falls back to
the manifest's lucide glyph when it does not. This is the same icon the phone
shows, resized — one mark per product, wherever it appears.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.