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 { const rows = await db.execute(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 { 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 { 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 { 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 { 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 { await db.update(kioskBuilds).set({ pinned }).where(eq(kioskBuilds.id, id)); } export async function deleteBuild(id: number): Promise { 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 { 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 { 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 { 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 { 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 { 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 { const [row] = await db.insert(kioskDevices).values({ userId, label }).returning(); return row!; } export async function updateDevice(id: number, patch: Partial): Promise { await db.update(kioskDevices).set(patch).where(eq(kioskDevices.id, id)); } export async function deleteDevice(id: number): Promise { 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 { await db.insert(kioskInstalls).values({ userId, buildId, deviceId: deviceId ?? null }); }