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.
191 lines
7.5 KiB
TypeScript
191 lines
7.5 KiB
TypeScript
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 });
|
|
});
|