ios provisioning profiles last a year and the app stops launching when one expires - it does not degrade, it refuses to start. that date drives the re-sign queue and the reminder, and per BUSINESS-MODEL.md the annual re-sign is the product rather than an upsell. free to add today, a migration after the first publish.
201 lines
9.3 KiB
TypeScript
201 lines
9.3 KiB
TypeScript
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(),
|
|
});
|