kiosk: the authenticated API and two panels

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.
This commit is contained in:
Claude Opus 5
2026-08-18 03:07:56 +00:00
parent 91763f4bf4
commit b729aa3a67
9 changed files with 709 additions and 0 deletions
+190
View File
@@ -0,0 +1,190 @@
import type { User } from 'types';
import { createRouter } from '@@/create-router';
import * as errors from '@@/custom-errors';
import { isSuperAdmin } from '@@/super-admin';
import {
createDevice,
deleteBuild,
deleteDevice,
getBuild,
getBuildById,
getDevice,
getOrCreateUserKey,
listBuildsForApp,
listDevices,
listLatestBuilds,
recordInstall,
rotateUserKey,
setBuildPinned,
updateDevice,
upsertBuild,
} from '../db/queries';
import type { KioskApp, KioskBuild } from '../db/types';
import { installUrl } from '../artefacts';
/*
/api/kiosk/* — the AUTHENTICATED surface.
Everything here requires a platform session, which is what the whole port exists to achieve: who may
install an app stops being "whoever holds the invite link" and becomes "whoever has an account here".
The four requests that CANNOT carry a session — iOS `installd` fetching the manifest and the `.ipa`, the
device posting its UDID, and Safari downloading a `.mobileconfig` — are deliberately NOT in this file.
They need a platform primitive that does not exist yet (`unauthenticatedPaths`), and when it does they
belong in `api/public.ts` so the two surfaces cannot be confused by anyone reading either one.
Owner-only routes say so inline with `isSuperAdmin`. That is not a second permission: the platform gives a
plugin ONE permission covering its whole route surface, so anything narrower is this router's own job.
*/
export const router = createRouter();
const ownerOnly = async (user: User) => {
if (!(await isSuperAdmin(user))) throw errors.FORBIDDEN('That is owner-only');
};
// ── Catalogue ──
/** Every app with its newest build per platform. What the mobile app and the install page both open with. */
router.get('/apps', async (ctx) => {
const builds = await listLatestBuilds();
const apps = new Map<string, KioskApp>();
for (const build of builds) {
const app = apps.get(build.appName) ?? {
appName: build.appName,
displayName: build.displayName,
ios: null,
android: null,
};
if (build.platform === 'ios') app.ios = build;
else app.android = build;
apps.set(build.appName, app);
}
return ctx.json({ apps: [...apps.values()].sort((a, b) => a.displayName.localeCompare(b.displayName)) });
});
/** One app, every retained version, both platforms. */
router.get('/apps/:appName', async (ctx) => {
const builds = await listBuildsForApp(ctx.req.param('appName'));
if (!builds.length) throw errors.NOT_FOUND('No builds for that app');
return ctx.json({
appName: builds[0]!.appName,
displayName: builds[0]!.displayName,
versions: builds,
});
});
router.get('/apps/:appName/:platform/:version', async (ctx) => {
const { appName, platform, version } = ctx.req.param();
const build = await getBuild(appName, platform, version);
if (!build) throw errors.NOT_FOUND('No such build');
return ctx.json(build);
});
// ── The key ──
//
// Generated lazily on first ask. The app holds it in memory only and re-fetches whenever it builds an
// install link, which is precisely what makes rotation free.
router.get('/key', async (ctx) => ctx.json({ key: await getOrCreateUserKey(ctx.get('user').id) }));
router.post('/key/rotate', async (ctx) => ctx.json({ key: await rotateUserKey(ctx.get('user').id) }));
// ── Install ──
/**
* The string to hand to the OS — `itms-services://` for iOS, a plain URL for Android.
*
* Built per request and never stored, because the URL inside it carries the CALLING USER'S key. Two people
* asking for the same build get different links, which is the whole point: the artefact route is public and
* the key is what authorises it.
*/
router.get('/install/:appName/:platform/:version', async (ctx) => {
const { appName, platform, version } = ctx.req.param();
const build = await getBuild(appName, platform, version);
if (!build) throw errors.NOT_FOUND('No such build');
const key = await getOrCreateUserKey(ctx.get('user').id);
return ctx.json(installUrl(build, key));
});
/** Reported by the app after an install completes. Best-effort; see `recordInstall`. */
router.post('/installs', async (ctx) => {
const body = ctx.get('body') as { buildId?: number; deviceId?: number };
if (!body?.buildId) throw errors.BAD_REQUEST('buildId is required');
const build = await getBuildById(body.buildId);
if (!build) throw errors.NOT_FOUND('No such build');
await recordInstall(ctx.get('user').id, body.buildId, body.deviceId);
return ctx.json({ ok: true });
});
// ── Devices ──
//
// A member sees their own; the owner sees everyone's. Enrolment itself needs the public callback and is not
// here yet.
router.get('/devices', async (ctx) => {
const user = ctx.get('user');
const mine = !(await isSuperAdmin(user));
return ctx.json({ devices: await listDevices(mine ? user.id : undefined) });
});
router.post('/devices', async (ctx) => {
const body = ctx.get('body') as { label?: string; userId?: number };
const user = ctx.get('user');
const label = body?.label?.trim();
if (!label) throw errors.BAD_REQUEST('label is required');
// Only the owner may mint a device for somebody else; everyone else gets their own.
const forUser = body?.userId && (await isSuperAdmin(user)) ? body.userId : user.id;
return ctx.json(await createDevice(forUser, label));
});
/** Rename, or set the kind. Yours, or anyone's if you are the owner. */
router.patch('/devices/:id', async (ctx) => {
const device = await getDevice(Number(ctx.req.param('id')));
if (!device) throw errors.NOT_FOUND('No such device');
const user = ctx.get('user');
if (device.userId !== user.id && !(await isSuperAdmin(user))) throw errors.FORBIDDEN('That is not your device');
const body = ctx.get('body') as { label?: string; kind?: string };
await updateDevice(device.id, {
...(body?.label ? { label: body.label.trim() } : {}),
...(body?.kind ? { kind: body.kind } : {}),
});
return ctx.json({ ok: true });
});
router.delete('/devices/:id', async (ctx) => {
const device = await getDevice(Number(ctx.req.param('id')));
if (!device) throw errors.NOT_FOUND('No such device');
const user = ctx.get('user');
if (device.userId !== user.id && !(await isSuperAdmin(user))) throw errors.FORBIDDEN('That is not your device');
await deleteDevice(device.id);
return ctx.json({ ok: true });
});
// ── Publishing — owner only ──
//
// Artefacts arrive by rsync from the build machine, which already works and keeps a credential it already
// has. So this records METADATA about a file that is already on disk rather than accepting an upload: the
// platform's shared upload route caps at 5MB, which no `.ipa` will ever satisfy.
router.post('/builds', async (ctx) => {
await ownerOnly(ctx.get('user'));
const body = ctx.get('body') as Partial<KioskBuild>;
for (const field of ['appName', 'displayName', 'platform', 'version', 'bundleId', 'artefactPath'] as const) {
if (!body?.[field]) throw errors.BAD_REQUEST(`${field} is required`);
}
return ctx.json(await upsertBuild(body as Parameters<typeof upsertBuild>[0]));
});
router.post('/builds/:id/pin', async (ctx) => {
await ownerOnly(ctx.get('user'));
const body = ctx.get('body') as { pinned?: boolean };
await setBuildPinned(Number(ctx.req.param('id')), body?.pinned !== false);
return ctx.json({ ok: true });
});
router.delete('/builds/:id', async (ctx) => {
await ownerOnly(ctx.get('user'));
const removed = await deleteBuild(Number(ctx.req.param('id')));
if (!removed) throw errors.NOT_FOUND('No such build');
return ctx.json({ ok: true });
});