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:
+190
@@ -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 });
|
||||
});
|
||||
Reference in New Issue
Block a user