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.
This commit is contained in:
Claude Opus 5
2026-08-18 01:16:07 +00:00
commit ed9bf828f8
3 changed files with 221 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
node_modules/
+188
View File
@@ -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(),
});
+32
View File
@@ -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',
},
],
};