From ed9bf828f8722ce05d3e96b7371275a6e8403181 Mon Sep 17 00:00:00 2001 From: Claude Opus 5 Date: Tue, 18 Aug 2026 01:16:07 +0000 Subject: [PATCH] oas plugin: manifest and schema the schema is deliberately over-specified. there are no migrations - install generates and creates, swallowing 'already exists', so a column added in v2 is silently absent on every machine that installed v1. email survived that only because its table had never held a row; oas_builds holds real rows from the first publish. legal values are CHECK constraints rather than free text, per the house rule: platform, signing, device kind and status. oneOf() uses sql.raw because an interpolated value binds as a parameter and postgres refuses IN ($1, $2). two decisions worth naming: version is TEXT despite being numeric. it is an identifier, it appears in a path, and 2026081801 is already at the edge of a 32-bit int. fixed width means lexicographic sort is correct. oas_user_keys stores the key twice - a hash to check against, and an encrypted copy because the mobile app must READ the key back to build an install URL. the platform's own api_keys needs no such thing because nothing re-displays them. udid is uniquely indexed only WHERE NOT NULL: many rows sit pending without one, but a captured identifier is one physical device and must not be claimable twice. --- .gitignore | 1 + db/schema.ts | 188 +++++++++++++++++++++++++++++++++++++++++++++++++++ manifest.ts | 32 +++++++++ 3 files changed, 221 insertions(+) create mode 100644 .gitignore create mode 100644 db/schema.ts create mode 100644 manifest.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c2658d7 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/db/schema.ts b/db/schema.ts new file mode 100644 index 0000000..3e983a6 --- /dev/null +++ b/db/schema.ts @@ -0,0 +1,188 @@ +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 `oas_`, 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; `oas_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 OAS artefact root, and this row points + * at it. See `artefactPath`. + */ +export const oasBuilds = pgTable( + 'oas_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 OAS 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(), + publishedAt: timestamp('published_at', { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [ + uniqueIndex('uq_oas_builds_app_platform_version').on(table.appName, table.platform, table.version), + check('ck_oas_builds_platform', oneOf(table.platform, PLATFORMS)), + check('ck_oas_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 oasUserKeys = pgTable( + 'oas_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('oas', 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_oas_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 oasDevices = pgTable( + 'oas_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_oas_devices_udid').on(table.udid).where(sql`${table.udid} IS NOT NULL`), + check('ck_oas_devices_status', oneOf(table.status, DEVICE_STATUSES)), + check('ck_oas_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 oasInstalls = pgTable('oas_installs', { + id: serial('id').primaryKey(), + userId: integer('user_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + deviceId: integer('device_id').references(() => oasDevices.id, { onDelete: 'set null' }), + buildId: integer('build_id') + .notNull() + .references(() => oasBuilds.id, { onDelete: 'cascade' }), + installedAt: timestamp('installed_at', { withTimezone: true }).notNull().defaultNow(), +}); diff --git a/manifest.ts b/manifest.ts new file mode 100644 index 0000000..799e966 --- /dev/null +++ b/manifest.ts @@ -0,0 +1,32 @@ +import type { PluginManifest } from '@@/plugins/manifest'; + +// OAS — Officer Application Services. Build, sign and deliver iOS and Android apps to registered +// devices: what EAS does, self-hosted. +// +// Ported from the standalone Bun/Hono service in `officer-oas/`, whose access control was an invite key +// held in a browser's local storage. The rule we actually want is "the people already registered on the +// platform", which is the thing the platform already knows how to enforce — so the key stops being +// something a person holds and becomes something an account has. +// +// `appName` is the directory name: `oas`. +export const manifest: PluginManifest = { + publisher: 'officerdev', + version: '1.0.0', + platform: '>=1.0.0', + + label: 'OAS', + summary: 'Build, sign and install iOS and Android apps on your own devices', + icon: 'Smartphone', + color: '#a78bfa', + + // One permission over the whole surface, which is also all the platform offers: the first entry + // claims the plugin's entire route surface. Owner-only actions (publishing, retention, other people's + // devices) are enforced inside the router with `isSuperAdmin`, not by a second permission. + permissions: [ + { + key: 'oas', + label: 'App install', + description: 'Install Officer apps on your own devices', + }, + ], +};