diff --git a/scripts/marketplace-sync.ts b/scripts/marketplace-sync.ts new file mode 100644 index 00000000..f3ee2a39 --- /dev/null +++ b/scripts/marketplace-sync.ts @@ -0,0 +1,168 @@ +#!/usr/bin/env bun +import { existsSync, readFileSync } from 'node:fs'; +import { basename, join, resolve } from 'node:path'; +import { upsertMetadata, upsertPlugin } from '../src/databases/marketplace_db/src/plugins/queries'; +import { discoverPlugins } from '../src/servers/plugins/discover'; + +// Fill the marketplace database from the plugin repositories. +// +// ── What this reads, and why it is not the marketplace's own opinion ── +// +// Every field except "where to clone from" comes out of the plugin's `manifest.ts` and its README. The +// marketplace stores a COPY so the website can render without cloning three repositories per page load; +// the manifest stays the source of truth, and the platform re-reads it after cloning. A row here that +// disagrees with the repository is a stale cache, not a second opinion — which is why re-running this is +// always safe and never merges: it overwrites. +// +// ── Reading the manifest by importing it ── +// +// A manifest is TypeScript, not JSON, so it is imported and evaluated rather than parsed. That is code +// execution, and it is fine for OUR repositories and not fine as a general mechanism. +// +// `[blocker for third-party submissions]` A stranger's plugin cannot be imported by the machine that +// serves the catalogue. Whoever builds submissions has to either evaluate manifests in a sandbox or make +// authors publish a `manifest.json` alongside. Recorded rather than solved: nobody can submit yet. +// +// ── Local clones, for now ── +// +// It syncs from `plugins/` on this machine rather than by cloning each repo into a temp directory. That +// keeps tonight's scope to "get real data into the database" — and it means the machine running the sync +// must have the plugins checked out. See `docs/marketplace.md` → "Sync" for the version that clones. + +type Sync = { appName: string; developer: string; repoUrl: string }; + +/** + * A clone URL the whole internet can use. + * + * `plugins/example` and `plugins/offscale` have `git@gitea.officer.dev:plugins/.git` remotes, + * because whoever set them up had a key on that host. Publishing that to a catalogue would hand every + * installer a URL that only works on the machine it was published from — the install fails at + * authentication, which reads as "the plugin is broken" rather than "the catalogue is wrong". + */ +export function toHttpsRemote(remote: string): string | null { + const trimmed = remote.trim(); + if (!trimmed) return null; + if (trimmed.startsWith('https://')) return trimmed; + + // git@host:path/to/repo.git → https://host/path/to/repo.git + const scp = trimmed.match(/^[^@]+@([^:]+):(.+)$/); + if (scp) return `https://${scp[1]}/${scp[2]}`; + + // ssh://git@host/path.git → https://host/path.git + if (trimmed.startsWith('ssh://')) { + try { + const url = new URL(trimmed); + return `https://${url.host}${url.pathname}`; + } catch { + return null; + } + } + return null; +} + +const git = (dir: string, args: string[]): string | null => { + const result = Bun.spawnSync(['git', '-C', dir, ...args], { stdout: 'pipe', stderr: 'ignore' }); + if (result.exitCode !== 0) return null; + return new TextDecoder().decode(result.stdout).trim() || null; +}; + +const PLUGINS_DIR = resolve(import.meta.dir, '..', 'plugins'); + +async function syncOne(dir: string): Promise { + const appName = basename(dir); + const manifestPath = join(dir, 'manifest.ts'); + if (!existsSync(manifestPath)) { + console.warn(`[marketplace-sync] ${appName}: no manifest.ts — skipped`); + return null; + } + + const remote = git(dir, ['remote', 'get-url', 'origin']); + const repoUrl = remote ? toHttpsRemote(remote) : null; + if (!repoUrl) { + console.warn(`[marketplace-sync] ${appName}: no usable https remote (${remote ?? 'none'}) — skipped`); + return null; + } + + const { manifest } = (await import(manifestPath)) as { manifest: Record }; + if (!manifest) { + console.warn(`[marketplace-sync] ${appName}: manifest.ts exports no \`manifest\` — skipped`); + return null; + } + + // What the plugin SHIPS, read from the directory rather than declared — the same rule discovery + // follows, so a catalogue entry cannot claim a sidecar the repository does not have. + const has = { + api: existsSync(join(dir, 'api', 'router.ts')), + schema: existsSync(join(dir, 'db', 'schema.ts')), + sidecar: existsSync(join(dir, 'sidecar', 'index.ts')) || existsSync(join(dir, 'sidecar', 'index.mjs')), + web: existsSync(join(dir, 'web')), + }; + + const readmePath = ['README.md', 'readme.md'].map((n) => join(dir, n)).find((p) => existsSync(p)); + const readme = readmePath ? readFileSync(readmePath, 'utf-8') : null; + + const developer = String(manifest.publisher ?? 'unknown'); + const pluginId = await upsertPlugin({ appName, developer, repoUrl }); + + await upsertMetadata(pluginId, { + version: String(manifest.version ?? '0.0.0'), + platform: String(manifest.platform ?? '>=1.0.0'), + label: String(manifest.label ?? appName), + summary: String(manifest.summary ?? ''), + color: (manifest.color as string) ?? null, + tile: (manifest.tile as string) ?? null, + // Artwork lives in the repo, so the URL is built from the remote rather than stored by hand. Only + // claimed when the file is actually there — a broken image is worse than a glyph. + iconUrl: existsSync(join(dir, 'assets', 'icon.png')) ? rawUrl(repoUrl, 'assets/icon.png') : null, + icon: (manifest.icon as string) ?? null, + permissions: manifest.permissions ?? [], + osDependencies: manifest.osDependencies ?? null, + has, + readme, + sourceCommit: git(dir, ['rev-parse', 'HEAD']), + }); + + console.log( + `[marketplace-sync] ${appName} ← ${repoUrl}${readme ? '' : ' (no README)'}${ + has.web ? '' : ' (no web/)' + }`, + ); + return { appName, developer, repoUrl }; +} + +/** + * A raw-file URL for a Gitea repository. + * + * `[open]` Gitea-shaped and branch-`main`-shaped. A GitHub-hosted plugin needs the other form, and a + * repo whose default branch is `master` gets a 404. Fine while every plugin is on our own Gitea; the + * moment one is not, this needs the forge to be part of the registry rather than inferred. + */ +function rawUrl(repoUrl: string, path: string): string { + return `${repoUrl.replace(/\.git$/, '')}/raw/branch/main/${path}`; +} + +const dirs = existsSync(PLUGINS_DIR) + ? [...new Bun.Glob('*/').scanSync({ cwd: PLUGINS_DIR, onlyFiles: false })].map((d) => join(PLUGINS_DIR, d)) + : []; + +if (dirs.length === 0) { + console.log(`[marketplace-sync] no plugin directories under ${PLUGINS_DIR} — nothing to sync`); + process.exit(0); +} + +// Discovery is not used to FIND them — a plugin that fails discovery is exactly the kind of thing a +// catalogue should still be able to describe — but its broken list is worth surfacing here. +const { broken } = await discoverPlugins().catch(() => ({ broken: [] as { appName: string; error: string }[] })); +for (const { appName, error } of broken) console.warn(`[marketplace-sync] ${appName} is broken on disk: ${error}`); + +const synced: Sync[] = []; +for (const dir of dirs) { + const result = await syncOne(dir).catch((err) => { + console.error(`[marketplace-sync] ${basename(dir)} failed: ${err instanceof Error ? err.message : err}`); + return null; + }); + if (result) synced.push(result); +} + +console.log(`[marketplace-sync] ${synced.length}/${dirs.length} plugin(s) in the catalogue`); +process.exit(0); diff --git a/src/apps/marketplace/App.tsx b/src/apps/marketplace/App.tsx new file mode 100644 index 00000000..0cd5a336 --- /dev/null +++ b/src/apps/marketplace/App.tsx @@ -0,0 +1,234 @@ +import { Link, Route, Routes, useParams } from 'react-router'; +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; +import { useCatalogue, usePlugin, type CatalogueEntry, type Load } from './catalogue'; + +/** + * The public catalogue. + * + * It is a WINDOW, and that is the whole design. Nothing here installs anything: installing means cloning + * a repository, pushing a schema and starting a process on someone's own machine, and a website has no + * business being able to do that. So every plugin page ends at the same place — copy the app name, open + * your own Officer, install it there. + * + * That constraint is what keeps this side simple enough to be public: no accounts, no sessions, no write + * routes, nothing to compromise. The interesting security property of this store is that there is + * nothing here worth attacking. + */ +export const App = () => ( +
+
+ + } /> + } /> + } /> + +
+
+); + +const Header = () => ( +
+
+ + officer + marketplace + + + officer.dev → + +
+
+); + +const Footer = () => ( +
+
+ Plugins are installed from your own Officer server. This site only lists them. +
+
+); + +/** One place where loading, empty and broken are told apart — see `useJson` for why that matters. */ +function Pending({ load, children }: { load: Load; children: (data: T) => React.ReactNode }) { + if (load.state === 'loading') return

Loading…

; + if (load.state === 'error') return

{load.message}

; + return <>{children(load.data)}; +} + +const CataloguePage = () => { + const load = useCatalogue(); + return ( +
+

Plugins

+

+ Everything here is free and open. Install from the marketplace inside your own Officer — nothing is + installed from this page. +

+ + + {(plugins) => + plugins.length === 0 ? ( +

No plugins listed yet.

+ ) : ( +
+ {plugins.map((plugin) => ( + + ))} +
+ ) + } +
+
+ ); +}; + +const PluginCard = ({ plugin }: { plugin: CatalogueEntry }) => ( + + +
+

{plugin.label}

+

{plugin.summary}

+

+ {plugin.publisher} · v{plugin.version} +

+
+ +); + +/** + * Artwork if the repository ships it, a coloured initial if not. + * + * No lucide here on purpose. The platform can draw `icon: 'Network'` because it bundles the icon set; + * pulling that whole dependency into a public page to render three glyphs is not a trade worth making, + * and a letter in the plugin's own colour is honest about what it is. + */ +const Icon = ({ plugin }: { plugin: CatalogueEntry }) => + plugin.iconUrl ? ( + { + ev.currentTarget.style.display = 'none'; + }} + /> + ) : ( +
+ {plugin.label.charAt(0).toUpperCase()} +
+ ); + +const PluginPage = () => { + const { appName } = useParams<{ appName: string }>(); + const load = usePlugin(appName ?? ''); + + return ( +
+ + ← All plugins + + + + {(plugin) => ( + <> +
+ +
+

{plugin.label}

+

{plugin.summary}

+

+ {plugin.publisher} · v{plugin.version} · requires Officer {plugin.platform} +

+
+
+ + + + {plugin.permissions.length > 0 && ( +
+

Permissions it asks for

+
    + {plugin.permissions.map((permission) => ( +
  • + {permission.label} +

    {permission.description}

    +
  • + ))} +
+
+ )} + + {plugin.osDependencies && plugin.osDependencies.length > 0 && ( +
+

+ Software it installs on your machine +

+
    + {plugin.osDependencies.map((dep) => ( +
  • + {dep.binary} +

    {dep.reason}

    +
  • + ))} +
+
+ )} + + {plugin.readme ? ( +
+ {plugin.readme} +
+ ) : ( +

+ This plugin has no README yet. +

+ )} + + )} +
+
+ ); +}; + +/** What to do with a plugin you want. Not a button — see the note on `App`. */ +const Install = ({ appName, source }: { appName: string; source: string }) => ( +
+

Installing it

+

+ Open your own Officer, go to Marketplace, and install{' '} + {appName} from there. Your server clones it directly from the + repository below — nothing passes through this site. +

+ + {source} + +
+); + +const Missing = () => ( +
+

Nothing here.

+ + Back to the catalogue + +
+); diff --git a/src/apps/marketplace/catalogue.ts b/src/apps/marketplace/catalogue.ts new file mode 100644 index 00000000..d5f04035 --- /dev/null +++ b/src/apps/marketplace/catalogue.ts @@ -0,0 +1,62 @@ +import { useEffect, useState } from 'react'; + +/** + * The wire format, restated for the browser. + * + * Third copy of this shape — the platform has one, the database package has one, this has one — and that + * is the correct number rather than a failure to factor. They are three programs that deploy separately: + * a marketplace that requires the platform to be redeployed in step with it is not a marketplace, and a + * website that imports server code cannot be served as static files. + */ +export type CatalogueEntry = { + appName: string; + publisher: string; + version: string; + platform: string; + label: string; + summary: string; + color: string; + tile?: 'badge' | 'bare'; + iconUrl?: string; + icon?: string; + source: string; + permissions: { key: string; label: string; description: string }[]; + osDependencies?: { binary: string; reason: string }[]; + has: { api: boolean; schema: boolean; sidecar: boolean; web: boolean }; + readme?: string; +}; + +export type Load = { state: 'loading' } | { state: 'error'; message: string } | { state: 'ready'; data: T }; + +/** + * Fetch, with the three states kept distinct. + * + * Loading, empty and broken are different things and the page says which. An empty catalogue rendered as + * a spinner looks like a slow site; a broken one rendered as an empty catalogue looks like a store with + * nothing in it — which is the worst of the three, because it is the only one nobody reports. + */ +function useJson(url: string): Load { + const [load, setLoad] = useState>({ state: 'loading' }); + + useEffect(() => { + let alive = true; + setLoad({ state: 'loading' }); + fetch(url) + .then(async (res) => { + if (!res.ok) throw new Error(res.status === 404 ? 'Not found' : `The catalogue is unavailable (${res.status})`); + return (await res.json()) as T; + }) + .then((data) => alive && setLoad({ state: 'ready', data })) + .catch((err: unknown) => { + if (alive) setLoad({ state: 'error', message: err instanceof Error ? err.message : 'Something went wrong' }); + }); + return () => { + alive = false; + }; + }, [url]); + + return load; +} + +export const useCatalogue = () => useJson('/api/plugins'); +export const usePlugin = (appName: string) => useJson(`/api/plugins/${appName}`); diff --git a/src/apps/marketplace/index.html b/src/apps/marketplace/index.html new file mode 100644 index 00000000..9d631516 --- /dev/null +++ b/src/apps/marketplace/index.html @@ -0,0 +1,23 @@ + + + + + + Officer Marketplace + + + + + + + + + + + + + + +
+ + diff --git a/src/apps/marketplace/main.tsx b/src/apps/marketplace/main.tsx new file mode 100644 index 00000000..883d5890 --- /dev/null +++ b/src/apps/marketplace/main.tsx @@ -0,0 +1,13 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import { BrowserRouter } from 'react-router'; +import { App } from './App'; +import './styles.css'; + +createRoot(document.getElementById('root')!).render( + + + + + , +); diff --git a/src/apps/marketplace/styles.css b/src/apps/marketplace/styles.css new file mode 100644 index 00000000..e47ee72c --- /dev/null +++ b/src/apps/marketplace/styles.css @@ -0,0 +1,81 @@ +@import "tailwindcss"; + +/* The platform's palette, so a plugin tile looks the same in the store as it does in the dock. + Copied rather than imported: `officer-web/styles/globals.css` carries the whole app's theme — + sidebars, charts, forty variables this page never uses — and dragging it in would make every + change to the app's chrome a change to the public website. */ +@theme { + --color-duck-yellow: #f4c430; + --color-duck-orange: #ea580c; + --color-duck-teal: #0891b2; + --color-duck-beige: #e7d4b5; + + --color-ink: #0e1411; + --color-ink-soft: #151d18; + --color-line: #26332b; + --color-body: #cfd8d2; + --color-faint: #8a978f; +} + +@layer base { + html { + color-scheme: dark; + } + body { + margin: 0; + background: var(--color-ink); + color: var(--color-body); + font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; + -webkit-font-smoothing: antialiased; + } +} + +/* README typography. Hand-rolled rather than @tailwindcss/typography: the plugin never renders untrusted + markdown (it comes from repositories we index), but a whole plugin dependency for six selectors on one + page is not worth the install-size or the version to keep current. */ +@layer components { + .markdown { + line-height: 1.7; + } + .markdown h1, + .markdown h2, + .markdown h3 { + color: var(--color-body); + font-weight: 600; + margin: 2rem 0 0.75rem; + line-height: 1.3; + } + .markdown h1 { font-size: 1.5rem; } + .markdown h2 { font-size: 1.25rem; } + .markdown h3 { font-size: 1.05rem; } + .markdown p, .markdown li { color: var(--color-faint); } + .markdown p, .markdown ul, .markdown ol, .markdown pre, .markdown table { margin: 0.85rem 0; } + .markdown ul { list-style: disc; padding-left: 1.25rem; } + .markdown ol { list-style: decimal; padding-left: 1.25rem; } + .markdown a { color: var(--color-duck-teal); } + .markdown code { + background: #0b100d; + border: 1px solid var(--color-line); + border-radius: 4px; + padding: 0.1rem 0.3rem; + font-size: 0.85em; + color: var(--color-duck-beige); + } + .markdown pre { + background: #0b100d; + border: 1px solid var(--color-line); + border-radius: 8px; + padding: 1rem; + overflow-x: auto; + } + .markdown pre code { background: none; border: 0; padding: 0; } + .markdown blockquote { + border-left: 3px solid var(--color-line); + padding-left: 1rem; + color: var(--color-faint); + } + .markdown table { border-collapse: collapse; width: 100%; font-size: 0.9rem; } + .markdown th, .markdown td { border: 1px solid var(--color-line); padding: 0.4rem 0.6rem; text-align: left; } + .markdown img { max-width: 100%; border-radius: 8px; } + .markdown hr { border: 0; border-top: 1px solid var(--color-line); margin: 2rem 0; } +} diff --git a/src/databases/marketplace_db/drizzle.config.ts b/src/databases/marketplace_db/drizzle.config.ts new file mode 100644 index 00000000..efa10430 --- /dev/null +++ b/src/databases/marketplace_db/drizzle.config.ts @@ -0,0 +1,35 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { defineConfig } from 'drizzle-kit'; + +// Same .env loading as officer_db's config: drizzle-kit runs as its own process from this directory and +// inherits nothing from the server. +const envPath = resolve(__dirname, '../../../.env'); +try { + const text = readFileSync(envPath, 'utf-8'); + for (const line of text.split('\n')) { + const match = line.match(/^(\w+)=(.*)$/); + if (match) { + const [, key, val] = match; + if (!process.env[key!]) process.env[key!] = val!.replace(/^["']|["']$/g, ''); + } + } +} catch {} + +// Derived exactly the way the runtime derives it — see src/db.ts. Duplicated rather than imported +// because drizzle-kit loads this file outside the workspace's module resolution. +const url = (() => { + if (process.env.MARKETPLACE_POSTGRES_URL) return process.env.MARKETPLACE_POSTGRES_URL; + const base = process.env.POSTGRES_URL; + if (!base) throw new Error('MARKETPLACE_POSTGRES_URL or POSTGRES_URL is required'); + const parsed = new URL(base); + parsed.pathname = '/officer_marketplace'; + return parsed.toString(); +})(); + +export default defineConfig({ + schema: './src/schema.ts', + out: './migrations', + dialect: 'postgresql', + dbCredentials: { url }, +}); diff --git a/src/databases/marketplace_db/src/db.ts b/src/databases/marketplace_db/src/db.ts new file mode 100644 index 00000000..205a92be --- /dev/null +++ b/src/databases/marketplace_db/src/db.ts @@ -0,0 +1,45 @@ +import { drizzle } from 'drizzle-orm/postgres-js'; +import postgres from 'postgres'; +import * as schema from './schema'; + +/** + * The marketplace's own database. + * + * ── Why a second database rather than more tables in `officer` ── + * + * They answer to different people. `officer` is one owner's machine: their accounts, their dashboards, + * what THEY have installed. The marketplace is a public catalogue that is the same for everyone and is + * served by a process with no platform login, no session and no user. A plugin being listed has nothing + * to do with any particular install, and `plugin_installs` in the platform's database has nothing to do + * with the store. + * + * The practical half: this database is the one thing here that can be dropped and rebuilt from the + * repositories it indexes, and a public HTTP surface reads it. Keeping that behind its own connection + * means the catalogue API cannot reach a single row of anybody's mail, wallet or passkeys — a property + * that is worth more than the convenience of one connection string. + * + * ── Where the URL comes from ── + * + * `MARKETPLACE_POSTGRES_URL` when set. Otherwise the platform's `POSTGRES_URL` with the database name + * swapped for `officer_marketplace`, because the two live on the same server in every deployment that + * exists — and a derived default is one fewer thing for `officer-setup.sh` to write correctly. Setting + * the variable explicitly is what a split across servers would need, and it costs nothing to support. + */ +const MARKETPLACE_DB_NAME = 'officer_marketplace'; + +export function marketplaceUrl(): string { + const explicit = process.env.MARKETPLACE_POSTGRES_URL; + if (explicit) return explicit; + + const base = process.env.POSTGRES_URL; + if (!base) throw new Error('MARKETPLACE_POSTGRES_URL or POSTGRES_URL is required'); + + // Swap only the path. Parsing rather than string-replacing, so a password containing the database + // name — or a name that appears in the host — cannot be rewritten by accident. + const url = new URL(base); + url.pathname = `/${MARKETPLACE_DB_NAME}`; + return url.toString(); +} + +const client = postgres(marketplaceUrl()); +export const marketplaceDb = drizzle(client, { schema }); diff --git a/src/databases/marketplace_db/src/index.ts b/src/databases/marketplace_db/src/index.ts new file mode 100644 index 00000000..d00a46d3 --- /dev/null +++ b/src/databases/marketplace_db/src/index.ts @@ -0,0 +1,3 @@ +export { marketplaceDb } from './db'; +export * as schema from './schema'; +export * from './plugins/queries'; diff --git a/src/databases/marketplace_db/src/plugins/queries.ts b/src/databases/marketplace_db/src/plugins/queries.ts new file mode 100644 index 00000000..4b411ff6 --- /dev/null +++ b/src/databases/marketplace_db/src/plugins/queries.ts @@ -0,0 +1,134 @@ +import { eq } from 'drizzle-orm'; +import { marketplaceDb } from '../db'; +import { pluginMetadata, plugins } from './schema'; + +/** + * One catalogue entry, in the shape the platform already consumes. + * + * This is `CatalogueEntry` from `servers/plugins/marketplace.ts`, and the duplication is deliberate: it + * is the WIRE FORMAT, and the two sides are different programs that will be deployed at different times. + * Importing the platform's type here would make a change to it a silent break in a running website, and + * a marketplace that must be redeployed in lockstep with every install is not a marketplace. + */ +export type CatalogueEntry = { + appName: string; + publisher: string; + version: string; + platform: string; + label: string; + summary: string; + color: string; + tile?: 'badge' | 'bare'; + iconUrl?: string; + icon?: string; + source: string; + permissions: unknown[]; + osDependencies?: unknown[]; + has: { api: boolean; schema: boolean; sidecar: boolean; web: boolean }; + /** Only on the single-plugin endpoint. A list of full READMEs is a page nobody asked for. */ + readme?: string; +}; + +type Row = { + appName: string; + developer: string; + repoUrl: string; + metadata: typeof pluginMetadata.$inferSelect | null; +}; + +/** + * A registry row plus its cached metadata, as the API returns it. + * + * Returns null when there is no metadata yet — a registered plugin that has never been synced is real, + * but there is nothing to render, and a half-filled tile is worse than an absent one. `listCatalogue` + * drops those; the count of dropped rows is what a sync failure looks like from outside. + */ +function toCatalogueEntry(row: Row): CatalogueEntry | null { + const m = row.metadata; + if (!m) return null; + return { + appName: row.appName, + publisher: row.developer, + version: m.version, + platform: m.platform, + label: m.label, + summary: m.summary, + color: m.color ?? '#94a3b8', + ...(m.tile ? { tile: m.tile as 'badge' | 'bare' } : {}), + ...(m.iconUrl ? { iconUrl: m.iconUrl } : {}), + ...(m.icon ? { icon: m.icon } : {}), + source: row.repoUrl, + permissions: (m.permissions as unknown[]) ?? [], + ...(m.osDependencies ? { osDependencies: m.osDependencies as unknown[] } : {}), + has: m.has as CatalogueEntry['has'], + }; +} + +/** Everything listed, newest registration last. Unlisted rows never leave this function. */ +export async function listCatalogue(): Promise { + const rows = await marketplaceDb + .select({ + appName: plugins.appName, + developer: plugins.developer, + repoUrl: plugins.repoUrl, + metadata: pluginMetadata, + }) + .from(plugins) + .leftJoin(pluginMetadata, eq(pluginMetadata.pluginId, plugins.id)) + .where(eq(plugins.listed, true)) + .orderBy(plugins.appName); + + return rows.map(toCatalogueEntry).filter((entry): entry is CatalogueEntry => entry !== null); +} + +/** One plugin, with its README. Null for an unknown or unlisted app name. */ +export async function catalogueEntry(appName: string): Promise { + const [row] = await marketplaceDb + .select({ + appName: plugins.appName, + developer: plugins.developer, + repoUrl: plugins.repoUrl, + metadata: pluginMetadata, + }) + .from(plugins) + .leftJoin(pluginMetadata, eq(pluginMetadata.pluginId, plugins.id)) + .where(eq(plugins.appName, appName)) + .limit(1); + + if (!row || !row.metadata) return null; + const entry = toCatalogueEntry(row); + if (!entry) return null; + return { ...entry, ...(row.metadata.readme ? { readme: row.metadata.readme } : {}) }; +} + +// ── Writes. Used by the sync script, never by the public API ── + +type RegisterParams = { appName: string; developer: string; repoUrl: string }; + +/** Register a plugin, or update where it lives. Returns its id. */ +export async function upsertPlugin({ appName, developer, repoUrl }: RegisterParams): Promise { + const [row] = await marketplaceDb + .insert(plugins) + .values({ appName, developer, repoUrl }) + .onConflictDoUpdate({ + target: plugins.appName, + set: { developer, repoUrl, updatedAt: new Date() }, + }) + .returning({ id: plugins.id }); + + if (!row) throw new Error(`upsertPlugin: no row returned for ${appName}`); + return row.id; +} + +export type MetadataParams = Omit; + +/** Replace a plugin's cached metadata. One row per plugin — see the schema for why. */ +export async function upsertMetadata(pluginId: number, data: MetadataParams): Promise { + await marketplaceDb + .insert(pluginMetadata) + .values({ ...data, pluginId }) + .onConflictDoUpdate({ + target: pluginMetadata.pluginId, + set: { ...data, fetchedAt: new Date() }, + }); +} diff --git a/src/databases/marketplace_db/src/plugins/schema.ts b/src/databases/marketplace_db/src/plugins/schema.ts new file mode 100644 index 00000000..757aad56 --- /dev/null +++ b/src/databases/marketplace_db/src/plugins/schema.ts @@ -0,0 +1,101 @@ +import { boolean, integer, jsonb, pgTable, serial, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core'; + +// What the marketplace offers, and what it knows about each thing it offers. +// +// ── Two tables, because they have different owners and different lifetimes ── +// +// `plugins` is the REGISTRY: the app name, who wrote it, where to clone it from. Every row is put there +// deliberately by whoever runs the marketplace, and nothing outside this database may change one. It is +// small, hand-curated, and the only part that is authoritative. +// +// `plugin_metadata` is a CACHE of what that repository says about itself — its label, its icon, the +// permissions it will ask for, its README. All of it already exists in the plugin's own `manifest.ts`, +// which is the real source of truth and stays that way. This table exists so the website can render a +// catalogue without cloning three repositories on every page load. +// +// Keeping them apart is what makes the cache safe to be wrong. A metadata row can be deleted, rebuilt +// from the repository, or found to be stale, and none of that can lose the fact that the plugin exists +// or where it lives. Merging them would make a refresh a write to the registry, which is exactly the +// kind of thing that turns a failed fetch into a plugin disappearing from the store. +// +// ── The platform re-reads everything after cloning, deliberately ── +// +// Nothing here is trusted by an install. The catalogue is what the owner sees BEFORE consenting — the +// permissions it will ask for, the host binaries it will install — and after the clone the plugin's own +// manifest is what the platform reads. A metadata row that disagrees with the repository is a marketplace +// bug, never a second source of truth. + +/** + * A plugin the marketplace lists. Identity and provenance only. + * + * `repoUrl` is HTTPS, never SSH: the marketplace serves one URL to every machine, and an `ssh://` clone + * assumes a key on that host for that user — true where the plugin was published and false everywhere + * else. Anonymous HTTPS also keeps credentials out of the install path entirely. + */ +export const plugins = pgTable( + 'plugins', + { + id: serial('id').primaryKey(), + /** The directory it clones into and its id everywhere else. Matches the plugin's own `appName`. */ + appName: text('app_name').notNull(), + /** Who publishes it. A name today; an account when submissions exist. */ + developer: text('developer').notNull(), + repoUrl: text('repo_url').notNull(), + /** + * Off hides it from the public API without deleting it or its metadata. A plugin taken down for a + * bad release has to come back with its history, and a delete makes that a re-registration. + */ + listed: boolean('listed').notNull().default(true), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [uniqueIndex('uq_marketplace_plugins_app_name').on(table.appName)], +); + +/** + * What a plugin's repository says about itself, as of the last sync. + * + * One row per plugin — this is a snapshot, not a history. Versions are a separate feature and would be a + * separate table; pretending this one holds them by letting rows accumulate would give a store with no + * way to say which row is current. + * + * The JSON columns hold the manifest's own shapes (`PluginPermission[]`, `OsDependency[]`, the `has` + * flags) rather than being flattened into columns. They are read as a unit, rendered as a unit, and + * their shape is owned by `plugins/manifest.ts` in the platform — flattening would mean a migration here + * every time a manifest gains a field, for no query anyone runs. + */ +export const pluginMetadata = pgTable( + 'plugin_metadata', + { + id: serial('id').primaryKey(), + pluginId: integer('plugin_id') + .notNull() + .references(() => plugins.id, { onDelete: 'cascade' }), + + version: text('version').notNull(), + /** The platform range it declares support for, e.g. `>=1.0.0`. */ + platform: text('platform').notNull(), + label: text('label').notNull(), + summary: text('summary').notNull(), + color: text('color'), + /** `badge` or `bare` — whether the tile draws a coloured square behind the icon. */ + tile: text('tile'), + /** Absolute URL to artwork. A store must show an icon for something the machine has never seen. */ + iconUrl: text('icon_url'), + /** A lucide glyph NAME, for a plugin that ships no artwork. `iconUrl` wins where both are given. */ + icon: text('icon'), + + permissions: jsonb('permissions').notNull(), + osDependencies: jsonb('os_dependencies'), + /** `{ api, schema, sidecar, web }` — what the plugin ships. */ + has: jsonb('has').notNull(), + + /** The repository's README, rendered on the plugin's page. Null until one exists. */ + readme: text('readme'), + + /** The commit this snapshot was taken from, so a stale row can be told from an unchanged one. */ + sourceCommit: text('source_commit'), + fetchedAt: timestamp('fetched_at', { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [uniqueIndex('uq_marketplace_plugin_metadata_plugin').on(table.pluginId)], +); diff --git a/src/databases/marketplace_db/src/schema.ts b/src/databases/marketplace_db/src/schema.ts new file mode 100644 index 00000000..51dd6551 --- /dev/null +++ b/src/databases/marketplace_db/src/schema.ts @@ -0,0 +1,5 @@ +// drizzle-kit's view of the marketplace database. +// +// Unlike `officer_db`, there is no generated plugin barrel to keep out of the runtime graph: this +// database has one owner and a fixed set of tables. Nothing on this machine may be absent from it. +export * from './plugins/schema'; diff --git a/src/servers/marketplace/server.tsx b/src/servers/marketplace/server.tsx new file mode 100644 index 00000000..a8f80efb --- /dev/null +++ b/src/servers/marketplace/server.tsx @@ -0,0 +1,94 @@ +import index from '../../apps/marketplace/index.html'; +import { catalogueEntry, listCatalogue } from '../../databases/marketplace_db/src/plugins/queries'; + +/** + * marketplace.officer.dev — the public plugin catalogue. + * + * ── Why this is not part of the platform's server ── + * + * It has no users. Nothing here reads a session, a token or a role, and there is no request it answers + * differently depending on who is asking — a catalogue is the same for everyone or it is not a + * catalogue. Mounting it inside the platform would have put a public, unauthenticated surface behind the + * same process that holds mail, a wallet and passkeys, and behind a permission gate it must always + * bypass. Those exemptions are precisely the ones that later turn out to have been too wide. + * + * Its own process, its own port, its own database connection. The failure modes stay separate too: the + * marketplace being down must not stop a platform booting, and a platform being down must not take the + * store offline. + * + * ── Read-only, and structurally so ── + * + * There are two GET routes and no others. Registering a plugin is `scripts/marketplace-sync.ts`, run by + * hand against the database. That is not a temporary shortcut standing in for a write API: while + * submissions do not exist, a public write surface is all risk and no feature. + * + * ── CORS is wide open, deliberately ── + * + * Every Officer install is a different origin — `officer.dev`, a tailnet name, `localhost` in dev — and + * the catalogue must answer all of them. There is nothing to protect: no credentials, no cookies, no + * per-caller answer. `*` is the honest description of a public catalogue, and an origin allowlist here + * would be security theatre that breaks a real install the first time somebody renames their host. + */ +const PORT = Number(process.env.MARKETPLACE_PORT ?? 9100); + +const CORS = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type', + 'Access-Control-Max-Age': '86400', +}; + +const json = (body: unknown, status = 200) => + Response.json(body, { status, headers: { ...CORS, 'Cache-Control': 'public, max-age=60' } }); + +const preflight = () => new Response(null, { status: 204, headers: CORS }); + +/** App names come from a URL. Same rule as everywhere else: never let one address something else. */ +const APP_NAME_RE = /^[a-z0-9][a-z0-9-]{0,63}$/; + +const server = Bun.serve({ + port: PORT, + hostname: '0.0.0.0', + routes: { + '/api/plugins': { + OPTIONS: preflight, + async GET() { + try { + return json(await listCatalogue()); + } catch (err) { + console.error('[marketplace] catalogue query failed —', err instanceof Error ? err.message : err); + // 503 rather than an empty list: a platform that caches the last good answer must be able to + // tell "the store is empty" from "the store is broken", and an empty 200 says the first. + return json({ error: 'catalogue unavailable' }, 503); + } + }, + }, + + '/api/plugins/:appName': { + OPTIONS: preflight, + async GET(req) { + const { appName } = req.params; + if (!APP_NAME_RE.test(appName)) return json({ error: 'bad app name' }, 400); + try { + const entry = await catalogueEntry(appName); + return entry ? json(entry) : json({ error: 'no such plugin' }, 404); + } catch (err) { + console.error('[marketplace] entry query failed —', err instanceof Error ? err.message : err); + return json({ error: 'catalogue unavailable' }, 503); + } + }, + }, + + // Liveness for the proxy and for `officer-setup.sh` to check. Touches no database on purpose: it + // answers "is this process up", and conflating that with "is Postgres up" makes both unanswerable. + '/api/health': () => json({ ok: true, service: 'marketplace' }), + + // Everything else is the SPA — including deep links like /plugins/music, which must return the + // shell rather than a 404 for client-side routing to work at all. + '/*': index, + }, + + development: process.env.NODE_ENV !== 'production', +}); + +console.log(`[marketplace] listening on ${server.url}`); diff --git a/src/servers/plugins/marketplace.test.ts b/src/servers/plugins/marketplace.test.ts new file mode 100644 index 00000000..f76de647 --- /dev/null +++ b/src/servers/plugins/marketplace.test.ts @@ -0,0 +1,98 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { clearCatalogueCache, fetchCatalogue } from './marketplace'; + +// The three answers — live, cached, bundled — and the rule that none of them is an exception. +// +// The plugins page is blocked on this call, and an owner's INSTALLED plugins are managed on that same +// page. So a marketplace that is down must degrade to something true rather than throw: the failure +// mode being defended against is "officer.dev has a bad afternoon and nobody can uninstall anything". + +const realFetch = globalThis.fetch; + +const entry = (appName: string) => ({ + appName, + publisher: 'officerdev', + version: '1.0.0', + platform: '>=1.0.0', + label: appName, + summary: 'a plugin', + color: '#fff', + source: `https://gitea.officer.dev/plugins/${appName}.git`, + permissions: [], + has: { api: true, schema: false, sidecar: false, web: true }, +}); + +const respondWith = (body: unknown, status = 200) => { + globalThis.fetch = (async () => + new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } })) as typeof fetch; +}; + +const failWith = (message: string) => { + globalThis.fetch = (async () => { + throw new Error(message); + }) as typeof fetch; +}; + +beforeEach(() => clearCatalogueCache()); +afterEach(() => { + globalThis.fetch = realFetch; + clearCatalogueCache(); +}); + +describe('fetchCatalogue', () => { + test('serves what the marketplace returns', async () => { + respondWith([entry('music'), entry('offscale')]); + expect((await fetchCatalogue()).map((e) => e.appName)).toEqual(['music', 'offscale']); + }); + + test('falls back to the bundled catalogue when the store is unreachable', async () => { + failWith('getaddrinfo ENOTFOUND'); + const entries = await fetchCatalogue(); + // Not asserting the exact list — that changes as plugins are published. The property is that a + // machine with no network still has a usable store. + expect(entries.length).toBeGreaterThan(0); + expect(entries.every((e) => e.source.startsWith('https://'))).toBe(true); + }); + + test('a cached answer outlives the store going down', async () => { + respondWith([entry('music')]); + await fetchCatalogue(); + + // The cache is deliberately NOT cleared here — surviving the store going down is the whole point. + failWith('connection refused'); + const entries = await fetchCatalogue(); + expect(entries.map((e) => e.appName)).toEqual(['music']); + }); + + test('an HTTP error is a failure, not a catalogue', async () => { + respondWith({ error: 'catalogue unavailable' }, 503); + const entries = await fetchCatalogue(); + expect(entries.some((e) => e.appName === 'music')).toBe(true); // the bundled list, not the error body + }); + + test('a proxy serving HTML instead of JSON does not empty the store', async () => { + globalThis.fetch = (async () => new Response('502 Bad Gateway', { status: 200 })) as typeof fetch; + const entries = await fetchCatalogue(); + expect(entries.length).toBeGreaterThan(0); + }); + + test('malformed entries are dropped individually, not fatally', async () => { + respondWith([entry('music'), { appName: 'broken' }, entry('example')]); + expect((await fetchCatalogue()).map((e) => e.appName)).toEqual(['music', 'example']); + }); + + test('a non-https source is refused — it drives git clone', async () => { + respondWith([{ ...entry('evil'), source: 'file:///etc/passwd' }, entry('music')]); + expect((await fetchCatalogue()).map((e) => e.appName)).toEqual(['music']); + }); + + test('an empty catalogue is a valid answer and is cached as one', async () => { + respondWith([]); + expect(await fetchCatalogue()).toEqual([]); + + // Cached: a store that legitimately lists nothing must not fall through to the bundled list, or an + // owner who unpublished everything would keep seeing plugins that are gone. + failWith('down'); + expect(await fetchCatalogue()).toEqual([]); + }); +}); diff --git a/src/servers/plugins/marketplace.ts b/src/servers/plugins/marketplace.ts index 2d2619a2..731ba8ed 100644 --- a/src/servers/plugins/marketplace.ts +++ b/src/servers/plugins/marketplace.ts @@ -75,13 +75,20 @@ export type CatalogueEntry = { }; /** - * The stand-in catalogue. + * What this machine knows without asking anyone. * - * One entry, and it is not a fixture: `plugins/music` is a real public repository that a real - * `git clone` reaches with no credentials. The install path is therefore exercised end to end even - * though the catalogue itself is a constant. + * This WAS the catalogue until marketplace.officer.dev existed. It is now the floor under it: the + * answer when the store cannot be reached and nothing has been cached yet. + * + * Kept rather than deleted, and kept CURRENT, because the alternative is a first-boot machine with no + * internet showing an empty store — which is indistinguishable from a store with nothing in it. Three + * entries is a small price for "the plugins page always has something true on it". + * + * It will drift from the real catalogue, and that is acceptable in exactly one direction: it may be + * missing something new. It must never describe a plugin that no longer exists, because that produces + * an install button that fails at clone time. */ -const CATALOGUE: CatalogueEntry[] = [ +const BUNDLED: CatalogueEntry[] = [ { appName: 'music', publisher: 'officerdev', @@ -156,19 +163,108 @@ const CATALOGUE: CatalogueEntry[] = [ }, ]; +// ── Reaching the real one ── + +/** Where the catalogue lives. Overridable so a fork, an air-gapped install or a test can point elsewhere. */ +const MARKETPLACE_URL = process.env.MARKETPLACE_URL ?? 'https://marketplace.officer.dev'; + +/** + * How long a good answer is reused before asking again. + * + * Long enough that opening the plugins page repeatedly does not hammer the store, short enough that a + * plugin published this morning shows up this morning. The cache is per PROCESS and dies with it — a + * restart is always a fresh look. + */ +const CACHE_TTL_MS = 5 * 60 * 1000; + +/** + * How long to wait for the store before giving up on it. + * + * The plugins page is blocked on this call, so the ceiling is "how long may that page take to render + * when marketplace.officer.dev is unreachable". Four seconds is already at the edge of what a page can + * spend before it feels broken — and a DNS black hole would otherwise hang it for the OS timeout. + */ +const FETCH_TIMEOUT_MS = 4000; + +let cache: { entries: CatalogueEntry[]; at: number } | null = null; + +/** + * A catalogue entry drives `git clone`, so nothing arrives from the network untyped. + * + * This is not defence against a hostile marketplace — a store that serves a malicious `source` has + * already won, since the owner asked to install what it lists. It is defence against a WRONG one: a + * half-deployed API, a proxy serving an error page as JSON, a field renamed on one side. Every one of + * those otherwise reaches the install path as `undefined` and fails somewhere far less legible. + * + * Entries that fail are dropped individually rather than failing the whole response: one malformed + * plugin should not empty the store. + */ +function isEntry(value: unknown): value is CatalogueEntry { + if (!value || typeof value !== 'object') return false; + const e = value as Record; + return ( + typeof e.appName === 'string' && + e.appName.length > 0 && + typeof e.source === 'string' && + e.source.startsWith('https://') && + typeof e.label === 'string' && + typeof e.summary === 'string' && + Array.isArray(e.permissions) && + typeof e.has === 'object' && + e.has !== null + ); +} + /** * Everything the marketplace offers. * - * Async and allowed to fail from the start, because the real one will be a network call. A catalogue - * that cannot be reached must leave the plugins page working with what is on disk — an owner who cannot - * see the store should still be able to manage what they have already installed. + * Three answers in preference order — live, cached, bundled — and the page cannot tell which it got. + * That is deliberate: the plugins page's job is to show what can be installed, and it can always show + * something true. The alternative, an error state on the store being unreachable, makes an owner's own + * installed plugins unmanageable because a website is down. + * + * Never throws. `catalogueEntry` below is on the install path, and an install that fails because the + * catalogue lookup threw would be reported as "that plugin does not exist". */ export async function fetchCatalogue(): Promise { - return CATALOGUE; + if (cache && Date.now() - cache.at < CACHE_TTL_MS) return cache.entries; + + try { + const res = await fetch(`${MARKETPLACE_URL}/api/plugins`, { + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + headers: { accept: 'application/json' }, + }); + if (!res.ok) throw new Error(`marketplace responded ${res.status}`); + + const body: unknown = await res.json(); + if (!Array.isArray(body)) throw new Error('marketplace returned a non-array'); + + const entries = body.filter(isEntry); + const dropped = body.length - entries.length; + if (dropped > 0) console.warn(`[marketplace] dropped ${dropped} malformed entr(y/ies)`); + + // An empty valid response is cached like any other: a store may legitimately list nothing, and + // treating empty as failure would pin a stale cache forever the first time it happened. + cache = { entries, at: Date.now() }; + return entries; + } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + if (cache) { + console.warn(`[marketplace] unreachable (${reason}) — serving the cached catalogue`); + return cache.entries; + } + console.warn(`[marketplace] unreachable (${reason}) — serving the bundled catalogue`); + return BUNDLED; + } } /** One entry by app name, or null. Used by install to find where to clone from. */ export async function catalogueEntry(appName: string): Promise { - const all = await fetchCatalogue().catch(() => [] as CatalogueEntry[]); + const all = await fetchCatalogue().catch(() => BUNDLED); return all.find((e) => e.appName === appName) ?? null; } + +/** Testing seam. The cache is process-lifetime, so a test that does not clear it leaks into the next. */ +export function clearCatalogueCache(): void { + cache = null; +}