import { sql } from 'drizzle-orm'; import { bigint, boolean, check, integer, pgTable, serial, text, timestamp, uniqueIndex, } from 'drizzle-orm/pg-core'; import { users } from 'officerdb/auth/schema'; /* Every table here is prefixed `kiosk_`, and that is load-bearing rather than tidy: `purge` finds a plugin's tables by that prefix, so an unprefixed one is never cleaned up and shows forever in the orphan list. ── Get this right the first time ── There are no migrations. Install runs a generate-and-create against this schema and swallows "already exists", so a column added in a later version is SILENTLY ABSENT on every machine that installed an earlier one. Email survived that because its table had never held a row; `kiosk_builds` holds real rows from the first publish. So the columns below are deliberately wider than today's needs, and the legal values are declared as CHECK constraints rather than left as free text — the house rule from `databases/CLAUDE.md`, which exists because rows for deleted providers kept their credentials long after the code that read them was gone. */ const PLATFORMS = ['ios', 'android'] as const; const SIGNINGS = ['adhoc', 'release', 'debug', 'unsigned'] as const; const DEVICE_KINDS = ['iphone', 'ipad', 'android-phone', 'android-tablet'] as const; const DEVICE_STATUSES = ['pending', 'captured', 'registered', 'failed'] as const; /** `sql.raw`, never an interpolated string — a bound parameter is emitted as `IN ($1, $2)`, which Postgres refuses. */ const oneOf = (column: unknown, values: readonly string[]) => sql`${column} IN (${sql.join( values.map((v) => sql.raw(`'${v}'`)), sql`, `, )})`; /** * A published build. One row per app, per platform, per version. * * The artefact itself is NOT here — it is a ~20MB file under the Kiosk artefact root, and this row points * at it. See `artefactPath`. */ export const kioskBuilds = pgTable( 'kiosk_builds', { id: serial('id').primaryKey(), /** The directory name in the monorepo — `chat`. Not what the person sees. */ appName: text('app_name').notNull(), /** What the person sees — `Butler`. */ displayName: text('display_name').notNull(), platform: text('platform').notNull(), /** * `YYYYMMDDNN`, and it only ever goes up. * * Text rather than an integer despite being numeric: it is an identifier, it appears in a path, and * `2026081801` is already at the edge of a 32-bit int. Sorting is lexicographic and correct because * the width is fixed. * * The publisher must take the highest used that day and add one, NEVER the first gap — walking up * from `01` looking for a free slot silently reuses a number after a deletion, and on 2026-08-18 that * published a new build BELOW an existing one, so "install the latest" would have handed back the * previous build. */ version: text('version').notNull(), bundleId: text('bundle_id').notNull(), shortVersion: text('short_version').notNull(), /** * What is stamped INSIDE the binary (`CFBundleVersion`), which should equal `version`. * * Stored separately precisely so the two can be compared: iOS shows this one, so a build published * as `2026081806` whose binary says `1` is indistinguishable on the device from every other build * that also says `1`. */ buildVersion: text('build_version').notNull(), signing: text('signing'), profileName: text('profile_name'), /** How many devices the signing profile covers. Null for anything not ad-hoc signed. */ deviceCount: integer('device_count'), sizeBytes: bigint('size_bytes', { mode: 'number' }).notNull(), /** Relative to the Kiosk artefact root, never absolute — the root moves between machines. */ artefactPath: text('artefact_path').notNull(), /** Survives retention. Replaces the `.keep` file the standalone service used. */ pinned: boolean('pinned').notNull().default(false), builtAt: timestamp('built_at', { withTimezone: true }).notNull(), /** * When this build's provisioning profile expires — the date the app STOPS LAUNCHING. * * iOS profiles are valid for a year, and on expiry the app does not degrade, it refuses to start. * Every installed copy needs re-signing before then, which is why the commercial shape is a * subscription rather than a one-off: the annual re-sign IS the product (BUSINESS-MODEL.md §6). * * So this is the most operationally important date in the table — it drives "which builds go dark * this month", the reminder, and the re-sign queue. Nullable because an unsigned or debug build has * no profile and therefore no expiry. */ profileExpiresAt: timestamp('profile_expires_at', { withTimezone: true }), publishedAt: timestamp('published_at', { withTimezone: true }).notNull().defaultNow(), }, (table) => [ uniqueIndex('uq_kiosk_builds_app_platform_version').on(table.appName, table.platform, table.version), check('ck_kiosk_builds_platform', oneOf(table.platform, PLATFORMS)), check('ck_kiosk_builds_signing', sql`${table.signing} IS NULL OR ${oneOf(table.signing, SIGNINGS)}`), ], ); /** * The per-user key that authorises an unauthenticated artefact request. * * iOS `installd` fetches the manifest and the `.ipa` itself — not the app, not the browser — so those * two requests arrive with no platform session and nothing to attach one to. The key in the URL is what * makes those paths safe to serve above the account gate. * * Stored twice on purpose. `keyHash` is what a request is checked against, so a database dump does not * hand over working keys. `keyEncrypted` exists because the mobile app has to be able to READ the key * back in order to build an install URL, which a hash cannot do — the platform's own `api_keys` needs * no such thing because nothing ever re-displays them. */ export const kioskUserKeys = pgTable( 'kiosk_user_keys', { id: serial('id').primaryKey(), userId: integer('user_id') .notNull() .references(() => users.id, { onDelete: 'cascade' }), /** SHA-256 of the key. Never the key itself. */ keyHash: text('key_hash').notNull(), /** `encryptSecret('kiosk', key)` — readable back, unlike the hash. */ keyEncrypted: text('key_encrypted').notNull(), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), rotatedAt: timestamp('rotated_at', { withTimezone: true }), /** Debounced to once a minute on write, the way `api_keys` does it. */ lastUsedAt: timestamp('last_used_at', { withTimezone: true }), }, (table) => [uniqueIndex('uq_kiosk_user_keys_user').on(table.userId)], ); /** * A device, and who it belongs to. * * `udid` is null until the device has spoken: enrolment mints the row with a label, and iOS posts the * identifier back afterwards. The label is captured BEFORE the device speaks and the one-time id ties * them together, which is why a name cannot end up on the wrong device — asking afterwards is how you * get three devices called "iOS Device". */ export const kioskDevices = pgTable( 'kiosk_devices', { id: serial('id').primaryKey(), userId: integer('user_id') .notNull() .references(() => users.id, { onDelete: 'cascade' }), label: text('label').notNull(), kind: text('kind'), /** Apple's identifier. Null until captured. */ udid: text('udid'), /** `iPhone14,5`. Comes free with the callback. */ product: text('product'), osVersion: text('os_version'), /** Returned by App Store Connect once the device is registered there. */ appleDeviceId: text('apple_device_id'), status: text('status').notNull().default('pending'), /** Why a registration failed, so the owner can see it without reading a log. */ failureReason: text('failure_reason'), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), capturedAt: timestamp('captured_at', { withTimezone: true }), registeredAt: timestamp('registered_at', { withTimezone: true }), }, (table) => [ // Partial: many devices may sit at `udid IS NULL` while pending, but a captured identifier is one // physical device and must not be claimable twice. uniqueIndex('uq_kiosk_devices_udid').on(table.udid).where(sql`${table.udid} IS NOT NULL`), check('ck_kiosk_devices_status', oneOf(table.status, DEVICE_STATUSES)), check('ck_kiosk_devices_kind', sql`${table.kind} IS NULL OR ${oneOf(table.kind, DEVICE_KINDS)}`), ], ); /** * Who installed what. The answer to "did that build actually reach anyone". * * Reported by the mobile app rather than observed by the server — an `itms-services://` install is * handed to the OS and the server never learns how it went. So this is best-effort by construction, and * an absent row means "not reported", never "not installed". */ export const kioskInstalls = pgTable('kiosk_installs', { id: serial('id').primaryKey(), userId: integer('user_id') .notNull() .references(() => users.id, { onDelete: 'cascade' }), deviceId: integer('device_id').references(() => kioskDevices.id, { onDelete: 'set null' }), buildId: integer('build_id') .notNull() .references(() => kioskBuilds.id, { onDelete: 'cascade' }), installedAt: timestamp('installed_at', { withTimezone: true }).notNull().defaultNow(), });