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(); 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; 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[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 }); });