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:
@@ -0,0 +1 @@
|
||||
node_modules/
|
||||
+188
@@ -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
@@ -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',
|
||||
},
|
||||
],
|
||||
};
|
||||
Reference in New Issue
Block a user