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.
163 lines
6.3 KiB
TypeScript
163 lines
6.3 KiB
TypeScript
import { and, desc, eq, sql } from 'drizzle-orm';
|
|
import { createHash, randomBytes } from 'node:crypto';
|
|
import { db } from 'officerdb/db';
|
|
import { decryptSecret, encryptSecret } from 'officerdb/crypto';
|
|
import { kioskBuilds, kioskDevices, kioskInstalls, kioskUserKeys } from './schema';
|
|
import type { KioskBuild, KioskBuildInsert, KioskDevice } from './types';
|
|
|
|
const PURPOSE = 'kiosk';
|
|
|
|
// ── Builds ──
|
|
|
|
/**
|
|
* The newest build per app per platform — what a catalogue screen and the mobile app both open with.
|
|
*
|
|
* `DISTINCT ON` rather than fetching everything and reducing in JS: the store reached 101 artefacts on the
|
|
* standalone service before retention was added, and "newest per group" is the one thing Postgres does in
|
|
* the index that the application would do in a loop.
|
|
*/
|
|
export async function listLatestBuilds(): Promise<KioskBuild[]> {
|
|
const rows = await db.execute<KioskBuild>(sql`
|
|
select distinct on (app_name, platform) *
|
|
from kiosk_builds
|
|
order by app_name, platform, version desc
|
|
`);
|
|
return ((rows as unknown as { rows?: KioskBuild[] }).rows ?? (rows as unknown as KioskBuild[])) ?? [];
|
|
}
|
|
|
|
/** Every retained version of one app, newest first, both platforms. */
|
|
export async function listBuildsForApp(appName: string): Promise<KioskBuild[]> {
|
|
return db.select().from(kioskBuilds).where(eq(kioskBuilds.appName, appName)).orderBy(desc(kioskBuilds.version));
|
|
}
|
|
|
|
export async function getBuild(appName: string, platform: string, version: string): Promise<KioskBuild | undefined> {
|
|
const [row] = await db
|
|
.select()
|
|
.from(kioskBuilds)
|
|
.where(
|
|
and(eq(kioskBuilds.appName, appName), eq(kioskBuilds.platform, platform), eq(kioskBuilds.version, version)),
|
|
);
|
|
return row;
|
|
}
|
|
|
|
export async function getBuildById(id: number): Promise<KioskBuild | undefined> {
|
|
const [row] = await db.select().from(kioskBuilds).where(eq(kioskBuilds.id, id));
|
|
return row;
|
|
}
|
|
|
|
/**
|
|
* Record a published build. Upsert, because publishing is a rerunnable step on the Mac and a re-publish of
|
|
* the same version is a correction rather than an error.
|
|
*/
|
|
export async function upsertBuild(build: KioskBuildInsert): Promise<KioskBuild> {
|
|
const [row] = await db
|
|
.insert(kioskBuilds)
|
|
.values(build)
|
|
.onConflictDoUpdate({
|
|
target: [kioskBuilds.appName, kioskBuilds.platform, kioskBuilds.version],
|
|
set: { ...build, publishedAt: new Date() },
|
|
})
|
|
.returning();
|
|
return row!;
|
|
}
|
|
|
|
export async function setBuildPinned(id: number, pinned: boolean): Promise<void> {
|
|
await db.update(kioskBuilds).set({ pinned }).where(eq(kioskBuilds.id, id));
|
|
}
|
|
|
|
export async function deleteBuild(id: number): Promise<KioskBuild | undefined> {
|
|
const [row] = await db.delete(kioskBuilds).where(eq(kioskBuilds.id, id)).returning();
|
|
return row;
|
|
}
|
|
|
|
// ── The per-user key ──
|
|
|
|
const hash = (key: string) => createHash('sha256').update(key).digest('hex');
|
|
|
|
/**
|
|
* The caller's key, minted on first ask.
|
|
*
|
|
* Lazy because there is no hook for a plugin to run when an account is created — `provisionOsAccount` is a
|
|
* fixed list — so first use of the plugin is when the key comes into existence. That is invisible to the
|
|
* user and needs no provisioning change.
|
|
*/
|
|
export async function getOrCreateUserKey(userId: number): Promise<string> {
|
|
const [existing] = await db.select().from(kioskUserKeys).where(eq(kioskUserKeys.userId, userId));
|
|
if (existing) return decryptSecret(PURPOSE, existing.keyEncrypted);
|
|
|
|
const key = `kio_${randomBytes(24).toString('hex')}`;
|
|
await db.insert(kioskUserKeys).values({
|
|
userId,
|
|
keyHash: hash(key),
|
|
keyEncrypted: encryptSecret(PURPOSE, key),
|
|
});
|
|
return key;
|
|
}
|
|
|
|
/** A new key; the old one stops working immediately. Cheap because the app never persists it. */
|
|
export async function rotateUserKey(userId: number): Promise<string> {
|
|
const key = `kio_${randomBytes(24).toString('hex')}`;
|
|
await db
|
|
.insert(kioskUserKeys)
|
|
.values({ userId, keyHash: hash(key), keyEncrypted: encryptSecret(PURPOSE, key) })
|
|
.onConflictDoUpdate({
|
|
target: kioskUserKeys.userId,
|
|
set: { keyHash: hash(key), keyEncrypted: encryptSecret(PURPOSE, key), rotatedAt: new Date() },
|
|
});
|
|
return key;
|
|
}
|
|
|
|
/**
|
|
* Whose key this is, or null.
|
|
*
|
|
* Compared by HASH, so the lookup never needs the stored ciphertext and a database dump hands over nothing
|
|
* usable. This is the function every public artefact route will call once those exist.
|
|
*/
|
|
export async function userIdForKey(key: string): Promise<number | null> {
|
|
const [row] = await db.select().from(kioskUserKeys).where(eq(kioskUserKeys.keyHash, hash(key)));
|
|
if (!row) return null;
|
|
// Debounced to a minute, the way `api_keys` does it: an install range-requests a 20MB file and would
|
|
// otherwise write on every chunk.
|
|
const last = row.lastUsedAt?.getTime() ?? 0;
|
|
if (Date.now() - last > 60_000) {
|
|
await db.update(kioskUserKeys).set({ lastUsedAt: new Date() }).where(eq(kioskUserKeys.id, row.id));
|
|
}
|
|
return row.userId;
|
|
}
|
|
|
|
// ── Devices ──
|
|
|
|
export async function listDevices(userId?: number): Promise<KioskDevice[]> {
|
|
const q = db.select().from(kioskDevices).orderBy(desc(kioskDevices.createdAt));
|
|
return userId === undefined ? q : q.where(eq(kioskDevices.userId, userId));
|
|
}
|
|
|
|
export async function getDevice(id: number): Promise<KioskDevice | undefined> {
|
|
const [row] = await db.select().from(kioskDevices).where(eq(kioskDevices.id, id));
|
|
return row;
|
|
}
|
|
|
|
/** Mint a pending device. The label is captured BEFORE the device speaks — see the schema note. */
|
|
export async function createDevice(userId: number, label: string): Promise<KioskDevice> {
|
|
const [row] = await db.insert(kioskDevices).values({ userId, label }).returning();
|
|
return row!;
|
|
}
|
|
|
|
export async function updateDevice(id: number, patch: Partial<KioskDevice>): Promise<void> {
|
|
await db.update(kioskDevices).set(patch).where(eq(kioskDevices.id, id));
|
|
}
|
|
|
|
export async function deleteDevice(id: number): Promise<void> {
|
|
await db.delete(kioskDevices).where(eq(kioskDevices.id, id));
|
|
}
|
|
|
|
// ── Installs ──
|
|
|
|
/**
|
|
* Best-effort by construction: an `itms-services://` install is handed to the OS and the server never learns
|
|
* how it went, so this is reported by the app. An absent row means "not reported", never "not installed".
|
|
*/
|
|
export async function recordInstall(userId: number, buildId: number, deviceId?: number): Promise<void> {
|
|
await db.insert(kioskInstalls).values({ userId, buildId, deviceId: deviceId ?? null });
|
|
}
|