- {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',
+ })
+})