diff --git a/src/frontend/src/components/settings/app-catalog-section.tsx b/src/frontend/src/components/settings/app-catalog-section.tsx
new file mode 100644
index 0000000..dd7acd6
--- /dev/null
+++ b/src/frontend/src/components/settings/app-catalog-section.tsx
@@ -0,0 +1,1150 @@
+/**
+ * Copyright (C) 2026 Dcntrlized
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+import { useId, useRef, useState } from 'preact/hooks'
+import type { ComponentChildren } from 'preact'
+import {
+ AlertCircle,
+ Copy,
+ Image as ImageIcon,
+ Link2,
+ LoaderCircle,
+ Package,
+ Pencil,
+ Plus,
+ Trash2,
+ Upload,
+} from 'lucide-react'
+import { Dialog, Modal, ModalOverlay } from 'react-aria-components'
+import { toast } from 'sonner'
+import { Button } from '@components/ui/button'
+import { Input } from '@components/ui/input'
+import { ApiError } from '@lib/api/client'
+import { useAsyncAction } from '@lib/hooks/useAsyncAction'
+import { useCatalogApps, type CatalogController } from '@lib/hooks/useCatalogApps'
+import { cn } from '@lib/utils'
+import type {
+ CatalogApp,
+ CatalogAppInput,
+ CatalogAppPatch,
+ CatalogArtifactKind,
+} from '@lib/types'
+
+/* ------------------------------------------------------------------ helpers */
+
+function formatBytes(bytes: number | undefined): string {
+ if (!bytes || bytes < 0) return '—'
+ if (bytes < 1024) return `${bytes} B`
+ const units = ['KB', 'MB', 'GB', 'TB']
+ let value = bytes / 1024
+ let unit = 0
+ while (value >= 1024 && unit < units.length - 1) {
+ value /= 1024
+ unit += 1
+ }
+ return `${value >= 100 ? Math.round(value) : value.toFixed(1)} ${units[unit]}`
+}
+
+function formatDate(iso?: string | null): string {
+ if (!iso) return '—'
+ const date = new Date(iso)
+ if (Number.isNaN(date.getTime())) return iso
+ return date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
+}
+
+function initials(name: string): string {
+ const words = name.trim().split(/[\s._-]+/).filter(Boolean)
+ if (words.length === 0) return '?'
+ if (words.length === 1) return words[0].slice(0, 2).toUpperCase()
+ return (words[0][0] + words[1][0]).toUpperCase()
+}
+
+/** Maps the frozen error envelope onto plain, recovery-naming copy. */
+function describeError(error: unknown, fallback: string): string {
+ const code = error instanceof ApiError ? error.code : undefined
+ const status = error instanceof ApiError ? error.status : 0
+
+ if (code === 'ERR_CHECKSUM_MISMATCH') {
+ return 'The artifact’s sha256 does not match the checksum you supplied. Nothing was stored.'
+ }
+ if (code === 'ERR_APP_EXISTS' || status === 409) {
+ return 'An app with that package is already in the catalog. Edit the existing entry instead.'
+ }
+ if (code === 'ERR_ARTIFACT_FETCH' || status === 502) {
+ return 'Could not download that artifact URL. Check the link is reachable, then try again.'
+ }
+ if (code === 'ERR_INVALID_PAYLOAD' || status === 400) {
+ return 'The server rejected one of the fields. Check the required values and try again.'
+ }
+ if (code === 'ERR_NOT_FOUND' || status === 404) {
+ return 'That app is no longer in the catalog. Reload the list.'
+ }
+ if (status === 401 || status === 428) return 'Your session ended. Sign in again.'
+ if (status === 0) return 'Could not reach this node’s API.'
+ return fallback
+}
+
+/* -------------------------------------------------------------- small parts */
+
+function Field({
+ id,
+ label,
+ required,
+ hint,
+ error,
+ children,
+}: {
+ id: string
+ label: string
+ required?: boolean
+ hint?: string
+ error?: string
+ children: ComponentChildren
+}) {
+ return (
+
+
+ {label}
+
+ {children}
+ {error ? (
+
+ {error}
+
+ ) : hint ? (
+
+ {hint}
+
+ ) : null}
+
+ )
+}
+
+interface TextFieldProps {
+ id: string
+ label: string
+ value: string
+ onInput: (value: string) => void
+ required?: boolean
+ hint?: string
+ error?: string
+ placeholder?: string
+ type?: string
+ mono?: boolean
+ readOnly?: boolean
+ inputMode?: 'text' | 'numeric' | 'url'
+ maxLength?: number
+}
+
+function TextField(props: TextFieldProps) {
+ const describedBy = props.error ? `${props.id}-error` : props.hint ? `${props.id}-hint` : undefined
+ return (
+
+ props.onInput((event.target as HTMLInputElement).value)}
+ />
+
+ )
+}
+
+function CopyChip({ value, label, display }: { value: string; label: string; display: string }) {
+ const copy = useAsyncAction(async () => {
+ try {
+ await navigator.clipboard.writeText(value)
+ toast.success('Copied', { description: label })
+ } catch {
+ toast.error('Could not copy to the clipboard')
+ }
+ })
+ return (
+ void copy.run()}
+ title={value}
+ aria-label={`Copy ${label}`}
+ className="flex w-full min-w-0 items-start gap-1.5 rounded-md border border-border bg-muted/40 px-2 py-1 text-left font-mono text-xs text-muted-foreground transition-colors hover:border-border/90 hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
+ >
+ {display}
+
+
+ )
+}
+
+/** Icon-only row action with a native hover title (RAC's ButtonProps has no `title`). */
+function IconAction({
+ label,
+ onPress,
+ isDisabled,
+ isPending,
+ children,
+}: {
+ label: string
+ onPress: () => void
+ isDisabled?: boolean
+ isPending?: boolean
+ children: ComponentChildren
+}) {
+ return (
+
+
+ {children}
+
+
+ )
+}
+
+const KIND_META: Record = {
+ hosted: {
+ label: 'Hosted',
+ chip: 'border-signal/25 bg-signal/10 text-signal',
+ icon: Upload,
+ },
+ link: {
+ label: 'Link only',
+ chip: 'border-border bg-muted/60 text-foreground/80',
+ icon: Link2,
+ },
+}
+
+function KindChip({ kind }: { kind: CatalogArtifactKind }) {
+ const meta = KIND_META[kind] ?? KIND_META.hosted
+ const Icon = meta.icon
+ return (
+
+
+ {meta.label}
+
+ )
+}
+
+function AppIcon({ app }: { app: CatalogApp }) {
+ return (
+
+ {app.icon_url ? (
+
+ ) : (
+
+ {initials(app.name)}
+
+ )}
+
+ )
+}
+
+/* --------------------------------------------------------------- app row */
+
+function AppRow({
+ app,
+ upload,
+ busy,
+ onEdit,
+ onDelete,
+ onUpload,
+ onCancelUpload,
+}: {
+ app: CatalogApp
+ upload: { percent: number; label: string } | undefined
+ busy: boolean
+ onEdit: (app: CatalogApp) => void
+ onDelete: (app: CatalogApp) => void
+ onUpload: (app: CatalogApp, kind: 'artifact' | 'icon', file: File) => void
+ onCancelUpload: (id: string) => void
+}) {
+ const artifactInput = useRef(null)
+ const iconInput = useRef(null)
+
+ const copyUrl = useAsyncAction(async () => {
+ try {
+ await navigator.clipboard.writeText(app.artifact.download_url)
+ toast.success('Copied download URL', { description: app.name })
+ } catch {
+ toast.error('Could not copy to the clipboard')
+ }
+ })
+
+ return (
+
+
+
+
+
+
+ {app.name}
+
+
+ v{app.version_name}
+
+
+
+
{app.package}
+ {app.summary && (
+
+ {app.summary}
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
Artifact size
+ {formatBytes(app.artifact.size_bytes)}
+
+
+
Added
+ Added {formatDate(app.added_at)}
+
+ {app.artifact.filename && (
+
+
Artifact file
+ {app.artifact.filename}
+
+ )}
+
+
+ {upload && (
+
+
+
+ {upload.label}… {upload.percent}%
+
+ onCancelUpload(app.id)}
+ className="shrink-0 rounded-sm text-xs text-muted-foreground underline-offset-2 hover:text-foreground hover:underline focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:outline-none"
+ >
+ Cancel
+
+
+
+
+ )}
+
+
+
onEdit(app)}
+ isDisabled={busy}
+ >
+
+
+
+
artifactInput.current?.click()}
+ isDisabled={busy}
+ >
+
+
+
{
+ const file = (event.target as HTMLInputElement).files?.[0]
+ if (file) onUpload(app, 'artifact', file)
+ ;(event.target as HTMLInputElement).value = ''
+ }}
+ />
+
+
iconInput.current?.click()}
+ isDisabled={busy}
+ >
+
+
+
{
+ const file = (event.target as HTMLInputElement).files?.[0]
+ if (file) onUpload(app, 'icon', file)
+ ;(event.target as HTMLInputElement).value = ''
+ }}
+ />
+
+
void copyUrl.run()}
+ isPending={copyUrl.pending}
+ >
+
+
+
+
onDelete(app)}
+ isDisabled={busy}
+ >
+
+ Delete
+
+
+
+ )
+}
+
+/* ------------------------------------------------------------ form dialog */
+
+interface Draft {
+ package: string
+ name: string
+ versionName: string
+ artifactUrl: string
+ linkOnly: boolean
+ sha256: string
+ summary: string
+ description: string
+ category: string
+ versionCode: string
+ minAndroid: string
+ iconUrl: string
+ signerSha256: string
+ sourceUrl: string
+}
+
+const EMPTY_DRAFT: Draft = {
+ package: '',
+ name: '',
+ versionName: '',
+ artifactUrl: '',
+ linkOnly: false,
+ sha256: '',
+ summary: '',
+ description: '',
+ category: '',
+ versionCode: '',
+ minAndroid: '',
+ iconUrl: '',
+ signerSha256: '',
+ sourceUrl: '',
+}
+
+function draftFrom(app: CatalogApp): Draft {
+ return {
+ ...EMPTY_DRAFT,
+ package: app.package,
+ name: app.name,
+ versionName: app.version_name,
+ sha256: app.artifact.sha256,
+ summary: app.summary ?? '',
+ description: app.description ?? '',
+ category: app.category ?? '',
+ versionCode: app.version_code !== undefined ? String(app.version_code) : '',
+ minAndroid: app.min_android !== undefined ? String(app.min_android) : '',
+ iconUrl: app.icon_url ?? '',
+ signerSha256: app.artifact.signer_sha256 ?? '',
+ sourceUrl: app.source_url ?? '',
+ }
+}
+
+const HEX64 = /^[0-9a-f]{64}$/i
+const PACKAGE_ID = /^[a-zA-Z][a-zA-Z0-9_]*(\.[a-zA-Z0-9_]+)+$/
+const URL_LIKE = /^https?:\/\/\S+$/i
+
+type Errors = Partial>
+
+function validate(draft: Draft, file: File | null, mode: 'add' | 'edit'): Errors {
+ const errors: Errors = {}
+ if (mode === 'add' && !PACKAGE_ID.test(draft.package.trim())) {
+ errors.package = 'Use a package id with at least two segments, like org.example.app.'
+ }
+ if (!draft.name.trim()) errors.name = 'Give the app a name clients will see.'
+ if (!draft.versionName.trim()) errors.versionName = 'A version name is required, like 1.4.2.'
+
+ if (mode === 'add') {
+ const url = draft.artifactUrl.trim()
+ if (!file && !url) {
+ errors.artifactUrl = 'Give an artifact URL or choose an APK file.'
+ } else if (!file && url && !URL_LIKE.test(url)) {
+ errors.artifactUrl = 'Enter a full URL starting with http:// or https://.'
+ }
+ if (draft.linkOnly && !draft.sha256.trim()) {
+ errors.sha256 = 'Link-only entries need the artifact’s sha256 so clients can verify it.'
+ }
+ }
+ if (draft.sha256.trim() && !HEX64.test(draft.sha256.trim().replace(/[\s:]/g, ''))) {
+ errors.sha256 = 'A sha256 checksum is 64 hexadecimal characters.'
+ }
+ if (draft.versionCode.trim() && !/^\d+$/.test(draft.versionCode.trim())) {
+ errors.versionCode = 'A version code is a whole number.'
+ }
+ if (draft.minAndroid.trim() && !/^\d+$/.test(draft.minAndroid.trim())) {
+ errors.minAndroid = 'A minimum Android API level is a whole number.'
+ }
+ if (draft.signerSha256.trim() && !/^[0-9a-f:]{40,}$/i.test(draft.signerSha256.trim())) {
+ errors.signerSha256 = 'A signer fingerprint is a hex sha256, with or without colons.'
+ }
+ if (draft.iconUrl.trim() && !URL_LIKE.test(draft.iconUrl.trim())) {
+ errors.iconUrl = 'Enter a full URL starting with http:// or https://.'
+ }
+ if (draft.sourceUrl.trim() && !URL_LIKE.test(draft.sourceUrl.trim())) {
+ errors.sourceUrl = 'Enter a full URL starting with http:// or https://.'
+ }
+ return errors
+}
+
+/** Flat snake_case metadata shared by the add and edit payloads. */
+function metadataPayload(draft: Draft) {
+ return {
+ name: draft.name.trim(),
+ version_name: draft.versionName.trim(),
+ summary: draft.summary.trim(),
+ description: draft.description.trim(),
+ category: draft.category.trim(),
+ icon_url: draft.iconUrl.trim(),
+ signer_sha256: draft.signerSha256.trim(),
+ source_url: draft.sourceUrl.trim(),
+ ...(draft.versionCode.trim() ? { version_code: Number(draft.versionCode) } : {}),
+ ...(draft.minAndroid.trim() ? { min_android: Number(draft.minAndroid) } : {}),
+ }
+}
+
+function AppFormDialog({
+ mode,
+ app,
+ controller,
+ onClose,
+}: {
+ mode: 'add' | 'edit'
+ app?: CatalogApp
+ controller: CatalogController
+ onClose: () => void
+}) {
+ const uid = useId()
+ const [draft, setDraft] = useState(() => (app ? draftFrom(app) : EMPTY_DRAFT))
+ const [file, setFile] = useState(null)
+ const [errors, setErrors] = useState({})
+ const [formError, setFormError] = useState(null)
+ const [busy, setBusy] = useState(false)
+ const [percent, setPercent] = useState(null)
+ const fileInput = useRef(null)
+ const formRef = useRef(null)
+
+ const field = (key: keyof Draft) => ({
+ value: draft[key] as string,
+ onInput: (value: string) => setDraft((prev) => ({ ...prev, [key]: value })),
+ error: errors[key],
+ })
+
+ async function submit() {
+ const nextErrors = validate(draft, file, mode)
+ setErrors(nextErrors)
+ setFormError(null)
+ const first = Object.keys(nextErrors)[0]
+ if (first) {
+ // Move the operator to the field that needs them, not just to the message.
+ const target = formRef.current?.querySelector(`#${uid}-${first}`)
+ target?.focus()
+ return
+ }
+
+ setBusy(true)
+ setPercent(file ? 0 : null)
+ try {
+ if (mode === 'add') {
+ const input: CatalogAppInput = {
+ package: draft.package.trim(),
+ ...metadataPayload(draft),
+ ...(file ? {} : { artifact_url: draft.artifactUrl.trim() }),
+ ...(draft.linkOnly ? { link_only: true } : {}),
+ ...(draft.sha256.trim()
+ ? { sha256: draft.sha256.trim().replace(/[\s:]/g, '').toLowerCase() }
+ : {}),
+ }
+ await controller.add(input, file, (value) => setPercent(value))
+ toast.success('App added to the catalog', { description: input.name })
+ } else if (app) {
+ await controller.update(app.id, metadataPayload(draft) as CatalogAppPatch)
+ toast.success('Metadata saved', { description: draft.name.trim() })
+ }
+ onClose()
+ } catch (error) {
+ setFormError(describeError(error, 'Could not save this app.'))
+ } finally {
+ setBusy(false)
+ setPercent(null)
+ }
+ }
+
+ const title = mode === 'add' ? 'Add an app' : `Edit ${app?.name ?? 'app'}`
+ const description =
+ mode === 'add'
+ ? 'Publish an app so clients can discover and install it from this instance. The catalog is independent of the services this node runs.'
+ : 'Package ids are fixed once an app is published; everything else can change.'
+
+ return (
+ {
+ if (!open && !busy) onClose()
+ }}
+ isDismissable={!busy}
+ className="fixed inset-0 z-[60] grid place-items-center overflow-y-auto bg-black/60 p-4 backdrop-blur-sm"
+ >
+
+
+
+
+
+
+ )
+}
+
+/* --------------------------------------------------------- delete dialog */
+
+function DeleteDialog({
+ app,
+ onClose,
+ onConfirm,
+}: {
+ app: CatalogApp
+ onClose: () => void
+ onConfirm: () => Promise
+}) {
+ const [busy, setBusy] = useState(false)
+ const [error, setError] = useState(null)
+
+ return (
+ {
+ if (!open && !busy) onClose()
+ }}
+ isDismissable={!busy}
+ className="fixed inset-0 z-[60] grid place-items-center overflow-y-auto bg-black/60 p-4 backdrop-blur-sm"
+ >
+
+
+
+ Delete {app.name}?
+
+
+ Removes the catalog entry{app.artifact.kind === 'hosted' ? ' and the copy this instance hosts' : ''}.
+ Clients will no longer find it here.
+
+ {error && (
+
+
+ {error}
+
+ )}
+
+
+ Cancel
+
+ {
+ setBusy(true)
+ setError(null)
+ onConfirm()
+ .then(onClose)
+ .catch((err) => setError(describeError(err, 'Could not delete this app.')))
+ .finally(() => setBusy(false))
+ }}
+ >
+ {busy ? (
+
+ ) : (
+
+ )}
+ Delete app
+
+
+
+
+
+ )
+}
+
+/* ------------------------------------------------------------ skeleton */
+
+function CatalogSkeleton() {
+ return (
+
+ {[0, 1].map((row) => (
+
+ ))}
+
+ )
+}
+
+/* -------------------------------------------------------------- section */
+
+export function AppCatalogSection() {
+ const controller = useCatalogApps()
+ const [addOpen, setAddOpen] = useState(false)
+ const [editApp, setEditApp] = useState(null)
+ const [deleteApp, setDeleteApp] = useState(null)
+
+ const { state, uploads } = controller
+ const apps = state.kind === 'ok' ? state.apps : []
+
+ const handleUpload = (app: CatalogApp, kind: 'artifact' | 'icon', file: File) => {
+ const run = kind === 'icon' ? controller.uploadIcon : controller.uploadArtifact
+ run(app.id, file).catch((error) => {
+ toast.error(kind === 'icon' ? 'Icon upload failed' : 'Artifact upload failed', {
+ description: describeError(error, 'The upload did not finish.'),
+ })
+ })
+ }
+
+ return (
+
+ {state.kind === 'ok' && apps.length > 0 && (
+
+
+ {apps.length} {apps.length === 1 ? 'app' : 'apps'}
+
+
setAddOpen(true)}>
+
+ Add app
+
+
+ )}
+
+ {state.kind === 'loading' &&
}
+
+ {state.kind === 'error' && (
+
+
+ {state.message === 'Request failed (404)'
+ ? 'This node’s catalog API is not available yet.'
+ : `Could not load the catalog: ${state.message}`}
+
+
+ controller.reload()}>
+ Try again
+
+
+
+ )}
+
+ {state.kind === 'ok' && apps.length === 0 && (
+
+
+
No apps in the catalog
+
+ Publish an app so clients can discover and install it from this instance.
+
+
setAddOpen(true)}>
+
+ Add app
+
+
+ )}
+
+ {state.kind === 'ok' && apps.length > 0 && (
+
+ {apps.map((app) => (
+
+ ))}
+
+ )}
+
+ {addOpen && (
+
setAddOpen(false)} />
+ )}
+ {editApp && (
+ setEditApp(null)}
+ />
+ )}
+ {deleteApp && (
+ setDeleteApp(null)}
+ onConfirm={() => controller.remove(deleteApp.id)}
+ />
+ )}
+
+ )
+}
diff --git a/src/frontend/src/components/settings/settings-dialog.tsx b/src/frontend/src/components/settings/settings-dialog.tsx
index 9706885..e9cfdf1 100644
--- a/src/frontend/src/components/settings/settings-dialog.tsx
+++ b/src/frontend/src/components/settings/settings-dialog.tsx
@@ -15,18 +15,15 @@
* along with this program. If not, see .
*/
-import { Settings, ShieldCheck, X } from 'lucide-react'
+import { Settings, Smartphone, X } from 'lucide-react'
import { Dialog, Modal, ModalOverlay } from 'react-aria-components'
import { Button } from '@components/ui/button'
import { AccountSection } from '@components/settings/account-section'
-import { TrustedServicesSection } from '@components/service/trusted-services'
-import type { TrustLoadState } from '@lib/hooks/useTrustStore'
+import { AppCatalogSection } from '@components/settings/app-catalog-section'
interface SettingsDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
- trust: TrustLoadState
- onRefreshTrust: () => void
username: string
onSignOut: () => void
}
@@ -34,8 +31,6 @@ interface SettingsDialogProps {
export function SettingsDialog({
open,
onOpenChange,
- trust,
- onRefreshTrust,
username,
onSignOut,
}: SettingsDialogProps) {
@@ -58,7 +53,7 @@ export function SettingsDialog({
Settings
- Manage how this node trusts the services on its fleet.
+ This node’s owner account, the servers it trusts, and the apps it publishes.
-
+
-
- Trusted services
+
+ App catalog
- Services asking to talk to the auth service. Accept authorizes a fingerprint (SSH
- authorized_keys, applied to the service fleet), reject drops a request without
- authorizing it, revoke cuts off an accepted peer immediately, re-authorize restores
- one.
+ Apps this instance publishes for people to install on their phones. Clients
+ discover them here; each entry carries the artifact’s checksum so installs can be
+ verified.
diff --git a/src/frontend/src/lib/api/catalog.ts b/src/frontend/src/lib/api/catalog.ts
new file mode 100644
index 0000000..5632df3
--- /dev/null
+++ b/src/frontend/src/lib/api/catalog.ts
@@ -0,0 +1,162 @@
+/**
+ * Copyright (C) 2026 Dcntrlized
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+import { ApiError, del, get, post, put } from './client'
+import type { CatalogApp, CatalogAppInput, CatalogAppPatch } from '@lib/types'
+
+const BASE = '/api/catalog/apps'
+
+export interface UploadOptions {
+ /** Byte-level progress, 0–100, reported by the browser's XHR upload stream. */
+ onProgress?: (percent: number) => void
+ signal?: AbortSignal
+}
+
+function readErrorCode(text: string): string | undefined {
+ try {
+ const body = JSON.parse(text) as { error?: { code?: unknown } }
+ return typeof body?.error?.code === 'string' ? body.error.code : undefined
+ } catch {
+ return undefined
+ }
+}
+
+function jsonField(value: unknown): string {
+ return typeof value === 'string' ? value : JSON.stringify(value)
+}
+
+/**
+ * Multipart over XMLHttpRequest instead of fetch: only XHR exposes the upload
+ * progress stream, and a 512 MiB artifact must never look frozen. Non-file
+ * metadata rides as flat snake_case form fields so the same handler can serve
+ * both the URL and the upload add paths. The uniform error envelope is decoded
+ * exactly the way the fetch client does it.
+ */
+function multipart(
+ method: 'POST' | 'PUT',
+ path: string,
+ parts: object,
+ fileField: string,
+ file: File | null,
+ options: UploadOptions = {}
+): Promise {
+ return new Promise((resolve, reject) => {
+ const xhr = new XMLHttpRequest()
+ xhr.open(method, path)
+ xhr.withCredentials = true
+ xhr.responseType = 'text'
+
+ xhr.upload.onprogress = (event) => {
+ if (event.lengthComputable && options.onProgress) {
+ options.onProgress(Math.min(100, Math.round((event.loaded / event.total) * 100)))
+ }
+ }
+ xhr.onload = () => {
+ const text = typeof xhr.response === 'string' ? xhr.response : ''
+ if (xhr.status >= 200 && xhr.status < 300) {
+ options.onProgress?.(100)
+ try {
+ resolve((text ? JSON.parse(text) : undefined) as T)
+ } catch {
+ resolve(undefined as T)
+ }
+ return
+ }
+ reject(new ApiError(`Request failed (${xhr.status})`, xhr.status, readErrorCode(text)))
+ }
+ xhr.onerror = () => reject(new ApiError('Could not reach this node’s API.', 0))
+ xhr.onabort = () => reject(new ApiError('Upload cancelled.', 0))
+ options.signal?.addEventListener('abort', () => xhr.abort(), { once: true })
+
+ const form = new FormData()
+ for (const [key, value] of Object.entries(parts)) {
+ if (value === undefined || value === null || value === '') continue
+ form.append(key, jsonField(value))
+ }
+ if (file) {
+ form.append(fileField, file, file.name)
+ // The record-specific field name and the generic `file` alias both
+ // travel, so the handler can read either without a client-side guess.
+ if (fileField !== 'file') form.append('file', file, file.name)
+ }
+ xhr.send(form)
+ })
+}
+
+/** GET /api/catalog/apps — an empty catalog serializes as `null`. */
+export function fetchCatalogApps(): Promise {
+ return get(BASE)
+}
+
+/** POST /api/catalog/apps — add from an artifact URL (hosted fetch or link-only). */
+export function addCatalogApp(input: CatalogAppInput): Promise {
+ return post(BASE, input)
+}
+
+/**
+ * POST /api/catalog/apps as multipart — add with the APK supplied directly.
+ * The record metadata travels as snake_case fields beside the artifact bytes.
+ */
+export function addCatalogAppWithFile(
+ input: CatalogAppInput,
+ file: File,
+ options?: UploadOptions
+): Promise {
+ return multipart('POST', BASE, input, 'artifact', file, options)
+}
+
+/** PUT /api/catalog/apps/{id} — edit metadata; `package` is immutable. */
+export function updateCatalogApp(id: string, patch: CatalogAppPatch): Promise {
+ return put(`${BASE}/${encodeURIComponent(id)}`, patch)
+}
+
+/** DELETE /api/catalog/apps/{id} — removes the entry and any hosted bytes. */
+export function deleteCatalogApp(id: string): Promise {
+ return del(`${BASE}/${encodeURIComponent(id)}`)
+}
+
+/** PUT /api/catalog/apps/{id}/artifact — replace the APK from a local file. */
+export function uploadCatalogArtifact(
+ id: string,
+ file: File,
+ options?: UploadOptions
+): Promise {
+ return multipart(
+ 'PUT',
+ `${BASE}/${encodeURIComponent(id)}/artifact`,
+ {},
+ 'artifact',
+ file,
+ options
+ )
+}
+
+/** PUT /api/catalog/apps/{id}/icon — replace the icon from a local image. */
+export function uploadCatalogIcon(
+ id: string,
+ file: File,
+ options?: UploadOptions
+): Promise {
+ return multipart(
+ 'PUT',
+ `${BASE}/${encodeURIComponent(id)}/icon`,
+ {},
+ 'icon',
+ file,
+ options
+ )
+}
diff --git a/src/frontend/src/lib/api/client.ts b/src/frontend/src/lib/api/client.ts
index 89cdc4a..d2e1876 100644
--- a/src/frontend/src/lib/api/client.ts
+++ b/src/frontend/src/lib/api/client.ts
@@ -17,11 +17,24 @@
export class ApiError extends Error {
readonly status: number
+ /** The uniform `{"error":{"code":…}}` code, when the body carried one. */
+ readonly code?: string
- constructor(message: string, status: number) {
+ constructor(message: string, status: number, code?: string) {
super(message)
this.name = 'ApiError'
this.status = status
+ this.code = code
+ }
+}
+
+/** Best-effort read of the uniform error envelope; never masks the HTTP status. */
+async function readErrorCode(res: Response): Promise {
+ try {
+ const body = (await res.json()) as { error?: { code?: unknown } }
+ return typeof body?.error?.code === 'string' ? body.error.code : undefined
+ } catch {
+ return undefined
}
}
@@ -67,7 +80,7 @@ async function request(path: string, init?: RequestInit, retried = false): Pr
}
}
if (!res.ok) {
- throw new ApiError(`Request failed (${res.status})`, res.status)
+ throw new ApiError(`Request failed (${res.status})`, res.status, await readErrorCode(res))
}
return readJson(res)
}
diff --git a/src/frontend/src/lib/hooks/useCatalogApps.ts b/src/frontend/src/lib/hooks/useCatalogApps.ts
new file mode 100644
index 0000000..1a644f4
--- /dev/null
+++ b/src/frontend/src/lib/hooks/useCatalogApps.ts
@@ -0,0 +1,190 @@
+/**
+ * Copyright (C) 2026 Dcntrlized
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+import { useCallback, useEffect, useRef, useState } from 'preact/hooks'
+import {
+ addCatalogApp,
+ addCatalogAppWithFile,
+ deleteCatalogApp,
+ fetchCatalogApps,
+ updateCatalogApp,
+ uploadCatalogArtifact,
+ uploadCatalogIcon,
+} from '@lib/api/catalog'
+import type { CatalogApp, CatalogAppInput, CatalogAppPatch } from '@lib/types'
+
+export type CatalogLoadState =
+ | { kind: 'loading' }
+ | { kind: 'ok'; apps: CatalogApp[] }
+ | { kind: 'error'; message: string }
+
+/** In-flight transfer for one row, so a 512 MiB artifact never looks frozen. */
+export interface CatalogUpload {
+ percent: number
+ label: string
+}
+
+function errorMessage(error: unknown): string {
+ return error instanceof Error ? error.message : 'Unknown error'
+}
+
+/** The catalog serializes empty as `null`; artifacts may omit optional fields. */
+function normalize(rows: CatalogApp[] | null | undefined): CatalogApp[] {
+ if (!Array.isArray(rows)) return []
+ return rows
+}
+
+/**
+ * Owns the operator's app catalog: the single-flight listing plus add, edit,
+ * delete and the two byte transfers. Mutations refetch from the server so the
+ * console never drifts from what clients can actually install; delete is
+ * applied optimistically first because the row disappears in the same turn.
+ * Uploads report byte progress per app id and can be cancelled, and the row
+ * stays interactive while the transfer runs.
+ */
+export function useCatalogApps() {
+ const [state, setState] = useState({ kind: 'loading' })
+ const [uploads, setUploads] = useState>({})
+ const inFlight = useRef | null>(null)
+ const aborts = useRef>({})
+
+ const load = useCallback((silent: boolean): Promise => {
+ // Single-flight: concurrent open/refresh/mutation calls share one request.
+ if (inFlight.current) return inFlight.current
+ const run = (async () => {
+ try {
+ const apps = normalize(await fetchCatalogApps())
+ setState({ kind: 'ok', apps })
+ } catch (error) {
+ setState((prev) =>
+ silent && prev.kind === 'ok' ? prev : { kind: 'error', message: errorMessage(error) }
+ )
+ } finally {
+ inFlight.current = null
+ }
+ })()
+ inFlight.current = run
+ return run
+ }, [])
+
+ useEffect(() => {
+ void load(false)
+ }, [load])
+
+ /** Quiet refetch — replaces live rows without flashing a loading state. */
+ const refresh = useCallback(() => load(true), [load])
+ /** Loud refetch — returns to the skeleton/error surface. */
+ const reload = useCallback(() => load(false), [load])
+
+ const setUpload = useCallback((id: string, upload: CatalogUpload) => {
+ setUploads((prev) => ({ ...prev, [id]: upload }))
+ }, [])
+
+ const clearUpload = useCallback((id: string) => {
+ setUploads((prev) => {
+ if (!(id in prev)) return prev
+ const next = { ...prev }
+ delete next[id]
+ return next
+ })
+ }, [])
+
+ const mutate = useCallback(
+ async (action: () => Promise) => {
+ await action()
+ await load(true)
+ },
+ [load]
+ )
+
+ const add = useCallback(
+ (input: CatalogAppInput, file?: File | null, onProgress?: (percent: number) => void) =>
+ mutate(() =>
+ file
+ ? addCatalogAppWithFile(input, file, { onProgress })
+ : addCatalogApp(input)
+ ),
+ [mutate]
+ )
+
+ const update = useCallback(
+ (id: string, patch: CatalogAppPatch) => mutate(() => updateCatalogApp(id, patch)),
+ [mutate]
+ )
+
+ const remove = useCallback(
+ async (id: string) => {
+ setState((prev) =>
+ prev.kind === 'ok' ? { kind: 'ok', apps: prev.apps.filter((app) => app.id !== id) } : prev
+ )
+ try {
+ await deleteCatalogApp(id)
+ } catch (error) {
+ await load(true)
+ throw error
+ }
+ await load(true)
+ },
+ [load]
+ )
+
+ const transfer = useCallback(
+ async (id: string, kind: 'artifact' | 'icon', file: File) => {
+ const controller = new AbortController()
+ aborts.current[id] = controller
+ const label = kind === 'icon' ? 'Uploading icon' : 'Uploading artifact'
+ setUpload(id, { percent: 0, label })
+ try {
+ const send = kind === 'icon' ? uploadCatalogIcon : uploadCatalogArtifact
+ await send(id, file, {
+ signal: controller.signal,
+ onProgress: (percent) => setUpload(id, { percent, label }),
+ })
+ await load(true)
+ } finally {
+ delete aborts.current[id]
+ clearUpload(id)
+ }
+ },
+ [clearUpload, load, setUpload]
+ )
+
+ const uploadArtifact = useCallback(
+ (id: string, file: File) => transfer(id, 'artifact', file),
+ [transfer]
+ )
+ const uploadIcon = useCallback((id: string, file: File) => transfer(id, 'icon', file), [transfer])
+
+ const cancelUpload = useCallback((id: string) => {
+ aborts.current[id]?.abort()
+ }, [])
+
+ return {
+ state,
+ uploads,
+ refresh,
+ reload,
+ add,
+ update,
+ remove,
+ uploadArtifact,
+ uploadIcon,
+ cancelUpload,
+ }
+}
+
+export type CatalogController = ReturnType
diff --git a/src/frontend/src/lib/types.ts b/src/frontend/src/lib/types.ts
index a7a6bb1..0da9e14 100644
--- a/src/frontend/src/lib/types.ts
+++ b/src/frontend/src/lib/types.ts
@@ -42,6 +42,11 @@ export interface Container {
role?: string
/** Infrastructure services managed by Maestro but hidden from the grid. */
hidden?: boolean
+ /**
+ * Started outside Maestro (e.g. a repo's own docker-compose.debug.yaml).
+ * The console may stop it but must not start, uninstall or reconfigure it.
+ */
+ external?: boolean
/** Capabilities declared by the service manifest (e.g. ["database"]). */
capabilities?: string[]
/** Whether the service consumes the shared auth service (manifest has_auth). */
@@ -50,6 +55,25 @@ export interface Container {
public_key?: string
/** Operator-configurable fields declared by the manifest (Service Config & Secrets theme). */
config?: ServiceConfigDeclaration[]
+ /**
+ * The service's Web App capability (contract rev 8.2 §16.2). Present only
+ * while the service advertises a usable web app; `href` is resolved
+ * SERVER-SIDE and is the only thing the console may open. The frontend never
+ * builds a URL from service strings.
+ */
+ web?: ServiceWeb
+}
+
+/**
+ * The `web` block carried on `GET /api/containers` (contract §16.3/§16.4).
+ * `href` is always server-resolved; an entry with no valid href is omitted
+ * entirely rather than sent with an empty href.
+ */
+export interface ServiceWeb {
+ enabled: boolean
+ port?: number
+ url?: string
+ href: string
}
/** A config field spec declared by a service manifest (Service Config & Secrets theme). */
@@ -82,6 +106,8 @@ export interface ServiceConfigEntry {
/** A remote catalog entry, as served by GET /api/registry. */
export interface RegistryItem {
name: string
+ /** The service identity declared by the image's org.dcntrlized.service label. */
+ service_name?: string
version: string
image: string
digest?: string
@@ -90,6 +116,12 @@ export interface RegistryItem {
author?: string
description?: string
source?: string
+ /**
+ * The source URL to render as a hyperlink — server-validated against the
+ * http/https allowlist (`lib/types.ValidWebBase`), empty when `source` is not
+ * a safe link. Never derive this from `source` in the frontend (audit N7).
+ */
+ source_href?: string
revision?: string
licenses?: string
url?: string
@@ -97,6 +129,8 @@ export interface RegistryItem {
created_at?: string
/** Infrastructure images (e.g. the shared database) excluded from the grid. */
hidden?: boolean
+ /** The Web App capability announced by the image manifest, when known. */
+ web?: ServiceWeb
}
/** Merged view model: one card per service, local state and remote catalog fused. */
@@ -106,6 +140,10 @@ export interface Service {
status: string
origin: ContainerOrigin
title?: string
+ /** The service identity from the dcntrlized service label (registry matching). */
+ serviceName?: string
+ /** True when the container was started outside Maestro (see Container.external). */
+ external?: boolean
vendor?: string
author?: string
description?: string
@@ -113,6 +151,8 @@ export interface Service {
/** The image reference published in the registry (for install/update). */
registryImage?: string
source?: string
+ /** Server-validated href for `source`; empty means render `source` as text. */
+ sourceHref?: string
registryUrl?: string
version?: string
latestVersion?: string
@@ -121,6 +161,8 @@ export interface Service {
capabilities: string[]
/** Operator-configurable fields declared by the manifest (Service Config & Secrets theme). */
config?: ServiceConfigDeclaration[]
+ /** The service's Web App capability, when advertised (see Container.web). */
+ web?: ServiceWeb
}
/** Live status of the shared database infra service. */
@@ -134,38 +176,6 @@ export interface FilterOption {
hint: string
}
-/** A first-contact authorization request waiting for the instance owner (security theme M2 §3). */
-export interface PeerPending {
- service: string
- fingerprint: string
- first_seen: string
- last_seen: string
-}
-
-/** An approved peer row of the auth service's trust store. */
-export interface PeerAuthorized {
- service: string
- fingerprint: string
- approved_by: string
- approved_at: string
- revoked_at?: string | null
- hidden_at?: string | null
-}
-
-/** Combined pending + approved trust state served by GET /api/peers. */
-export interface PeerTrustState {
- pending: PeerPending[]
- authorized: PeerAuthorized[]
-}
-
-/** The auth service's public signing key statement (GET /api/peers/attestation). */
-export interface PeerAttestation {
- public_key: string
- fingerprint: string
- kid: string
- issued_at: string
-}
-
/** The instance-owner gate state served by GET /api/auth/status (onboarding M1). */
export interface AuthStatus {
setup_required: boolean
@@ -200,7 +210,11 @@ export interface PublicService {
image: string
}
-/** A remote instance this instance trusts (federation store row). */
+/**
+ * A remote instance this instance trusts, as surfaced on the public profile.
+ * Kept for wire compatibility: rev 9 retired federation, so the array is
+ * always empty.
+ */
export interface TrustedInstance {
name: string
url: string
@@ -215,3 +229,72 @@ export interface InstancePublicProfile {
services?: PublicService[] | null
trusted_instances?: TrustedInstance[] | null
}
+
+/**
+ * How this instance serves a catalog app's artifact (app catalog A4):
+ * `hosted` = Maestro keeps a copy and serves it; `link` = only the checksum is
+ * stored and downloads redirect to the operator-supplied URL.
+ */
+export type CatalogArtifactKind = 'hosted' | 'link'
+
+/** The artifact half of a catalog app record (GET /api/catalog/apps). */
+export interface CatalogArtifact {
+ filename: string
+ size_bytes: number
+ /** Lowercase hex sha256 of the exact bytes; clients verify what they install. */
+ sha256: string
+ content_type?: string
+ kind: CatalogArtifactKind
+ /** Absolute, session-free download URL served by this instance. */
+ download_url: string
+ /** sha256 of the APK signing certificate, when the operator supplied one. */
+ signer_sha256?: string
+}
+
+/**
+ * One operator-authored app in this instance's catalog (app catalog A1/A2).
+ * The catalog is independent of the services the instance runs; `package` is
+ * immutable after creation so installs are never orphaned.
+ */
+export interface CatalogApp {
+ /** `a_` + first 12 hex of sha256(package); stable across edits. */
+ id: string
+ package: string
+ name: string
+ summary?: string
+ description?: string
+ category?: string
+ version_name: string
+ version_code?: number
+ min_android?: number
+ icon_url?: string
+ artifact: CatalogArtifact
+ source_url?: string
+ added_at: string
+ updated_at?: string
+ /** Owner identity; present on the owner-gated list, never on the public profile. */
+ added_by?: string
+}
+
+/** The writable shape of a catalog app. Only package/name/version_name are required. */
+export interface CatalogAppInput {
+ package: string
+ name: string
+ version_name: string
+ /** Fetched and copied at add time unless `link_only` is set. */
+ artifact_url?: string
+ /** Skip the fetch and store only the checksum; requires `sha256`. */
+ link_only?: boolean
+ sha256?: string
+ summary?: string
+ description?: string
+ category?: string
+ version_code?: number
+ min_android?: number
+ icon_url?: string
+ signer_sha256?: string
+ source_url?: string
+}
+
+/** PUT /api/catalog/apps/{id} — package is immutable, so it never appears here. */
+export type CatalogAppPatch = Omit
diff --git a/src/handlers/catalog/catalog.go b/src/handlers/catalog/catalog.go
new file mode 100644
index 0000000..f257817
--- /dev/null
+++ b/src/handlers/catalog/catalog.go
@@ -0,0 +1,749 @@
+// Copyright (C) 2026 Dcntrlized
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published
+// by the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+// Package catalog serves Maestro's operator-authored app catalog
+// (app-catalog theme): the owner-gated add/edit/delete/replace API and the
+// public, session-free artifact/icon download endpoints. The catalog is
+// independent of services and the registry (A1/A2).
+package catalog
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "mime/multipart"
+ "net/http"
+ "net/url"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "time"
+
+ "git.dcntrlized.org/Dcntrlized/Maestro/lib/catalog"
+ "git.dcntrlized.org/Dcntrlized/Maestro/lib/logger"
+)
+
+// ownerFallback stamps added_by when the owner gate did not furnish a username
+// (standalone wiring/tests).
+const ownerFallback = "owner"
+
+// Handler owns catalog store access and the artifact fetch for the
+// /api/catalog endpoints.
+type Handler struct {
+ store *catalog.Store
+ dataRoot string
+ client *http.Client
+}
+
+// NewHandler builds the catalog handler over the store at dataRoot. The fetch
+// client follows redirects (Go default) with a 60s timeout; the 512 MiB cap is
+// enforced while the copy streams to disk.
+func NewHandler(dataRoot string) *Handler {
+ return &Handler{
+ store: catalog.NewStore(dataRoot),
+ dataRoot: dataRoot,
+ client: &http.Client{Timeout: 60 * time.Second},
+ }
+}
+
+// Apps dispatches GET (list) and POST (add) on /api/catalog/apps.
+func (h *Handler) Apps(w http.ResponseWriter, r *http.Request) {
+ switch r.Method {
+ case http.MethodGet:
+ h.list(w, r)
+ case http.MethodPost:
+ h.add(w, r)
+ default:
+ writeError(w, http.StatusMethodNotAllowed, "ERR_METHOD_NOT_ALLOWED", "GET or POST is required")
+ }
+}
+
+// App dispatches PUT (edit) and DELETE on /api/catalog/apps/{id}.
+func (h *Handler) App(w http.ResponseWriter, r *http.Request) {
+ switch r.Method {
+ case http.MethodPut:
+ h.edit(w, r)
+ case http.MethodDelete:
+ h.remove(w, r)
+ default:
+ writeError(w, http.StatusMethodNotAllowed, "ERR_METHOD_NOT_ALLOWED", "PUT or DELETE is required")
+ }
+}
+
+// list serves GET /api/catalog/apps — every record with absolute URLs and the
+// private added_by field. An empty catalog is [] (never null).
+func (h *Handler) list(w http.ResponseWriter, r *http.Request) {
+ apps, err := h.store.List()
+ if err != nil {
+ logger.Error("catalog: list: %v", err)
+ writeError(w, http.StatusInternalServerError, "ERR_INTERNAL", "failed to read catalog")
+ return
+ }
+ base := catalog.RequestBaseURL(r)
+ out := make([]catalog.App, 0, len(apps))
+ for _, app := range apps {
+ out = append(out, app.WithBase(base))
+ }
+ writeJSON(w, http.StatusOK, out)
+}
+
+// addRequest is the POST /api/catalog/apps body.
+type addRequest struct {
+ Package string `json:"package"`
+ Name string `json:"name"`
+ Summary string `json:"summary"`
+ Description string `json:"description"`
+ Category string `json:"category"`
+ VersionName string `json:"version_name"`
+ VersionCode int `json:"version_code"`
+ MinAndroid int `json:"min_android"`
+ IconURL string `json:"icon_url"`
+ SourceURL string `json:"source_url"`
+ ArtifactURL string `json:"artifact_url"`
+ Filename string `json:"filename"`
+ ContentType string `json:"content_type"`
+ SHA256 string `json:"sha256"`
+ SignerSHA256 string `json:"signer_sha256"`
+ SizeBytes int64 `json:"size_bytes"`
+ LinkOnly bool `json:"link_only"`
+}
+
+// add serves POST /api/catalog/apps — fetch-and-host an artifact from
+// artifact_url, host an uploaded file (multipart `file` part), or record a
+// link, then answer 201. A checksum mismatch stores nothing.
+func (h *Handler) add(w http.ResponseWriter, r *http.Request) {
+ req, upload, uploadHeader, err := decodeAddRequest(r)
+ if err != nil {
+ writeError(w, http.StatusBadRequest, "ERR_INVALID_PAYLOAD", err.Error())
+ return
+ }
+ if upload != nil {
+ defer upload.Close()
+ }
+
+ pkg := strings.TrimSpace(req.Package)
+ name := strings.TrimSpace(req.Name)
+ version := strings.TrimSpace(req.VersionName)
+ if pkg == "" || name == "" || version == "" {
+ writeError(w, http.StatusBadRequest, "ERR_INVALID_PAYLOAD", "package, name and version_name are required")
+ return
+ }
+ if strings.ContainsAny(pkg, " \t\r\n") {
+ writeError(w, http.StatusBadRequest, "ERR_INVALID_PAYLOAD", "package must not contain whitespace")
+ return
+ }
+
+ // The artifact source is either the uploaded multipart file or artifact_url.
+ artifactURL := ""
+ if upload == nil {
+ normalized, err := normalizeArtifactURL(req.ArtifactURL)
+ if err != nil {
+ writeError(w, http.StatusBadRequest, "ERR_INVALID_PAYLOAD", "artifact_url must be an absolute http(s) URL")
+ return
+ }
+ artifactURL = normalized
+ }
+
+ existing, err := h.store.GetByPackage(pkg)
+ if err != nil {
+ logger.Error("catalog: package lookup: %v", err)
+ writeError(w, http.StatusInternalServerError, "ERR_INTERNAL", "failed to read catalog")
+ return
+ }
+ if existing != nil {
+ writeError(w, http.StatusConflict, "ERR_APP_EXISTS", "an app with that package already exists")
+ return
+ }
+
+ id := catalog.AppID(pkg)
+ filename := ""
+ if uploadHeader != nil {
+ filename = catalog.SafeFilename(uploadHeader.Filename)
+ }
+ if filename == "" {
+ filename = catalog.SafeFilename(req.Filename)
+ }
+ if filename == "" {
+ filename = catalog.FilenameFromURL(artifactURL)
+ }
+ if filename == "" {
+ filename = pkg + ".apk"
+ }
+
+ artifact, artifactRel, err := h.buildArtifact(id, filename, artifactURL, &req, upload, uploadHeader)
+ if err != nil {
+ writeArtifactError(w, err)
+ return
+ }
+
+ by := r.Header.Get("X-Maestro-User")
+ if by == "" {
+ by = ownerFallback
+ }
+ now := catalog.NowRFC3339()
+ app := catalog.App{
+ ID: id,
+ Package: pkg,
+ Name: name,
+ Summary: strings.TrimSpace(req.Summary),
+ Description: strings.TrimSpace(req.Description),
+ Category: strings.TrimSpace(req.Category),
+ VersionName: version,
+ VersionCode: req.VersionCode,
+ MinAndroid: req.MinAndroid,
+ IconURL: strings.TrimSpace(req.IconURL),
+ Artifact: artifact,
+ SourceURL: strings.TrimSpace(req.SourceURL),
+ AddedAt: now,
+ UpdatedAt: now,
+ AddedBy: by,
+ ArtifactPath: artifactRel,
+ }
+ if err := h.store.Upsert(app); err != nil {
+ logger.Error("catalog: persist app: %v", err)
+ _ = h.store.DeleteFiles(app)
+ writeError(w, http.StatusInternalServerError, "ERR_INTERNAL", "failed to write catalog")
+ return
+ }
+ writeJSON(w, http.StatusCreated, app.WithBase(catalog.RequestBaseURL(r)))
+}
+
+// artifactError carries the HTTP mapping for a buildArtifact failure.
+type artifactError struct {
+ status int
+ code string
+ msg string
+}
+
+func (e *artifactError) Error() string { return e.msg }
+
+// buildArtifact hosts an uploaded file, fetches+hosts the artifact URL, or
+// records a link. It returns the artifact sub-record plus the data-root-relative
+// path of the hosted copy ("" for link entries). Nothing is persisted on a
+// checksum mismatch or fetch failure.
+func (h *Handler) buildArtifact(id, filename, artifactURL string, req *addRequest, upload multipart.File, uploadHeader *multipart.FileHeader) (catalog.Artifact, string, error) {
+ supplied := strings.ToLower(strings.TrimSpace(req.SHA256))
+
+ if upload != nil {
+ rel, size, sum, err := h.store.SaveArtifact(id, filename, upload, catalog.MaxArtifactBytes)
+ if err != nil {
+ if errors.Is(err, catalog.ErrTooLarge) {
+ return catalog.Artifact{}, "", &artifactError{http.StatusBadRequest, "ERR_INVALID_PAYLOAD", "artifact exceeds the 512 MiB limit"}
+ }
+ logger.Warn("catalog: store uploaded artifact: %v", err)
+ return catalog.Artifact{}, "", &artifactError{http.StatusBadRequest, "ERR_INVALID_PAYLOAD", "could not store the uploaded artifact"}
+ }
+ if supplied != "" && !strings.EqualFold(supplied, sum) {
+ // Nothing is stored on mismatch: drop the freshly written copy.
+ _ = os.RemoveAll(filepath.Join(catalog.Dir(h.dataRoot), "artifacts", id))
+ return catalog.Artifact{}, "", &artifactError{http.StatusBadRequest, "ERR_CHECKSUM_MISMATCH", "sha256 does not match the uploaded artifact"}
+ }
+ contentType := strings.TrimSpace(req.ContentType)
+ if contentType == "" && uploadHeader != nil {
+ contentType = strings.TrimSpace(uploadHeader.Header.Get("Content-Type"))
+ }
+ if contentType == "" || contentType == "application/octet-stream" {
+ contentType = detectFileContentType(h.store, rel)
+ }
+ if contentType == "" {
+ contentType = catalog.DefaultArtifactContentType
+ }
+ return catalog.Artifact{
+ Filename: filename,
+ SizeBytes: size,
+ SHA256: sum,
+ ContentType: contentType,
+ Kind: catalog.KindHosted,
+ }, rel, nil
+ }
+
+ if req.LinkOnly {
+ if supplied == "" {
+ return catalog.Artifact{}, "", &artifactError{http.StatusBadRequest, "ERR_INVALID_PAYLOAD", "link_only requires sha256"}
+ }
+ if !isHexSHA256(supplied) {
+ return catalog.Artifact{}, "", &artifactError{http.StatusBadRequest, "ERR_INVALID_PAYLOAD", "sha256 must be 64 hex characters"}
+ }
+ return catalog.Artifact{
+ Filename: filename,
+ SizeBytes: req.SizeBytes,
+ SHA256: supplied,
+ ContentType: firstNonEmpty(strings.TrimSpace(req.ContentType), catalog.DefaultArtifactContentType),
+ Kind: catalog.KindLink,
+ SourceURL: artifactURL,
+ }, "", nil
+ }
+
+ resp, err := h.client.Get(artifactURL)
+ if err != nil {
+ logger.Warn("catalog: fetch %s: %v", artifactURL, err)
+ return catalog.Artifact{}, "", &artifactError{http.StatusBadGateway, "ERR_ARTIFACT_FETCH", "could not fetch artifact_url"}
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ logger.Warn("catalog: fetch %s: status %d", artifactURL, resp.StatusCode)
+ return catalog.Artifact{}, "", &artifactError{http.StatusBadGateway, "ERR_ARTIFACT_FETCH", "artifact_url returned a non-success status"}
+ }
+
+ rel, size, sum, err := h.store.SaveArtifact(id, filename, resp.Body, catalog.MaxArtifactBytes)
+ if err != nil {
+ if errors.Is(err, catalog.ErrTooLarge) {
+ return catalog.Artifact{}, "", &artifactError{http.StatusBadRequest, "ERR_INVALID_PAYLOAD", "artifact exceeds the 512 MiB limit"}
+ }
+ logger.Warn("catalog: store artifact: %v", err)
+ return catalog.Artifact{}, "", &artifactError{http.StatusBadGateway, "ERR_ARTIFACT_FETCH", "could not store the fetched artifact"}
+ }
+ if supplied != "" && !strings.EqualFold(supplied, sum) {
+ // Nothing is stored on mismatch: drop the freshly written copy.
+ _ = os.RemoveAll(filepath.Join(catalog.Dir(h.dataRoot), "artifacts", id))
+ return catalog.Artifact{}, "", &artifactError{http.StatusBadRequest, "ERR_CHECKSUM_MISMATCH", "sha256 does not match the fetched artifact"}
+ }
+
+ contentType := strings.TrimSpace(req.ContentType)
+ if contentType == "" {
+ contentType = strings.TrimSpace(resp.Header.Get("Content-Type"))
+ }
+ if contentType == "" {
+ contentType = catalog.DefaultArtifactContentType
+ }
+ return catalog.Artifact{
+ Filename: filename,
+ SizeBytes: size,
+ SHA256: sum,
+ ContentType: contentType,
+ Kind: catalog.KindHosted,
+ }, rel, nil
+}
+
+// decodeAddRequest reads POST /api/catalog/apps from either application/json or
+// multipart/form-data (flat snake_case fields + an optional file part named
+// "file"), so the owner UI can publish a new app from a local file in one step.
+func decodeAddRequest(r *http.Request) (addRequest, multipart.File, *multipart.FileHeader, error) {
+ if strings.HasPrefix(strings.ToLower(r.Header.Get("Content-Type")), "multipart/form-data") {
+ if err := r.ParseMultipartForm(32 << 20); err != nil {
+ return addRequest{}, nil, nil, errors.New("invalid multipart body")
+ }
+ req := addRequest{
+ Package: formValue(r, "package"),
+ Name: formValue(r, "name"),
+ Summary: formValue(r, "summary"),
+ Description: formValue(r, "description"),
+ Category: formValue(r, "category"),
+ VersionName: formValue(r, "version_name"),
+ VersionCode: formInt(r, "version_code"),
+ MinAndroid: formInt(r, "min_android"),
+ IconURL: formValue(r, "icon_url"),
+ SourceURL: formValue(r, "source_url"),
+ ArtifactURL: formValue(r, "artifact_url"),
+ Filename: formValue(r, "filename"),
+ ContentType: formValue(r, "content_type"),
+ SHA256: formValue(r, "sha256"),
+ SignerSHA256: formValue(r, "signer_sha256"),
+ SizeBytes: formInt64(r, "size_bytes"),
+ LinkOnly: formBool(r, "link_only"),
+ }
+ file, header, err := r.FormFile("file")
+ if err != nil {
+ if errors.Is(err, http.ErrMissingFile) {
+ return req, nil, nil, nil
+ }
+ return addRequest{}, nil, nil, errors.New("could not read the file part")
+ }
+ return req, file, header, nil
+ }
+
+ var req addRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ return addRequest{}, nil, nil, errors.New("invalid JSON body")
+ }
+ return req, nil, nil, nil
+}
+
+func formValue(r *http.Request, key string) string {
+ if r.MultipartForm == nil {
+ return ""
+ }
+ if values := r.MultipartForm.Value[key]; len(values) > 0 {
+ return values[0]
+ }
+ return ""
+}
+
+func formInt(r *http.Request, key string) int {
+ value := strings.TrimSpace(formValue(r, key))
+ if value == "" {
+ return 0
+ }
+ n, _ := strconv.Atoi(value)
+ return n
+}
+
+func formInt64(r *http.Request, key string) int64 {
+ value := strings.TrimSpace(formValue(r, key))
+ if value == "" {
+ return 0
+ }
+ n, _ := strconv.ParseInt(value, 10, 64)
+ return n
+}
+
+func formBool(r *http.Request, key string) bool {
+ switch strings.ToLower(strings.TrimSpace(formValue(r, key))) {
+ case "true", "1", "on", "yes":
+ return true
+ }
+ return false
+}
+
+// editRequest is the PUT /api/catalog/apps/{id} body. Pointers distinguish
+// "absent" from "explicitly cleared".
+// editRequest is the PUT /api/catalog/apps/{id} body. The editable metadata is
+// a full replacement: present fields are applied and optional fields that are
+// omitted/null/empty are cleared. id and package are immutable.
+type editRequest struct {
+ Package string `json:"package"`
+ Name string `json:"name"`
+ Summary string `json:"summary"`
+ Description string `json:"description"`
+ Category string `json:"category"`
+ VersionName string `json:"version_name"`
+ VersionCode int `json:"version_code"`
+ MinAndroid int `json:"min_android"`
+ IconURL string `json:"icon_url"`
+ SourceURL string `json:"source_url"`
+ SignerSHA256 string `json:"signer_sha256"`
+}
+
+// edit serves PUT /api/catalog/apps/{id} — metadata edit. package is immutable.
+func (h *Handler) edit(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("id")
+ app, err := h.store.Get(id)
+ if err != nil {
+ logger.Error("catalog: get %s: %v", id, err)
+ writeError(w, http.StatusInternalServerError, "ERR_INTERNAL", "failed to read catalog")
+ return
+ }
+ if app == nil {
+ writeError(w, http.StatusNotFound, "ERR_NOT_FOUND", "unknown app")
+ return
+ }
+
+ var req editRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ writeError(w, http.StatusBadRequest, "ERR_INVALID_PAYLOAD", "invalid JSON body")
+ return
+ }
+ if req.Package != "" && strings.TrimSpace(req.Package) != app.Package {
+ writeError(w, http.StatusBadRequest, "ERR_INVALID_PAYLOAD", "package is immutable")
+ return
+ }
+
+ name := strings.TrimSpace(req.Name)
+ if name == "" {
+ writeError(w, http.StatusBadRequest, "ERR_INVALID_PAYLOAD", "name must not be empty")
+ return
+ }
+ version := strings.TrimSpace(req.VersionName)
+ if version == "" {
+ writeError(w, http.StatusBadRequest, "ERR_INVALID_PAYLOAD", "version_name must not be empty")
+ return
+ }
+
+ // Full replacement of the editable metadata: omitted optional fields
+ // decode to their zero value and therefore clear (README §Frozen request
+ // conventions).
+ updated := *app
+ updated.Name = name
+ updated.VersionName = version
+ updated.Summary = strings.TrimSpace(req.Summary)
+ updated.Description = strings.TrimSpace(req.Description)
+ updated.Category = strings.TrimSpace(req.Category)
+ updated.VersionCode = req.VersionCode
+ updated.MinAndroid = req.MinAndroid
+ updated.IconURL = strings.TrimSpace(req.IconURL)
+ updated.SourceURL = strings.TrimSpace(req.SourceURL)
+ updated.Artifact.SignerSHA256 = strings.TrimSpace(req.SignerSHA256)
+ updated.UpdatedAt = catalog.NowRFC3339()
+
+ if err := h.store.Upsert(updated); err != nil {
+ logger.Error("catalog: persist edit %s: %v", id, err)
+ writeError(w, http.StatusInternalServerError, "ERR_INTERNAL", "failed to write catalog")
+ return
+ }
+ writeJSON(w, http.StatusOK, updated.WithBase(catalog.RequestBaseURL(r)))
+}
+
+// remove serves DELETE /api/catalog/apps/{id} — drops the record, the hosted
+// bytes and the uploaded icon.
+func (h *Handler) remove(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("id")
+ app, err := h.store.Get(id)
+ if err != nil {
+ logger.Error("catalog: get %s: %v", id, err)
+ writeError(w, http.StatusInternalServerError, "ERR_INTERNAL", "failed to read catalog")
+ return
+ }
+ if app == nil {
+ writeError(w, http.StatusNotFound, "ERR_NOT_FOUND", "unknown app")
+ return
+ }
+ if err := h.store.Remove(id); err != nil {
+ logger.Error("catalog: remove %s: %v", id, err)
+ writeError(w, http.StatusInternalServerError, "ERR_INTERNAL", "failed to write catalog")
+ return
+ }
+ if err := h.store.DeleteFiles(*app); err != nil {
+ logger.Warn("catalog: delete files for %s: %v", id, err)
+ }
+ writeJSON(w, http.StatusOK, map[string]string{"status": "deleted", "id": id})
+}
+
+// Artifact serves PUT /api/catalog/apps/{id}/artifact — replace hosted bytes
+// from a multipart `file`, recomputing size + sha256.
+func (h *Handler) Artifact(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPut {
+ writeError(w, http.StatusMethodNotAllowed, "ERR_METHOD_NOT_ALLOWED", "PUT is required")
+ return
+ }
+ id := r.PathValue("id")
+ app, err := h.store.Get(id)
+ if err != nil {
+ logger.Error("catalog: get %s: %v", id, err)
+ writeError(w, http.StatusInternalServerError, "ERR_INTERNAL", "failed to read catalog")
+ return
+ }
+ if app == nil {
+ writeError(w, http.StatusNotFound, "ERR_NOT_FOUND", "unknown app")
+ return
+ }
+ if err := r.ParseMultipartForm(32 << 20); err != nil {
+ writeError(w, http.StatusBadRequest, "ERR_INVALID_PAYLOAD", "expected a multipart file upload")
+ return
+ }
+ file, header, err := r.FormFile("file")
+ if err != nil {
+ writeError(w, http.StatusBadRequest, "ERR_INVALID_PAYLOAD", "multipart field file is required")
+ return
+ }
+ defer file.Close()
+
+ filename := catalog.SafeFilename(header.Filename)
+ if filename == "" {
+ filename = catalog.SafeFilename(app.Artifact.Filename)
+ }
+ if filename == "" {
+ filename = app.Package + ".apk"
+ }
+
+ rel, size, sum, err := h.store.SaveArtifact(id, filename, file, catalog.MaxArtifactBytes)
+ if err != nil {
+ if errors.Is(err, catalog.ErrTooLarge) {
+ writeError(w, http.StatusBadRequest, "ERR_INVALID_PAYLOAD", "artifact exceeds the 512 MiB limit")
+ return
+ }
+ logger.Error("catalog: store uploaded artifact: %v", err)
+ writeError(w, http.StatusInternalServerError, "ERR_INTERNAL", "failed to store artifact")
+ return
+ }
+
+ updated := *app
+ updated.Artifact.Filename = filename
+ updated.Artifact.SizeBytes = size
+ updated.Artifact.SHA256 = sum
+ updated.Artifact.Kind = catalog.KindHosted
+ updated.Artifact.SourceURL = ""
+ updated.Artifact.ContentType = firstNonEmpty(
+ strings.TrimSpace(header.Header.Get("Content-Type")),
+ detectFileContentType(h.store, rel),
+ catalog.DefaultArtifactContentType,
+ )
+ updated.ArtifactPath = rel
+ updated.UpdatedAt = catalog.NowRFC3339()
+
+ if err := h.store.Upsert(updated); err != nil {
+ logger.Error("catalog: persist artifact %s: %v", id, err)
+ writeError(w, http.StatusInternalServerError, "ERR_INTERNAL", "failed to write catalog")
+ return
+ }
+ // Drop any previous copy that lived under a different filename.
+ if err := h.store.PruneArtifactDir(id, filename); err != nil {
+ logger.Warn("catalog: prune old artifact %s: %v", id, err)
+ }
+ writeJSON(w, http.StatusOK, updated.WithBase(catalog.RequestBaseURL(r)))
+}
+
+// Icon serves PUT /api/catalog/apps/{id}/icon — replace the uploaded icon
+// (png/jpeg/webp, ≤512 KiB).
+func (h *Handler) Icon(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPut {
+ writeError(w, http.StatusMethodNotAllowed, "ERR_METHOD_NOT_ALLOWED", "PUT is required")
+ return
+ }
+ id := r.PathValue("id")
+ app, err := h.store.Get(id)
+ if err != nil {
+ logger.Error("catalog: get %s: %v", id, err)
+ writeError(w, http.StatusInternalServerError, "ERR_INTERNAL", "failed to read catalog")
+ return
+ }
+ if app == nil {
+ writeError(w, http.StatusNotFound, "ERR_NOT_FOUND", "unknown app")
+ return
+ }
+ if err := r.ParseMultipartForm(2 << 20); err != nil {
+ writeError(w, http.StatusBadRequest, "ERR_INVALID_PAYLOAD", "expected a multipart file upload")
+ return
+ }
+ file, _, err := r.FormFile("file")
+ if err != nil {
+ writeError(w, http.StatusBadRequest, "ERR_INVALID_PAYLOAD", "multipart field file is required")
+ return
+ }
+ defer file.Close()
+
+ data, err := io.ReadAll(io.LimitReader(file, catalog.MaxIconBytes+1))
+ if err != nil {
+ writeError(w, http.StatusBadRequest, "ERR_INVALID_PAYLOAD", "could not read icon upload")
+ return
+ }
+ if len(data) == 0 {
+ writeError(w, http.StatusBadRequest, "ERR_INVALID_PAYLOAD", "icon file is empty")
+ return
+ }
+ if int64(len(data)) > catalog.MaxIconBytes {
+ writeError(w, http.StatusBadRequest, "ERR_INVALID_PAYLOAD", "icon exceeds the 512 KiB limit")
+ return
+ }
+ contentType := http.DetectContentType(data)
+ if !allowedIconType(contentType) {
+ writeError(w, http.StatusBadRequest, "ERR_INVALID_PAYLOAD", "icon must be png, jpeg or webp")
+ return
+ }
+
+ rel, err := h.store.SaveIcon(id, data)
+ if err != nil {
+ logger.Error("catalog: store icon %s: %v", id, err)
+ writeError(w, http.StatusInternalServerError, "ERR_INTERNAL", "failed to store icon")
+ return
+ }
+
+ updated := *app
+ updated.IconPath = rel
+ updated.UpdatedAt = catalog.NowRFC3339()
+ if err := h.store.Upsert(updated); err != nil {
+ logger.Error("catalog: persist icon %s: %v", id, err)
+ writeError(w, http.StatusInternalServerError, "ERR_INTERNAL", "failed to write catalog")
+ return
+ }
+ writeJSON(w, http.StatusOK, updated.WithBase(catalog.RequestBaseURL(r)))
+}
+
+// --- helpers ---
+
+func normalizeArtifactURL(raw string) (string, error) {
+ trimmed := strings.TrimSpace(raw)
+ if trimmed == "" {
+ return "", errors.New("artifact_url is required")
+ }
+ u, err := url.Parse(trimmed)
+ if err != nil {
+ return "", err
+ }
+ if u.Scheme != "http" && u.Scheme != "https" {
+ return "", fmt.Errorf("unsupported scheme %q", u.Scheme)
+ }
+ if u.Host == "" {
+ return "", errors.New("artifact_url must include a host")
+ }
+ return u.String(), nil
+}
+
+func isHexSHA256(s string) bool {
+ if len(s) != 64 {
+ return false
+ }
+ for _, r := range s {
+ if !(r >= '0' && r <= '9' || r >= 'a' && r <= 'f') {
+ return false
+ }
+ }
+ return true
+}
+
+func allowedIconType(contentType string) bool {
+ switch contentType {
+ case "image/png", "image/jpeg", "image/webp":
+ return true
+ }
+ return false
+}
+
+func firstNonEmpty(values ...string) string {
+ for _, v := range values {
+ if v != "" {
+ return v
+ }
+ }
+ return ""
+}
+
+func detectFileContentType(store *catalog.Store, rel string) string {
+ abs, err := store.ResolveArtifact(catalog.App{ArtifactPath: rel})
+ if err != nil {
+ return ""
+ }
+ f, err := os.Open(abs)
+ if err != nil {
+ return ""
+ }
+ defer f.Close()
+ buf := make([]byte, 512)
+ n, _ := f.Read(buf)
+ return http.DetectContentType(buf[:n])
+}
+
+// writeArtifactError maps a buildArtifact failure onto the error envelope.
+func writeArtifactError(w http.ResponseWriter, err error) {
+ var ae *artifactError
+ if errors.As(err, &ae) {
+ writeError(w, ae.status, ae.code, ae.msg)
+ return
+ }
+ writeError(w, http.StatusBadGateway, "ERR_ARTIFACT_FETCH", "could not fetch artifact_url")
+}
+
+func writeJSON(w http.ResponseWriter, status int, v any) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(status)
+ _ = json.NewEncoder(w).Encode(v)
+}
+
+type errorBody struct {
+ Error struct {
+ Code string `json:"code"`
+ Message string `json:"message,omitempty"`
+ } `json:"error"`
+}
+
+func writeError(w http.ResponseWriter, status int, code, message string) {
+ var body errorBody
+ body.Error.Code = code
+ body.Error.Message = message
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(status)
+ _ = json.NewEncoder(w).Encode(body)
+}
diff --git a/src/handlers/catalog/catalog_test.go b/src/handlers/catalog/catalog_test.go
new file mode 100644
index 0000000..978d23c
--- /dev/null
+++ b/src/handlers/catalog/catalog_test.go
@@ -0,0 +1,515 @@
+// Copyright (C) 2026 Dcntrlized
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published
+// by the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package catalog
+
+import (
+ "bytes"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "mime/multipart"
+ "net/http"
+ "net/http/httptest"
+ "net/textproto"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "git.dcntrlized.org/Dcntrlized/Maestro/lib/catalog"
+)
+
+func newTestHandler(t *testing.T) (*Handler, string) {
+ t.Helper()
+ root := t.TempDir()
+ return NewHandler(root), root
+}
+
+func seedHostedApp(t *testing.T, h *Handler, pkg, content string) catalog.App {
+ t.Helper()
+ id := catalog.AppID(pkg)
+ rel, size, sum, err := h.store.SaveArtifact(id, "app.apk", strings.NewReader(content), catalog.MaxArtifactBytes)
+ if err != nil {
+ t.Fatalf("seed artifact: %v", err)
+ }
+ now := catalog.NowRFC3339()
+ app := catalog.App{
+ ID: id, Package: pkg, Name: "Seed", VersionName: "1.0",
+ AddedAt: now, UpdatedAt: now, AddedBy: "owner",
+ Artifact: catalog.Artifact{
+ Filename: "app.apk", SizeBytes: size, SHA256: sum,
+ Kind: catalog.KindHosted, ContentType: catalog.DefaultArtifactContentType,
+ },
+ ArtifactPath: rel,
+ }
+ if err := h.store.Upsert(app); err != nil {
+ t.Fatalf("seed app: %v", err)
+ }
+ return app
+}
+
+func doJSON(t *testing.T, h *Handler, method, target, body string, pathValues map[string]string) *httptest.ResponseRecorder {
+ t.Helper()
+ req := httptest.NewRequest(method, target, strings.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("X-Maestro-User", "admin")
+ for k, v := range pathValues {
+ req.SetPathValue(k, v)
+ }
+ rec := httptest.NewRecorder()
+ switch {
+ case strings.HasPrefix(target, "/api/catalog/apps") && strings.HasSuffix(target, "/artifact"):
+ h.Artifact(rec, req)
+ case strings.HasPrefix(target, "/api/catalog/apps") && strings.HasSuffix(target, "/icon"):
+ h.Icon(rec, req)
+ case target == "/api/catalog/apps":
+ h.Apps(rec, req)
+ default:
+ h.App(rec, req)
+ }
+ return rec
+}
+
+func multipartBody(t *testing.T, field, filename, contentType string, data []byte) (*bytes.Buffer, string) {
+ t.Helper()
+ var buf bytes.Buffer
+ mw := multipart.NewWriter(&buf)
+ head := make(textproto.MIMEHeader)
+ head.Set("Content-Disposition", fmt.Sprintf(`form-data; name=%q; filename=%q`, field, filename))
+ head.Set("Content-Type", contentType)
+ part, err := mw.CreatePart(head)
+ if err != nil {
+ t.Fatalf("create part: %v", err)
+ }
+ if _, err := part.Write(data); err != nil {
+ t.Fatalf("write part: %v", err)
+ }
+ if err := mw.Close(); err != nil {
+ t.Fatalf("close multipart: %v", err)
+ }
+ return &buf, mw.FormDataContentType()
+}
+
+func errorCode(t *testing.T, rec *httptest.ResponseRecorder) string {
+ t.Helper()
+ var body struct {
+ Error struct {
+ Code string `json:"code"`
+ } `json:"error"`
+ }
+ if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
+ t.Fatalf("decode error envelope %q: %v", rec.Body.String(), err)
+ }
+ return body.Error.Code
+}
+
+func TestAddHostedFromURL(t *testing.T) {
+ payload := []byte("APK-CONTENT")
+ sum := sha256.Sum256(payload)
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/vnd.android.package-archive")
+ _, _ = w.Write(payload)
+ }))
+ t.Cleanup(srv.Close)
+
+ h, root := newTestHandler(t)
+ body := fmt.Sprintf(`{"package":"com.example.app","name":"Example","summary":"summary","version_name":"1.2.3","version_code":123,"min_android":26,"artifact_url":%q,"sha256":%q}`,
+ srv.URL+"/example-1.2.3.apk", hex.EncodeToString(sum[:]))
+ rec := doJSON(t, h, http.MethodPost, "/api/catalog/apps", body, nil)
+ if rec.Code != http.StatusCreated {
+ t.Fatalf("status = %d, body %s", rec.Code, rec.Body.String())
+ }
+ var app catalog.App
+ if err := json.Unmarshal(rec.Body.Bytes(), &app); err != nil {
+ t.Fatalf("decode app: %v", err)
+ }
+ wantID := catalog.AppID("com.example.app")
+ if app.ID != wantID {
+ t.Fatalf("id = %q, want %q", app.ID, wantID)
+ }
+ if app.Artifact.Kind != catalog.KindHosted || app.Artifact.SizeBytes != int64(len(payload)) {
+ t.Fatalf("artifact = %#v", app.Artifact)
+ }
+ if app.Artifact.SHA256 != hex.EncodeToString(sum[:]) || app.Artifact.Filename != "example-1.2.3.apk" {
+ t.Fatalf("artifact = %#v", app.Artifact)
+ }
+ wantURL := "http://example.com/api/catalog/artifacts/" + wantID + "/example-1.2.3.apk"
+ if app.Artifact.DownloadURL != wantURL {
+ t.Fatalf("download_url = %q, want %q", app.Artifact.DownloadURL, wantURL)
+ }
+ if app.AddedBy != "admin" {
+ t.Fatalf("added_by = %q", app.AddedBy)
+ }
+ stored, err := os.ReadFile(filepath.Join(root, "catalog", "artifacts", wantID, "example-1.2.3.apk"))
+ if err != nil || !bytes.Equal(stored, payload) {
+ t.Fatalf("hosted bytes = %q, err=%v", stored, err)
+ }
+}
+
+func TestListIncludesAddedByAndAbsoluteURLs(t *testing.T) {
+ h, _ := newTestHandler(t)
+ seedHostedApp(t, h, "com.example.list", "bytes")
+ rec := doJSON(t, h, http.MethodGet, "/api/catalog/apps", "", nil)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d", rec.Code)
+ }
+ var apps []catalog.App
+ if err := json.Unmarshal(rec.Body.Bytes(), &apps); err != nil {
+ t.Fatalf("decode list: %v", err)
+ }
+ if len(apps) != 1 {
+ t.Fatalf("apps = %#v", apps)
+ }
+ if apps[0].AddedBy != "owner" {
+ t.Fatalf("owner list must include added_by, got %q", apps[0].AddedBy)
+ }
+ if !strings.HasPrefix(apps[0].Artifact.DownloadURL, "http://example.com/api/catalog/artifacts/") {
+ t.Fatalf("download_url not absolute: %q", apps[0].Artifact.DownloadURL)
+ }
+
+ // Empty catalog is [] not null.
+ empty, _ := newTestHandler(t)
+ rec = doJSON(t, empty, http.MethodGet, "/api/catalog/apps", "", nil)
+ if strings.TrimSpace(rec.Body.String()) != "[]" {
+ t.Fatalf("empty catalog = %q, want []", rec.Body.String())
+ }
+}
+
+func TestAddChecksumMismatchStoresNothing(t *testing.T) {
+ payload := []byte("APK-CONTENT")
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ _, _ = w.Write(payload)
+ }))
+ t.Cleanup(srv.Close)
+
+ h, root := newTestHandler(t)
+ body := fmt.Sprintf(`{"package":"com.example.bad","name":"Bad","version_name":"1.0","artifact_url":%q,"sha256":%q}`,
+ srv.URL+"/bad.apk", strings.Repeat("a", 64))
+ rec := doJSON(t, h, http.MethodPost, "/api/catalog/apps", body, nil)
+ if rec.Code != http.StatusBadRequest || errorCode(t, rec) != "ERR_CHECKSUM_MISMATCH" {
+ t.Fatalf("status = %d code = %s body %s", rec.Code, errorCode(t, rec), rec.Body.String())
+ }
+ apps, _ := h.store.List()
+ if len(apps) != 0 {
+ t.Fatalf("checksum mismatch stored a record: %#v", apps)
+ }
+ if _, err := os.Stat(filepath.Join(root, "catalog", "artifacts", catalog.AppID("com.example.bad"))); !os.IsNotExist(err) {
+ t.Fatalf("checksum mismatch left artifact bytes behind: %v", err)
+ }
+}
+
+func TestAddLinkOnly(t *testing.T) {
+ h, _ := newTestHandler(t)
+ link := "https://downloads.example.org/keepnote/app.apk"
+ body := fmt.Sprintf(`{"package":"com.example.link","name":"Linked","version_name":"2.0","artifact_url":%q,"link_only":true,"sha256":%q,"size_bytes":42}`,
+ link, strings.Repeat("b", 64))
+ rec := doJSON(t, h, http.MethodPost, "/api/catalog/apps", body, nil)
+ if rec.Code != http.StatusCreated {
+ t.Fatalf("status = %d, body %s", rec.Code, rec.Body.String())
+ }
+ var app catalog.App
+ if err := json.Unmarshal(rec.Body.Bytes(), &app); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+ if app.Artifact.Kind != catalog.KindLink || app.Artifact.SHA256 != strings.Repeat("b", 64) || app.Artifact.SizeBytes != 42 {
+ t.Fatalf("artifact = %#v", app.Artifact)
+ }
+
+ // link_only without a sha256 is invalid.
+ bad := fmt.Sprintf(`{"package":"com.example.link2","name":"NoSum","version_name":"1.0","artifact_url":%q,"link_only":true}`, link)
+ rec = doJSON(t, h, http.MethodPost, "/api/catalog/apps", bad, nil)
+ if rec.Code != http.StatusBadRequest || errorCode(t, rec) != "ERR_INVALID_PAYLOAD" {
+ t.Fatalf("missing sha256: status = %d code = %s", rec.Code, errorCode(t, rec))
+ }
+}
+
+func TestAddDuplicatePackage(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ _, _ = w.Write([]byte("data"))
+ }))
+ t.Cleanup(srv.Close)
+ h, _ := newTestHandler(t)
+ body := fmt.Sprintf(`{"package":"com.example.dup","name":"Dup","version_name":"1.0","artifact_url":%q}`, srv.URL+"/d.apk")
+ if rec := doJSON(t, h, http.MethodPost, "/api/catalog/apps", body, nil); rec.Code != http.StatusCreated {
+ t.Fatalf("first add status = %d", rec.Code)
+ }
+ rec := doJSON(t, h, http.MethodPost, "/api/catalog/apps", body, nil)
+ if rec.Code != http.StatusConflict || errorCode(t, rec) != "ERR_APP_EXISTS" {
+ t.Fatalf("status = %d code = %s", rec.Code, errorCode(t, rec))
+ }
+}
+
+func TestAddInvalidPayloadAndFetchFailure(t *testing.T) {
+ h, _ := newTestHandler(t)
+ rec := doJSON(t, h, http.MethodPost, "/api/catalog/apps", `{}`, nil)
+ if rec.Code != http.StatusBadRequest || errorCode(t, rec) != "ERR_INVALID_PAYLOAD" {
+ t.Fatalf("empty body: status = %d code = %s", rec.Code, errorCode(t, rec))
+ }
+
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ http.Error(w, "nope", http.StatusInternalServerError)
+ }))
+ t.Cleanup(srv.Close)
+ body := fmt.Sprintf(`{"package":"com.example.fetch","name":"Fetch","version_name":"1.0","artifact_url":%q}`, srv.URL+"/f.apk")
+ rec = doJSON(t, h, http.MethodPost, "/api/catalog/apps", body, nil)
+ if rec.Code != http.StatusBadGateway || errorCode(t, rec) != "ERR_ARTIFACT_FETCH" {
+ t.Fatalf("fetch failure: status = %d code = %s", rec.Code, errorCode(t, rec))
+ }
+ apps, _ := h.store.List()
+ if len(apps) != 0 {
+ t.Fatalf("fetch failure stored a record: %#v", apps)
+ }
+}
+
+func TestEditMetadataFullReplacement(t *testing.T) {
+ h, _ := newTestHandler(t)
+ app := seedHostedApp(t, h, "com.example.edit", "bytes")
+ if err := h.store.Upsert(func() catalog.App {
+ edited := app
+ edited.Summary = "old summary"
+ edited.Category = "old category"
+ edited.VersionCode = 9
+ edited.Artifact.SignerSHA256 = "signer"
+ return edited
+ }()); err != nil {
+ t.Fatalf("seed extra metadata: %v", err)
+ }
+
+ rec := doJSON(t, h, http.MethodPut, "/api/catalog/apps/"+app.ID,
+ `{"name":"Edited","version_name":"1.0","summary":"new summary","category":"tools","version_code":12}`,
+ map[string]string{"id": app.ID})
+ if rec.Code != http.StatusOK {
+ t.Fatalf("edit status = %d body %s", rec.Code, rec.Body.String())
+ }
+ got, _ := h.store.Get(app.ID)
+ if got == nil || got.Name != "Edited" || got.Summary != "new summary" || got.Category != "tools" || got.VersionCode != 12 {
+ t.Fatalf("edit not persisted: %#v", got)
+ }
+ if got.Artifact.SHA256 != app.Artifact.SHA256 {
+ t.Fatalf("metadata edit must not touch the artifact: %#v", got.Artifact)
+ }
+
+ // Omitted optional fields are cleared (full replacement), but the hosted
+ // artifact and the immutable package survive.
+ rec = doJSON(t, h, http.MethodPut, "/api/catalog/apps/"+app.ID,
+ `{"name":"Edited","version_name":"1.0"}`, map[string]string{"id": app.ID})
+ if rec.Code != http.StatusOK {
+ t.Fatalf("clear status = %d body %s", rec.Code, rec.Body.String())
+ }
+ got, _ = h.store.Get(app.ID)
+ if got.Summary != "" || got.Category != "" || got.VersionCode != 0 || got.Artifact.SignerSHA256 != "" {
+ t.Fatalf("omitted optional metadata not cleared: %#v", got)
+ }
+ if got.Package != app.Package || got.Artifact.SHA256 != app.Artifact.SHA256 {
+ t.Fatalf("package/artifact must be untouched: %#v", got)
+ }
+
+ // A changed package is refused even with valid required fields.
+ rec = doJSON(t, h, http.MethodPut, "/api/catalog/apps/"+app.ID,
+ `{"package":"com.example.repackaged","name":"Edited","version_name":"1.0"}`, map[string]string{"id": app.ID})
+ if rec.Code != http.StatusBadRequest || errorCode(t, rec) != "ERR_INVALID_PAYLOAD" {
+ t.Fatalf("immutable package: status = %d code = %s", rec.Code, errorCode(t, rec))
+ }
+
+ // name/version_name are required by the full replacement.
+ rec = doJSON(t, h, http.MethodPut, "/api/catalog/apps/"+app.ID, `{"summary":"only summary"}`, map[string]string{"id": app.ID})
+ if rec.Code != http.StatusBadRequest || errorCode(t, rec) != "ERR_INVALID_PAYLOAD" {
+ t.Fatalf("missing name: status = %d code = %s", rec.Code, errorCode(t, rec))
+ }
+
+ rec = doJSON(t, h, http.MethodPut, "/api/catalog/apps/a_missing", `{"name":"x","version_name":"1"}`, map[string]string{"id": "a_missing"})
+ if rec.Code != http.StatusNotFound || errorCode(t, rec) != "ERR_NOT_FOUND" {
+ t.Fatalf("unknown edit: status = %d code = %s", rec.Code, errorCode(t, rec))
+ }
+}
+
+func TestDeleteRemovesRecordAndBytes(t *testing.T) {
+ h, root := newTestHandler(t)
+ app := seedHostedApp(t, h, "com.example.del", "bytes")
+ if _, err := h.store.SaveIcon(app.ID, []byte("\x89PNG\r\n\x1a\nicon")); err != nil {
+ t.Fatalf("seed icon: %v", err)
+ }
+ stored, _ := h.store.Get(app.ID)
+ stored.IconPath = filepath.Join("icons", app.ID)
+ if err := h.store.Upsert(*stored); err != nil {
+ t.Fatalf("seed icon path: %v", err)
+ }
+
+ rec := doJSON(t, h, http.MethodDelete, "/api/catalog/apps/"+app.ID, "", map[string]string{"id": app.ID})
+ if rec.Code != http.StatusOK {
+ t.Fatalf("delete status = %d body %s", rec.Code, rec.Body.String())
+ }
+ if got, _ := h.store.Get(app.ID); got != nil {
+ t.Fatalf("record survived delete: %#v", got)
+ }
+ if _, err := os.Stat(filepath.Join(root, "catalog", "artifacts", app.ID)); !os.IsNotExist(err) {
+ t.Fatalf("hosted bytes survived delete: %v", err)
+ }
+ if _, err := os.Stat(filepath.Join(root, "catalog", "icons", app.ID)); !os.IsNotExist(err) {
+ t.Fatalf("icon survived delete: %v", err)
+ }
+
+ rec = doJSON(t, h, http.MethodDelete, "/api/catalog/apps/"+app.ID, "", map[string]string{"id": app.ID})
+ if rec.Code != http.StatusNotFound {
+ t.Fatalf("second delete status = %d", rec.Code)
+ }
+}
+
+func TestReplaceArtifactUpload(t *testing.T) {
+ h, root := newTestHandler(t)
+ app := seedHostedApp(t, h, "com.example.replace", "old-bytes")
+
+ payload := []byte("new-bytes-longer")
+ buf, contentType := multipartBody(t, "file", "new.apk", "application/vnd.android.package-archive", payload)
+ req := httptest.NewRequest(http.MethodPut, "/api/catalog/apps/"+app.ID+"/artifact", buf)
+ req.Header.Set("Content-Type", contentType)
+ req.SetPathValue("id", app.ID)
+ rec := httptest.NewRecorder()
+ h.Artifact(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d body %s", rec.Code, rec.Body.String())
+ }
+ var updated catalog.App
+ if err := json.Unmarshal(rec.Body.Bytes(), &updated); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+ sum := sha256.Sum256(payload)
+ if updated.Artifact.Filename != "new.apk" || updated.Artifact.SizeBytes != int64(len(payload)) ||
+ updated.Artifact.SHA256 != hex.EncodeToString(sum[:]) || updated.Artifact.Kind != catalog.KindHosted {
+ t.Fatalf("artifact = %#v", updated.Artifact)
+ }
+ data, err := os.ReadFile(filepath.Join(root, "catalog", "artifacts", app.ID, "new.apk"))
+ if err != nil || !bytes.Equal(data, payload) {
+ t.Fatalf("uploaded bytes = %q err=%v", data, err)
+ }
+ if _, err := os.Stat(filepath.Join(root, "catalog", "artifacts", app.ID, "app.apk")); !os.IsNotExist(err) {
+ t.Fatalf("old artifact not pruned: %v", err)
+ }
+}
+
+func TestReplaceIconUpload(t *testing.T) {
+ h, root := newTestHandler(t)
+ app := seedHostedApp(t, h, "com.example.icon", "bytes")
+
+ icon := append([]byte("\x89PNG\r\n\x1a\n"), []byte("png-body")...)
+ buf, contentType := multipartBody(t, "file", "icon.png", "image/png", icon)
+ req := httptest.NewRequest(http.MethodPut, "/api/catalog/apps/"+app.ID+"/icon", buf)
+ req.Header.Set("Content-Type", contentType)
+ req.SetPathValue("id", app.ID)
+ rec := httptest.NewRecorder()
+ h.Icon(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d body %s", rec.Code, rec.Body.String())
+ }
+ got, _ := h.store.Get(app.ID)
+ if got == nil || got.IconPath == "" {
+ t.Fatalf("icon path not persisted: %#v", got)
+ }
+ if want := "http://example.com/api/catalog/icons/" + app.ID; got.WithBase("http://example.com").IconURL != want {
+ t.Fatalf("icon_url = %q, want %q", got.WithBase("http://example.com").IconURL, want)
+ }
+ if data, err := os.ReadFile(filepath.Join(root, "catalog", "icons", app.ID)); err != nil || !bytes.Equal(data, icon) {
+ t.Fatalf("icon bytes = %q err=%v", data, err)
+ }
+
+ // Non-image content is rejected.
+ buf, contentType = multipartBody(t, "file", "note.txt", "text/plain", []byte("hello"))
+ req = httptest.NewRequest(http.MethodPut, "/api/catalog/apps/"+app.ID+"/icon", buf)
+ req.Header.Set("Content-Type", contentType)
+ req.SetPathValue("id", app.ID)
+ rec = httptest.NewRecorder()
+ h.Icon(rec, req)
+ if rec.Code != http.StatusBadRequest || errorCode(t, rec) != "ERR_INVALID_PAYLOAD" {
+ t.Fatalf("non-image icon: status = %d code = %s", rec.Code, errorCode(t, rec))
+ }
+}
+
+// multipartAdd builds a multipart POST /api/catalog/apps body with flat
+// snake_case fields plus an optional file part named "file".
+func multipartAdd(t *testing.T, fields map[string]string, filename string, data []byte) (*bytes.Buffer, string) {
+ t.Helper()
+ var buf bytes.Buffer
+ mw := multipart.NewWriter(&buf)
+ for key, value := range fields {
+ if err := mw.WriteField(key, value); err != nil {
+ t.Fatalf("write field %s: %v", key, err)
+ }
+ }
+ if filename != "" {
+ part, err := mw.CreateFormFile("file", filename)
+ if err != nil {
+ t.Fatalf("create file part: %v", err)
+ }
+ if _, err := part.Write(data); err != nil {
+ t.Fatalf("write file part: %v", err)
+ }
+ }
+ if err := mw.Close(); err != nil {
+ t.Fatalf("close multipart: %v", err)
+ }
+ return &buf, mw.FormDataContentType()
+}
+
+func TestAddFromMultipartUpload(t *testing.T) {
+ payload := []byte("UPLOADED-APK")
+ sum := sha256.Sum256(payload)
+ h, root := newTestHandler(t)
+
+ buf, contentType := multipartAdd(t, map[string]string{
+ "package": "com.example.upload", "name": "Uploaded", "version_name": "3.1",
+ "summary": "from file", "sha256": hex.EncodeToString(sum[:]),
+ }, "uploaded-3.1.apk", payload)
+ req := httptest.NewRequest(http.MethodPost, "/api/catalog/apps", buf)
+ req.Header.Set("Content-Type", contentType)
+ req.Header.Set("X-Maestro-User", "admin")
+ rec := httptest.NewRecorder()
+ h.Apps(rec, req)
+ if rec.Code != http.StatusCreated {
+ t.Fatalf("status = %d body %s", rec.Code, rec.Body.String())
+ }
+ var app catalog.App
+ if err := json.Unmarshal(rec.Body.Bytes(), &app); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+ if app.Package != "com.example.upload" || app.Summary != "from file" {
+ t.Fatalf("metadata not applied: %#v", app)
+ }
+ if app.Artifact.Kind != catalog.KindHosted || app.Artifact.Filename != "uploaded-3.1.apk" ||
+ app.Artifact.SizeBytes != int64(len(payload)) || app.Artifact.SHA256 != hex.EncodeToString(sum[:]) {
+ t.Fatalf("artifact = %#v", app.Artifact)
+ }
+ stored, err := os.ReadFile(filepath.Join(root, "catalog", "artifacts", app.ID, "uploaded-3.1.apk"))
+ if err != nil || !bytes.Equal(stored, payload) {
+ t.Fatalf("hosted bytes = %q err=%v", stored, err)
+ }
+
+ // A mismatching supplied sha256 rejects the upload and stores nothing.
+ wrong, wrongType := multipartAdd(t, map[string]string{
+ "package": "com.example.upload2", "name": "Bad", "version_name": "1.0",
+ "sha256": strings.Repeat("e", 64),
+ }, "bad.apk", payload)
+ req = httptest.NewRequest(http.MethodPost, "/api/catalog/apps", wrong)
+ req.Header.Set("Content-Type", wrongType)
+ rec = httptest.NewRecorder()
+ h.Apps(rec, req)
+ if rec.Code != http.StatusBadRequest || errorCode(t, rec) != "ERR_CHECKSUM_MISMATCH" {
+ t.Fatalf("mismatch: status = %d code = %s", rec.Code, errorCode(t, rec))
+ }
+ if _, err := os.Stat(filepath.Join(root, "catalog", "artifacts", catalog.AppID("com.example.upload2"))); !os.IsNotExist(err) {
+ t.Fatalf("mismatched upload left bytes: %v", err)
+ }
+}
diff --git a/src/handlers/catalog/public.go b/src/handlers/catalog/public.go
new file mode 100644
index 0000000..c250458
--- /dev/null
+++ b/src/handlers/catalog/public.go
@@ -0,0 +1,147 @@
+// Copyright (C) 2026 Dcntrlized
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published
+// by the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package catalog
+
+import (
+ "io"
+ "net/http"
+ "os"
+ "strconv"
+
+ "git.dcntrlized.org/Dcntrlized/Maestro/lib/catalog"
+ "git.dcntrlized.org/Dcntrlized/Maestro/lib/env"
+ "git.dcntrlized.org/Dcntrlized/Maestro/lib/logger"
+)
+
+// Download serves GET /api/catalog/artifacts/{id}/{filename} — the public,
+// session-free artifact surface. Hosted entries stream exact bytes with their
+// integrity headers; link entries 302 to the operator's URL. Unknown entries,
+// a mismatched filename and DCNTRLIZED_PUBLIC_PROFILE=false all answer 404.
+func (h *Handler) Download(w http.ResponseWriter, r *http.Request) {
+ if !env.PublicProfile() {
+ writeError(w, http.StatusNotFound, "ERR_NOT_FOUND", "catalog is not public")
+ return
+ }
+ id := r.PathValue("id")
+ filename := r.PathValue("filename")
+
+ app, err := h.store.Get(id)
+ if err != nil {
+ logger.Error("catalog: get %s: %v", id, err)
+ writeError(w, http.StatusInternalServerError, "ERR_INTERNAL", "failed to read catalog")
+ return
+ }
+ if app == nil || app.Artifact.Filename == "" || catalog.SafeFilename(filename) != app.Artifact.Filename {
+ writeError(w, http.StatusNotFound, "ERR_NOT_FOUND", "unknown artifact")
+ return
+ }
+
+ if app.Artifact.Kind == catalog.KindLink {
+ if app.Artifact.SourceURL == "" {
+ writeError(w, http.StatusNotFound, "ERR_NOT_FOUND", "unknown artifact")
+ return
+ }
+ w.Header().Set("Access-Control-Allow-Origin", "*")
+ http.Redirect(w, r, app.Artifact.SourceURL, http.StatusFound)
+ return
+ }
+
+ path, err := h.store.ResolveArtifact(*app)
+ if err != nil {
+ writeError(w, http.StatusNotFound, "ERR_NOT_FOUND", "unknown artifact")
+ return
+ }
+ file, err := os.Open(path)
+ if err != nil {
+ writeError(w, http.StatusNotFound, "ERR_NOT_FOUND", "unknown artifact")
+ return
+ }
+ defer file.Close()
+ info, err := file.Stat()
+ if err != nil {
+ writeError(w, http.StatusNotFound, "ERR_NOT_FOUND", "unknown artifact")
+ return
+ }
+
+ contentType := app.Artifact.ContentType
+ if contentType == "" {
+ contentType = catalog.DefaultArtifactContentType
+ }
+ w.Header().Set("Content-Type", contentType)
+ w.Header().Set("Content-Length", strconv.FormatInt(info.Size(), 10))
+ w.Header().Set("X-Content-SHA256", app.Artifact.SHA256)
+ w.Header().Set("Access-Control-Allow-Origin", "*")
+ w.WriteHeader(http.StatusOK)
+ if _, err := io.Copy(w, file); err != nil {
+ logger.Warn("catalog: stream artifact %s: %v", id, err)
+ }
+}
+
+// IconBytes serves GET /api/catalog/icons/{id} — uploaded icon bytes, or 404
+// when there is no uploaded icon / the catalog is private.
+func (h *Handler) IconBytes(w http.ResponseWriter, r *http.Request) {
+ if !env.PublicProfile() {
+ writeError(w, http.StatusNotFound, "ERR_NOT_FOUND", "catalog is not public")
+ return
+ }
+ id := r.PathValue("id")
+ app, err := h.store.Get(id)
+ if err != nil {
+ logger.Error("catalog: get %s: %v", id, err)
+ writeError(w, http.StatusInternalServerError, "ERR_INTERNAL", "failed to read catalog")
+ return
+ }
+ if app == nil || app.IconPath == "" {
+ writeError(w, http.StatusNotFound, "ERR_NOT_FOUND", "unknown icon")
+ return
+ }
+
+ path, err := h.store.ResolveIcon(*app)
+ if err != nil {
+ writeError(w, http.StatusNotFound, "ERR_NOT_FOUND", "unknown icon")
+ return
+ }
+ file, err := os.Open(path)
+ if err != nil {
+ writeError(w, http.StatusNotFound, "ERR_NOT_FOUND", "unknown icon")
+ return
+ }
+ defer file.Close()
+ info, err := file.Stat()
+ if err != nil {
+ writeError(w, http.StatusNotFound, "ERR_NOT_FOUND", "unknown icon")
+ return
+ }
+
+ head := make([]byte, 512)
+ n, _ := file.Read(head)
+ contentType := http.DetectContentType(head[:n])
+ if contentType == "" {
+ contentType = "application/octet-stream"
+ }
+ // Rewind: Read advanced the file offset.
+ if _, err := file.Seek(0, io.SeekStart); err != nil {
+ writeError(w, http.StatusInternalServerError, "ERR_INTERNAL", "failed to read icon")
+ return
+ }
+ w.Header().Set("Content-Type", contentType)
+ w.Header().Set("Content-Length", strconv.FormatInt(info.Size(), 10))
+ w.Header().Set("Access-Control-Allow-Origin", "*")
+ w.WriteHeader(http.StatusOK)
+ if _, err := io.Copy(w, file); err != nil {
+ logger.Warn("catalog: stream icon %s: %v", id, err)
+ }
+}
diff --git a/src/handlers/catalog/public_test.go b/src/handlers/catalog/public_test.go
new file mode 100644
index 0000000..eea53f0
--- /dev/null
+++ b/src/handlers/catalog/public_test.go
@@ -0,0 +1,156 @@
+// Copyright (C) 2026 Dcntrlized
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published
+// by the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package catalog
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "git.dcntrlized.org/Dcntrlized/Maestro/lib/catalog"
+)
+
+func serveDownload(t *testing.T, h *Handler, id, filename string) *httptest.ResponseRecorder {
+ t.Helper()
+ req := httptest.NewRequest(http.MethodGet, "/api/catalog/artifacts/"+id+"/"+filename, nil)
+ req.SetPathValue("id", id)
+ req.SetPathValue("filename", filename)
+ rec := httptest.NewRecorder()
+ h.Download(rec, req)
+ return rec
+}
+
+func TestDownloadHostedExactBytesAndHeaders(t *testing.T) {
+ h, _ := newTestHandler(t)
+ app := seedHostedApp(t, h, "com.example.download", "exact-apk-bytes")
+
+ rec := serveDownload(t, h, app.ID, app.Artifact.Filename)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d body %s", rec.Code, rec.Body.String())
+ }
+ if rec.Body.String() != "exact-apk-bytes" {
+ t.Fatalf("body = %q", rec.Body.String())
+ }
+ if got := rec.Header().Get("Content-Length"); got != "15" {
+ t.Fatalf("Content-Length = %q, want 15", got)
+ }
+ if got := rec.Header().Get("X-Content-SHA256"); got != app.Artifact.SHA256 {
+ t.Fatalf("X-Content-SHA256 = %q, want %q", got, app.Artifact.SHA256)
+ }
+ if got := rec.Header().Get("Content-Type"); got != catalog.DefaultArtifactContentType {
+ t.Fatalf("Content-Type = %q", got)
+ }
+ if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "*" {
+ t.Fatalf("Access-Control-Allow-Origin = %q", got)
+ }
+}
+
+func TestDownloadUnknownAndWrongFilename(t *testing.T) {
+ h, _ := newTestHandler(t)
+ app := seedHostedApp(t, h, "com.example.dl2", "bytes")
+
+ if rec := serveDownload(t, h, app.ID, "other.apk"); rec.Code != http.StatusNotFound {
+ t.Fatalf("wrong filename status = %d", rec.Code)
+ }
+ if rec := serveDownload(t, h, "a_missing", "app.apk"); rec.Code != http.StatusNotFound {
+ t.Fatalf("unknown id status = %d", rec.Code)
+ }
+}
+
+func TestDownloadLinkRedirects(t *testing.T) {
+ h, _ := newTestHandler(t)
+ link := "https://downloads.example.org/app.apk"
+ app := catalog.App{
+ ID: catalog.AppID("com.example.redirect"), Package: "com.example.redirect", Name: "Link", VersionName: "1.0",
+ Artifact: catalog.Artifact{
+ Filename: "app.apk", SHA256: strings.Repeat("c", 64),
+ Kind: catalog.KindLink, SourceURL: link,
+ },
+ }
+ if err := h.store.Upsert(app); err != nil {
+ t.Fatalf("seed link: %v", err)
+ }
+ rec := serveDownload(t, h, app.ID, "app.apk")
+ if rec.Code != http.StatusFound {
+ t.Fatalf("status = %d, want 302", rec.Code)
+ }
+ if loc := rec.Header().Get("Location"); loc != link {
+ t.Fatalf("Location = %q, want %q", loc, link)
+ }
+ if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "*" {
+ t.Fatalf("redirect ACAO = %q", got)
+ }
+}
+
+func TestDownloadPrivacySwitchOff(t *testing.T) {
+ t.Setenv("DCNTRLIZED_PUBLIC_PROFILE", "false")
+ h, _ := newTestHandler(t)
+ app := seedHostedApp(t, h, "com.example.private", "bytes")
+
+ if rec := serveDownload(t, h, app.ID, app.Artifact.Filename); rec.Code != http.StatusNotFound {
+ t.Fatalf("status = %d, want 404 when the profile switch is off", rec.Code)
+ }
+
+ req := httptest.NewRequest(http.MethodGet, "/api/catalog/icons/"+app.ID, nil)
+ req.SetPathValue("id", app.ID)
+ rec := httptest.NewRecorder()
+ h.IconBytes(rec, req)
+ if rec.Code != http.StatusNotFound {
+ t.Fatalf("icon status = %d, want 404 when the profile switch is off", rec.Code)
+ }
+}
+
+func TestIconBytes(t *testing.T) {
+ h, _ := newTestHandler(t)
+ app := seedHostedApp(t, h, "com.example.icon2", "bytes")
+ icon := append([]byte("\x89PNG\r\n\x1a\n"), []byte("png-body")...)
+ if _, err := h.store.SaveIcon(app.ID, icon); err != nil {
+ t.Fatalf("save icon: %v", err)
+ }
+ stored, _ := h.store.Get(app.ID)
+ stored.IconPath = "icons/" + app.ID
+ if err := h.store.Upsert(*stored); err != nil {
+ t.Fatalf("persist icon path: %v", err)
+ }
+
+ req := httptest.NewRequest(http.MethodGet, "/api/catalog/icons/"+app.ID, nil)
+ req.SetPathValue("id", app.ID)
+ rec := httptest.NewRecorder()
+ h.IconBytes(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d body %s", rec.Code, rec.Body.String())
+ }
+ if rec.Body.String() != string(icon) {
+ t.Fatalf("icon body = %q", rec.Body.String())
+ }
+ if got := rec.Header().Get("Content-Type"); got != "image/png" {
+ t.Fatalf("Content-Type = %q", got)
+ }
+ if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "*" {
+ t.Fatalf("ACAO = %q", got)
+ }
+
+ // App without an uploaded icon → 404.
+ other := seedHostedApp(t, h, "com.example.noicon", "bytes")
+ req = httptest.NewRequest(http.MethodGet, "/api/catalog/icons/"+other.ID, nil)
+ req.SetPathValue("id", other.ID)
+ rec = httptest.NewRecorder()
+ h.IconBytes(rec, req)
+ if rec.Code != http.StatusNotFound {
+ t.Fatalf("missing icon status = %d", rec.Code)
+ }
+}
diff --git a/src/handlers/instance/public.go b/src/handlers/instance/public.go
index 5956b5f..463b89d 100644
--- a/src/handlers/instance/public.go
+++ b/src/handlers/instance/public.go
@@ -25,15 +25,17 @@ import (
"sort"
"strings"
+ "git.dcntrlized.org/Dcntrlized/Maestro/lib/catalog"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/env"
- "git.dcntrlized.org/Dcntrlized/Maestro/lib/federation"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/instance"
- "git.dcntrlized.org/Dcntrlized/Maestro/lib/pki"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/types"
)
// InstanceIdentity is the public face of this instance. Fingerprint is the
-// instance CA's public-key hash — the same value a peer pins at first contact.
+// instance's stable app-level identity fingerprint (rev 9: with the PKI layer
+// removed there is no CA, so it is derived from a persisted instance key). It
+// is an unauthenticated string a consumer can still compare to detect a
+// changed peer.
type InstanceIdentity struct {
Name string `json:"name"`
Title string `json:"title,omitempty"`
@@ -55,18 +57,31 @@ type PublicService struct {
Image string `json:"image"`
}
+// TrustedInstance is the retired federation trust row. Rev 9 removed the
+// peer/federation trust store (no peer identity exists on a plaintext
+// network), so the public profile always reports an empty list; the field is
+// kept so existing consumers (the hub SPA) still see the documented shape.
+type TrustedInstance struct {
+ Name string `json:"name"`
+ URL string `json:"url,omitempty"`
+ Fingerprint string `json:"fingerprint,omitempty"`
+ Status string `json:"status,omitempty"`
+ Scopes []string `json:"scopes,omitempty"`
+}
+
// PublicProfile is the GET /api/instance wire shape. The section pointers
// carry three distinct states: nil → field omitted (privacy switch off),
// pointer-to-nil → JSON null (store absent), pointer-to-empty → JSON [].
type PublicProfile struct {
- Instance InstanceIdentity `json:"instance"`
- Services *[]PublicService `json:"services,omitempty"`
- TrustedInstances *[]federation.RemoteInstance `json:"trusted_instances,omitempty"`
+ Instance InstanceIdentity `json:"instance"`
+ Services *[]PublicService `json:"services,omitempty"`
+ TrustedInstances *[]TrustedInstance `json:"trusted_instances,omitempty"`
+ Apps *[]catalog.App `json:"apps,omitempty"`
}
// NewPublicProfileHandler serves GET /api/instance. getServices is the cache
-// accessor (cache.GetServices self-locks); dataRoot resolves identity, CA and
-// federation stores on demand.
+// accessor (cache.GetServices self-locks); dataRoot resolves identity, the
+// instance fingerprint and the catalog store on demand.
func NewPublicProfileHandler(getServices func() []types.Container, dataRoot string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
profile := PublicProfile{
@@ -75,14 +90,14 @@ func NewPublicProfileHandler(getServices func() []types.Container, dataRoot stri
if env.PublicProfile() {
services := publicServices(getServices())
- trusted, err := federation.List(dataRoot)
- if err != nil {
- // A malformed store must not take the profile down;
- // report absence instead.
- trusted = nil
- }
+ // Rev 9: the peer/federation trust store is retired, so the
+ // trusted list is always empty (never nil, so the field stays an
+ // array rather than JSON null).
+ trusted := []TrustedInstance{}
+ apps := publicApps(dataRoot, catalog.RequestBaseURL(r))
profile.Services = &services
profile.TrustedInstances = &trusted
+ profile.Apps = &apps
}
w.Header().Set("Content-Type", "application/json")
@@ -99,10 +114,27 @@ func buildIdentity(dataRoot string) InstanceIdentity {
Name: id.Name,
Title: id.Title,
Description: id.Description,
- Fingerprint: pki.CAFingerprint(dataRoot),
+ Fingerprint: instance.Fingerprint(dataRoot),
}
}
+// publicApps projects the operator-authored catalog (app-catalog theme) onto
+// the public profile: metadata plus absolute download/icon URLs, with the
+// private owner field stripped (A7). The result is always non-nil so an empty
+// catalog serializes as [] rather than null; a malformed store degrades to []
+// instead of taking the profile down.
+func publicApps(dataRoot, base string) []catalog.App {
+ apps, err := catalog.List(dataRoot)
+ if err != nil || apps == nil {
+ apps = []catalog.App{}
+ }
+ out := make([]catalog.App, 0, len(apps))
+ for _, app := range apps {
+ out = append(out, app.WithBase(base).Public())
+ }
+ return out
+}
+
// publicServices filters hidden infrastructure (role-labeled containers such
// as dcntrlized-db / dcntrlized-auth and anything flagged hidden) down to the
// public field set. The result is sorted case-insensitively by service name:
diff --git a/src/handlers/instance/public_test.go b/src/handlers/instance/public_test.go
index abc688f..b6dfa1f 100644
--- a/src/handlers/instance/public_test.go
+++ b/src/handlers/instance/public_test.go
@@ -21,9 +21,10 @@ import (
"net/http/httptest"
"os"
"path/filepath"
+ "strings"
"testing"
- "git.dcntrlized.org/Dcntrlized/Maestro/lib/federation"
+ "git.dcntrlized.org/Dcntrlized/Maestro/lib/catalog"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/types"
)
@@ -125,38 +126,41 @@ func TestPublicProfileIdentityFallbackAndFile(t *testing.T) {
}
}
-func TestPublicProfileTrustedInstancesNullVsEmpty(t *testing.T) {
+// TestPublicProfileTrustedInstancesAlwaysEmpty covers rev 9: the federation
+// trust store is retired, so trusted_instances is always an empty array (never
+// null) regardless of on-disk state, and the instance fingerprint is a stable
+// app-level value derived from the persisted instance key.
+func TestPublicProfileTrustedInstancesAlwaysEmpty(t *testing.T) {
dataRoot := t.TempDir()
- // Store absent → JSON null (field present with null value).
body := serve(t, dataRoot, testServices())
- if v, ok := body["trusted_instances"]; !ok || v != nil {
- raw, _ := json.Marshal(body)
- t.Fatalf("absent federation store should yield trusted_instances:null, got %s", raw)
+ raw, _ := json.Marshal(body["trusted_instances"])
+ if string(raw) != "[]" {
+ t.Fatalf("trusted_instances should be [] after the federation removal, got %s", raw)
}
- // Present but empty → [].
+ // A stale federation directory on disk must not resurrect the surface.
if err := os.MkdirAll(filepath.Join(dataRoot, "federation"), 0o700); err != nil {
t.Fatal(err)
}
- if err := os.WriteFile(federation.Path(dataRoot), []byte(`[]`), 0o600); err != nil {
+ if err := os.WriteFile(filepath.Join(dataRoot, "federation", "instances.json"),
+ []byte(`{"instances":[{"name":"peer-b","status":"authorized"}]}`), 0o600); err != nil {
t.Fatal(err)
}
body = serve(t, dataRoot, testServices())
- raw, _ := json.Marshal(body["trusted_instances"])
+ raw, _ = json.Marshal(body["trusted_instances"])
if string(raw) != "[]" {
- t.Fatalf("empty store should marshal as [], got %s", raw)
+ t.Fatalf("stale federation store leaked into the profile: %s", raw)
}
- // Populated → rows pass through.
- populated := `{"instances":[{"name":"peer-b","url":"http://192.168.1.20:8080","fingerprint":"sha256:ab","status":"authorized","scopes":["read"]}]}`
- if err := os.WriteFile(federation.Path(dataRoot), []byte(populated), 0o600); err != nil {
- t.Fatal(err)
+ // The fingerprint is present and stable across requests.
+ first := body["instance"].(map[string]any)["fingerprint"].(string)
+ if !strings.HasPrefix(first, "sha256:") {
+ t.Fatalf("fingerprint = %q, want sha256: prefix", first)
}
body = serve(t, dataRoot, testServices())
- list := body["trusted_instances"].([]any)
- if len(list) != 1 || list[0].(map[string]any)["name"] != "peer-b" {
- t.Fatalf("populated store not surfaced: %v", list)
+ if again := body["instance"].(map[string]any)["fingerprint"].(string); again != first {
+ t.Fatalf("fingerprint not stable: %q vs %q", first, again)
}
}
@@ -174,3 +178,71 @@ func TestPublicProfilePrivacySwitchOmitsSections(t *testing.T) {
t.Error("instance identity must remain visible with the profile off")
}
}
+
+func TestPublicProfileAppsSection(t *testing.T) {
+ dataRoot := t.TempDir()
+ id := catalog.AppID("com.example.profile")
+ app := catalog.App{
+ ID: id, Package: "com.example.profile", Name: "Profile App",
+ VersionName: "1.0", AddedAt: "2026-10-06T12:00:00Z", UpdatedAt: "2026-10-06T12:00:00Z", AddedBy: "owner",
+ Artifact: catalog.Artifact{
+ Filename: "app.apk", SizeBytes: 5, SHA256: strings.Repeat("d", 64),
+ Kind: catalog.KindHosted, ContentType: catalog.DefaultArtifactContentType,
+ },
+ ArtifactPath: filepath.Join("artifacts", id, "app.apk"),
+ IconPath: filepath.Join("icons", id),
+ }
+ if err := catalog.NewStore(dataRoot).Upsert(app); err != nil {
+ t.Fatalf("seed catalog: %v", err)
+ }
+
+ body := serve(t, dataRoot, testServices())
+ raw, _ := json.Marshal(body["apps"])
+ var apps []map[string]any
+ if err := json.Unmarshal(raw, &apps); err != nil {
+ t.Fatalf("apps not an array: %v (%s)", err, raw)
+ }
+ if len(apps) != 1 {
+ t.Fatalf("apps = %s", raw)
+ }
+ row := apps[0]
+ if row["package"] != "com.example.profile" || row["name"] != "Profile App" {
+ t.Fatalf("app metadata wrong: %v", row)
+ }
+ if _, leaked := row["added_by"]; leaked {
+ t.Fatal("public profile leaked added_by")
+ }
+ if _, leaked := row["artifact_path"]; leaked {
+ t.Fatal("public profile leaked the internal artifact path")
+ }
+ artifact, ok := row["artifact"].(map[string]any)
+ if !ok {
+ t.Fatalf("artifact missing: %v", row)
+ }
+ download := artifact["download_url"].(string)
+ if !strings.HasPrefix(download, "http://127.0.0.1:") || !strings.HasSuffix(download, "/api/catalog/artifacts/"+id+"/app.apk") {
+ t.Fatalf("download_url = %q", download)
+ }
+ icon := row["icon_url"].(string)
+ if !strings.HasSuffix(icon, "/api/catalog/icons/"+id) {
+ t.Fatalf("icon_url = %q", icon)
+ }
+ if artifact["sha256"] != strings.Repeat("d", 64) {
+ t.Fatalf("sha256 not surfaced: %v", artifact)
+ }
+}
+
+func TestPublicProfileAppsEmptyAndSwitchOff(t *testing.T) {
+ dataRoot := t.TempDir()
+ body := serve(t, dataRoot, testServices())
+ raw, _ := json.Marshal(body["apps"])
+ if string(raw) != "[]" {
+ t.Fatalf("empty catalog must marshal as [], got %s", raw)
+ }
+
+ t.Setenv("DCNTRLIZED_PUBLIC_PROFILE", "false")
+ body = serve(t, dataRoot, testServices())
+ if _, ok := body["apps"]; ok {
+ t.Error("apps must be omitted when DCNTRLIZED_PUBLIC_PROFILE=false")
+ }
+}
diff --git a/src/lib/catalog/catalog.go b/src/lib/catalog/catalog.go
new file mode 100644
index 0000000..585e150
--- /dev/null
+++ b/src/lib/catalog/catalog.go
@@ -0,0 +1,165 @@
+// Copyright (C) 2026 Dcntrlized
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published
+// by the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+// Package catalog owns the instance's operator-authored app distribution: the
+// app records the owner publishes (Android artifacts Maestro hosts or links),
+// their on-disk bytes, and the public projection phones download from. It is
+// deliberately independent of services and the container registry (app-catalog
+// decisions A1/A2): nothing here is derived from installed containers.
+package catalog
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "net/http"
+ "net/url"
+ "path"
+ "strings"
+ "time"
+)
+
+// Artifact kinds (A4).
+const (
+ KindHosted = "hosted"
+ KindLink = "link"
+)
+
+// DefaultArtifactContentType is Android's APK MIME type.
+const DefaultArtifactContentType = "application/vnd.android.package-archive"
+
+// Artifact is the artifact sub-record of an app. DownloadURL is computed per
+// request (absolute, points at this instance's public artifact endpoint);
+// SourceURL is the operator-supplied link target for kind=link and stays
+// internal (`json:"-"` — A7).
+type Artifact struct {
+ Filename string `json:"filename"`
+ SizeBytes int64 `json:"size_bytes"`
+ SHA256 string `json:"sha256"`
+ ContentType string `json:"content_type,omitempty"`
+ Kind string `json:"kind"`
+ DownloadURL string `json:"download_url,omitempty"`
+ SignerSHA256 string `json:"signer_sha256,omitempty"`
+ SourceURL string `json:"-"`
+}
+
+// App is one catalog record (wire shape, snake_case). AddedBy is the owner
+// username for the owner view and is blanked on the public projection;
+// ArtifactPath/IconPath are the local filesystem locations of the hosted bytes
+// and never serialize (A7).
+type App struct {
+ ID string `json:"id"`
+ Package string `json:"package"`
+ Name string `json:"name"`
+ Summary string `json:"summary,omitempty"`
+ Description string `json:"description,omitempty"`
+ Category string `json:"category,omitempty"`
+ VersionName string `json:"version_name"`
+ VersionCode int `json:"version_code,omitempty"`
+ MinAndroid int `json:"min_android,omitempty"`
+ IconURL string `json:"icon_url,omitempty"`
+ Artifact Artifact `json:"artifact"`
+ SourceURL string `json:"source_url,omitempty"`
+ AddedAt string `json:"added_at"`
+ UpdatedAt string `json:"updated_at"`
+
+ AddedBy string `json:"added_by,omitempty"`
+ ArtifactPath string `json:"-"`
+ IconPath string `json:"-"`
+}
+
+// AppID derives the stable id for a package: "a_" + first 12 hex chars of
+// sha256(package). Stable across edits so a client refresh never renames an app.
+func AppID(pkg string) string {
+ sum := sha256.Sum256([]byte(pkg))
+ return "a_" + hex.EncodeToString(sum[:])[:12]
+}
+
+// NowRFC3339 stamps records in UTC RFC3339, the store's timestamp format.
+func NowRFC3339() string {
+ return time.Now().UTC().Format(time.RFC3339)
+}
+
+// Public returns the public projection of a record: identical metadata minus
+// the private owner field (A7). Callers still apply WithBase for the absolute
+// URLs.
+func (a App) Public() App {
+ a.AddedBy = ""
+ return a
+}
+
+// WithBase returns a copy whose icon/download URLs are absolute for the given
+// request base (e.g. "https://instance.example"). A hosted icon wins over an
+// operator-supplied external icon_url; a link artifact still advertises this
+// instance's redirect endpoint as its download URL.
+func (a App) WithBase(base string) App {
+ out := a
+ base = strings.TrimRight(base, "/")
+ if a.IconPath != "" {
+ out.IconURL = base + "/api/catalog/icons/" + a.ID
+ }
+ if a.Artifact.Filename != "" {
+ out.Artifact.DownloadURL = base + "/api/catalog/artifacts/" + a.ID + "/" + url.PathEscape(a.Artifact.Filename)
+ }
+ return out
+}
+
+// RequestBaseURL derives the instance's absolute base URL from the request,
+// honoring a reverse proxy's X-Forwarded-Proto / X-Forwarded-Host (falling
+// back to the direct TLS state and Host header). One helper so every catalog
+// surface builds URLs identically.
+func RequestBaseURL(r *http.Request) string {
+ scheme := "http"
+ if r.TLS != nil {
+ scheme = "https"
+ }
+ if proto := r.Header.Get("X-Forwarded-Proto"); proto != "" {
+ first := strings.ToLower(strings.TrimSpace(strings.Split(proto, ",")[0]))
+ if first == "http" || first == "https" {
+ scheme = first
+ }
+ }
+ host := r.Header.Get("X-Forwarded-Host")
+ if host == "" {
+ host = r.Host
+ } else {
+ host = strings.TrimSpace(strings.Split(host, ",")[0])
+ }
+ if host == "" {
+ return scheme + "://localhost"
+ }
+ return scheme + "://" + host
+}
+
+// SafeFilename reduces an untrusted filename (URL path or multipart header) to
+// a single clean path segment, or "" when nothing usable remains. It blocks
+// traversal before any file is written.
+func SafeFilename(name string) string {
+ n := strings.TrimSpace(name)
+ n = strings.ReplaceAll(n, "\\", "/")
+ n = path.Base(n)
+ if n == "" || n == "." || n == ".." || n == "/" {
+ return ""
+ }
+ return n
+}
+
+// FilenameFromURL extracts a usable filename from an artifact URL path.
+func FilenameFromURL(raw string) string {
+ u, err := url.Parse(strings.TrimSpace(raw))
+ if err != nil {
+ return ""
+ }
+ return SafeFilename(u.Path)
+}
diff --git a/src/lib/catalog/store.go b/src/lib/catalog/store.go
new file mode 100644
index 0000000..dd8b6ff
--- /dev/null
+++ b/src/lib/catalog/store.go
@@ -0,0 +1,368 @@
+// Copyright (C) 2026 Dcntrlized
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published
+// by the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package catalog
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+)
+
+// Limits from the frozen contract: a hosted artifact fetch is capped at
+// 512 MiB, an uploaded icon at 512 KiB.
+const (
+ MaxArtifactBytes = int64(512) << 20
+ MaxIconBytes = int64(512) << 10
+)
+
+// ErrTooLarge is returned when a copied file exceeds the caller-supplied limit.
+var ErrTooLarge = errors.New("catalog: file exceeds size limit")
+
+const (
+ dirName = "catalog"
+ appsFile = "apps.json"
+ artifacts = "artifacts"
+ icons = "icons"
+)
+
+// Dir returns the catalog directory for a data root.
+func Dir(dataRoot string) string {
+ return filepath.Join(dataRoot, dirName)
+}
+
+// Path returns the catalog index file location for a data root.
+func Path(dataRoot string) string {
+ return filepath.Join(Dir(dataRoot), appsFile)
+}
+
+// Store is the file-backed catalog index plus the helpers that manage the
+// hosted artifact/icon bytes beside it. A mutex serializes read-modify-write
+// cycles so concurrent owner actions cannot lose rows.
+type Store struct {
+ mu sync.Mutex
+ dataRoot string
+}
+
+// NewStore opens (without creating) the catalog under dataRoot.
+func NewStore(dataRoot string) *Store {
+ return &Store{dataRoot: dataRoot}
+}
+
+// List reads every app in insertion order (A5: the operator's ordering is
+// stable). A missing index yields a non-nil empty slice: an empty catalog
+// serializes as [] and never null.
+func (s *Store) List() ([]App, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return s.load()
+}
+
+// List is the package-level helper over the data-root-backed store.
+func List(dataRoot string) ([]App, error) {
+ return NewStore(dataRoot).List()
+}
+
+// Get returns the app with the given id, or (nil, nil) when absent.
+func (s *Store) Get(id string) (*App, error) {
+ apps, err := s.List()
+ if err != nil {
+ return nil, err
+ }
+ for i := range apps {
+ if apps[i].ID == id {
+ app := apps[i]
+ return &app, nil
+ }
+ }
+ return nil, nil
+}
+
+// GetByPackage returns the app owning the given package name, or (nil, nil).
+func (s *Store) GetByPackage(pkg string) (*App, error) {
+ apps, err := s.List()
+ if err != nil {
+ return nil, err
+ }
+ for i := range apps {
+ if apps[i].Package == pkg {
+ app := apps[i]
+ return &app, nil
+ }
+ }
+ return nil, nil
+}
+
+// Upsert inserts a new app at the end (insertion order) or replaces the record
+// with the same id in place.
+func (s *Store) Upsert(app App) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ apps, err := s.load()
+ if err != nil {
+ return err
+ }
+ for i := range apps {
+ if apps[i].ID == app.ID {
+ apps[i] = app
+ return s.save(apps)
+ }
+ }
+ return s.save(append(apps, app))
+}
+
+// Remove drops the named record. Removing an unknown id is a no-op.
+func (s *Store) Remove(id string) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ apps, err := s.load()
+ if err != nil {
+ return err
+ }
+ kept := make([]App, 0, len(apps))
+ removed := false
+ for _, app := range apps {
+ if app.ID == id {
+ removed = true
+ continue
+ }
+ kept = append(kept, app)
+ }
+ if !removed {
+ return nil
+ }
+ return s.save(kept)
+}
+
+// SaveArtifact streams src to artifacts// (dir 0700, file 0600,
+// tmp+rename), returning the data-root-relative path, byte count and sha256.
+// The copy is capped at limit; exceeding it yields ErrTooLarge and leaves no
+// file behind.
+func (s *Store) SaveArtifact(id, filename string, src io.Reader, limit int64) (string, int64, string, error) {
+ name := SafeFilename(filename)
+ if id == "" || name == "" {
+ return "", 0, "", errors.New("catalog: invalid artifact id or filename")
+ }
+ dir := filepath.Join(Dir(s.dataRoot), artifacts, id)
+ if err := os.MkdirAll(dir, 0o700); err != nil {
+ return "", 0, "", fmt.Errorf("create artifact dir: %w", err)
+ }
+
+ final := filepath.Join(dir, name)
+ tmp := final + ".tmp"
+ f, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
+ if err != nil {
+ return "", 0, "", fmt.Errorf("create artifact file: %w", err)
+ }
+
+ hash := sha256.New()
+ copied, copyErr := io.Copy(io.MultiWriter(f, hash), io.LimitReader(src, limit+1))
+ closeErr := f.Close()
+ if copyErr != nil {
+ _ = os.Remove(tmp)
+ return "", 0, "", copyErr
+ }
+ if closeErr != nil {
+ _ = os.Remove(tmp)
+ return "", 0, "", closeErr
+ }
+ if copied > limit {
+ _ = os.Remove(tmp)
+ return "", 0, "", ErrTooLarge
+ }
+ if err := os.Rename(tmp, final); err != nil {
+ _ = os.Remove(tmp)
+ return "", 0, "", fmt.Errorf("store artifact: %w", err)
+ }
+ return filepath.Join(artifacts, id, name), copied, hex.EncodeToString(hash.Sum(nil)), nil
+}
+
+// SaveIcon writes icon bytes to icons/ (dir 0700, file 0600, tmp+rename)
+// and returns the data-root-relative path.
+func (s *Store) SaveIcon(id string, data []byte) (string, error) {
+ if id == "" || len(data) == 0 {
+ return "", errors.New("catalog: invalid icon id or empty data")
+ }
+ if int64(len(data)) > MaxIconBytes {
+ return "", ErrTooLarge
+ }
+ dir := filepath.Join(Dir(s.dataRoot), icons)
+ if err := os.MkdirAll(dir, 0o700); err != nil {
+ return "", fmt.Errorf("create icons dir: %w", err)
+ }
+ final := filepath.Join(dir, id)
+ tmp := final + ".tmp"
+ if err := os.WriteFile(tmp, data, 0o600); err != nil {
+ return "", fmt.Errorf("write icon: %w", err)
+ }
+ if err := os.Rename(tmp, final); err != nil {
+ _ = os.Remove(tmp)
+ return "", fmt.Errorf("store icon: %w", err)
+ }
+ return filepath.Join(icons, id), nil
+}
+
+// PruneArtifactDir removes every file in artifacts/ except keep, so an
+// artifact replacement does not leave the previous copy behind.
+func (s *Store) PruneArtifactDir(id, keep string) error {
+ dir := filepath.Join(Dir(s.dataRoot), artifacts, id)
+ entries, err := os.ReadDir(dir)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return nil
+ }
+ return err
+ }
+ for _, e := range entries {
+ if e.Name() == keep {
+ continue
+ }
+ if err := os.RemoveAll(filepath.Join(dir, e.Name())); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+// DeleteFiles removes an app's hosted artifact directory and uploaded icon.
+func (s *Store) DeleteFiles(app App) error {
+ if app.ID != "" {
+ if err := os.RemoveAll(filepath.Join(Dir(s.dataRoot), artifacts, app.ID)); err != nil {
+ return err
+ }
+ }
+ if app.IconPath != "" {
+ p, err := s.ResolveIcon(app)
+ if err == nil {
+ if err := os.Remove(p); err != nil && !os.IsNotExist(err) {
+ return err
+ }
+ }
+ }
+ return nil
+}
+
+// ResolveArtifact returns the absolute path of an app's hosted artifact.
+func (s *Store) ResolveArtifact(app App) (string, error) {
+ return s.resolve(app.ArtifactPath)
+}
+
+// ResolveIcon returns the absolute path of an app's uploaded icon.
+func (s *Store) ResolveIcon(app App) (string, error) {
+ return s.resolve(app.IconPath)
+}
+
+// resolve joins a stored relative path under the catalog dir, refusing any
+// value that would escape it.
+func (s *Store) resolve(rel string) (string, error) {
+ if rel == "" {
+ return "", os.ErrNotExist
+ }
+ base := Dir(s.dataRoot)
+ p := filepath.Clean(filepath.Join(base, rel))
+ if p != base && !strings.HasPrefix(p, base+string(filepath.Separator)) {
+ return "", fmt.Errorf("catalog: path escapes store dir: %q", rel)
+ }
+ return p, nil
+}
+
+// diskApp is the private on-disk projection of a record: it adds the fields
+// that must never serialize to clients (hosted artifact/icon paths and the
+// operator link target) under private keys.
+type diskApp struct {
+ App
+ ArtifactPath string `json:"artifact_path,omitempty"`
+ IconPath string `json:"icon_path,omitempty"`
+ ArtifactSourceURL string `json:"artifact_source_url,omitempty"`
+}
+
+func toDisk(app App) diskApp {
+ return diskApp{
+ App: app,
+ ArtifactPath: app.ArtifactPath,
+ IconPath: app.IconPath,
+ ArtifactSourceURL: app.Artifact.SourceURL,
+ }
+}
+
+func fromDisk(app diskApp) App {
+ out := app.App
+ out.ArtifactPath = app.ArtifactPath
+ out.IconPath = app.IconPath
+ out.Artifact.SourceURL = app.ArtifactSourceURL
+ return out
+}
+
+// load reads and parses the index. A missing file is an empty catalog.
+func (s *Store) load() ([]App, error) {
+ data, err := os.ReadFile(Path(s.dataRoot))
+ if err != nil {
+ if os.IsNotExist(err) {
+ return []App{}, nil
+ }
+ return nil, err
+ }
+ var wrapped struct {
+ Apps []diskApp `json:"apps"`
+ }
+ if err := json.Unmarshal(data, &wrapped); err != nil {
+ var direct []diskApp
+ if err2 := json.Unmarshal(data, &direct); err2 != nil {
+ return nil, fmt.Errorf("parse catalog index: %w", err)
+ }
+ wrapped.Apps = direct
+ }
+ apps := make([]App, 0, len(wrapped.Apps))
+ for _, app := range wrapped.Apps {
+ apps = append(apps, fromDisk(app))
+ }
+ return apps, nil
+}
+
+// save atomically replaces the index (dir 0700, tmp+rename, file 0600).
+func (s *Store) save(apps []App) error {
+ disk := make([]diskApp, 0, len(apps))
+ for _, app := range apps {
+ disk = append(disk, toDisk(app))
+ }
+ payload, err := json.MarshalIndent(struct {
+ Apps []diskApp `json:"apps"`
+ }{Apps: disk}, "", " ")
+ if err != nil {
+ return fmt.Errorf("marshal catalog index: %w", err)
+ }
+ payload = append(payload, '\n')
+
+ dir := Dir(s.dataRoot)
+ if err := os.MkdirAll(dir, 0o700); err != nil {
+ return fmt.Errorf("create catalog dir: %w", err)
+ }
+ path := Path(s.dataRoot)
+ tmp := path + ".tmp"
+ if err := os.WriteFile(tmp, payload, 0o600); err != nil {
+ return fmt.Errorf("write catalog index: %w", err)
+ }
+ if err := os.Rename(tmp, path); err != nil {
+ return fmt.Errorf("replace catalog index: %w", err)
+ }
+ return nil
+}
diff --git a/src/lib/catalog/store_test.go b/src/lib/catalog/store_test.go
new file mode 100644
index 0000000..2747b38
--- /dev/null
+++ b/src/lib/catalog/store_test.go
@@ -0,0 +1,251 @@
+// Copyright (C) 2026 Dcntrlized
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published
+// by the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package catalog
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func TestAppIDStable(t *testing.T) {
+ pkg := "org.dcntrlized.keepnote"
+ sum := sha256.Sum256([]byte(pkg))
+ want := "a_" + hex.EncodeToString(sum[:])[:12]
+ if got := AppID(pkg); got != want {
+ t.Fatalf("AppID = %q, want %q", got, want)
+ }
+ if id := AppID(pkg); id != want {
+ t.Fatalf("AppID not stable: %q vs %q", id, want)
+ }
+ if strings.ContainsAny(want, "ABCDEF") {
+ t.Fatalf("AppID must be lowercase hex: %q", want)
+ }
+}
+
+func TestStoreCRUDAndPermissions(t *testing.T) {
+ root := t.TempDir()
+ store := NewStore(root)
+
+ apps, err := store.List()
+ if err != nil {
+ t.Fatalf("List: %v", err)
+ }
+ if apps == nil || len(apps) != 0 {
+ t.Fatalf("empty catalog must be a non-nil empty slice, got %#v", apps)
+ }
+
+ first := App{ID: AppID("com.example.one"), Package: "com.example.one", Name: "One", VersionName: "1.0", Artifact: Artifact{Kind: KindLink, SHA256: strings.Repeat("a", 64)}}
+ second := App{ID: AppID("com.example.two"), Package: "com.example.two", Name: "Two", VersionName: "2.0"}
+ if err := store.Upsert(first); err != nil {
+ t.Fatalf("Upsert first: %v", err)
+ }
+ if err := store.Upsert(second); err != nil {
+ t.Fatalf("Upsert second: %v", err)
+ }
+
+ apps, err = store.List()
+ if err != nil {
+ t.Fatalf("List: %v", err)
+ }
+ if len(apps) != 2 || apps[0].Package != "com.example.one" || apps[1].Package != "com.example.two" {
+ t.Fatalf("insertion order not preserved: %#v", apps)
+ }
+
+ // Store file must be 0600 and dir 0700.
+ info, err := os.Stat(Path(root))
+ if err != nil {
+ t.Fatalf("stat index: %v", err)
+ }
+ if info.Mode().Perm() != 0o600 {
+ t.Fatalf("apps.json mode = %o, want 600", info.Mode().Perm())
+ }
+ dirInfo, err := os.Stat(Dir(root))
+ if err != nil {
+ t.Fatalf("stat dir: %v", err)
+ }
+ if dirInfo.Mode().Perm() != 0o700 {
+ t.Fatalf("catalog dir mode = %o, want 700", dirInfo.Mode().Perm())
+ }
+
+ got, err := store.Get(first.ID)
+ if err != nil || got == nil || got.Name != "One" {
+ t.Fatalf("Get = %#v, %v", got, err)
+ }
+ if missing, err := store.Get("a_doesnotexist"); err != nil || missing != nil {
+ t.Fatalf("Get unknown = %#v, %v; want nil, nil", missing, err)
+ }
+ byPkg, err := store.GetByPackage("com.example.two")
+ if err != nil || byPkg == nil || byPkg.Name != "Two" {
+ t.Fatalf("GetByPackage = %#v, %v", byPkg, err)
+ }
+
+ // Upsert same id replaces in place, keeping size/order.
+ second.Name = "Two renamed"
+ if err := store.Upsert(second); err != nil {
+ t.Fatalf("Upsert replace: %v", err)
+ }
+ apps, _ = store.List()
+ if len(apps) != 2 || apps[1].Name != "Two renamed" {
+ t.Fatalf("in-place replace failed: %#v", apps)
+ }
+
+ if err := store.Remove(first.ID); err != nil {
+ t.Fatalf("Remove: %v", err)
+ }
+ apps, _ = store.List()
+ if len(apps) != 1 || apps[0].ID != second.ID {
+ t.Fatalf("Remove left %#v", apps)
+ }
+ if err := store.Remove("a_missing"); err != nil {
+ t.Fatalf("Remove unknown must be a no-op, got %v", err)
+ }
+}
+
+func TestSaveArtifactAndDeleteFiles(t *testing.T) {
+ root := t.TempDir()
+ store := NewStore(root)
+ id := AppID("com.example.app")
+
+ rel, size, sum, err := store.SaveArtifact(id, "app-1.0.apk", strings.NewReader("APK-BYTES"), MaxArtifactBytes)
+ if err != nil {
+ t.Fatalf("SaveArtifact: %v", err)
+ }
+ if size != int64(len("APK-BYTES")) {
+ t.Fatalf("size = %d", size)
+ }
+ want := sha256.Sum256([]byte("APK-BYTES"))
+ if sum != hex.EncodeToString(want[:]) {
+ t.Fatalf("sha = %s", sum)
+ }
+ app := App{ID: id, ArtifactPath: rel, Artifact: Artifact{Filename: "app-1.0.apk"}}
+ abs, err := store.ResolveArtifact(app)
+ if err != nil {
+ t.Fatalf("ResolveArtifact: %v", err)
+ }
+ data, err := os.ReadFile(abs)
+ if err != nil {
+ t.Fatalf("read artifact: %v", err)
+ }
+ if string(data) != "APK-BYTES" {
+ t.Fatalf("artifact bytes = %q", data)
+ }
+ if info, _ := os.Stat(abs); info.Mode().Perm() != 0o600 {
+ t.Fatalf("artifact mode = %o, want 600", info.Mode().Perm())
+ }
+
+ iconRel, err := store.SaveIcon(id, []byte("\x89PNG\r\n\x1a\nicon"))
+ if err != nil {
+ t.Fatalf("SaveIcon: %v", err)
+ }
+ app.IconPath = iconRel
+ iconAbs, err := store.ResolveIcon(app)
+ if err != nil {
+ t.Fatalf("ResolveIcon: %v", err)
+ }
+ if _, err := os.Stat(iconAbs); err != nil {
+ t.Fatalf("icon not stored: %v", err)
+ }
+
+ if err := store.DeleteFiles(app); err != nil {
+ t.Fatalf("DeleteFiles: %v", err)
+ }
+ if _, err := os.Stat(abs); !os.IsNotExist(err) {
+ t.Fatalf("artifact survived DeleteFiles: %v", err)
+ }
+ if _, err := os.Stat(iconAbs); !os.IsNotExist(err) {
+ t.Fatalf("icon survived DeleteFiles: %v", err)
+ }
+}
+
+func TestSaveArtifactEnforcesLimit(t *testing.T) {
+ root := t.TempDir()
+ store := NewStore(root)
+ id := AppID("com.example.big")
+ _, _, _, err := store.SaveArtifact(id, "big.apk", strings.NewReader("0123456789"), 4)
+ if !errors.Is(err, ErrTooLarge) {
+ t.Fatalf("err = %v, want ErrTooLarge", err)
+ }
+ entries, _ := os.ReadDir(filepath.Join(Dir(root), "artifacts", id))
+ if len(entries) != 0 {
+ t.Fatalf("oversize artifact left files: %v", entries)
+ }
+}
+
+func TestSaveArtifactSanitizesTraversal(t *testing.T) {
+ root := t.TempDir()
+ store := NewStore(root)
+ id := AppID("com.example.x")
+
+ // A name with nothing usable left is rejected outright.
+ if _, _, _, err := store.SaveArtifact(id, "..", strings.NewReader("x"), MaxArtifactBytes); err == nil {
+ t.Fatal("unusable filename must be rejected")
+ }
+
+ // A traversal attempt is reduced to a single segment inside the store.
+ rel, _, _, err := store.SaveArtifact(id, "../escape.apk", strings.NewReader("x"), MaxArtifactBytes)
+ if err != nil {
+ t.Fatalf("SaveArtifact: %v", err)
+ }
+ if rel != filepath.Join("artifacts", id, "escape.apk") {
+ t.Fatalf("rel = %q", rel)
+ }
+ if _, err := os.Stat(filepath.Join(root, "escape.apk")); !os.IsNotExist(err) {
+ t.Fatalf("artifact escaped the catalog dir (err=%v)", err)
+ }
+
+ // Stored relative paths are contained as well.
+ if _, err := store.resolve("../../etc/passwd"); err == nil {
+ t.Fatal("resolve must refuse paths escaping the catalog dir")
+ }
+}
+
+func TestSafeFilename(t *testing.T) {
+ cases := map[string]string{
+ "app.apk": "app.apk",
+ "../evil.apk": "evil.apk",
+ "..\\evil.apk": "evil.apk",
+ "/etc/passwd": "passwd",
+ "nested/dir/app.apk": "app.apk",
+ "": "",
+ "..": "",
+ "keepnote-1.4.2.apk": "keepnote-1.4.2.apk",
+ }
+ for in, want := range cases {
+ if got := SafeFilename(in); got != want {
+ t.Errorf("SafeFilename(%q) = %q, want %q", in, got, want)
+ }
+ }
+}
+
+func TestRequestBaseURL(t *testing.T) {
+ r := httptest.NewRequest(http.MethodGet, "http://internal:8080/api/instance", nil)
+ r.Header.Set("X-Forwarded-Proto", "https")
+ r.Header.Set("X-Forwarded-Host", "apps.example")
+ if got := RequestBaseURL(r); got != "https://apps.example" {
+ t.Fatalf("base = %q", got)
+ }
+ direct := httptest.NewRequest(http.MethodGet, "http://direct.example/x", nil)
+ if got := RequestBaseURL(direct); got != "http://direct.example" {
+ t.Fatalf("direct base = %q", got)
+ }
+}
diff --git a/src/lib/registry/registry.go b/src/lib/registry/registry.go
index d040a91..9f5cd17 100644
--- a/src/lib/registry/registry.go
+++ b/src/lib/registry/registry.go
@@ -246,8 +246,8 @@ func ListItems(ctx context.Context) ([]types.RegistryContainer, error) {
if hidden, ok := manifest.Annotations["org.dcntrlized.hidden"]; ok && hidden == "true" {
item.Hidden = true
}
+ item.ServiceName = serviceNameFromLabels(manifest.Annotations, nil)
}
-
// 2. Fallback to Image Config Labels (where docker buildx puts them when targeting v2 registries)
if configFile, err := img.ConfigFile(); err == nil && configFile != nil {
labels := configFile.Config.Labels
@@ -285,6 +285,18 @@ func ListItems(ctx context.Context) ([]types.RegistryContainer, error) {
if !item.Hidden && labels["org.dcntrlized.hidden"] == "true" {
item.Hidden = true
}
+ if item.ServiceName == "" {
+ item.ServiceName = serviceNameFromLabels(nil, labels)
+ }
+ }
+
+ // A container package is a Maestro service only if its image declares the
+ // dcntrlized service label. Org-level packages that don't (e.g. the
+ // hardened runner job image dcntrlized/ci-tools) are infrastructure, not
+ // installable services, and must not appear in the console.
+ if item.ServiceName == "" {
+ logger.Debug("Skipping registry package %s:%s: no org.dcntrlized.service label", pkg.Name, pkg.Version)
+ continue
}
// Fallback for infra images published before the hidden label existed:
@@ -303,6 +315,11 @@ func ListItems(ctx context.Context) ([]types.RegistryContainer, error) {
if item.URL == "" {
item.URL = item.Source
}
+ // Source is displayed and linked by the console. It arrives verbatim
+ // from the image's OCI annotations, so the href is only populated when
+ // the value passes the shared http/https allowlist (audit N7); the raw
+ // Source is still served for plain-text display.
+ item.SourceHref = sourceHrefFor(item.Source)
registryItems = append(registryItems, item)
}
@@ -310,6 +327,17 @@ func ListItems(ctx context.Context) ([]types.RegistryContainer, error) {
return registryItems, nil
}
+// sourceHrefFor returns source when it is safe to use as a hyperlink, else "".
+// It reuses the Web App chip's allowlist (types.ValidWebBase: http/https
+// exactly, no userinfo, no control chars, non-empty host) rather than defining
+// a second one — an invalid source is rendered as plain text, never as an href.
+func sourceHrefFor(source string) string {
+ if source == "" || !types.ValidWebBase(source) {
+ return ""
+ }
+ return source
+}
+
// ItemByName returns the registry item for the named service, or an error when
// the registry does not carry a container package with that name.
func ItemByName(ctx context.Context, name string) (*types.RegistryContainer, error) {
@@ -325,6 +353,17 @@ func ItemByName(ctx context.Context, name string) (*types.RegistryContainer, err
return nil, fmt.Errorf("registry: service %q not found", name)
}
+// serviceNameFromLabels reads the service identity a container image declares
+// via org.dcntrlized.service. The OCI manifest annotations win; docker buildx
+// writes labels to the image config instead, depending on the target. An empty
+// result means the package is not a Maestro service.
+func serviceNameFromLabels(annotations, labels map[string]string) string {
+ if v := annotations["org.dcntrlized.service"]; v != "" {
+ return v
+ }
+ return labels["org.dcntrlized.service"]
+}
+
// normalizeSemver expands partial version tags to the full X.Y.Z form,
// preserving an optional pre-release suffix (v3 -> 3.0.0, v3.1 -> 3.1.0,
// v3.3.3-hotfix -> 3.3.3-hotfix).
diff --git a/src/lib/registry/registry_test.go b/src/lib/registry/registry_test.go
index 3b98cf1..fe949ce 100644
--- a/src/lib/registry/registry_test.go
+++ b/src/lib/registry/registry_test.go
@@ -17,12 +17,94 @@ package registry
import (
"context"
+ "encoding/json"
"os"
"strings"
"testing"
"time"
+
+ "git.dcntrlized.org/Dcntrlized/Maestro/lib/types"
)
+// TestSourceHrefAllowlist pins audit finding N7: the registry item's Source
+// comes verbatim from the image's OCI annotations, so the href the console
+// renders must be gated by the same http/https allowlist as the Web App chip.
+// An invalid source must never yield a usable href — it is served only as
+// plain display text.
+func TestSourceHrefAllowlist(t *testing.T) {
+ valid := []string{
+ "https://github.com/Dcntrlized/Maestro",
+ "https://git.dcntrlized.org/Dcntrlized/Calendar.git",
+ "http://192.168.122.183:3000/Dcntrlized/KeepNote",
+ "https://example.com/x?a=b#frag",
+ "http://[::1]:8080/repo",
+ }
+ for _, raw := range valid {
+ if got := sourceHrefFor(raw); got != raw {
+ t.Errorf("valid source %q: got href %q, want it verbatim", raw, got)
+ }
+ }
+
+ invalid := []string{
+ "javascript:alert(1)",
+ "JAVASCRIPT:alert(1)",
+ "data:text/html,",
+ "vbscript:msgbox(1)",
+ "file:///etc/passwd",
+ "https://user:pass@example.com/repo",
+ "https://user@example.com/repo",
+ "github.com/Dcntrlized/Maestro", // relative: no scheme
+ "//example.com/repo", // scheme-relative
+ "/repo", // path only
+ "", // absent
+ " ", // whitespace only
+ "https://example.com/re po", // space
+ "https://example.com/", // angle brackets
+ "https://example.com/repo\njavascript:1", // newline
+ "https://example.com/repo\x00tail", // NUL
+ "https://example.com/repo\x7f", // DEL
+ "https:", // no host
+ "ftp://example.com/repo", // wrong scheme
+ }
+ for _, raw := range invalid {
+ if got := sourceHrefFor(raw); got != "" {
+ t.Errorf("invalid source %q must not become an href, got %q", raw, got)
+ }
+ }
+}
+
+// TestSourceHrefWireKey locks the console contract: the validated href rides
+// as `source_href` next to the raw `source`, so the frontend can render plain
+// text for an invalid value instead of an anchor.
+func TestSourceHrefWireKey(t *testing.T) {
+ b, err := json.Marshal(types.RegistryContainer{
+ Name: "keepnote",
+ Source: "javascript:alert(1)",
+ SourceHref: "",
+ })
+ if err != nil {
+ t.Fatalf("marshal: %v", err)
+ }
+ if !strings.Contains(string(b), `"source":"javascript:alert(1)"`) {
+ t.Fatalf("raw source must stay on the wire for display: %s", b)
+ }
+ if strings.Contains(string(b), "source_href") {
+ t.Fatalf("an invalid source must not emit source_href: %s", b)
+ }
+
+ b, err = json.Marshal(types.RegistryContainer{
+ Name: "keepnote",
+ Source: "https://github.com/Dcntrlized/KeepNote",
+ SourceHref: "https://github.com/Dcntrlized/KeepNote",
+ })
+ if err != nil {
+ t.Fatalf("marshal: %v", err)
+ }
+ if !strings.Contains(string(b), `"source_href":"https://github.com/Dcntrlized/KeepNote"`) {
+ t.Fatalf("valid source must emit source_href: %s", b)
+ }
+}
+
// TestItemByNameLive resolves the infrastructure services against a live
// registry. It is opt-in (MAESTRO_REGISTRY_TEST=1) so the default test run
// stays offline; the dev VM and prod CI both set the Gitea env the function
@@ -51,6 +133,32 @@ func TestItemByNameLive(t *testing.T) {
}
}
+// TestServiceNameFromLabels locks the "is this package a Maestro service?" rule:
+// the org.dcntrlized.service label must be declared by the image (manifest
+// annotations or config labels). The runner job image dcntrlized/ci-tools
+// declares neither and must not surface in the console.
+func TestServiceNameFromLabels(t *testing.T) {
+ if got := serviceNameFromLabels(map[string]string{"org.dcntrlized.service": "KeepNote"}, nil); got != "KeepNote" {
+ t.Fatalf("annotation: got %q", got)
+ }
+ if got := serviceNameFromLabels(nil, map[string]string{"org.dcntrlized.service": "Calendar"}); got != "Calendar" {
+ t.Fatalf("label: got %q", got)
+ }
+ // Annotations win when both are present.
+ if got := serviceNameFromLabels(
+ map[string]string{"org.dcntrlized.service": "KeepNote"},
+ map[string]string{"org.dcntrlized.service": "Calendar"},
+ ); got != "KeepNote" {
+ t.Fatalf("annotation precedence: got %q", got)
+ }
+ if got := serviceNameFromLabels(nil, nil); got != "" {
+ t.Fatalf("no label must yield empty, got %q", got)
+ }
+ if got := serviceNameFromLabels(map[string]string{"org.opencontainers.image.title": "ci-tools"}, nil); got != "" {
+ t.Fatalf("unrelated labels must yield empty, got %q", got)
+ }
+}
+
// TestBaseURLNeverFallsBackToPublicInDev locks the dev registry seam: with
// DCNTRLIZED_DEV=true Maestro must talk to the debug registry
// (DCNTRLIZED_GITEA_URL) only — an unset dev URL yields "" (loud failure),
diff --git a/src/lib/types/registry.go b/src/lib/types/registry.go
index 2ff374f..473ca24 100644
--- a/src/lib/types/registry.go
+++ b/src/lib/types/registry.go
@@ -17,15 +17,25 @@ package types
// RegistryContainer represents a clean API payload specifically for container registry items.
type RegistryContainer struct {
- Name string `json:"name"`
- Version string `json:"version"`
- Image string `json:"image"`
- Digest string `json:"digest,omitempty"`
- Title string `json:"title,omitempty"`
- Vendor string `json:"vendor,omitempty"`
- Author string `json:"author,omitempty"`
- Description string `json:"description,omitempty"`
- Source string `json:"source,omitempty"`
+ Name string `json:"name"`
+ // ServiceName is the service's declared identity, read from the image's
+ // org.dcntrlized.service label. A container package without it is not a
+ // Maestro service and is not listed.
+ ServiceName string `json:"service_name,omitempty"`
+ Version string `json:"version"`
+ Image string `json:"image"`
+ Digest string `json:"digest,omitempty"`
+ Title string `json:"title,omitempty"`
+ Vendor string `json:"vendor,omitempty"`
+ Author string `json:"author,omitempty"`
+ Description string `json:"description,omitempty"`
+ Source string `json:"source,omitempty"`
+ // SourceHref is Source only when it passed the http/https allowlist
+ // (ValidWebBase), else "". Source is attacker-influenced (it comes verbatim
+ // from the image's OCI annotations), so it must never reach the DOM as an
+ // href on trust alone; the console renders the raw Source as plain text
+ // when this is empty (audit finding N7).
+ SourceHref string `json:"source_href,omitempty"`
Revision string `json:"revision,omitempty"`
Licenses string `json:"licenses,omitempty"`
URL string `json:"url,omitempty"`
@@ -38,6 +48,11 @@ type RegistryContainer struct {
// manifest (Service Config & Secrets theme M1), persisted so installed
// services can surface them even while stopped.
Config []ConfigItem `json:"config,omitempty"`
+ // Web is the Web App capability declared by the service manifest (contract
+ // rev 8.2 §16.4), persisted like Config. A registry listing carries
+ // {enabled,port,url} with an empty Href — there is no live container to
+ // resolve against.
+ Web *WebApp `json:"web,omitempty"`
}
type RawPackage struct {