From b7fa29849d5b749350e15aca424c7059d70e375c Mon Sep 17 00:00:00 2001
From: Admin <1+admin@noreply.git.dcntrlized.org>
Date: Mon, 19 Oct 2026 02:40:26 +0100
Subject: [PATCH] [fix](maestro): pair services by declared identity and show
web/source chips
Image-name comparison split on the first colon, so every image on a registry with a port looked like a different service (or the same one); split on the last colon before the tag and strip the registry host when present. Pair a catalog entry with a container by org.dcntrlized.service first, then by repository name, and never with a registry-only placeholder. The card gains the resolved Web app chip and renders a source link only when the server validated it, with the raw value inert otherwise.
---
src/frontend/package.json | 1 +
.../src/components/infra/database-chip.tsx | 1 +
.../src/components/service/service-card.tsx | 73 ++++++++++++++---
src/frontend/src/lib/domain/merge.ts | 61 ++++++++++++--
src/frontend/src/lib/domain/source-link.ts | 42 ++++++++++
src/frontend/src/lib/domain/version.ts | 43 ++++++++--
src/frontend/tests/source-link.test.ts | 81 +++++++++++++++++++
7 files changed, 278 insertions(+), 24 deletions(-)
create mode 100644 src/frontend/src/lib/domain/source-link.ts
create mode 100644 src/frontend/tests/source-link.test.ts
diff --git a/src/frontend/package.json b/src/frontend/package.json
index b628105..1c4da67 100644
--- a/src/frontend/package.json
+++ b/src/frontend/package.json
@@ -6,6 +6,7 @@
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
+ "test": "bun test tests",
"preview": "vite preview"
},
"dependencies": {
diff --git a/src/frontend/src/components/infra/database-chip.tsx b/src/frontend/src/components/infra/database-chip.tsx
index 103a3cb..2b135e2 100644
--- a/src/frontend/src/components/infra/database-chip.tsx
+++ b/src/frontend/src/components/infra/database-chip.tsx
@@ -39,6 +39,7 @@ function dbService(item: RegistryItem): Service {
image: item.image,
registryImage: item.image,
source: item.source,
+ sourceHref: item.source_href,
registryUrl: item.url,
latestVersion: item.version,
hasUpdate: false,
diff --git a/src/frontend/src/components/service/service-card.tsx b/src/frontend/src/components/service/service-card.tsx
index d46c3da..3a212fb 100644
--- a/src/frontend/src/components/service/service-card.tsx
+++ b/src/frontend/src/components/service/service-card.tsx
@@ -15,7 +15,7 @@
* along with this program. If not, see .
*/
-import { ExternalLink, Power, Trash2, ArrowUp, LoaderCircle, Copy, Download, SlidersHorizontal } from 'lucide-react'
+import { ExternalLink, Power, Trash2, ArrowUp, LoaderCircle, Copy, Download, SlidersHorizontal, Globe } from 'lucide-react'
import { Button } from '@components/ui/button'
import { Card, CardContent, CardFooter } from '@components/ui/card'
import { statusMeta } from '@components/container/status'
@@ -25,6 +25,7 @@ import type { Service } from '@lib/types'
import { cn } from '@lib/utils'
import { useAsyncAction } from '@lib/hooks/useAsyncAction'
import { DatabaseChip } from '@components/infra/database-chip'
+import { sourceLink } from '@lib/domain/source-link'
import type { DatabaseState } from './service-grid'
interface ServiceCardProps {
@@ -47,9 +48,36 @@ function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : 'Unknown error'
}
+/**
+ * The "Web app" chip (contract rev 8.2 §16.5). `href` is resolved SERVER-SIDE
+ * and is the only thing this component may open — the console must never build
+ * a URL from service strings. The caller renders it only when `href` is
+ * non-empty, so a service without a usable web app shows no chip at all.
+ */
+function WebAppChip({ href, name }: { href: string; name: string }) {
+ return (
+
+
+ Web app
+
+
+ )
+}
+
export function ServiceCard({ service, db, onOpenConfig }: ServiceCardProps) {
const meta = statusMeta(service.status)
const installed = service.origin !== 'remote'
+ // Externally managed containers (e.g. a repo's own debug compose) are not
+ // Maestro's to run: the console may stop them but must not start, uninstall,
+ // update or reconfigure them.
+ const managed = installed && !service.external
const running =
service.status.toLowerCase() === 'running' ||
service.status.toLowerCase() === 'starting' ||
@@ -60,6 +88,9 @@ export function ServiceCard({ service, db, onOpenConfig }: ServiceCardProps) {
const shortContainerId = service.id.slice(0, 12)
const shortImage = service.image ? service.image.split('/').slice(-2).join('/') : service.name
+ // Registry `source` is only linked through its server-validated href; an
+ // unvalidated value renders as inert text (audit N7).
+ const source = sourceLink(service)
const copyId = useAsyncAction(async () => {
try {
@@ -193,6 +224,15 @@ export function ServiceCard({ service, db, onOpenConfig }: ServiceCardProps) {
{latest}
)}
+ {service.external && (
+
+ external
+
+ )}
{running && installed && (
)}
- {service.source && (
+ {source?.kind === 'link' ? (
Source
- )}
+ ) : source?.kind === 'text' ? (
+
+ {source.text}
+
+ ) : null}
- {service.capabilities.includes('database') && }
+
- {installed && (
+ {managed && (
- ) : (
+ ) : installed ? null : (
{install.pending ? : }
Install
diff --git a/src/frontend/src/lib/domain/merge.ts b/src/frontend/src/lib/domain/merge.ts
index 73a7476..d424cca 100644
--- a/src/frontend/src/lib/domain/merge.ts
+++ b/src/frontend/src/lib/domain/merge.ts
@@ -16,7 +16,7 @@
*/
import type { Container, RegistryItem, Service } from '../types'
-import { baseImageOf, compareVersions, normalizeVersion, versionFromImage } from './version'
+import { compareVersions, normalizeVersion, repoNameOf, versionFromImage } from './version'
/** Statuses that should win the merge when a name maps to several containers. */
const STATUS_PRIORITY: Record = {
@@ -46,6 +46,25 @@ function isHiddenRegistry(r: RegistryItem): boolean {
return Boolean(r.hidden)
}
+/** Statuses that count as up (mirrors the card's running predicate). */
+function isRunning(status?: string): boolean {
+ const s = (status ?? '').toLowerCase()
+ return s === 'running' || s === 'starting' || s === 'restarting'
+}
+
+/**
+ * Containers Maestro did not install are transient dev processes, not services:
+ * a stopped one has no actions left (no start, no uninstall, no config), so it
+ * does not deserve a card. The grid shows only services Maestro knows are
+ * installed (an install record in the data dir) or that are still up, plus the
+ * remote catalog. Dropping these *before* the registry merge matters: the
+ * catalog entry then stays unmatched and comes back as an installable remote
+ * card instead of being absorbed by a dead container.
+ */
+function isStoppedExternal(c: Container): boolean {
+ return Boolean(c.external) && !isRunning(c.status)
+}
+
function toService(c: Container): Service {
return {
id: c.id || c.name,
@@ -53,27 +72,45 @@ function toService(c: Container): Service {
status: c.status,
origin: 'local',
title: c.service_name || c.name,
+ serviceName: c.service_name,
+ external: c.external,
image: c.image,
version: c.version || versionFromImage(c.image),
hasUpdate: false,
capabilities: c.capabilities ?? [],
config: c.config,
+ web: c.web,
}
}
function findMatch(
+ local: Service[],
map: Map,
name: string,
- registryImage: string
+ registryImage: string,
+ registryService?: string
): Service | undefined {
const byName = map.get(name)
if (byName) return byName
- const base = baseImageOf(registryImage)
- if (!base) return undefined
+ // The declared identity (org.dcntrlized.service) is the contract: pair the
+ // catalog entry with the container that declares the same service.
+ if (registryService) {
+ const target = registryService.toLowerCase()
+ for (const item of local) {
+ if ((item.serviceName ?? '').toLowerCase() === target) return item
+ }
+ }
- for (const item of map.values()) {
- if (baseImageOf(item.image) === base) return item
+ const repo = repoNameOf(registryImage)
+ if (!repo) return undefined
+
+ // Only container-backed services are candidates. Registry-only placeholders
+ // are deliberately excluded: matching against them let one catalog entry
+ // absorb another (two images on the same registry compared equal), so one
+ // service's metadata ended up rendered under another service's image.
+ for (const item of local) {
+ if (repoNameOf(item.image) === repo) return item
}
return undefined
}
@@ -87,7 +124,7 @@ export function mergeServices(containers: Container[], registry: RegistryItem[])
const map = new Map()
for (const c of containers) {
- if (isMaestro(c.name) || isHiddenContainer(c)) continue
+ if (isMaestro(c.name) || isHiddenContainer(c) || isStoppedExternal(c)) continue
const key = c.name.toLowerCase()
const next = toService(c)
const existing = map.get(key)
@@ -96,17 +133,23 @@ export function mergeServices(containers: Container[], registry: RegistryItem[])
}
}
+ // Snapshot the container-backed services before any registry item is
+ // inserted, so registry items can only ever pair up with real containers.
+ const local = Array.from(map.values())
+
for (const r of registry) {
if (isMaestro(r.name) || isHiddenRegistry(r)) continue
const key = r.name.toLowerCase()
- const existing = findMatch(map, key, r.image)
+ const existing = findMatch(local, map, key, r.image, r.service_name)
if (existing) {
existing.origin = 'both'
existing.registryImage = r.image
existing.registryUrl = r.url || r.source
existing.source = r.source || existing.source
+ existing.sourceHref = r.source_href || existing.sourceHref
existing.title = r.title || existing.title
+ existing.serviceName = r.service_name || existing.serviceName
existing.vendor = r.vendor || existing.vendor
existing.author = r.author || existing.author
existing.description = r.description || existing.description
@@ -124,9 +167,11 @@ export function mergeServices(containers: Container[], registry: RegistryItem[])
status: 'uninstalled',
origin: 'remote',
title: r.title || r.name,
+ serviceName: r.service_name,
image: r.image,
registryImage: r.image,
source: r.source,
+ sourceHref: r.source_href,
registryUrl: r.url,
description: r.description,
vendor: r.vendor,
diff --git a/src/frontend/src/lib/domain/source-link.ts b/src/frontend/src/lib/domain/source-link.ts
new file mode 100644
index 0000000..35fc3fb
--- /dev/null
+++ b/src/frontend/src/lib/domain/source-link.ts
@@ -0,0 +1,42 @@
+/**
+ * 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 .
+ */
+
+/**
+ * How a service card should render an image's `source`.
+ *
+ * `source` comes verbatim from the image's OCI annotations, so it is
+ * attacker-influenced for anyone who can push to the operator's registry.
+ * `sourceHref` is the server's allowlisted projection of it (the Go side runs
+ * `types.ValidWebBase` — http/https exactly, no userinfo, no control chars,
+ * non-empty host). The frontend therefore never builds an href from `source`:
+ * a value without a validated href is shown as inert text (audit finding N7).
+ */
+export type SourceLink =
+ | { kind: 'link'; href: string; text: string }
+ | { kind: 'text'; text: string }
+ | null
+
+export function sourceLink(item: {
+ source?: string
+ sourceHref?: string
+}): SourceLink {
+ const href = item.sourceHref?.trim() ?? ''
+ const text = item.source?.trim() ?? ''
+ if (href) return { kind: 'link', href, text: text || href }
+ if (text) return { kind: 'text', text }
+ return null
+}
diff --git a/src/frontend/src/lib/domain/version.ts b/src/frontend/src/lib/domain/version.ts
index d99304a..91bb5e6 100644
--- a/src/frontend/src/lib/domain/version.ts
+++ b/src/frontend/src/lib/domain/version.ts
@@ -23,16 +23,49 @@
* Everything here normalizes to a comparable X.Y.Z[-pre] string.
*/
-/** Base image reference without registry host, tag, or digest. */
+/**
+ * Repository path of an image reference: registry host, tag and digest removed
+ * (e.g. `192.168.122.183:3000/dcntrlized/keepnote:1` → `dcntrlized/keepnote`).
+ *
+ * The tag separator is the LAST colon — a colon before the last slash is part
+ * of a registry host with a port, not a tag. Splitting naively on the first
+ * colon would reduce every image on such a registry to the bare host and make
+ * unrelated services compare equal.
+ */
export function baseImageOf(imageRef?: string): string {
if (!imageRef) return ''
- return imageRef.split('@')[0].split(':')[0].toLowerCase()
+ const noDigest = imageRef.split('@')[0]
+ const slash = noDigest.lastIndexOf('/')
+ const colon = noDigest.lastIndexOf(':')
+ const repo = colon > slash ? noDigest.slice(0, colon) : noDigest
+
+ const parts = repo.split('/')
+ const host = parts[0]
+ if (parts.length > 1 && (host.includes('.') || host.includes(':') || host === 'localhost')) {
+ parts.shift()
+ }
+ return parts.join('/').toLowerCase()
}
-/** The tag of an image reference, "" when it is `latest` or absent. */
+/**
+ * Repository name (final path segment) of an image reference
+ * (e.g. `…/dcntrlized/keepnote:1` → `keepnote`). Used to pair a locally built
+ * image (`calendar:debug`) with its catalog entry regardless of registry path.
+ */
+export function repoNameOf(imageRef?: string): string {
+ const base = baseImageOf(imageRef)
+ if (!base) return ''
+ const slash = base.lastIndexOf('/')
+ return slash >= 0 ? base.slice(slash + 1) : base
+}
+
+/** The tag of an image reference, "" when untagged, digest-pinned, or `latest`. */
export function versionFromImage(imageRef?: string): string {
- if (!imageRef) return ''
- const tag = imageRef.split('@')[0].split(':').pop() ?? ''
+ if (!imageRef || imageRef.includes('@')) return ''
+ const slash = imageRef.lastIndexOf('/')
+ const colon = imageRef.lastIndexOf(':')
+ if (colon <= slash) return ''
+ const tag = imageRef.slice(colon + 1)
return tag === 'latest' ? '' : tag
}
diff --git a/src/frontend/tests/source-link.test.ts b/src/frontend/tests/source-link.test.ts
new file mode 100644
index 0000000..dbeff95
--- /dev/null
+++ b/src/frontend/tests/source-link.test.ts
@@ -0,0 +1,81 @@
+/**
+ * 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 { expect, test } from 'bun:test'
+import { sourceLink } from '../src/lib/domain/source-link'
+
+// Audit finding N7: `source` arrives verbatim from the image's OCI
+// annotations, so the console must never turn it into an href on trust alone.
+// The href can only come from the server's allowlisted `source_href`.
+
+test('a validated http(s) source renders as a link with the server href', () => {
+ const link = sourceLink({
+ source: 'https://github.com/Dcntrlized/Maestro',
+ sourceHref: 'https://github.com/Dcntrlized/Maestro',
+ })
+ expect(link).toEqual({
+ kind: 'link',
+ href: 'https://github.com/Dcntrlized/Maestro',
+ text: 'https://github.com/Dcntrlized/Maestro',
+ })
+})
+
+test('an invalid source never becomes an href — it renders as plain text', () => {
+ const hostile = [
+ 'javascript:alert(1)',
+ 'JAVASCRIPT:alert(1)',
+ 'data:text/html,',
+ 'vbscript:msgbox(1)',
+ 'file:///etc/passwd',
+ 'https://user:pass@example.com/repo',
+ 'github.com/Dcntrlized/Maestro',
+ '//example.com/repo',
+ '/repo',
+ 'https://example.com/re po',
+ 'https://example.com/repo\njavascript:alert(1)',
+ 'ftp://example.com/repo',
+ ]
+ for (const source of hostile) {
+ const link = sourceLink({ source })
+ expect(link).toEqual({ kind: 'text', text: source })
+ expect(link && 'href' in link).toBe(false)
+ }
+})
+
+test('an empty href is ignored even when the server sends one as whitespace', () => {
+ expect(sourceLink({ source: 'javascript:alert(1)', sourceHref: ' ' })).toEqual({
+ kind: 'text',
+ text: 'javascript:alert(1)',
+ })
+ expect(sourceLink({ source: 'javascript:alert(1)', sourceHref: '' })).toEqual({
+ kind: 'text',
+ text: 'javascript:alert(1)',
+ })
+})
+
+test('no source renders nothing', () => {
+ expect(sourceLink({})).toBeNull()
+ expect(sourceLink({ source: ' ' })).toBeNull()
+})
+
+test('a link without a display source still gets a title', () => {
+ expect(sourceLink({ sourceHref: 'https://example.com/repo' })).toEqual({
+ kind: 'link',
+ href: 'https://example.com/repo',
+ text: 'https://example.com/repo',
+ })
+})