diff --git a/.pandaos/adrs/0001-git-source-management-boundary.md b/.pandaos/adrs/0001-git-source-management-boundary.md index 613ed2d..98ca805 100644 --- a/.pandaos/adrs/0001-git-source-management-boundary.md +++ b/.pandaos/adrs/0001-git-source-management-boundary.md @@ -22,6 +22,11 @@ allow-listed, its API token comes from the process environment, requests use a bounded timeout, and callers are rate-limited. Discovered entries are merged atomically into inventory and begin with projection disabled. +Until inventory is wired into `BuildInputLoader`, mutation, discovery, +connectivity-test, and content-scan endpoints return +`409 E_SOURCE_MANAGEMENT_NOT_WIRED`. The UI publishes the resolved build source, +labels inventory read-only, and disables the corresponding controls. + Source-management UX may add review and configuration workflows later, but any accepted change must be committed to the Git-owned configuration before it can affect a release. diff --git a/README.md b/README.md index ebdc857..a8ea1ad 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,7 @@ Validation failures produce a failed build record but never publish a partial re ## Current boundaries - Theme packages are presentation data only; Node, shell, WASM, package-manager scripts, custom Liquid tags, and custom filters are not executed. -- The reference loader resolves the configured site-definition repository and supports separately pinned source snapshots through the typed boundary; source-management UX remains follow-up work governed by [ADR 0001](.pandaos/adrs/0001-git-source-management-boundary.md). +- The reference loader resolves the configured site-definition repository and supports separately pinned source snapshots through the typed boundary. Repository inventory is explicitly read-only: its mutation and content-scan APIs return `409 E_SOURCE_MANAGEMENT_NOT_WIRED`, and the UI disables those actions until inventory feeds `BuildInputLoader`; see [ADR 0001](.pandaos/adrs/0001-git-source-management-boundary.md). - Remote rsync deployment is not implemented. Push and connection-test endpoints fail closed with HTTP 501, and no configuration field can enable execution; see [ADR 0002](.pandaos/adrs/0002-remote-publication-not-implemented.md). See [docs/OPERATIONS.md](docs/OPERATIONS.md) for snapshot, release, promotion, failure, and rollback procedures. diff --git a/package.json b/package.json index 351615b..d5a27f6 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "preview": "vite preview", "clean": "rm -rf dist server.js", "lint": "tsc --noEmit", - "test": "node --import tsx --test src/backend/buildEngine.test.ts src/backend/giteaDiscovery.test.ts src/backend/releaseLifecycle.test.ts src/backend/repositorySnapshot.test.ts" + "test": "node --import tsx --test src/backend/buildEngine.test.ts src/backend/giteaDiscovery.test.ts src/backend/releaseLifecycle.test.ts src/backend/repositorySnapshot.test.ts src/backend/sourceManagement.test.ts" }, "dependencies": { "@tailwindcss/vite": "^4.1.14", diff --git a/src/App.tsx b/src/App.tsx index b831032..9bbbcd5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -14,6 +14,7 @@ import { ConfigAndAuditTab } from "./components/ConfigAndAuditTab"; import { GitSource, + SourceManagementCapability, StyleConfigPackage, StyleConfigInstance, ContentItem, @@ -27,11 +28,26 @@ import { SiteConfig, } from "./types"; +const sourceManagementFallback: SourceManagementCapability = { + protocol: "labyricorn-source-management/v1", + buildIntegration: "not-wired", + mutationsEnabled: false, + buildSource: { + sourceId: "site-definition", + repository: "unresolved", + commit: null, + }, + message: + "Source-management status is unavailable; mutations remain disabled.", +}; + export default function App() { const [activeTab, setActiveTab] = useState("dashboard"); const [siteConfig, setSiteConfig] = useState(null); const [sources, setSources] = useState([]); + const [sourceManagement, setSourceManagement] = + useState(sourceManagementFallback); const [stylePackages, setStylePackages] = useState([]); const [styleInstances, setStyleInstances] = useState( [], @@ -53,6 +69,7 @@ export default function App() { const [ cfgRes, srcRes, + srcMgmtRes, pkgRes, instRes, cntRes, @@ -66,6 +83,7 @@ export default function App() { ] = await Promise.all([ fetch("/api/site-config"), fetch("/api/git-sources"), + fetch("/api/source-management"), fetch("/api/style-packages"), fetch("/api/style-instances"), fetch("/api/content"), @@ -80,6 +98,7 @@ export default function App() { if (cfgRes.ok) setSiteConfig(await cfgRes.json()); if (srcRes.ok) setSources(await srcRes.json()); + if (srcMgmtRes.ok) setSourceManagement(await srcMgmtRes.json()); if (pkgRes.ok) setStylePackages(await pkgRes.json()); if (instRes.ok) setStyleInstances(await instRes.json()); if (cntRes.ok) setContentItems(await cntRes.json()); @@ -238,6 +257,7 @@ export default function App() { @@ -247,6 +267,7 @@ export default function App() { )} diff --git a/src/backend/http/contentRouter.ts b/src/backend/http/contentRouter.ts index 61412b2..efbcf8b 100644 --- a/src/backend/http/contentRouter.ts +++ b/src/backend/http/contentRouter.ts @@ -2,6 +2,7 @@ import express from "express"; import fs from "node:fs"; import path from "node:path"; import { cachedRepositoryPath, listRepositoryFiles } from "../gitOperations"; +import { requireSourceManagementBuildIntegration } from "../sourceManagement"; import { store } from "../store"; const router = express.Router(); @@ -43,7 +44,7 @@ router.get("/content", (_req, res) => { res.json(store.contentItems); }); -router.post("/content/discover", (_req, res) => { +router.post("/content/discover", requireSourceManagementBuildIntegration, (_req, res) => { store.addAudit( "Discover Content", "config", diff --git a/src/backend/http/gitSourceRouter.ts b/src/backend/http/gitSourceRouter.ts index 6704539..ff28302 100644 --- a/src/backend/http/gitSourceRouter.ts +++ b/src/backend/http/gitSourceRouter.ts @@ -8,6 +8,10 @@ import { synchronizeRepository, validateRepository, } from "../gitOperations"; +import { + requireSourceManagementBuildIntegration, + sourceManagementCapability, +} from "../sourceManagement"; import { store } from "../store"; import { fetchGiteaRepositories, @@ -23,7 +27,11 @@ let discoveryInFlight: Promise[]> | null = null; const errorMessage = (error: unknown): string => error instanceof Error ? error.message : String(error); -router.post("/gitea/discover", async (req, res) => { +router.get("/source-management", (_req, res) => { + res.json(sourceManagementCapability(store.siteConfig)); +}); + +router.post("/gitea/discover", requireSourceManagementBuildIntegration, async (req, res) => { const limit = discoveryLimiter.take(req.ip || req.socket.remoteAddress || "unknown"); if (!limit.allowed) { res.setHeader("Retry-After", String(limit.retryAfter)); @@ -75,7 +83,7 @@ router.get("/git-sources", (_req, res) => { res.json(store.gitSources); }); -router.post("/git-sources", async (req, res) => { +router.post("/git-sources", requireSourceManagementBuildIntegration, async (req, res) => { const body = req.body as Partial; if ( typeof body.id !== "string" || @@ -136,7 +144,7 @@ router.post("/git-sources", async (req, res) => { return res.json({ success: true, source }); }); -router.put("/git-sources/:id", (req, res) => { +router.put("/git-sources/:id", requireSourceManagementBuildIntegration, (req, res) => { const index = store.gitSources.findIndex((source) => source.id === req.params.id); if (index === -1) return res.status(404).json({ error: "Source not found" }); store.gitSources[index] = { ...store.gitSources[index], ...req.body }; @@ -148,7 +156,7 @@ router.put("/git-sources/:id", (req, res) => { return res.json({ success: true, source: store.gitSources[index] }); }); -router.delete("/git-sources/:id", (req, res) => { +router.delete("/git-sources/:id", requireSourceManagementBuildIntegration, (req, res) => { store.gitSources = store.gitSources.filter( (source) => source.id !== req.params.id, ); @@ -160,7 +168,7 @@ router.delete("/git-sources/:id", (req, res) => { res.json({ success: true }); }); -router.post("/git-sources/:id/test", async (req, res) => { +router.post("/git-sources/:id/test", requireSourceManagementBuildIntegration, async (req, res) => { const source = store.gitSources.find((item) => item.id === req.params.id); if (!source) return res.status(404).json({ error: "Source not found" }); source.status = "syncing"; diff --git a/src/backend/sourceManagement.test.ts b/src/backend/sourceManagement.test.ts new file mode 100644 index 0000000..db779ee --- /dev/null +++ b/src/backend/sourceManagement.test.ts @@ -0,0 +1,49 @@ +import assert from "node:assert/strict"; +import { Request, Response } from "express"; +import test from "node:test"; +import { + requireSourceManagementBuildIntegration, + SOURCE_MANAGEMENT_BUILD_INTEGRATION, + sourceManagementCapability, +} from "./sourceManagement"; + +test("source management reports a fail-closed not-wired build boundary", () => { + const capability = sourceManagementCapability({ + sourceId: "site-definition", + repository: "https://git.example.test/site.git", + commit: "a".repeat(40), + }); + assert.equal(SOURCE_MANAGEMENT_BUILD_INTEGRATION, false); + assert.equal(capability.buildIntegration, "not-wired"); + assert.equal(capability.mutationsEnabled, false); + assert.equal(capability.buildSource.commit, "a".repeat(40)); + assert.match(capability.message, /Builds use only/); +}); + +test("source-management mutation middleware rejects direct API calls", () => { + let status = 0; + let payload: unknown; + const response = { + status(code: number) { + status = code; + return this; + }, + json(body: unknown) { + payload = body; + return this; + }, + } as Response; + + requireSourceManagementBuildIntegration( + {} as Request, + response, + () => assert.fail("disabled source management must not call next"), + ); + assert.equal(status, 409); + assert.deepEqual(payload, { + code: "E_SOURCE_MANAGEMENT_NOT_WIRED", + success: false, + message: + "Source-management mutations are disabled because repository inventory does not feed BuildInputLoader.", + }); +}); diff --git a/src/backend/sourceManagement.ts b/src/backend/sourceManagement.ts new file mode 100644 index 0000000..a1f6de6 --- /dev/null +++ b/src/backend/sourceManagement.ts @@ -0,0 +1,31 @@ +import { RequestHandler } from "express"; +import { SiteConfig, SourceManagementCapability } from "../types"; + +export const SOURCE_MANAGEMENT_BUILD_INTEGRATION = false as const; + +export const sourceManagementCapability = ( + siteConfig: Pick, +): SourceManagementCapability => ({ + protocol: "labyricorn-source-management/v1", + buildIntegration: "not-wired", + mutationsEnabled: SOURCE_MANAGEMENT_BUILD_INTEGRATION, + buildSource: { + sourceId: siteConfig.sourceId ?? "site-definition", + repository: siteConfig.repository ?? "unresolved", + commit: siteConfig.commit ?? null, + }, + message: + "Repository inventory is read-only. Builds use only the Git-owned site-definition snapshot; discovery, add, test, and content-scan actions are not wired to build inputs.", +}); + +export const requireSourceManagementBuildIntegration: RequestHandler = ( + _req, + res, +) => { + res.status(409).json({ + code: "E_SOURCE_MANAGEMENT_NOT_WIRED", + success: false, + message: + "Source-management mutations are disabled because repository inventory does not feed BuildInputLoader.", + }); +}; diff --git a/src/components/ContentTab.tsx b/src/components/ContentTab.tsx index 68bfad4..f60d45a 100644 --- a/src/components/ContentTab.tsx +++ b/src/components/ContentTab.tsx @@ -15,17 +15,19 @@ import { Layers, Image as ImageIcon, } from "lucide-react"; -import { ContentItem, MediaAsset } from "../types"; +import { ContentItem, MediaAsset, SourceManagementCapability } from "../types"; interface ContentTabProps { contentItems: ContentItem[]; mediaAssets: MediaAsset[]; + sourceManagement: SourceManagementCapability; onTriggerDiscovery: () => Promise; } export const ContentTab: React.FC = ({ contentItems, mediaAssets, + sourceManagement, onTriggerDiscovery, }) => { const [selectedItemId, setSelectedItemId] = useState( @@ -50,6 +52,7 @@ export const ContentTab: React.FC = ({ item.path.toLowerCase().includes(searchTerm.toLowerCase()) || item.tags.some((t) => t.toLowerCase().includes(searchTerm.toLowerCase())), ); + const discoveryEnabled = sourceManagement.mutationsEnabled; const handleScan = async () => { setIsScanning(true); @@ -69,18 +72,17 @@ export const ContentTab: React.FC = ({

- Discovers artifacts across repositories, parses front matter, - validates YouTube directives, Wikipedia links, raw HTML policy, and - media mappings under{" "} - /media. + Shows artifacts resolved from the immutable site-definition + snapshot used by the deterministic build engine.

+ {!discoveryEnabled && ( +
+ +

+ Live inventory scans are disabled because their results do not feed + BuildInputLoader. This view reflects the content resolved from the + pinned build source. +

+
+ )} {/* SUB-TABS: MARKDOWN VS MEDIA */}
+ {!mutationsEnabled && ( +
+ +
+ + Source management is not wired to builds + +

+ Discovery, add, connectivity test, and content-scan actions are + disabled. The next build reads only{" "} + + {sourceManagement.buildSource.repository}@ + {sourceManagement.buildSource.commit ?? "unresolved"} + {" "} + through BuildInputLoader. +

+
+
+ )} {/* TEST RESULT NOTIFICATION */} {testResult && (
= ({ {/* LEFT COLUMN: SOURCES LIST (5 COLS) */}
- Configured Repositories ({sources.length}) + Resolved Build Repositories ({sources.length})
{sources.map((source) => { @@ -294,8 +315,9 @@ export const GitSourcesTab: React.FC = ({ e.stopPropagation(); handleTest(source.id); }} - disabled={isTesting === source.id} - className="px-2.5 py-1 bg-[#1A1A1A] hover:bg-[#262626] border border-[#2A2A2A] rounded-xs text-neutral-300 text-[11px] font-medium transition-colors flex items-center space-x-1" + disabled={!mutationsEnabled || isTesting === source.id} + title={mutationsEnabled ? undefined : sourceManagement.message} + className="px-2.5 py-1 bg-[#1A1A1A] hover:bg-[#262626] border border-[#2A2A2A] rounded-xs text-neutral-300 text-[11px] font-medium transition-colors flex items-center space-x-1 disabled:opacity-40 disabled:cursor-not-allowed" >