From 98341c76659ff41a9f0da491b8486744a05bb6c9 Mon Sep 17 00:00:00 2001
From: Admin <1+admin@noreply.git.dcntrlized.org>
Date: Sun, 23 Aug 2026 01:02:29 +0100
Subject: [PATCH] feat(instance): public profile endpoint GET /api/instance
(hub-app M1)
---
spec.yaml | 104 +++++++++++++++++-
src/frontend/src/lib/types.ts | 36 +++++++
src/handlers/instance/public.go | 129 ++++++++++++++++++++++
src/handlers/instance/public_test.go | 156 +++++++++++++++++++++++++++
src/lib/env/env.go | 7 ++
src/lib/federation/store.go | 69 ++++++++++++
src/lib/instance/instance.go | 94 ++++++++++++++++
src/lib/pki/helpers.go | 20 ++++
src/main.go | 7 ++
9 files changed, 621 insertions(+), 1 deletion(-)
create mode 100644 src/handlers/instance/public.go
create mode 100644 src/handlers/instance/public_test.go
create mode 100644 src/lib/federation/store.go
create mode 100644 src/lib/instance/instance.go
diff --git a/spec.yaml b/spec.yaml
index e866aa9..c159a3f 100644
--- a/spec.yaml
+++ b/spec.yaml
@@ -2,7 +2,7 @@ openapi: 3.0.3
info:
title: Dcntrlized Maestro API
version: 1.0.0
- description: The API of the Maestro orchestration router. Maestro manages the lifecycle of Dcntrlized service containers and routes inter-service action calls to them. Every /api/* route except /api/auth/* requires an instance-owner session (cookies issued by the auth endpoints); in setup mode gated routes answer 428 ERR_SETUP_REQUIRED, otherwise 401 ERR_UNAUTHENTICATED. Mutating requests from browser origins are additionally checked against the request host (CSRF).
+ description: The API of the Maestro orchestration router. Maestro manages the lifecycle of Dcntrlized service containers and routes inter-service action calls to them. Every /api/* route except /api/auth/* and the deliberately public GET /api/instance (hub-app theme) requires an instance-owner session (cookies issued by the auth endpoints); in setup mode gated routes answer 428 ERR_SETUP_REQUIRED, otherwise 401 ERR_UNAUTHENTICATED. Mutating requests from browser origins are additionally checked against the request host (CSRF).
components:
schemas:
@@ -144,6 +144,90 @@ components:
issued_at:
type: string
+ InstanceIdentity:
+ type: object
+ description: The instance's public self-description (hub-app M1).
+ required: [name]
+ properties:
+ name:
+ type: string
+ example: "home-node"
+ title:
+ type: string
+ description:
+ type: string
+ fingerprint:
+ type: string
+ description: Instance CA public-key SHA256 (lib/pki); omitted when no CA is provisioned.
+ example: "sha256:…"
+
+ PublicService:
+ type: object
+ description: |
+ Deliberately reduced view of a running service — no container id,
+ docker name, private IP, or port mapping leaves the instance (hub-app D3).
+ required: [service_name, status, image]
+ properties:
+ service_name:
+ type: string
+ title:
+ type: string
+ description:
+ type: string
+ status:
+ type: string
+ version:
+ type: string
+ capabilities:
+ type: array
+ items:
+ type: string
+ has_auth:
+ type: boolean
+ image:
+ type: string
+
+ TrustedInstance:
+ type: object
+ description: A remote instance this instance trusts (federation store row).
+ required: [name, url, fingerprint, status]
+ properties:
+ name:
+ type: string
+ url:
+ type: string
+ fingerprint:
+ type: string
+ example: "sha256:…"
+ status:
+ type: string
+ example: "authorized"
+ scopes:
+ type: array
+ items:
+ type: string
+
+ InstancePublicProfile:
+ type: object
+ description: |
+ The unauthenticated GET /api/instance response (hub-app theme). With
+ DCNTRLIZED_PUBLIC_PROFILE=false only `instance` is returned. Absent
+ federation store → trusted_instances:null; present-but-empty → [].
+ required: [instance]
+ properties:
+ instance:
+ $ref: '#/components/schemas/InstanceIdentity'
+ services:
+ type: array
+ nullable: true
+ items:
+ $ref: '#/components/schemas/PublicService'
+ trusted_instances:
+ type: array
+ nullable: true
+ items:
+ $ref: '#/components/schemas/TrustedInstance'
+
RegistryContainer:
type: object
required: [name, image]
@@ -506,6 +590,24 @@ paths:
'502':
description: Failed to connect to upstream registry
+ /api/instance:
+ get:
+ summary: Public instance profile (unauthenticated)
+ tags: ["instance"]
+ security: []
+ description: >-
+ The one read surface the owner gate deliberately allows through
+ (hub-app theme M1/D2): what this instance is, which non-hidden services
+ it runs (public fields only), and which remote instances it trusts.
+ DCNTRLIZED_PUBLIC_PROFILE=false omits services + trusted_instances.
+ responses:
+ '200':
+ description: The instance public profile
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/InstancePublicProfile'
+
/api/auth/status:
get:
summary: Instance-owner gate state (public)
diff --git a/src/frontend/src/lib/types.ts b/src/frontend/src/lib/types.ts
index 43ab31f..94ab4db 100644
--- a/src/frontend/src/lib/types.ts
+++ b/src/frontend/src/lib/types.ts
@@ -148,3 +148,39 @@ export interface SessionResponse {
role: string
access_ttl_s: number
}
+
+/** The instance's public self-description (hub-app M1). */
+export interface InstanceIdentity {
+ name: string
+ title?: string
+ description?: string
+ fingerprint?: string
+}
+
+/** Deliberately reduced service view — no ids, IPs, or ports (hub-app D3). */
+export interface PublicService {
+ service_name: string
+ title?: string
+ description?: string
+ status: string
+ version?: string
+ capabilities?: string[]
+ has_auth?: boolean
+ image: string
+}
+
+/** A remote instance this instance trusts (federation store row). */
+export interface TrustedInstance {
+ name: string
+ url: string
+ fingerprint: string
+ status: string
+ scopes?: string[]
+}
+
+/** GET /api/instance — the one unauthenticated read surface. */
+export interface InstancePublicProfile {
+ instance: InstanceIdentity
+ services?: PublicService[] | null
+ trusted_instances?: TrustedInstance[] | null
+}
diff --git a/src/handlers/instance/public.go b/src/handlers/instance/public.go
new file mode 100644
index 0000000..c16ff56
--- /dev/null
+++ b/src/handlers/instance/public.go
@@ -0,0 +1,129 @@
+// 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 instance serves the instance's public profile: the single
+// unauthenticated read surface the owner gate allows through (hub-app theme
+// M1/D2). It answers "what is this instance, what does it run, and who does
+// it trust?" for the untrusted-first hub client.
+package instance
+
+import (
+ "encoding/json"
+ "net/http"
+
+ "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.
+type InstanceIdentity struct {
+ Name string `json:"name"`
+ Title string `json:"title,omitempty"`
+ Description string `json:"description,omitempty"`
+ Fingerprint string `json:"fingerprint,omitempty"`
+}
+
+// PublicService is the deliberately reduced view of a running service:
+// no container id, docker name, private IP, or port mapping ever leaves the
+// instance (hub-app D3).
+type PublicService struct {
+ ServiceName string `json:"service_name"`
+ Title string `json:"title,omitempty"`
+ Description string `json:"description,omitempty"`
+ Status string `json:"status"`
+ Version string `json:"version,omitempty"`
+ Capabilities []string `json:"capabilities,omitempty"`
+ HasAuth bool `json:"has_auth,omitempty"`
+ Image string `json:"image"`
+}
+
+// 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"`
+}
+
+// NewPublicProfileHandler serves GET /api/instance. getServices is the cache
+// accessor (cache.GetServices self-locks); dataRoot resolves identity, CA and
+// federation stores on demand.
+func NewPublicProfileHandler(getServices func() []types.Container, dataRoot string) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ profile := PublicProfile{
+ Instance: buildIdentity(dataRoot),
+ }
+
+ 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
+ }
+ profile.Services = &services
+ profile.TrustedInstances = &trusted
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ if err := json.NewEncoder(w).Encode(profile); err != nil {
+ http.Error(w, "Error encoding instance profile", http.StatusInternalServerError)
+ return
+ }
+ }
+}
+
+func buildIdentity(dataRoot string) InstanceIdentity {
+ id := instance.Load(dataRoot)
+ return InstanceIdentity{
+ Name: id.Name,
+ Title: id.Title,
+ Description: id.Description,
+ Fingerprint: pki.CAFingerprint(dataRoot),
+ }
+}
+
+// publicServices filters hidden infrastructure (role-labeled containers such
+// as dcntrlized-db / dcntrlized-auth and anything flagged hidden) down to the
+// public field set.
+func publicServices(all []types.Container) []PublicService {
+ public := make([]PublicService, 0, len(all))
+ for _, c := range all {
+ if c.Hidden || c.Role != "" {
+ continue
+ }
+ name := c.ServiceName
+ if name == "" {
+ name = c.Name
+ }
+ public = append(public, PublicService{
+ ServiceName: name,
+ Title: c.Title,
+ Description: c.Description,
+ Status: c.Status,
+ Version: c.Version,
+ Capabilities: c.Capabilities,
+ HasAuth: c.HasAuth,
+ Image: c.Image,
+ })
+ }
+ return public
+}
diff --git a/src/handlers/instance/public_test.go b/src/handlers/instance/public_test.go
new file mode 100644
index 0000000..3378ae8
--- /dev/null
+++ b/src/handlers/instance/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 instance
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "git.dcntrlized.org/Dcntrlized/Maestro/lib/federation"
+ "git.dcntrlized.org/Dcntrlized/Maestro/lib/types"
+)
+
+func testServices() []types.Container {
+ return []types.Container{
+ {
+ ID: "c1", Name: "keepnote-1", ServiceName: "keepnote", DockerName: "keepnote-1",
+ Image: "registry.example/dcntrlized/keepnote:1.0", Version: "1.0",
+ Status: "running", Title: "KeepNote", Description: "notes app",
+ Capabilities: []string{"database"}, HasAuth: true,
+ Network: types.Network{Ports: []types.PortMapping{{Host: 8081, Guest: 8080}}},
+ },
+ {ID: "c2", Name: "dcntrlized-db", Role: "db", Hidden: true, Status: "running"},
+ {ID: "c3", Name: "dcntrlized-auth", Role: "auth", Hidden: true, Status: "running"},
+ {ID: "c4", Name: "secret", Hidden: true, Status: "running"},
+ }
+}
+
+func serve(t *testing.T, dataRoot string, services []types.Container) map[string]any {
+ t.Helper()
+ srv := httptest.NewServer(NewPublicProfileHandler(func() []types.Container { return services }, dataRoot))
+ t.Cleanup(srv.Close)
+ resp, err := http.Get(srv.URL)
+ if err != nil {
+ t.Fatalf("GET failed: %v", err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("status = %d, want 200", resp.StatusCode)
+ }
+ var body map[string]any
+ if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+ return body
+}
+
+func TestPublicProfileFiltersHiddenInfra(t *testing.T) {
+ dataRoot := t.TempDir()
+ body := serve(t, dataRoot, testServices())
+
+ raw, _ := json.Marshal(body["services"])
+ var services []map[string]any
+ if err := json.Unmarshal(raw, &services); err != nil {
+ t.Fatalf("services not an array: %v", err)
+ }
+ if len(services) != 1 {
+ t.Fatalf("got %d services, want exactly the one public service (%s)", len(services), raw)
+ }
+ svc := services[0]
+ if svc["service_name"] != "keepnote" || svc["status"] != "running" || svc["image"] == "" {
+ t.Fatalf("unexpected public service shape: %v", svc)
+ }
+
+ // No private fields may serialize.
+ for _, forbidden := range []string{"id", "docker_name", "network", "ipAddress", "ports", "role", "hidden", "public_key"} {
+ if _, ok := svc[forbidden]; ok {
+ t.Errorf("public service leaked %q", forbidden)
+ }
+ }
+}
+
+func TestPublicProfileIdentityFallbackAndFile(t *testing.T) {
+ dataRoot := t.TempDir()
+ body := serve(t, dataRoot, nil)
+ instance := body["instance"].(map[string]any)
+ if instance["name"] == "" {
+ t.Fatal("identity name must fall back to hostname/env, got empty")
+ }
+
+ if err := os.WriteFile(filepath.Join(dataRoot, "instance.json"),
+ []byte(`{"name":"home-node","title":"Home node","description":"main rig"}`), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ body = serve(t, dataRoot, nil)
+ instance = body["instance"].(map[string]any)
+ if instance["name"] != "home-node" || instance["title"] != "Home node" {
+ t.Fatalf("file identity not used: %v", instance)
+ }
+}
+
+func TestPublicProfileTrustedInstancesNullVsEmpty(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)
+ }
+
+ // Present but empty → [].
+ 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 {
+ t.Fatal(err)
+ }
+ body = serve(t, dataRoot, testServices())
+ raw, _ := json.Marshal(body["trusted_instances"])
+ if string(raw) != "[]" {
+ t.Fatalf("empty store should marshal as [], got %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)
+ }
+ 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)
+ }
+}
+
+func TestPublicProfilePrivacySwitchOmitsSections(t *testing.T) {
+ t.Setenv("DCNTRLIZED_PUBLIC_PROFILE", "false")
+ dataRoot := t.TempDir()
+ body := serve(t, dataRoot, testServices())
+
+ for _, section := range []string{"services", "trusted_instances"} {
+ if _, ok := body[section]; ok {
+ t.Errorf("%q must be omitted when DCNTRLIZED_PUBLIC_PROFILE=false", section)
+ }
+ }
+ if _, ok := body["instance"]; !ok {
+ t.Error("instance identity must remain visible with the profile off")
+ }
+}
diff --git a/src/lib/env/env.go b/src/lib/env/env.go
index 3b69242..62787ed 100644
--- a/src/lib/env/env.go
+++ b/src/lib/env/env.go
@@ -32,3 +32,10 @@ func IsDev() bool {
func GiteaURL() string {
return os.Getenv("DCNTRLIZED_GITEA_URL")
}
+
+// PublicProfile reports whether the unauthenticated instance profile
+// (GET /api/instance, hub-app theme M1) is served. Default on; set
+// DCNTRLIZED_PUBLIC_PROFILE=false to blank services + trusted_instances.
+func PublicProfile() bool {
+ return os.Getenv("DCNTRLIZED_PUBLIC_PROFILE") != "false"
+}
diff --git a/src/lib/federation/store.go b/src/lib/federation/store.go
new file mode 100644
index 0000000..02ec34e
--- /dev/null
+++ b/src/lib/federation/store.go
@@ -0,0 +1,69 @@
+// 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 federation is a defensive reader for the remote-instance trust
+// store that the multi-registry & instance federation theme will own. The
+// hub app's public profile (hub-app theme M1) surfaces authorized peers from
+// it when present; until that theme lands, the file simply does not exist and
+// every read returns nil.
+package federation
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+)
+
+// RemoteInstance mirrors the planned trust-store row: a peer this instance
+// trusts, addressed by URL and identified by its CA fingerprint.
+type RemoteInstance struct {
+ Name string `json:"name"`
+ URL string `json:"url"`
+ Fingerprint string `json:"fingerprint"`
+ Status string `json:"status"`
+ Scopes []string `json:"scopes,omitempty"`
+}
+
+// Path returns the trust-store location for the given data root.
+func Path(dataRoot string) string {
+ return filepath.Join(dataRoot, "federation", "instances.json")
+}
+
+// List reads the authorized remote instances. A missing store yields
+// (nil, nil): callers distinguish "absent" (nil → JSON null) from
+// "present but empty" (non-nil, len 0 → JSON []).
+func List(dataRoot string) ([]RemoteInstance, error) {
+ data, err := os.ReadFile(Path(dataRoot))
+ if err != nil {
+ if os.IsNotExist(err) {
+ return nil, nil
+ }
+ return nil, err
+ }
+
+ // Defensive parse: accept both a bare array and the wrapped shape the
+ // federation theme is expected to persist.
+ var direct []RemoteInstance
+ if json.Unmarshal(data, &direct) == nil {
+ return direct, nil
+ }
+ var wrapped struct {
+ Instances []RemoteInstance `json:"instances"`
+ }
+ if err := json.Unmarshal(data, &wrapped); err != nil {
+ return nil, err
+ }
+ return wrapped.Instances, nil
+}
diff --git a/src/lib/instance/instance.go b/src/lib/instance/instance.go
new file mode 100644
index 0000000..a3bbe83
--- /dev/null
+++ b/src/lib/instance/instance.go
@@ -0,0 +1,94 @@
+// 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 instance holds the instance's public identity: a small, self-
+// contained file at the data root that answers "who am I" for the hub app's
+// public profile (hub-app theme M1) and, later, federation attestation.
+package instance
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+)
+
+// Identity is the instance's user-visible self-description. Name falls back to
+// DCNTRLIZED_INSTANCE_NAME and then the hostname when instance.json is absent;
+// Title and Description are optional and only ever come from the file.
+type Identity struct {
+ Name string `json:"name"`
+ Title string `json:"title,omitempty"`
+ Description string `json:"description,omitempty"`
+}
+
+// Path returns the identity file location for the given data root.
+func Path(dataRoot string) string {
+ return filepath.Join(dataRoot, "instance.json")
+}
+
+// Load resolves the instance identity: instance.json when present, else
+// DCNTRLIZED_INSTANCE_NAME, else os.Hostname(). Read-on-demand by design —
+// no daemon state, so an edited file shows up on the next request.
+func Load(dataRoot string) Identity {
+ id := defaultIdentity()
+
+ if data, err := os.ReadFile(Path(dataRoot)); err == nil {
+ var fromFile Identity
+ if json.Unmarshal(data, &fromFile) == nil && fromFile.Name != "" {
+ if fromFile.Title != "" {
+ id.Title = fromFile.Title
+ }
+ if fromFile.Description != "" {
+ id.Description = fromFile.Description
+ }
+ id.Name = fromFile.Name
+ }
+ }
+ return id
+}
+
+// Save atomically writes the identity file (tmp + rename), chmod 600 like the
+// rest of Maestro's file stores. The owner-gated edit endpoint that uses this
+// lands with the federation theme; the helper exists so both callers share one
+// write path.
+func Save(dataRoot string, id Identity) error {
+ if id.Name == "" {
+ return fmt.Errorf("instance name must not be empty")
+ }
+ data, err := json.MarshalIndent(id, "", " ")
+ if err != nil {
+ return fmt.Errorf("marshal instance identity: %w", err)
+ }
+ path := Path(dataRoot)
+ tmp := path + ".tmp"
+ if err := os.WriteFile(tmp, append(data, '\n'), 0o600); err != nil {
+ return fmt.Errorf("write instance identity: %w", err)
+ }
+ if err := os.Rename(tmp, path); err != nil {
+ return fmt.Errorf("replace instance identity: %w", err)
+ }
+ return nil
+}
+
+func defaultIdentity() Identity {
+ name := os.Getenv("DCNTRLIZED_INSTANCE_NAME")
+ if name == "" {
+ if host, err := os.Hostname(); err == nil {
+ name = host
+ }
+ }
+ return Identity{Name: name}
+}
diff --git a/src/lib/pki/helpers.go b/src/lib/pki/helpers.go
index 500501f..5106c59 100644
--- a/src/lib/pki/helpers.go
+++ b/src/lib/pki/helpers.go
@@ -117,3 +117,23 @@ func FingerprintDER(spki []byte) string {
}
return string(b)
}
+
+// CAFingerprint returns the instance CA's public-key fingerprint by reading
+// the certificate from disk on demand (hub-app M1's public profile surfaces
+// it as the instance identity). Empty string when no CA exists yet — dev-mode
+// static services never provision one.
+func CAFingerprint(dataRoot string) string {
+ pemBytes, err := os.ReadFile(CertPath(dataRoot))
+ if err != nil {
+ return ""
+ }
+ block, _ := pem.Decode(pemBytes)
+ if block == nil {
+ return ""
+ }
+ cert, err := x509.ParseCertificate(block.Bytes)
+ if err != nil {
+ return ""
+ }
+ return Fingerprint(cert)
+}
diff --git a/src/main.go b/src/main.go
index b7c625c..eb84431 100644
--- a/src/main.go
+++ b/src/main.go
@@ -27,6 +27,7 @@ import (
"git.dcntrlized.org/Dcntrlized/Maestro/handlers/actions"
"git.dcntrlized.org/Dcntrlized/Maestro/handlers/auth"
"git.dcntrlized.org/Dcntrlized/Maestro/handlers/containers"
+ handlersinstance "git.dcntrlized.org/Dcntrlized/Maestro/handlers/instance"
"git.dcntrlized.org/Dcntrlized/Maestro/handlers/peers"
handlers "git.dcntrlized.org/Dcntrlized/Maestro/handlers/registry"
libcontainers "git.dcntrlized.org/Dcntrlized/Maestro/lib/containers"
@@ -150,6 +151,12 @@ func main() {
mux.HandleFunc("/api/auth/logout", authService.Logout)
mux.Handle("/api/auth/change-password", gate(http.HandlerFunc(authService.ChangePassword)))
+ // Instance public profile (hub-app theme M1): the one additional
+ // unauthenticated surface — deliberately allowlisted around the owner
+ // gate (cross-theme contract, hub-app D2). Exact path only; sub-paths
+ // like /api/instance/attestation (federation) are registered separately.
+ mux.Handle("/api/instance", handlersinstance.NewPublicProfileHandler(cache.GetServices, dataRoot))
+
mux.Handle("/api/ws", authService.GateWebSocket(http.HandlerFunc(wsHub.HandleWS)))
mux.Handle("/api/containers", gate(containers.NewListContainersHandler(cache)))
mux.Handle("/api/containers/install", gate(containers.InstallContainerHandler(cache)))