diff --git a/middleware/migrations/0045_publish_versions.sql b/middleware/migrations/0045_publish_versions.sql new file mode 100644 index 00000000..3c16df25 --- /dev/null +++ b/middleware/migrations/0045_publish_versions.sql @@ -0,0 +1,39 @@ +-- Issue #581 — publish primitive: immutable versions + a per-app pointer. +-- +-- `publish_versions` is insert-only from the application's perspective (see +-- `PublishStore`'s interface, which has no update/delete method) — the +-- primary key `(app_id, version)` is the DB-level backstop for that +-- invariant: a bug that tried to hand out a version number twice fails +-- loudly (unique-violation) instead of silently overwriting a prior +-- publish's recorded content hash/entrypoint. +-- +-- `publish_apps` holds the two mutable pieces of state an app has: the +-- counter used to allocate the NEXT version number, and the pointer to +-- whichever version is currently live. The composite foreign key ties the +-- pointer to a real, already-created version row — `rollbackTo` can only +-- ever point at a version that genuinely exists. + +CREATE TABLE IF NOT EXISTS publish_versions ( + app_id TEXT NOT NULL, + version INTEGER NOT NULL, + name TEXT NOT NULL, + entrypoint TEXT NOT NULL, + dir_hash TEXT NOT NULL, + source_scope_key TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (app_id, version) +); + +CREATE TABLE IF NOT EXISTS publish_apps ( + app_id TEXT PRIMARY KEY, + next_version INTEGER NOT NULL DEFAULT 1, + current_version INTEGER, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT publish_apps_current_version_fk + FOREIGN KEY (app_id, current_version) REFERENCES publish_versions (app_id, version) +); + +-- listVersions / the (future) admin version-list view both scan by app_id. +CREATE INDEX IF NOT EXISTS publish_versions_app_id_idx ON publish_versions (app_id); + +-- rollback: DROP TABLE publish_apps; DROP TABLE publish_versions; diff --git a/middleware/package-lock.json b/middleware/package-lock.json index 1cba9f54..2462338a 100644 --- a/middleware/package-lock.json +++ b/middleware/package-lock.json @@ -2292,6 +2292,10 @@ "resolved": "packages/harness-plugin-web-search", "link": true }, + "node_modules/@omadia/publish": { + "resolved": "packages/harness-publish", + "link": true + }, "node_modules/@omadia/sandbox": { "resolved": "packages/harness-sandbox", "link": true @@ -9405,6 +9409,18 @@ "zod": "^4.0.0" } }, + "packages/harness-publish": { + "name": "@omadia/publish", + "version": "0.1.0", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@omadia/sandbox": "*", + "pg": "^8.13.0" + } + }, "packages/harness-sandbox": { "name": "@omadia/sandbox", "version": "0.1.0", diff --git a/middleware/package.json b/middleware/package.json index 67ad4c04..e3e420cb 100644 --- a/middleware/package.json +++ b/middleware/package.json @@ -20,14 +20,14 @@ ], "scripts": { "preinstall": "node scripts/check-node-version.mjs", - "build": "npm run build -w @omadia/sandbox && npm run build -w @omadia/plugin-api && npm run build -w @omadia/llm-provider-api && npm run build -w @omadia/llm-provider && npm run build -w @omadia/llm-adapter-anthropic && npm run build -w @omadia/llm-adapter-openai && npm run build -w @omadia/canvas-core && npm run build -w @omadia/conductor-core && npm run build -w @omadia/plugin-ui-helpers && npm run build -w @omadia/api-key-auth && npm run build -w @omadia/channel-sdk && npm run build -w @omadia/diagrams && npm run build -w @omadia/memory && npm run build -w @omadia/memory-postgres && npm run build -w @omadia/embeddings && npm run build -w @omadia/embedding-adapter-openai && npm run build -w @omadia/knowledge-graph-inmemory && npm run build -w @omadia/knowledge-graph-neon && npm run build -w @omadia/usage-telemetry && npm run build -w @omadia/orchestrator-extras && npm run build -w @omadia/verifier && npm run build -w @omadia/plugin-privacy-guard && npm run build -w @omadia/orchestrator && npm run build -w @omadia/ui-orchestrator && npm run build -w @omadia/ui-channel && npm run build -w @omadia/channel-api && npm run build -w @omadia/plugin-office && npm run build -w @omadia/plugin-web-search && npm run build -w @omadia/plugin-quality-guard && npm run build -w @omadia/agent-seo-analyst && npm run build -w @omadia/agent-reference-maximum && npm run build -w @omadia/plugin-plan-runner && tsc && node scripts/copy-build-assets.mjs", + "build": "npm run build -w @omadia/sandbox && npm run build -w @omadia/publish && npm run build -w @omadia/plugin-api && npm run build -w @omadia/llm-provider-api && npm run build -w @omadia/llm-provider && npm run build -w @omadia/llm-adapter-anthropic && npm run build -w @omadia/llm-adapter-openai && npm run build -w @omadia/canvas-core && npm run build -w @omadia/conductor-core && npm run build -w @omadia/plugin-ui-helpers && npm run build -w @omadia/api-key-auth && npm run build -w @omadia/channel-sdk && npm run build -w @omadia/diagrams && npm run build -w @omadia/memory && npm run build -w @omadia/memory-postgres && npm run build -w @omadia/embeddings && npm run build -w @omadia/embedding-adapter-openai && npm run build -w @omadia/knowledge-graph-inmemory && npm run build -w @omadia/knowledge-graph-neon && npm run build -w @omadia/usage-telemetry && npm run build -w @omadia/orchestrator-extras && npm run build -w @omadia/verifier && npm run build -w @omadia/plugin-privacy-guard && npm run build -w @omadia/orchestrator && npm run build -w @omadia/ui-orchestrator && npm run build -w @omadia/ui-channel && npm run build -w @omadia/channel-api && npm run build -w @omadia/plugin-office && npm run build -w @omadia/plugin-web-search && npm run build -w @omadia/plugin-quality-guard && npm run build -w @omadia/agent-seo-analyst && npm run build -w @omadia/agent-reference-maximum && npm run build -w @omadia/plugin-plan-runner && tsc && node scripts/copy-build-assets.mjs", "start": "node dist/index.js", - "dev": "node scripts/ensure-native-abi.mjs && npm run build -w @omadia/sandbox && npm run build -w @omadia/plugin-api && npm run build -w @omadia/llm-provider-api && npm run build -w @omadia/llm-provider && npm run build -w @omadia/llm-adapter-anthropic && npm run build -w @omadia/llm-adapter-openai && npm run build -w @omadia/canvas-core && npm run build -w @omadia/conductor-core && npm run build -w @omadia/plugin-ui-helpers && npm run build -w @omadia/api-key-auth && npm run build -w @omadia/channel-sdk && npm run build -w @omadia/diagrams && npm run build -w @omadia/memory && npm run build -w @omadia/memory-postgres && npm run build -w @omadia/embeddings && npm run build -w @omadia/embedding-adapter-openai && npm run build -w @omadia/knowledge-graph-inmemory && npm run build -w @omadia/knowledge-graph-neon && npm run build -w @omadia/usage-telemetry && npm run build -w @omadia/orchestrator-extras && npm run build -w @omadia/verifier && npm run build -w @omadia/plugin-privacy-guard && npm run build -w @omadia/orchestrator && npm run build -w @omadia/ui-orchestrator && npm run build -w @omadia/ui-channel && npm run build -w @omadia/channel-api && npm run build -w @omadia/plugin-office && npm run build -w @omadia/plugin-web-search && npm run build -w @omadia/plugin-quality-guard && npm run build -w @omadia/agent-seo-analyst && npm run build -w @omadia/agent-reference-maximum && npm run build -w @omadia/plugin-plan-runner && tsx watch --ignore './.memory/**' --ignore './.uploaded-packages/**' --ignore './data/**' --ignore './dist/**' --ignore './packages/*/dist/**' --ignore './seed/**' src/index.ts", + "dev": "node scripts/ensure-native-abi.mjs && npm run build -w @omadia/sandbox && npm run build -w @omadia/publish && npm run build -w @omadia/plugin-api && npm run build -w @omadia/llm-provider-api && npm run build -w @omadia/llm-provider && npm run build -w @omadia/llm-adapter-anthropic && npm run build -w @omadia/llm-adapter-openai && npm run build -w @omadia/canvas-core && npm run build -w @omadia/conductor-core && npm run build -w @omadia/plugin-ui-helpers && npm run build -w @omadia/api-key-auth && npm run build -w @omadia/channel-sdk && npm run build -w @omadia/diagrams && npm run build -w @omadia/memory && npm run build -w @omadia/memory-postgres && npm run build -w @omadia/embeddings && npm run build -w @omadia/embedding-adapter-openai && npm run build -w @omadia/knowledge-graph-inmemory && npm run build -w @omadia/knowledge-graph-neon && npm run build -w @omadia/usage-telemetry && npm run build -w @omadia/orchestrator-extras && npm run build -w @omadia/verifier && npm run build -w @omadia/plugin-privacy-guard && npm run build -w @omadia/orchestrator && npm run build -w @omadia/ui-orchestrator && npm run build -w @omadia/ui-channel && npm run build -w @omadia/channel-api && npm run build -w @omadia/plugin-office && npm run build -w @omadia/plugin-web-search && npm run build -w @omadia/plugin-quality-guard && npm run build -w @omadia/agent-seo-analyst && npm run build -w @omadia/agent-reference-maximum && npm run build -w @omadia/plugin-plan-runner && tsx watch --ignore './.memory/**' --ignore './.uploaded-packages/**' --ignore './data/**' --ignore './dist/**' --ignore './packages/*/dist/**' --ignore './seed/**' src/index.ts", "dev:clean": "node scripts/dev-clean.mjs && npm run dev", "ensure-native-abi": "node scripts/ensure-native-abi.mjs", - "lint": "eslint src/ packages/harness-sandbox/src/ packages/plugin-api/src/ packages/llm-provider-api/src/ packages/llm-provider/src/ packages/llm-adapter-anthropic/src/ packages/llm-adapter-openai/src/ packages/harness-ui-helpers/src/ packages/harness-api-key-auth/src/ packages/harness-channel-sdk/src/ packages/harness-diagrams/src/ packages/harness-memory/src/ packages/harness-memory-postgres/src/ packages/harness-embeddings/src/ packages/embedding-adapter-openai/src/ packages/harness-knowledge-graph-inmemory/src/ packages/harness-knowledge-graph-neon/src/ packages/harness-usage-telemetry/src/ packages/harness-orchestrator-extras/src/ packages/harness-verifier/src/ packages/harness-orchestrator/src/ packages/harness-plugin-web-search/src/ packages/harness-plugin-quality-guard/src/ packages/harness-plugin-privacy-guard/src/ packages/harness-plugin-office/src/ packages/omadia-ui-orchestrator/src/ packages/omadia-ui-channel/src/ packages/harness-channel-api/src/ packages/harness-plugin-plan-runner/src/", - "lint:fix": "eslint src/ packages/harness-sandbox/src/ packages/plugin-api/src/ packages/llm-provider-api/src/ packages/llm-provider/src/ packages/llm-adapter-anthropic/src/ packages/llm-adapter-openai/src/ packages/harness-ui-helpers/src/ packages/harness-api-key-auth/src/ packages/harness-channel-sdk/src/ packages/harness-diagrams/src/ packages/harness-memory/src/ packages/harness-memory-postgres/src/ packages/harness-embeddings/src/ packages/embedding-adapter-openai/src/ packages/harness-knowledge-graph-inmemory/src/ packages/harness-knowledge-graph-neon/src/ packages/harness-usage-telemetry/src/ packages/harness-orchestrator-extras/src/ packages/harness-verifier/src/ packages/harness-orchestrator/src/ packages/harness-plugin-web-search/src/ packages/harness-plugin-quality-guard/src/ packages/harness-plugin-privacy-guard/src/ packages/harness-plugin-office/src/ packages/omadia-ui-orchestrator/src/ packages/omadia-ui-channel/src/ packages/harness-channel-api/src/ packages/harness-plugin-plan-runner/src/ --fix", - "typecheck": "npm run typecheck -w @omadia/sandbox && npm run typecheck -w @omadia/plugin-api && npm run typecheck -w @omadia/llm-provider-api && npm run typecheck -w @omadia/llm-provider && npm run typecheck -w @omadia/llm-adapter-anthropic && npm run typecheck -w @omadia/llm-adapter-openai && npm run typecheck -w @omadia/canvas-core && npm run typecheck -w @omadia/conductor-core && npm run typecheck -w @omadia/plugin-ui-helpers && npm run typecheck -w @omadia/api-key-auth && npm run typecheck -w @omadia/channel-sdk && npm run typecheck -w @omadia/diagrams && npm run typecheck -w @omadia/memory && npm run typecheck -w @omadia/memory-postgres && npm run typecheck -w @omadia/embeddings && npm run typecheck -w @omadia/embedding-adapter-openai && npm run typecheck -w @omadia/knowledge-graph-inmemory && npm run typecheck -w @omadia/knowledge-graph-neon && npm run typecheck -w @omadia/orchestrator-extras && npm run typecheck -w @omadia/verifier && npm run typecheck -w @omadia/plugin-privacy-guard && npm run typecheck -w @omadia/orchestrator && npm run typecheck -w @omadia/ui-orchestrator && npm run typecheck -w @omadia/ui-channel && npm run typecheck -w @omadia/channel-api && npm run typecheck -w @omadia/plugin-office && npm run typecheck -w @omadia/plugin-web-search && npm run typecheck -w @omadia/plugin-quality-guard && npm run typecheck -w @omadia/agent-seo-analyst && npm run typecheck -w @omadia/agent-reference-maximum && npm run typecheck -w @omadia/plugin-plan-runner && tsc --noEmit && npm run typecheck:golden && npm run typecheck:adversarial", + "lint": "eslint src/ packages/harness-sandbox/src/ packages/harness-publish/src/ packages/plugin-api/src/ packages/llm-provider-api/src/ packages/llm-provider/src/ packages/llm-adapter-anthropic/src/ packages/llm-adapter-openai/src/ packages/harness-ui-helpers/src/ packages/harness-api-key-auth/src/ packages/harness-channel-sdk/src/ packages/harness-diagrams/src/ packages/harness-memory/src/ packages/harness-memory-postgres/src/ packages/harness-embeddings/src/ packages/embedding-adapter-openai/src/ packages/harness-knowledge-graph-inmemory/src/ packages/harness-knowledge-graph-neon/src/ packages/harness-usage-telemetry/src/ packages/harness-orchestrator-extras/src/ packages/harness-verifier/src/ packages/harness-orchestrator/src/ packages/harness-plugin-web-search/src/ packages/harness-plugin-quality-guard/src/ packages/harness-plugin-privacy-guard/src/ packages/harness-plugin-office/src/ packages/omadia-ui-orchestrator/src/ packages/omadia-ui-channel/src/ packages/harness-channel-api/src/ packages/harness-plugin-plan-runner/src/", + "lint:fix": "eslint src/ packages/harness-sandbox/src/ packages/harness-publish/src/ packages/plugin-api/src/ packages/llm-provider-api/src/ packages/llm-provider/src/ packages/llm-adapter-anthropic/src/ packages/llm-adapter-openai/src/ packages/harness-ui-helpers/src/ packages/harness-api-key-auth/src/ packages/harness-channel-sdk/src/ packages/harness-diagrams/src/ packages/harness-memory/src/ packages/harness-memory-postgres/src/ packages/harness-embeddings/src/ packages/embedding-adapter-openai/src/ packages/harness-knowledge-graph-inmemory/src/ packages/harness-knowledge-graph-neon/src/ packages/harness-usage-telemetry/src/ packages/harness-orchestrator-extras/src/ packages/harness-verifier/src/ packages/harness-orchestrator/src/ packages/harness-plugin-web-search/src/ packages/harness-plugin-quality-guard/src/ packages/harness-plugin-privacy-guard/src/ packages/harness-plugin-office/src/ packages/omadia-ui-orchestrator/src/ packages/omadia-ui-channel/src/ packages/harness-channel-api/src/ packages/harness-plugin-plan-runner/src/ --fix", + "typecheck": "npm run typecheck -w @omadia/sandbox && npm run typecheck -w @omadia/publish && npm run typecheck -w @omadia/plugin-api && npm run typecheck -w @omadia/llm-provider-api && npm run typecheck -w @omadia/llm-provider && npm run typecheck -w @omadia/llm-adapter-anthropic && npm run typecheck -w @omadia/llm-adapter-openai && npm run typecheck -w @omadia/canvas-core && npm run typecheck -w @omadia/conductor-core && npm run typecheck -w @omadia/plugin-ui-helpers && npm run typecheck -w @omadia/api-key-auth && npm run typecheck -w @omadia/channel-sdk && npm run typecheck -w @omadia/diagrams && npm run typecheck -w @omadia/memory && npm run typecheck -w @omadia/memory-postgres && npm run typecheck -w @omadia/embeddings && npm run typecheck -w @omadia/embedding-adapter-openai && npm run typecheck -w @omadia/knowledge-graph-inmemory && npm run typecheck -w @omadia/knowledge-graph-neon && npm run typecheck -w @omadia/orchestrator-extras && npm run typecheck -w @omadia/verifier && npm run typecheck -w @omadia/plugin-privacy-guard && npm run typecheck -w @omadia/orchestrator && npm run typecheck -w @omadia/ui-orchestrator && npm run typecheck -w @omadia/ui-channel && npm run typecheck -w @omadia/channel-api && npm run typecheck -w @omadia/plugin-office && npm run typecheck -w @omadia/plugin-web-search && npm run typecheck -w @omadia/plugin-quality-guard && npm run typecheck -w @omadia/agent-seo-analyst && npm run typecheck -w @omadia/agent-reference-maximum && npm run typecheck -w @omadia/plugin-plan-runner && tsc --noEmit && npm run typecheck:golden && npm run typecheck:adversarial", "typecheck:golden": "tsc -p test/golden/tsconfig.json", "typecheck:adversarial": "tsc -p test/adversarial/tsconfig.json", "typecheck:test": "node scripts/check-test-typecheck.mjs", diff --git a/middleware/packages/harness-publish/package.json b/middleware/packages/harness-publish/package.json new file mode 100644 index 00000000..2844f899 --- /dev/null +++ b/middleware/packages/harness-publish/package.json @@ -0,0 +1,31 @@ +{ + "name": "@omadia/publish", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsc", + "typecheck": "tsc --noEmit", + "clean": "rm -rf dist" + }, + "peerDependencies": { + "@omadia/sandbox": "*", + "pg": "^8.13.0" + }, + "engines": { + "node": ">=20" + }, + "description": "publish primitive (issue #581, qm competitive analysis): turns a directory in an agent's #576 sandbox into an immutably-versioned, rollback-capable running web app, served from its own origin — never the admin/portal origin. Version store (Postgres-backed, in-memory for tests), a Docker runtime for materializing/serving a version, and an origin-isolating HTTP gateway. No native tool or route lives here — see #778/#581 P2 for that wiring.", + "license": "MIT" +} diff --git a/middleware/packages/harness-publish/src/dockerPublishRuntime.ts b/middleware/packages/harness-publish/src/dockerPublishRuntime.ts new file mode 100644 index 00000000..8f7aabc6 --- /dev/null +++ b/middleware/packages/harness-publish/src/dockerPublishRuntime.ts @@ -0,0 +1,181 @@ +import { createHash } from 'node:crypto'; + +import { execDockerViaSpawn, type DockerExec } from '@omadia/sandbox'; + +import type { PublishRuntime } from './publish.js'; + +/** + * v1 `PublishRuntime` (issue #581 P1): local Docker, same injectable-exec + * seam as `@omadia/sandbox`'s `DockerSandboxBackend` (`dockerExec.ts`) — + * deliberately reused rather than re-implemented, so publish's Docker tests + * follow the exact stub/real-Docker split `dockerSandbox.test.ts` already + * established. + * + * ## One container per VERSION, one data volume per APP + * + * Each version gets its OWN container, named deterministically from + * `(appId, version)` — `deploy()` for a version that already has a + * container is a no-op, which is what makes a version immutable at the + * runtime layer too (not just in the store): nothing in this class ever + * re-materializes an existing version's files. + * + * The `$DATA_DIR` volume, by contrast, is named from `appId` ALONE and + * mounted into every version's container at the same in-container path + * (`/data`). That is the entire mechanism behind the durability contract: + * a fresh version's container starts with a brand-new filesystem for + * everything else, but `/data` is the SAME Docker volume every prior + * version for this app also mounted — so a file written outside `/data` is + * gone the moment a new version replaces the running container, and a file + * written inside `/data` survives every redeploy. + * + * ## Reachability: `docker port`, not a fixed mapping + * + * Containers publish their app port to an OS-assigned host port + * (`-p 127.0.0.1::`) rather than a fixed one, so many + * versions/apps can run concurrently without a port-allocation table this + * class would have to own. `portFor()` asks Docker itself via + * `docker port /tcp` — the same "Docker is the + * durable store" posture `DockerSandboxBackend` takes for container + * naming. + */ +export interface DockerPublishRuntimeOptions { + /** Must have Node on PATH — v1 supports only a Node entrypoint (a static + * site publishes as a small Node script serving its own files; see the + * package README). */ + readonly image?: string; + readonly execDocker?: DockerExec; + /** In-container port the entrypoint is told (via `PORT`) to listen on. */ + readonly appPort?: number; + /** In-container path for the durable data volume (`DATA_DIR`). */ + readonly dataDir?: string; +} + +const DEFAULT_IMAGE = 'node:20-alpine'; +const DEFAULT_APP_PORT = 8080; +const DEFAULT_DATA_DIR = '/data'; +const APP_ROOT = '/app'; +const CONTAINER_PREFIX = 'omadia-pub-'; +const VOLUME_PREFIX = 'omadia-pub-data-'; + +function containerNameFor(appId: string, version: number): string { + const digest = createHash('sha256').update(`${appId}:${String(version)}`, 'utf8').digest('hex').slice(0, 24); + return `${CONTAINER_PREFIX}${digest}`; +} + +function dataVolumeFor(appId: string): string { + const digest = createHash('sha256').update(appId, 'utf8').digest('hex').slice(0, 24); + return `${VOLUME_PREFIX}${digest}`; +} + +export class DockerPublishRuntime implements PublishRuntime { + private readonly image: string; + private readonly execDocker: DockerExec; + private readonly appPort: number; + private readonly dataDir: string; + + constructor(options: DockerPublishRuntimeOptions = {}) { + this.image = options.image ?? DEFAULT_IMAGE; + this.execDocker = options.execDocker ?? execDockerViaSpawn; + this.appPort = options.appPort ?? DEFAULT_APP_PORT; + this.dataDir = options.dataDir ?? DEFAULT_DATA_DIR; + } + + async deploy(args: { + readonly appId: string; + readonly version: number; + readonly entrypoint: string; + readonly files: ReadonlyMap; + }): Promise { + const name = containerNameFor(args.appId, args.version); + if (await this.containerExists(name)) return; // immutable: never re-materialize a version + + const volume = dataVolumeFor(args.appId); + await this.exec(['volume', 'create', volume], 30_000); + + const runArgs = [ + 'run', + '-d', + '--name', + name, + '--label', + `omadia.publish.app=${args.appId}`, + '--label', + `omadia.publish.version=${String(args.version)}`, + '-p', + `127.0.0.1::${String(this.appPort)}`, + '-v', + `${volume}:${this.dataDir}`, + '--workdir', + APP_ROOT, + this.image, + 'sh', + '-c', + `mkdir -p '${APP_ROOT}' && exec sleep infinity`, + ]; + const run = await this.exec(runArgs, 60_000); + if (run.exitCode !== 0) { + throw new Error(`DockerPublishRuntime: failed to start container for '${args.appId}' v${String(args.version)}: ${run.stderr || run.stdout}`); + } + + for (const [relativePath, content] of args.files) { + const target = `${APP_ROOT}/${relativePath}`; + const parentDir = target.slice(0, target.lastIndexOf('/')) || APP_ROOT; + const write = await this.exec( + ['exec', '-i', name, 'sh', '-c', `mkdir -p '${parentDir}' && cat > '${target}'`], + 30_000, + content, + ); + if (write.exitCode !== 0) { + throw new Error(`DockerPublishRuntime: failed to write '${relativePath}' for '${args.appId}' v${String(args.version)}: ${write.stderr}`); + } + } + + const start = await this.exec( + [ + 'exec', + '-d', + '-e', + `PORT=${String(this.appPort)}`, + '-e', + `DATA_DIR=${this.dataDir}`, + name, + 'sh', + '-c', + `cd '${APP_ROOT}' && node '${args.entrypoint}' > /tmp/omadia-publish-app.log 2>&1`, + ], + 15_000, + ); + if (start.exitCode !== 0) { + throw new Error(`DockerPublishRuntime: failed to start entrypoint for '${args.appId}' v${String(args.version)}: ${start.stderr}`); + } + } + + /** The host port currently serving `appId`'s `version`, or `undefined` + * when that version was never deployed (or its container is gone). Never + * consults `PublishStore` — the caller decides which version's port it + * wants (typically the store's current pointer). */ + async portFor(appId: string, version: number): Promise { + const name = containerNameFor(appId, version); + const result = await this.exec(['port', name, `${String(this.appPort)}/tcp`], 15_000); + if (result.exitCode !== 0) return undefined; + const line = result.stdout.trim().split('\n')[0] ?? ''; + const port = Number(line.slice(line.lastIndexOf(':') + 1)); + return Number.isFinite(port) && port > 0 ? port : undefined; + } + + private async containerExists(name: string): Promise { + const result = await this.exec(['ps', '-a', '--filter', `name=^${name}$`, '--format', '{{.Names}}'], 15_000); + return result.stdout.trim().split('\n').includes(name); + } + + private exec(args: readonly string[], timeoutMs: number, input?: string): ReturnType { + return this.execDocker({ + args, + timeoutMs, + maxOutputBytes: 4 * 1024 * 1024, + ...(input !== undefined ? { input } : {}), + }); + } +} + +export const _internal = { containerNameFor, dataVolumeFor }; diff --git a/middleware/packages/harness-publish/src/index.ts b/middleware/packages/harness-publish/src/index.ts new file mode 100644 index 00000000..b54a3034 --- /dev/null +++ b/middleware/packages/harness-publish/src/index.ts @@ -0,0 +1,26 @@ +export type { + PublishVersionRecord, + PublishPointer, +} from './publishManifest.js'; +export { + PublishVersionNotFoundError, + PublishEntrypointNotFoundError, + PublishTreeTooLargeError, +} from './publishManifest.js'; + +export type { CreateVersionInput, PublishStore } from './publishStore.js'; +export { InMemoryPublishStore } from './publishStore.js'; + +export { PostgresPublishStore } from './postgresPublishStore.js'; + +export type { CollectTreeOptions } from './treeCollector.js'; +export { collectTree } from './treeCollector.js'; + +export type { PublishRuntime, PublishInput } from './publish.js'; +export { publish, rollbackTo } from './publish.js'; + +export type { DockerPublishRuntimeOptions } from './dockerPublishRuntime.js'; +export { DockerPublishRuntime } from './dockerPublishRuntime.js'; + +export type { PublishGatewayOptions, PublishGatewayTarget } from './publishGateway.js'; +export { createPublishGateway } from './publishGateway.js'; diff --git a/middleware/packages/harness-publish/src/postgresPublishStore.ts b/middleware/packages/harness-publish/src/postgresPublishStore.ts new file mode 100644 index 00000000..709343a5 --- /dev/null +++ b/middleware/packages/harness-publish/src/postgresPublishStore.ts @@ -0,0 +1,147 @@ +import type { Pool, PoolClient } from 'pg'; + +import type { PublishPointer, PublishVersionRecord } from './publishManifest.js'; +import type { CreateVersionInput, PublishStore } from './publishStore.js'; + +/** + * Postgres-backed `PublishStore` — migration `0045_publish_versions.sql`. + * + * Version allocation runs `SELECT ... FOR UPDATE` on the app's counter row + * inside a transaction, so two concurrent publishes to the same `appId` + * serialize on that row lock rather than racing for the same version + * number — the DB-level guarantee backing the same invariant + * `InMemoryPublishStore` gets from JS's single-threaded execution. + * `publish_versions`'s primary key `(app_id, version)` is the second, + * independent line of defense: even a bug in the allocator can only ever + * fail loudly (a unique-violation) rather than silently overwrite a row. + */ +export class PostgresPublishStore implements PublishStore { + constructor(private readonly pool: Pool) {} + + async createVersion(input: CreateVersionInput): Promise { + const client = await this.pool.connect(); + try { + await client.query('BEGIN'); + const allocated = await this.allocateVersion(client, input.appId, input.now); + await client.query( + `INSERT INTO publish_versions (app_id, version, name, entrypoint, dir_hash, source_scope_key, created_at) + VALUES ($1, $2, $3, $4, $5, $6, $7)`, + [input.appId, allocated, input.name, input.entrypoint, input.dirHash, input.sourceScopeKey, input.now.toISOString()], + ); + await client.query('COMMIT'); + return { + appId: input.appId, + version: allocated, + name: input.name, + entrypoint: input.entrypoint, + dirHash: input.dirHash, + sourceScopeKey: input.sourceScopeKey, + createdAt: input.now, + }; + } catch (err) { + await client.query('ROLLBACK'); + throw err; + } finally { + client.release(); + } + } + + /** Row-locks (or creates) `appId`'s counter row and returns the version + * number to use THIS call, leaving the row set to hand out the next one + * after. Caller must already be inside the transaction that also inserts + * the version row, so a crash between the two never leaves a gap a + * concurrent caller could reuse. */ + private async allocateVersion(client: PoolClient, appId: string, now: Date): Promise { + const existing = await client.query<{ next_version: number }>( + `SELECT next_version FROM publish_apps WHERE app_id = $1 FOR UPDATE`, + [appId], + ); + if (existing.rows.length === 0) { + await client.query( + `INSERT INTO publish_apps (app_id, next_version, updated_at) VALUES ($1, 2, $2)`, + [appId, now.toISOString()], + ); + return 1; + } + const allocated = existing.rows[0]!.next_version; + await client.query(`UPDATE publish_apps SET next_version = $2, updated_at = $3 WHERE app_id = $1`, [ + appId, + allocated + 1, + now.toISOString(), + ]); + return allocated; + } + + async getVersion(appId: string, version: number): Promise { + const result = await this.pool.query( + `SELECT app_id, version, name, entrypoint, dir_hash, source_scope_key, created_at + FROM publish_versions WHERE app_id = $1 AND version = $2`, + [appId, version], + ); + const row = result.rows[0]; + return row ? rowToRecord(row) : undefined; + } + + async listVersions(appId: string): Promise { + const result = await this.pool.query( + `SELECT app_id, version, name, entrypoint, dir_hash, source_scope_key, created_at + FROM publish_versions WHERE app_id = $1 ORDER BY version ASC`, + [appId], + ); + return result.rows.map(rowToRecord); + } + + async getPointer(appId: string): Promise { + const result = await this.pool.query<{ app_id: string; current_version: number | null; updated_at: Date }>( + `SELECT app_id, current_version, updated_at FROM publish_apps WHERE app_id = $1`, + [appId], + ); + const row = result.rows[0]; + if (!row || row.current_version === null) return undefined; + return { appId: row.app_id, currentVersion: row.current_version, updatedAt: new Date(row.updated_at) }; + } + + async setPointer(appId: string, version: number, now: Date): Promise { + // `publish_apps` already has a row for `appId` by the time any version + // exists to point at (created by `allocateVersion`), but a caller could + // in principle set a pointer without ever having published through + // THIS store instance (e.g. a restored backup) — ON CONFLICT keeps + // this safe either way. The composite FK to `publish_versions` (see the + // migration) is what actually enforces "version must exist", not this + // query. + const result = await this.pool.query<{ app_id: string; current_version: number; updated_at: Date }>( + `INSERT INTO publish_apps (app_id, next_version, current_version, updated_at) + VALUES ($1, $2 + 1, $2, $3) + ON CONFLICT (app_id) DO UPDATE SET + current_version = EXCLUDED.current_version, + next_version = GREATEST(publish_apps.next_version, EXCLUDED.next_version), + updated_at = EXCLUDED.updated_at + RETURNING app_id, current_version, updated_at`, + [appId, version, now.toISOString()], + ); + const row = result.rows[0]!; + return { appId: row.app_id, currentVersion: row.current_version, updatedAt: new Date(row.updated_at) }; + } +} + +interface VersionRow { + app_id: string; + version: number; + name: string; + entrypoint: string; + dir_hash: string; + source_scope_key: string; + created_at: Date; +} + +function rowToRecord(row: VersionRow): PublishVersionRecord { + return { + appId: row.app_id, + version: row.version, + name: row.name, + entrypoint: row.entrypoint, + dirHash: row.dir_hash, + sourceScopeKey: row.source_scope_key, + createdAt: new Date(row.created_at), + }; +} diff --git a/middleware/packages/harness-publish/src/publish.ts b/middleware/packages/harness-publish/src/publish.ts new file mode 100644 index 00000000..f3fb0fbc --- /dev/null +++ b/middleware/packages/harness-publish/src/publish.ts @@ -0,0 +1,108 @@ +import type { Sandbox } from '@omadia/sandbox'; +import { computeContentHash } from '@omadia/sandbox'; + +import { + PublishEntrypointNotFoundError, + PublishVersionNotFoundError, + type PublishPointer, + type PublishVersionRecord, +} from './publishManifest.js'; +import type { PublishStore } from './publishStore.js'; +import { collectTree, type CollectTreeOptions } from './treeCollector.js'; + +/** What a runtime backend (Docker in v1) must provide for `publish()`/ + * `rollbackTo()` to drive it. Modelled on `SandboxBackend` from + * `@omadia/sandbox`: a narrow required surface, backend-agnostic. */ +export interface PublishRuntime { + /** Materializes `files` as a NEW, independently-running instance of this + * exact version and starts it. Called exactly once per version, right + * after `PublishStore.createVersion` succeeds for it — an implementation + * MAY treat a second `deploy()` call for a version it already deployed + * as a no-op (never re-materializing over it) as defense in depth, but + * `publish()` itself never calls it twice for the same version. */ + deploy(args: { + readonly appId: string; + readonly version: number; + readonly entrypoint: string; + readonly files: ReadonlyMap; + }): Promise; +} + +export interface PublishInput { + /** Stable app identifier (URL/host-prefix-safe slug); the SAME `appId` + * across calls is what makes them versions of one app rather than + * separate apps. */ + readonly appId: string; + readonly name: string; + /** Path to the file (relative to `dir`) the runtime should run. */ + readonly entrypoint: string; + /** Root-relative directory in `sandbox` to publish. */ + readonly dir: string; + readonly sourceScopeKey: string; +} + +/** + * The `publish` primitive (issue #581): reads `input.dir` out of `sandbox` + * (traversal-clamped — see `treeCollector.ts`), records it as a brand-new, + * immutable version in `store`, deploys it via `runtime`, and only THEN + * flips the app's pointer to it. A failed `runtime.deploy()` leaves the + * version recorded (it did happen — the row is evidence, per the store's + * insert-only contract) but the pointer untouched, so a broken publish + * never takes down whatever was live before it. + */ +export async function publish(args: { + readonly sandbox: Pick; + readonly store: PublishStore; + readonly runtime: PublishRuntime; + readonly input: PublishInput; + readonly now?: Date; + readonly collectOptions?: CollectTreeOptions; +}): Promise { + const now = args.now ?? new Date(); + const files = await collectTree(args.sandbox, args.input.dir, args.input.appId, args.collectOptions); + if (!files.has(args.input.entrypoint)) { + throw new PublishEntrypointNotFoundError(args.input.appId, args.input.entrypoint); + } + + const dirHash = computeContentHash(Object.fromEntries(files)); + const record = await args.store.createVersion({ + appId: args.input.appId, + name: args.input.name, + entrypoint: args.input.entrypoint, + dirHash, + sourceScopeKey: args.input.sourceScopeKey, + now, + }); + + await args.runtime.deploy({ + appId: record.appId, + version: record.version, + entrypoint: record.entrypoint, + files, + }); + + await args.store.setPointer(record.appId, record.version, now); + return record; +} + +/** + * `rollbackTo` is a pointer flip, full stop: it reads the target version + * (to fail clearly if it does not exist) and then calls ONLY + * `store.setPointer`. It never calls `PublishRuntime.deploy` and never + * calls `store.createVersion` — a version that was already deployed is + * still running from its own prior `publish()` call; there is nothing to + * rebuild. Callers verifying this end-to-end should assert their + * `PublishRuntime.deploy` spy's call count is unchanged after a rollback. + */ +export async function rollbackTo(args: { + readonly store: PublishStore; + readonly appId: string; + readonly version: number; + readonly now?: Date; +}): Promise { + const target = await args.store.getVersion(args.appId, args.version); + if (!target) { + throw new PublishVersionNotFoundError(args.appId, args.version); + } + return args.store.setPointer(args.appId, args.version, args.now ?? new Date()); +} diff --git a/middleware/packages/harness-publish/src/publishGateway.ts b/middleware/packages/harness-publish/src/publishGateway.ts new file mode 100644 index 00000000..49c7c0a5 --- /dev/null +++ b/middleware/packages/harness-publish/src/publishGateway.ts @@ -0,0 +1,106 @@ +import * as http from 'node:http'; + +/** + * Issue #581 — the origin boundary published apps run behind. + * + * This is NOT a deployment detail; it is a security boundary. Cookies are + * scoped by the browser to a request's HOST (RFC 6265 does not consider the + * port at all), so an app served on the admin/portal's own hostname — even + * on a different PORT — could read the admin session cookie and set cookies + * the admin origin would honor. `PublishGateway` therefore: + * + * 1. Only ever serves a request whose `Host` header ends in a dedicated + * apps suffix (`appsHostSuffix`, e.g. `.apps.omadia.internal`) that is + * NEVER also the admin/portal's own host — any other `Host`, INCLUDING + * an exact match on the admin host, is rejected outright before any + * app backend is even resolved. + * 2. Never forwards `Cookie`/`Authorization` headers to the app backend — + * an app has no legitimate reason to see the caller's admin session, + * and defense in depth beats trusting every future backend to ignore + * them. + * 3. Strips any `Set-Cookie` the app backend tries to send back that + * declares an explicit `Domain=` attribute — an app has no legitimate + * reason to scope a cookie anywhere other than "wherever the browser + * already thinks it is" (the default, un-scoped case), and an explicit + * `Domain=` is exactly how a cookie could otherwise be aimed at the + * admin host. + * + * `resolveTarget` is the only way this module learns where to proxy to — + * it never imports Docker or any `PublishRuntime` directly, so the gateway + * itself is fully testable with two plain `http.Server`s and no container + * runtime at all (see `publishGateway.test.ts`). + */ +export interface PublishGatewayTarget { + readonly host: string; + readonly port: number; +} + +export interface PublishGatewayOptions { + /** A request's `Host` header (port stripped) must END with this suffix to + * be treated as an app request. Must never equal the admin/portal's own + * host — that is the caller's responsibility to configure correctly; + * this module only enforces the suffix match. */ + readonly appsHostSuffix: string; + /** Resolves the app slug (the `Host` header with `appsHostSuffix` + * stripped) to where its currently-live version is listening. Returning + * `undefined` yields a 404. */ + readonly resolveTarget: (appSlug: string) => Promise; +} + +const HOP_BY_HOP_REQUEST_HEADERS = ['cookie', 'authorization', 'host']; + +function stripDomainScopedSetCookie(setCookie: string | string[] | undefined): string[] | undefined { + if (setCookie === undefined) return undefined; + const list = Array.isArray(setCookie) ? setCookie : [setCookie]; + const kept = list.filter((entry) => !/;\s*domain\s*=/i.test(entry)); + return kept.length > 0 ? kept : undefined; +} + +export function createPublishGateway(options: PublishGatewayOptions): http.Server { + return http.createServer((req, res) => { + void handleRequest(req, res, options); + }); +} + +async function handleRequest( + req: http.IncomingMessage, + res: http.ServerResponse, + options: PublishGatewayOptions, +): Promise { + const hostHeader = (req.headers.host ?? '').split(':')[0] ?? ''; + const suffix = options.appsHostSuffix; + const appSlug = hostHeader.endsWith(suffix) ? hostHeader.slice(0, -suffix.length) : undefined; + if (!appSlug) { + res.writeHead(400, { 'content-type': 'text/plain' }); + res.end('publish gateway: host is not an apps host'); + return; + } + + const target = await options.resolveTarget(appSlug); + if (!target) { + res.writeHead(404, { 'content-type': 'text/plain' }); + res.end('publish gateway: no live version for this app'); + return; + } + + const forwardedHeaders: http.OutgoingHttpHeaders = { ...req.headers }; + for (const header of HOP_BY_HOP_REQUEST_HEADERS) delete forwardedHeaders[header]; + + const proxyReq = http.request( + { host: target.host, port: target.port, path: req.url, method: req.method, headers: forwardedHeaders }, + (proxyRes) => { + const responseHeaders: http.OutgoingHttpHeaders = { ...proxyRes.headers }; + const strippedSetCookie = stripDomainScopedSetCookie(proxyRes.headers['set-cookie']); + if (strippedSetCookie === undefined) delete responseHeaders['set-cookie']; + else responseHeaders['set-cookie'] = strippedSetCookie; + + res.writeHead(proxyRes.statusCode ?? 502, responseHeaders); + proxyRes.pipe(res); + }, + ); + proxyReq.on('error', () => { + if (!res.headersSent) res.writeHead(502, { 'content-type': 'text/plain' }); + res.end('publish gateway: upstream app did not respond'); + }); + req.pipe(proxyReq); +} diff --git a/middleware/packages/harness-publish/src/publishManifest.ts b/middleware/packages/harness-publish/src/publishManifest.ts new file mode 100644 index 00000000..b37f5f6f --- /dev/null +++ b/middleware/packages/harness-publish/src/publishManifest.ts @@ -0,0 +1,55 @@ +/** + * Issue #581 — the `publish` primitive's core types. + * + * A "version" here is the immutable unit: once `PublishStore.createVersion` + * returns one, nothing in this package ever updates or deletes that row + * again — the interface simply has no such method (see `publishStore.ts`). + * A "pointer" is the ONLY mutable thing an app has: which version number is + * currently live. `rollbackTo` (in `publish.ts`) only ever moves the + * pointer; it never touches `publish_versions`. + */ + +/** An immutable, already-created version record. */ +export interface PublishVersionRecord { + readonly appId: string; + readonly version: number; + readonly name: string; + readonly entrypoint: string; + /** sha256 over the published file tree — see `computeContentHash` in + * `@omadia/sandbox`. Two publishes of byte-identical content still get + * distinct version numbers (this is a log, not a content-addressed + * store); `dirHash` is for audit/diffing, not deduplication. */ + readonly dirHash: string; + /** The scope key of the sandbox this version's files were read from — + * audit trail, never re-resolved to fetch anything later. */ + readonly sourceScopeKey: string; + readonly createdAt: Date; +} + +/** The mutable "which version is live" pointer for one app. */ +export interface PublishPointer { + readonly appId: string; + readonly currentVersion: number; + readonly updatedAt: Date; +} + +export class PublishVersionNotFoundError extends Error { + constructor(appId: string, version: number) { + super(`publish: no version ${String(version)} recorded for app '${appId}'`); + this.name = 'PublishVersionNotFoundError'; + } +} + +export class PublishEntrypointNotFoundError extends Error { + constructor(appId: string, entrypoint: string) { + super(`publish: entrypoint '${entrypoint}' was not found under the published directory for app '${appId}'`); + this.name = 'PublishEntrypointNotFoundError'; + } +} + +export class PublishTreeTooLargeError extends Error { + constructor(appId: string, limit: number) { + super(`publish: directory for app '${appId}' exceeds the ${String(limit)}-file publish limit`); + this.name = 'PublishTreeTooLargeError'; + } +} diff --git a/middleware/packages/harness-publish/src/publishStore.ts b/middleware/packages/harness-publish/src/publishStore.ts new file mode 100644 index 00000000..3c98a5bc Binary files /dev/null and b/middleware/packages/harness-publish/src/publishStore.ts differ diff --git a/middleware/packages/harness-publish/src/treeCollector.ts b/middleware/packages/harness-publish/src/treeCollector.ts new file mode 100644 index 00000000..c72d5faa --- /dev/null +++ b/middleware/packages/harness-publish/src/treeCollector.ts @@ -0,0 +1,60 @@ +import type { Sandbox } from '@omadia/sandbox'; + +import { PublishTreeTooLargeError } from './publishManifest.js'; + +/** + * Issue #581 — reads the directory a `publish` call names entirely through + * `Sandbox.list`/`Sandbox.read`. This is deliberate: those two methods are + * ALREADY traversal-clamped against the sandbox's own root (`pathGuard.ts` + * in `@omadia/sandbox`), so an agent-supplied `dir` or an agent-authored + * filename inside it can never walk this collector outside the sandbox — + * there is no raw filesystem path anywhere in this module, only paths the + * `Sandbox` itself just enumerated via `list()` and re-validates on every + * `read()`/`list()` call. + */ +const DEFAULT_MAX_FILES = 2_000; +const DEFAULT_MAX_DEPTH = 32; + +export interface CollectTreeOptions { + readonly maxFiles?: number; + readonly maxDepth?: number; +} + +/** Collects every regular file under `dir` (recursively) as `relativePath + * (relative to `dir`, POSIX-joined) -> content`. Throws + * `PublishTreeTooLargeError` rather than truncating silently — a publish + * that hit the cap should fail loudly, not ship a partial app. */ +export async function collectTree( + sandbox: Pick, + dir: string, + appIdForErrors: string, + options: CollectTreeOptions = {}, +): Promise> { + const maxFiles = options.maxFiles ?? DEFAULT_MAX_FILES; + const maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH; + const files = new Map(); + + async function walk(sandboxPath: string, relativePath: string, depth: number): Promise { + if (depth > maxDepth) throw new PublishTreeTooLargeError(appIdForErrors, maxFiles); + const listing = await sandbox.list(sandboxPath); + if (!listing.ok) return; // an empty/missing directory publishes zero files, not an error here + for (const entry of listing.entries) { + const childSandboxPath = sandboxPath === '.' || sandboxPath === '' ? entry.name : `${sandboxPath}/${entry.name}`; + const childRelativePath = relativePath === '' ? entry.name : `${relativePath}/${entry.name}`; + if (entry.kind === 'dir') { + await walk(childSandboxPath, childRelativePath, depth + 1); + continue; + } + if (entry.kind !== 'file') continue; + if (files.size >= maxFiles) throw new PublishTreeTooLargeError(appIdForErrors, maxFiles); + const read = await sandbox.read(childSandboxPath); + if (read.ok) files.set(childRelativePath, read.content); + // A file that vanished or became unreadable between list() and + // read() is skipped, not fatal — the same "best effort over a live + // tree" posture `syncReadOnlyLayer` takes in `@omadia/sandbox`. + } + } + + await walk(dir, '', 0); + return files; +} diff --git a/middleware/packages/harness-publish/tsconfig.json b/middleware/packages/harness-publish/tsconfig.json new file mode 100644 index 00000000..c1f03861 --- /dev/null +++ b/middleware/packages/harness-publish/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "types": ["node"], + "outDir": "dist", + "rootDir": "src", + "strict": true, + "noImplicitAny": true, + "strictNullChecks": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "composite": true + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/middleware/test/publish/dockerPublishRuntime.test.ts b/middleware/test/publish/dockerPublishRuntime.test.ts new file mode 100644 index 00000000..6f99ec16 --- /dev/null +++ b/middleware/test/publish/dockerPublishRuntime.test.ts @@ -0,0 +1,229 @@ +import { describe, it, after } from 'node:test'; +import { strict as assert } from 'node:assert'; +import { execFileSync } from 'node:child_process'; + +import { DockerPublishRuntime, _internal } from '../../packages/harness-publish/src/dockerPublishRuntime.js'; +import type { DockerExec, DockerExecContext, DockerExecResult } from '@omadia/sandbox'; + +/** + * Issue #581 P1 — `DockerPublishRuntime` tests, same two-tier split as + * `dockerSandbox.test.ts`: a stub `execDocker` exercises every argv/wiring + * branch deterministically; a `SANDBOX_DOCKER_TEST=1` tier proves the stub's + * assumptions against a real daemon, including the `$DATA_DIR` durability + * contract end to end. + */ +interface RecordedCall { + readonly args: readonly string[]; + readonly input: string | undefined; +} + +function stubExec(script: (ctx: DockerExecContext, callIndex: number) => DockerExecResult): { + exec: DockerExec; + calls: RecordedCall[]; +} { + const calls: RecordedCall[] = []; + const exec: DockerExec = async (ctx) => { + calls.push({ args: ctx.args, input: ctx.input }); + return script(ctx, calls.length - 1); + }; + return { exec, calls }; +} + +function ok(stdout = '', stderr = ''): DockerExecResult { + return { exitCode: 0, stdout, stderr, timedOut: false, outputTruncated: false }; +} +function fail(stderr: string, exitCode = 1): DockerExecResult { + return { exitCode, stdout: '', stderr, timedOut: false, outputTruncated: false }; +} + +describe('DockerPublishRuntime.deploy — wiring (stub)', () => { + it('a fresh version: creates the data volume, runs a new container, writes every file, and launches the entrypoint', async () => { + const { exec, calls } = stubExec((ctx) => { + if (ctx.args[0] === 'ps') return ok(''); + return ok(); + }); + const runtime = new DockerPublishRuntime({ execDocker: exec }); + await runtime.deploy({ + appId: 'todo', + version: 1, + entrypoint: 'server.js', + files: new Map([ + ['server.js', 'listen()'], + ['public/index.html', ''], + ]), + }); + + assert.ok(calls.some((c) => c.args[0] === 'volume' && c.args[1] === 'create')); + const runCall = calls.find((c) => c.args[0] === 'run'); + assert.ok(runCall, 'expected a docker run'); + assert.ok(runCall!.args.includes('-p')); + assert.ok(runCall!.args.some((a) => a.includes('127.0.0.1::8080'))); + assert.ok(runCall!.args.includes('-v')); + + const writeCalls = calls.filter((c) => c.args[0] === 'exec' && c.args.includes('-i')); + assert.equal(writeCalls.length, 2, 'expected one write per file'); + assert.ok(writeCalls.some((c) => c.input === 'listen()')); + assert.ok(writeCalls.some((c) => c.input === '')); + + const startCall = calls.find((c) => c.args[0] === 'exec' && c.args.includes('-d')); + assert.ok(startCall, 'expected a detached exec launching the entrypoint'); + assert.ok(startCall!.args.some((a) => a.includes('PORT=8080'))); + assert.ok(startCall!.args.some((a) => a.includes('DATA_DIR=/data'))); + assert.ok(startCall!.args.some((a) => a.includes("node 'server.js'"))); + }); + + it('deploy() is a no-op when the version already has a container — never re-materializes it', async () => { + const name = _internal.containerNameFor('todo', 1); + const { exec, calls } = stubExec((ctx) => { + if (ctx.args[0] === 'ps') return ok(name); + return ok(); + }); + const runtime = new DockerPublishRuntime({ execDocker: exec }); + await runtime.deploy({ appId: 'todo', version: 1, entrypoint: 'server.js', files: new Map([['server.js', 'NEW CONTENT']]) }); + assert.equal(calls.length, 1, 'only the existence check should run; nothing else for an already-deployed version'); + assert.ok(!calls.some((c) => c.args[0] === 'run')); + }); + + it('two versions of the SAME app share the identical data volume name', async () => { + const { exec, calls } = stubExec((ctx) => { + if (ctx.args[0] === 'ps') return ok(''); + return ok(); + }); + const runtime = new DockerPublishRuntime({ execDocker: exec }); + await runtime.deploy({ appId: 'todo', version: 1, entrypoint: 'x.js', files: new Map([['x.js', 'a']]) }); + await runtime.deploy({ appId: 'todo', version: 2, entrypoint: 'x.js', files: new Map([['x.js', 'b']]) }); + const volumeCreates = calls.filter((c) => c.args[0] === 'volume' && c.args[1] === 'create'); + assert.equal(volumeCreates.length, 2); + assert.equal(volumeCreates[0]!.args[2], volumeCreates[1]!.args[2], 'same app => same $DATA_DIR volume across versions'); + }); + + it('two DIFFERENT apps get DIFFERENT data volumes', async () => { + const { exec, calls } = stubExec((ctx) => { + if (ctx.args[0] === 'ps') return ok(''); + return ok(); + }); + const runtime = new DockerPublishRuntime({ execDocker: exec }); + await runtime.deploy({ appId: 'app-a', version: 1, entrypoint: 'x.js', files: new Map([['x.js', 'a']]) }); + await runtime.deploy({ appId: 'app-b', version: 1, entrypoint: 'x.js', files: new Map([['x.js', 'a']]) }); + const volumeCreates = calls.filter((c) => c.args[0] === 'volume' && c.args[1] === 'create'); + assert.notEqual(volumeCreates[0]!.args[2], volumeCreates[1]!.args[2]); + }); + + it('containerNameFor is deterministic per (appId, version) and distinct across versions', () => { + const a = _internal.containerNameFor('app', 1); + const b = _internal.containerNameFor('app', 1); + const c = _internal.containerNameFor('app', 2); + assert.equal(a, b); + assert.notEqual(a, c); + }); + + it('surfaces a clear error when the container fails to start', async () => { + const { exec } = stubExec((ctx) => { + if (ctx.args[0] === 'ps') return ok(''); + if (ctx.args[0] === 'run') return fail('no such image'); + return ok(); + }); + const runtime = new DockerPublishRuntime({ execDocker: exec }); + await assert.rejects(() => runtime.deploy({ appId: 'todo', version: 1, entrypoint: 'x.js', files: new Map([['x.js', 'a']]) })); + }); +}); + +describe('DockerPublishRuntime.portFor', () => { + it('parses the host port docker assigned', async () => { + const { exec } = stubExec((ctx) => { + if (ctx.args[0] === 'port') return ok('0.0.0.0:54321\n'); + return ok(); + }); + const runtime = new DockerPublishRuntime({ execDocker: exec }); + assert.equal(await runtime.portFor('todo', 1), 54321); + }); + + it('returns undefined when the container is not running (docker port fails)', async () => { + const { exec } = stubExec(() => fail('No such container')); + const runtime = new DockerPublishRuntime({ execDocker: exec }); + assert.equal(await runtime.portFor('todo', 1), undefined); + }); +}); + +// --------------------------------------------------------------------------- +// REAL-DOCKER tier — SANDBOX_DOCKER_TEST=1, opt-in (#576 pattern). Proves the +// $DATA_DIR contract end to end: a file written outside it is gone after a +// redeploy; a file written inside it survives. +// --------------------------------------------------------------------------- +const DOCKER_TEST_ENABLED = process.env['SANDBOX_DOCKER_TEST'] === '1'; +const describeIfDocker = DOCKER_TEST_ENABLED ? describe : describe.skip; +const containersToClean: string[] = []; +const volumesToClean: string[] = []; + +describeIfDocker('DockerPublishRuntime — real Docker (SANDBOX_DOCKER_TEST=1)', () => { + after(() => { + for (const name of containersToClean) { + try { + execFileSync('docker', ['rm', '-f', name], { stdio: 'ignore' }); + } catch { + /* best-effort cleanup */ + } + } + for (const name of volumesToClean) { + try { + execFileSync('docker', ['volume', 'rm', '-f', name], { stdio: 'ignore' }); + } catch { + /* best-effort cleanup */ + } + } + }); + + it('deploys a real Node entrypoint and serves it on the assigned port', async () => { + const runtime = new DockerPublishRuntime(); + const appId = `pub-serve-${String(Date.now())}`; + const script = + "const http=require('http'); http.createServer((req,res)=>{res.end('hello-from-published-app')}).listen(process.env.PORT);"; + await runtime.deploy({ appId, version: 1, entrypoint: 'server.js', files: new Map([['server.js', script]]) }); + containersToClean.push(_internal.containerNameFor(appId, 1)); + volumesToClean.push(_internal.dataVolumeFor(appId)); + + const port = await runtime.portFor(appId, 1); + assert.ok(port, 'expected an assigned host port'); + const response = await fetch(`http://127.0.0.1:${String(port)}/`); + const body = await response.text(); + assert.equal(body, 'hello-from-published-app'); + }); + + it('$DATA_DIR contract: a file outside it is gone after redeploy; a file inside it survives', async () => { + const runtime = new DockerPublishRuntime(); + const appId = `pub-datadir-${String(Date.now())}`; + const writerScript = [ + "const fs=require('fs'), http=require('http');", + "fs.writeFileSync('/app/ephemeral.txt', 'v1-ephemeral');", + "fs.writeFileSync((process.env.DATA_DIR||'/data') + '/durable.txt', 'v1-durable');", + "http.createServer((req,res)=>{res.end('v1')}).listen(process.env.PORT);", + ].join('\n'); + await runtime.deploy({ appId, version: 1, entrypoint: 'writer.js', files: new Map([['writer.js', writerScript]]) }); + const v1Name = _internal.containerNameFor(appId, 1); + containersToClean.push(v1Name); + volumesToClean.push(_internal.dataVolumeFor(appId)); + + // both files exist right after v1 deploys + assert.equal(execFileSync('docker', ['exec', v1Name, 'cat', '/app/ephemeral.txt']).toString(), 'v1-ephemeral'); + assert.equal(execFileSync('docker', ['exec', v1Name, 'cat', '/data/durable.txt']).toString(), 'v1-durable'); + + const readerScript = [ + "const http=require('http');", + "http.createServer((req,res)=>{res.end('v2')}).listen(process.env.PORT);", + ].join('\n'); + await runtime.deploy({ appId, version: 2, entrypoint: 'reader.js', files: new Map([['reader.js', readerScript]]) }); + const v2Name = _internal.containerNameFor(appId, 2); + containersToClean.push(v2Name); + + let ephemeralSurvived = true; + try { + execFileSync('docker', ['exec', v2Name, 'cat', '/app/ephemeral.txt'], { stdio: 'pipe' }); + } catch { + ephemeralSurvived = false; + } + assert.equal(ephemeralSurvived, false, 'a file written outside $DATA_DIR must NOT survive a redeploy'); + + const durable = execFileSync('docker', ['exec', v2Name, 'cat', '/data/durable.txt']).toString(); + assert.equal(durable, 'v1-durable', 'a file written inside $DATA_DIR must survive a redeploy'); + }); +}); diff --git a/middleware/test/publish/publish.test.ts b/middleware/test/publish/publish.test.ts new file mode 100644 index 00000000..b585cd6b --- /dev/null +++ b/middleware/test/publish/publish.test.ts @@ -0,0 +1,111 @@ +import { strict as assert } from 'node:assert'; +import { describe, it } from 'node:test'; + +import { publish, rollbackTo, type PublishRuntime } from '../../packages/harness-publish/src/publish.js'; +import { InMemoryPublishStore } from '../../packages/harness-publish/src/publishStore.js'; +import { PublishEntrypointNotFoundError, PublishVersionNotFoundError } from '../../packages/harness-publish/src/publishManifest.js'; + +function fakeSandbox(tree: Record) { + return { + async list(relativePath: string) { + const norm = relativePath === '.' || relativePath === '' ? '' : `${relativePath}/`; + const names = new Set(); + for (const p of Object.keys(tree)) { + if (!p.startsWith(norm)) continue; + const rest = p.slice(norm.length); + names.add(rest.includes('/') ? rest.split('/')[0]! : rest); + } + if (names.size === 0) return { ok: false as const, reason: 'not_found' as const, detail: 'x' }; + return { + ok: true as const, + entries: Array.from(names).map((name) => ({ + name, + kind: (Object.keys(tree).some((p) => p === `${norm}${name}`) ? 'file' : 'dir') as 'file' | 'dir', + })), + }; + }, + async read(relativePath: string) { + const content = tree[relativePath]; + return content === undefined ? { ok: false as const, reason: 'not_found' as const, detail: 'x' } : { ok: true as const, content }; + }, + }; +} + +function spyRuntime(): PublishRuntime & { readonly deployCalls: unknown[] } { + const deployCalls: unknown[] = []; + return { + deployCalls, + async deploy(args) { + deployCalls.push(args); + }, + }; +} + +describe('publish()', () => { + it('creates version 1 on first publish, deploys it, and points the app at it', async () => { + const store = new InMemoryPublishStore(); + const runtime = spyRuntime(); + const record = await publish({ + sandbox: fakeSandbox({ 'server.js': 'listen()' }), + store, + runtime, + input: { appId: 'todo', name: 'Todo', entrypoint: 'server.js', dir: '.', sourceScopeKey: 'personal:x' }, + }); + assert.equal(record.version, 1); + assert.equal(runtime.deployCalls.length, 1); + assert.equal((await store.getPointer('todo'))!.currentVersion, 1); + }); + + it('a second publish creates version 2 WITHOUT altering version 1s stored record', async () => { + const store = new InMemoryPublishStore(); + const runtime = spyRuntime(); + const input = { appId: 'todo', name: 'Todo', entrypoint: 'server.js', dir: '.', sourceScopeKey: 'personal:x' }; + await publish({ sandbox: fakeSandbox({ 'server.js': 'v1' }), store, runtime, input }); + const v1Before = await store.getVersion('todo', 1); + + const record2 = await publish({ sandbox: fakeSandbox({ 'server.js': 'v2' }), store, runtime, input }); + assert.equal(record2.version, 2); + + const v1After = await store.getVersion('todo', 1); + assert.deepEqual(v1After, v1Before, 'republishing must never mutate an earlier version'); + assert.equal((await store.getPointer('todo'))!.currentVersion, 2, 'the pointer moves to the new version'); + }); + + it('throws PublishEntrypointNotFoundError when the entrypoint is not in the published tree, and never deploys or advances the pointer', async () => { + const store = new InMemoryPublishStore(); + const runtime = spyRuntime(); + await assert.rejects( + () => + publish({ + sandbox: fakeSandbox({ 'index.html': '' }), + store, + runtime, + input: { appId: 'todo', name: 'Todo', entrypoint: 'server.js', dir: '.', sourceScopeKey: 'personal:x' }, + }), + PublishEntrypointNotFoundError, + ); + assert.equal(runtime.deployCalls.length, 0); + assert.equal(await store.getPointer('todo'), undefined); + }); +}); + +describe('rollbackTo() — pointer flip only', () => { + it('rolling back to an earlier version does NOT call PublishRuntime.deploy again', async () => { + const store = new InMemoryPublishStore(); + const runtime = spyRuntime(); + const input = { appId: 'todo', name: 'Todo', entrypoint: 'server.js', dir: '.', sourceScopeKey: 'personal:x' }; + await publish({ sandbox: fakeSandbox({ 'server.js': 'v1' }), store, runtime, input }); + await publish({ sandbox: fakeSandbox({ 'server.js': 'v2' }), store, runtime, input }); + assert.equal(runtime.deployCalls.length, 2); + + const pointer = await rollbackTo({ store, appId: 'todo', version: 1 }); + assert.equal(pointer.currentVersion, 1); + assert.equal(runtime.deployCalls.length, 2, 'rollbackTo must trigger NO new build/deploy'); + }); + + it('rejects a rollback to a version that was never published', async () => { + const store = new InMemoryPublishStore(); + await store.createVersion({ appId: 'todo', name: 'Todo', entrypoint: 'x.js', dirHash: 'h', sourceScopeKey: 's', now: new Date() }); + await assert.rejects(() => rollbackTo({ store, appId: 'todo', version: 99 }), PublishVersionNotFoundError); + }); +}); diff --git a/middleware/test/publish/publishGateway.test.ts b/middleware/test/publish/publishGateway.test.ts new file mode 100644 index 00000000..58e9eaae --- /dev/null +++ b/middleware/test/publish/publishGateway.test.ts @@ -0,0 +1,133 @@ +import { strict as assert } from 'node:assert'; +import { describe, it, after, before } from 'node:test'; +import * as http from 'node:http'; +import type { AddressInfo } from 'node:net'; + +import { createPublishGateway, type PublishGatewayTarget } from '../../packages/harness-publish/src/publishGateway.js'; + +/** + * Issue #581 — the non-negotiable proof: a published app can neither READ + * an admin session cookie forwarded through the gateway, nor SET a cookie + * scoped to the admin's own host. Both fake "admin" and "app backend" here + * are plain `http.Server`s — no Docker, no real browser, just the actual + * HTTP semantics the gateway is responsible for. `appsHostSuffix` models a + * dedicated apps domain (e.g. `.apps.omadia.internal`); the admin's own + * host (`admin.omadia.internal`) never ends with it. + * + * Uses `http.request` directly rather than the `fetch` global: the Fetch + * spec forbids a caller from overriding the `Host` header, which is exactly + * what these tests need to control to simulate different virtual hosts + * hitting one gateway port. + */ +const APPS_HOST_SUFFIX = '.apps.omadia.internal'; +const ADMIN_HOST = 'admin.omadia.internal'; + +async function listen(server: http.Server): Promise<{ port: number; close: () => Promise }> { + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const port = (server.address() as AddressInfo).port; + return { port, close: () => new Promise((resolve) => server.close(() => resolve())) }; +} + +interface RawResponse { + readonly status: number; + readonly headers: http.IncomingHttpHeaders; + readonly body: string; +} + +function request(port: number, host: string, extraHeaders: Record = {}): Promise { + return new Promise((resolve, reject) => { + const req = http.request( + { host: '127.0.0.1', port, path: '/', method: 'GET', headers: { host, ...extraHeaders } }, + (res) => { + const chunks: Buffer[] = []; + res.on('data', (c: Buffer) => chunks.push(c)); + res.on('end', () => { + resolve({ status: res.statusCode ?? 0, headers: res.headers, body: Buffer.concat(chunks).toString('utf8') }); + }); + }, + ); + req.on('error', reject); + req.end(); + }); +} + +describe('PublishGateway — origin isolation from the admin/portal origin', () => { + let appBackend: http.Server; + let appPort: number; + let closeAppBackend: () => Promise; + let receivedCookieHeader: string | undefined; + let appReceivedRequests = 0; + let appSetCookieToSend: string | undefined; + + let gateway: http.Server; + let gatewayPort: number; + let gatewayResolveCalls: string[] = []; + + before(async () => { + appBackend = http.createServer((req, res) => { + appReceivedRequests += 1; + receivedCookieHeader = req.headers.cookie; + if (appSetCookieToSend) res.setHeader('set-cookie', appSetCookieToSend); + res.end('app-response'); + }); + const listening = await listen(appBackend); + appPort = listening.port; + closeAppBackend = listening.close; + + const resolveTarget = async (appSlug: string): Promise => { + gatewayResolveCalls.push(appSlug); + if (appSlug === 'todo') return { host: '127.0.0.1', port: appPort }; + return undefined; + }; + gateway = createPublishGateway({ appsHostSuffix: APPS_HOST_SUFFIX, resolveTarget }); + const gatewayListening = await listen(gateway); + gatewayPort = gatewayListening.port; + }); + + after(async () => { + await closeAppBackend(); + await new Promise((resolve) => gateway.close(() => resolve())); + }); + + it('rejects a request whose Host is the admin origin — the app backend is never even resolved', async () => { + appReceivedRequests = 0; + gatewayResolveCalls = []; + const res = await request(gatewayPort, ADMIN_HOST); + assert.equal(res.status, 400); + assert.equal(appReceivedRequests, 0, 'the app backend must never see a request addressed to the admin host'); + assert.deepEqual(gatewayResolveCalls, [], 'resolveTarget must not even be consulted for a non-apps host'); + }); + + it('strips the Cookie header before forwarding to the app backend, even when it carries an admin session cookie', async () => { + receivedCookieHeader = 'not-set-yet'; + const res = await request(gatewayPort, `todo${APPS_HOST_SUFFIX}`, { cookie: 'admin_session=super-secret-token' }); + assert.equal(res.status, 200); + assert.equal(receivedCookieHeader, undefined, 'the app backend must receive NO Cookie header at all'); + }); + + it('strips a Set-Cookie the app tries to scope to an explicit Domain=', async () => { + appSetCookieToSend = 'hijack=1; Domain=admin.omadia.internal; Path=/'; + const res = await request(gatewayPort, `todo${APPS_HOST_SUFFIX}`); + assert.equal(res.status, 200); + assert.equal(res.headers['set-cookie'], undefined, 'a domain-scoped Set-Cookie from the app must never reach the client'); + appSetCookieToSend = undefined; + }); + + it('passes through an app-scoped Set-Cookie (no explicit Domain=) unchanged', async () => { + appSetCookieToSend = 'app_session=fine; Path=/'; + const res = await request(gatewayPort, `todo${APPS_HOST_SUFFIX}`); + assert.equal(res.status, 200); + assert.deepEqual(res.headers['set-cookie'], ['app_session=fine; Path=/']); + appSetCookieToSend = undefined; + }); + + it('proxies a normal request/response through correctly (functional correctness, not just security)', async () => { + const res = await request(gatewayPort, `todo${APPS_HOST_SUFFIX}`); + assert.equal(res.body, 'app-response'); + }); + + it('returns 404 when resolveTarget has no live version for the app', async () => { + const res = await request(gatewayPort, `nope${APPS_HOST_SUFFIX}`); + assert.equal(res.status, 404); + }); +}); diff --git a/middleware/test/publish/publishStore.pg.test.ts b/middleware/test/publish/publishStore.pg.test.ts new file mode 100644 index 00000000..a7241235 --- /dev/null +++ b/middleware/test/publish/publishStore.pg.test.ts @@ -0,0 +1,114 @@ +/** + * Issue #581 — `PostgresPublishStore` against a real Postgres. Skips + * cleanly (same convention as `postgresSandboxRegistry.pg.test.ts`) when no + * test database is configured. Schema is created inline from migration + * `0045_publish_versions.sql` so this suite does not depend on the + * multi-orchestrator migrator having run against the test DB. + */ + +import { strict as assert } from 'node:assert'; +import { after, before, describe, it } from 'node:test'; +import { Pool } from 'pg'; + +import { probePgTest } from '../_helpers/pgTestDb.js'; + +import { PostgresPublishStore } from '../../packages/harness-publish/src/postgresPublishStore.js'; + +const { url: PG_URL, reachable: pgAvailable } = await probePgTest({ + label: 'postgresPublishStore', + vars: ['GRAPH_PG_TEST_URL', 'MEMORY_PG_TEST_URL', 'DATABASE_URL'], + timeoutMs: 1_500, +}); + +const SCHEMA = ` +CREATE TABLE IF NOT EXISTS publish_versions ( + app_id TEXT NOT NULL, + version INTEGER NOT NULL, + name TEXT NOT NULL, + entrypoint TEXT NOT NULL, + dir_hash TEXT NOT NULL, + source_scope_key TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (app_id, version) +); +CREATE TABLE IF NOT EXISTS publish_apps ( + app_id TEXT PRIMARY KEY, + next_version INTEGER NOT NULL DEFAULT 1, + current_version INTEGER, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT publish_apps_current_version_fk + FOREIGN KEY (app_id, current_version) REFERENCES publish_versions (app_id, version) +); +`; + +const describeIf = pgAvailable ? describe : describe.skip; + +describeIf('PostgresPublishStore (#581)', () => { + let pool: Pool; + let store: PostgresPublishStore; + + before(async () => { + pool = new Pool({ connectionString: PG_URL }); + await pool.query(SCHEMA); + store = new PostgresPublishStore(pool); + }); + + after(async () => { + await pool.query('DROP TABLE IF EXISTS publish_apps'); + await pool.query('DROP TABLE IF EXISTS publish_versions'); + await pool.end(); + }); + + it('createVersion allocates 1, 2, 3 for repeated publishes to one app', async () => { + const appId = `pg-alloc-${String(Date.now())}`; + const v1 = await store.createVersion({ appId, name: 'A', entrypoint: 'x.js', dirHash: 'h1', sourceScopeKey: 's', now: new Date() }); + const v2 = await store.createVersion({ appId, name: 'A', entrypoint: 'x.js', dirHash: 'h2', sourceScopeKey: 's', now: new Date() }); + assert.equal(v1.version, 1); + assert.equal(v2.version, 2); + }); + + it('concurrent createVersion calls for the same app never collide, and neither version is overwritten', async () => { + const appId = `pg-race-${String(Date.now())}`; + const [a, b] = await Promise.all([ + store.createVersion({ appId, name: 'A', entrypoint: 'a.js', dirHash: 'hash-a', sourceScopeKey: 's', now: new Date() }), + store.createVersion({ appId, name: 'A', entrypoint: 'b.js', dirHash: 'hash-b', sourceScopeKey: 's', now: new Date() }), + ]); + assert.notEqual(a.version, b.version); + const versions = await store.listVersions(appId); + assert.equal(versions.length, 2); + const byVersion = new Map(versions.map((v) => [v.version, v])); + assert.equal(byVersion.get(a.version)!.dirHash, a.dirHash); + assert.equal(byVersion.get(b.version)!.dirHash, b.dirHash); + }); + + it('the (app_id, version) primary key rejects a direct duplicate insert at the schema level', async () => { + const appId = `pg-pk-${String(Date.now())}`; + await store.createVersion({ appId, name: 'A', entrypoint: 'x.js', dirHash: 'h1', sourceScopeKey: 's', now: new Date() }); + await assert.rejects(() => + pool.query( + `INSERT INTO publish_versions (app_id, version, name, entrypoint, dir_hash, source_scope_key) VALUES ($1, 1, 'B', 'y.js', 'h2', 's')`, + [appId], + ), + ); + const stillOriginal = await store.getVersion(appId, 1); + assert.equal(stillOriginal!.dirHash, 'h1', 'the original version 1 row must be untouched'); + }); + + it('setPointer requires the version to already exist (composite FK)', async () => { + const appId = `pg-fk-${String(Date.now())}`; + await assert.rejects(() => store.setPointer(appId, 1, new Date())); + }); + + it('rollback: setPointer to an earlier version updates getPointer without touching version rows', async () => { + const appId = `pg-rollback-${String(Date.now())}`; + await store.createVersion({ appId, name: 'A', entrypoint: 'x.js', dirHash: 'h1', sourceScopeKey: 's', now: new Date() }); + await store.createVersion({ appId, name: 'A', entrypoint: 'x.js', dirHash: 'h2', sourceScopeKey: 's', now: new Date() }); + await store.setPointer(appId, 2, new Date()); + assert.equal((await store.getPointer(appId))!.currentVersion, 2); + + await store.setPointer(appId, 1, new Date()); + assert.equal((await store.getPointer(appId))!.currentVersion, 1); + const v1 = await store.getVersion(appId, 1); + assert.equal(v1!.dirHash, 'h1'); + }); +}); diff --git a/middleware/test/publish/publishStore.test.ts b/middleware/test/publish/publishStore.test.ts new file mode 100644 index 00000000..5699f179 --- /dev/null +++ b/middleware/test/publish/publishStore.test.ts @@ -0,0 +1,124 @@ +import { strict as assert } from 'node:assert'; +import { describe, it } from 'node:test'; + +import { InMemoryPublishStore } from '../../packages/harness-publish/src/publishStore.js'; + +/** + * Issue #581 — `PublishStore` invariants: every version is immutable once + * created, and `setPointer` never touches the version rows. + */ +describe('InMemoryPublishStore.createVersion — immutability', () => { + it('allocates version 1, then 2, then 3 for repeated publishes to the same app', async () => { + const store = new InMemoryPublishStore(); + const v1 = await store.createVersion({ + appId: 'todo-app', + name: 'Todo', + entrypoint: 'server.js', + dirHash: 'hash-a', + sourceScopeKey: 'personal:x', + now: new Date('2026-01-01T00:00:00Z'), + }); + const v2 = await store.createVersion({ + appId: 'todo-app', + name: 'Todo', + entrypoint: 'server.js', + dirHash: 'hash-b', + sourceScopeKey: 'personal:x', + now: new Date('2026-01-02T00:00:00Z'), + }); + assert.equal(v1.version, 1); + assert.equal(v2.version, 2); + }); + + it('a second app gets its own version sequence starting at 1', async () => { + const store = new InMemoryPublishStore(); + await store.createVersion({ + appId: 'app-a', + name: 'A', + entrypoint: 'a.js', + dirHash: 'h', + sourceScopeKey: 's', + now: new Date(), + }); + const bFirst = await store.createVersion({ + appId: 'app-b', + name: 'B', + entrypoint: 'b.js', + dirHash: 'h', + sourceScopeKey: 's', + now: new Date(), + }); + assert.equal(bFirst.version, 1); + }); + + it('concurrent publishes to the same app never collide on a version number, and neither version is lost or overwritten', async () => { + const store = new InMemoryPublishStore(); + const now = new Date(); + const [a, b] = await Promise.all([ + store.createVersion({ + appId: 'race-app', + name: 'Race', + entrypoint: 'a.js', + dirHash: 'hash-a', + sourceScopeKey: 's', + now, + }), + store.createVersion({ + appId: 'race-app', + name: 'Race', + entrypoint: 'b.js', + dirHash: 'hash-b', + sourceScopeKey: 's', + now, + }), + ]); + assert.notEqual(a.version, b.version, 'concurrent publishes must not receive the same version number'); + + const all = await store.listVersions('race-app'); + assert.equal(all.length, 2); + const stored = new Map(all.map((v) => [v.version, v])); + assert.equal(stored.get(a.version)!.dirHash, a.dirHash, "version a's content must be exactly what was published as version a"); + assert.equal(stored.get(b.version)!.dirHash, b.dirHash, "version b's content must be exactly what was published as version b"); + }); + + it('PublishStore has no update/delete method — TypeScript proves this at compile time', () => { + const store: import('../../packages/harness-publish/src/publishStore.js').PublishStore = new InMemoryPublishStore(); + // @ts-expect-error — updateVersion must not exist on the PublishStore contract + assert.equal(typeof store.updateVersion, 'undefined'); + // @ts-expect-error — deleteVersion must not exist on the PublishStore contract + assert.equal(typeof store.deleteVersion, 'undefined'); + }); +}); + +describe('InMemoryPublishStore — pointer is the only mutable state', () => { + it('setPointer does not appear in listVersions and does not change any version record', async () => { + const store = new InMemoryPublishStore(); + const v1 = await store.createVersion({ + appId: 'app', + name: 'App', + entrypoint: 'x.js', + dirHash: 'hash-1', + sourceScopeKey: 's', + now: new Date(), + }); + await store.setPointer('app', v1.version, new Date()); + const versionAfter = await store.getVersion('app', v1.version); + assert.deepEqual(versionAfter, v1, 'setPointer must never mutate a version record'); + }); + + it('getPointer reflects the most recent setPointer call, and rollback (an earlier version) is a valid target', async () => { + const store = new InMemoryPublishStore(); + await store.createVersion({ appId: 'app', name: 'App', entrypoint: 'x.js', dirHash: 'h1', sourceScopeKey: 's', now: new Date() }); + await store.createVersion({ appId: 'app', name: 'App', entrypoint: 'x.js', dirHash: 'h2', sourceScopeKey: 's', now: new Date() }); + await store.setPointer('app', 2, new Date()); + assert.equal((await store.getPointer('app'))!.currentVersion, 2); + + await store.setPointer('app', 1, new Date()); + assert.equal((await store.getPointer('app'))!.currentVersion, 1); + }); + + it('getPointer is undefined for an app that has never had a pointer set', async () => { + const store = new InMemoryPublishStore(); + assert.equal(await store.getPointer('never-published'), undefined); + }); +}); diff --git a/middleware/test/publish/treeCollector.test.ts b/middleware/test/publish/treeCollector.test.ts new file mode 100644 index 00000000..83291277 --- /dev/null +++ b/middleware/test/publish/treeCollector.test.ts @@ -0,0 +1,111 @@ +import { strict as assert } from 'node:assert'; +import { describe, it } from 'node:test'; + +import { collectTree } from '../../packages/harness-publish/src/treeCollector.js'; +import { PublishTreeTooLargeError } from '../../packages/harness-publish/src/publishManifest.js'; + +/** + * Issue #581 — `collectTree` must ONLY ever call `list()`/`read()` on the + * `Sandbox` it is given, and must recurse using the paths `list()` itself + * returned — never an agent-supplied raw path. A fake in-memory + * "filesystem" backing a minimal `list`/`read` pair stands in for a real + * `Sandbox`; what matters here is the WALK logic, not the traversal guard + * itself (that is `@omadia/sandbox`'s job and is tested there). + */ +function fakeSandbox(tree: Record) { + const dirs = new Set(['.']); + for (const filePath of Object.keys(tree)) { + const parts = filePath.split('/'); + let acc = ''; + for (let i = 0; i < parts.length - 1; i += 1) { + acc = acc === '' ? parts[i]! : `${acc}/${parts[i]}`; + dirs.add(acc); + } + } + const calls: string[] = []; + return { + calls, + sandbox: { + async list(relativePath: string) { + calls.push(`list:${relativePath}`); + const norm = relativePath === '.' || relativePath === '' ? '.' : relativePath; + if (!dirs.has(norm)) return { ok: false as const, reason: 'not_found' as const, detail: 'no such dir' }; + const prefix = norm === '.' ? '' : `${norm}/`; + const seen = new Map(); + for (const filePath of Object.keys(tree)) { + if (!filePath.startsWith(prefix)) continue; + const rest = filePath.slice(prefix.length); + if (rest.includes('/')) seen.set(rest.split('/')[0]!, 'dir'); + else seen.set(rest, 'file'); + } + return { ok: true as const, entries: Array.from(seen.entries()).map(([name, kind]) => ({ name, kind })) }; + }, + async read(relativePath: string) { + calls.push(`read:${relativePath}`); + const content = tree[relativePath]; + if (content === undefined) return { ok: false as const, reason: 'not_found' as const, detail: 'no such file' }; + return { ok: true as const, content }; + }, + }, + }; +} + +describe('collectTree', () => { + it('collects a flat directory', async () => { + const { sandbox } = fakeSandbox({ 'server.js': 'console.log(1)', 'readme.txt': 'hi' }); + const files = await collectTree(sandbox, '.', 'app'); + assert.deepEqual( + Array.from(files.entries()).sort(), + [ + ['readme.txt', 'hi'], + ['server.js', 'console.log(1)'], + ], + ); + }); + + it('recurses into subdirectories and produces POSIX-joined relative paths', async () => { + const { sandbox } = fakeSandbox({ + 'server.js': 'root', + 'public/index.html': '', + 'public/css/style.css': 'body{}', + }); + const files = await collectTree(sandbox, '.', 'app'); + assert.equal(files.get('public/index.html'), ''); + assert.equal(files.get('public/css/style.css'), 'body{}'); + assert.equal(files.size, 3); + }); + + it('publishes a subdirectory rooted at `dir`, not the whole sandbox', async () => { + const { sandbox } = fakeSandbox({ + 'apps/todo/server.js': 'todo app', + 'apps/todo/data.json': '{}', + 'apps/other/server.js': 'other app', + }); + const files = await collectTree(sandbox, 'apps/todo', 'app'); + assert.deepEqual(Array.from(files.keys()).sort(), ['data.json', 'server.js']); + assert.equal(files.get('server.js'), 'todo app'); + }); + + it('only ever calls list()/read() — never touches a raw filesystem path', async () => { + const { sandbox, calls } = fakeSandbox({ 'a/b.js': 'x' }); + await collectTree(sandbox, '.', 'app'); + assert.ok(calls.every((c) => c.startsWith('list:') || c.startsWith('read:'))); + assert.ok(calls.includes('list:.')); + assert.ok(calls.includes('list:a')); + assert.ok(calls.includes('read:a/b.js')); + }); + + it('throws PublishTreeTooLargeError instead of silently truncating when maxFiles is exceeded', async () => { + const { sandbox } = fakeSandbox({ 'a.js': '1', 'b.js': '2', 'c.js': '3' }); + await assert.rejects( + () => collectTree(sandbox, '.', 'app', { maxFiles: 2 }), + PublishTreeTooLargeError, + ); + }); + + it('an empty/missing directory publishes zero files rather than throwing', async () => { + const { sandbox } = fakeSandbox({}); + const files = await collectTree(sandbox, 'does/not/exist', 'app'); + assert.equal(files.size, 0); + }); +});