diff --git a/README.md b/README.md index 0051949..2887542 100644 --- a/README.md +++ b/README.md @@ -539,6 +539,13 @@ The conformance command intentionally treats unknown schemas, malformed JSON, and empty fixture sets as harness failures. Invalid fixtures must fail because the schema rejected them, not because the fixture cannot be parsed. +## Infra Service Entrypoints + +Service-capable infra repos should declare supported, deferred, or unsupported +HTTP/MCP service surfaces in `hasna.contract.json` instead of adding `*-serve` +bins mechanically. The current Worker 2 baseline matrix is in +[`docs/infra-service-entrypoint-matrix.md`](docs/infra-service-entrypoint-matrix.md). + ## Verification ```bash diff --git a/docs/infra-service-entrypoint-matrix.md b/docs/infra-service-entrypoint-matrix.md new file mode 100644 index 0000000..327ed50 --- /dev/null +++ b/docs/infra-service-entrypoint-matrix.md @@ -0,0 +1,55 @@ +# Infra Service Entrypoint Matrix + +Date: 2026-07-06 + +Source tasks: + +- `0c8caca6-c564-4ad3-a878-23a8405ca2bc` - Add infra service binaries and deployment contracts. +- `reports/task-proposals/adversarial-12/reviewer-02-open-infra.md` proposal 4. +- `reports/task-proposals/adversarial-12/reviewer-09-service-baseline.md` SVC-01 through SVC-03. +- `reports/task-proposals/adversarial-12/reviewer-12-final-dedupe.md` task 17. + +This matrix is intentionally conservative. A `*-serve` package bin is only +`supported` when the repo already exposes a service boundary that can declare +health/version routes, auth behavior, deployment modes, and readiness gates in +`hasna.contract.json`. Repos with raw secret values, undefined hosted auth, or +missing state ownership must use `deferred` instead of publishing a broad server +bin. + +| Repo | Package | Current bins | Service decision | Required next contract evidence | +| --- | --- | --- | --- | --- | +| `open-backup` | `@hasna/backup@0.1.2` | `backup`, `backup-mcp` | `deferred` | Define hosted state owner, backup target credential refs, `/health`, `/ready`, redacted inventory output, and restore-smoke evidence before `backup-serve`. | +| `open-bridge` | `@hasna/bridge@0.2.1` | `bridge`, `bridge-mcp` | `deferred` | Define bridge auth scopes, connector secret refs, event replay boundaries, and no-secret output gates before `bridge-serve`. | +| `open-domains` | `@hasna/domains@0.0.27` | `domains`, `domains-mcp`, `domains-serve` | `supported` | Add/refresh `hasna.contract.json` service surface with `local` and `self-hosted` support, `/health`, `/ready`, `/version`, `/v1`, Route53/Cloudflare secret refs, dry-run DNS mutation gates, and redaction tests. | +| `open-hooks` | `@hasna/hooks@0.2.20` | `hooks` | `deferred` | Decide whether hooks is CLI-only, MCP-capable, or service-capable; service mode needs webhook signature/replay gates and operator-visible DLQ before `hooks-serve`. | +| `open-machines` | `@hasna/machines@0.0.63` | `machines`, `machines-mcp`, `machines-agent`, `machines-serve` | `supported` | Add/refresh service surface with lease/claim auth scopes, private metadata redaction, `/v1` ownership boundaries, and fleet dry-run fixture gates. | +| `open-releases` | `@hasna/releases@0.1.0` | `releases`, `releases-mcp` | `deferred` | Promote release evidence schema and append-only ledger first; then add `releases-serve` with package/version/gate/evidence APIs and unauthorized mutation denial. | +| `open-secrets` | `@hasna/secrets@0.1.33` | `secrets`, `secrets-mcp`, `secrets-serve` | `deferred` for hosted raw-value access | Hosted service surfaces must declare secret-reference and lease semantics, local-only reveal exclusions, audit gates, and tests proving HTTP/MCP never returns raw secret values. | +| `open-servers` | `@hasna/servers@0.1.21` | `servers`, `servers-mcp` | `deferred` | Define lifecycle locks, operation ids, job-scoped auth, command/env redaction, and registered-server boundaries before `servers-serve`. | +| `open-uptime` | `@hasna/uptime@0.1.69` | `uptime`, `uptime-mcp` | `deferred` pending standard bin | Existing `serve` command should either publish `uptime-serve` or declare `serve: unsupported`; contract needs probe storage readiness and redacted status gates. | +| `open-gateway` | `@hasna/gateway@0.1.3` | `gateway`, `gateway-mcp` | `deferred` until gateway runtime task completes | Add `gateway-serve` only with auth, secret references, budget/ledger readiness gates, redacted diagnostics, CORS policy, and mock chat smoke. | +| `open-monitor` | `@hasna/monitor@0.1.24` | `monitor`, `monitor-mcp`, `monitor-server`, `monitor-web` | `supported with alias decision` | Either add `monitor-serve` as an alias or declare `monitor-server` as the canonical exception; service surface must include health/readiness/version and redacted machine metadata. | + +## Contract fields to use + +Use `hasna.service_contract.v1` with: + +- `deploymentModes`: one or more of `local`, `self-hosted`, `cloud`. Do not use + `remote` as a deployment mode. +- `serviceSurfaces[]`: one record per HTTP/MCP service boundary. +- `serviceSurfaces[].status`: `supported`, `deferred`, or `unsupported`. +- `serviceSurfaces[].deferReason`: required for `deferred` and `unsupported`. +- `serviceSurfaces[].readinessGates[]`: auth, storage, secret-ref, migration, + health, readiness, redaction, smoke, and operator gates with command/evidence + status when available. + +## First implementation tasks + +1. Apply this contract to `open-domains`, `open-machines`, and `open-monitor` + first because they already expose service/server bins. +2. Complete the separate `open-gateway` runtime task before declaring + `gateway-serve` supported. +3. Keep `open-secrets` hosted service deferred until raw-value reveal is + strictly local-only and network surfaces return refs/leases only. +4. Create app-specific tasks for `open-releases`, `open-servers`, and + `open-uptime` once the service boundary and readiness owner are agreed. diff --git a/examples/service-contract.valid.json b/examples/service-contract.valid.json index 8cb832e..d0f1ca4 100644 --- a/examples/service-contract.valid.json +++ b/examples/service-contract.valid.json @@ -6,6 +6,38 @@ "kitVersion": "0.3.0", "description": "Universal task management for AI coding agents.", "bins": ["todos", "todos-mcp", "todos-serve"], + "deploymentModes": ["local", "self-hosted"], + "serviceSurfaces": [ + { + "name": "http", + "status": "supported", + "bin": "todos-serve", + "mcpBin": "todos-mcp", + "authMode": "api-key", + "deploymentModes": ["local", "self-hosted"], + "health": { "method": "GET", "path": "/health", "public": true }, + "readiness": { "method": "GET", "path": "/ready", "public": false }, + "version": { "method": "GET", "path": "/version", "public": true }, + "apiBasePath": "/v1", + "readinessGates": [ + { + "id": "auth-negative-smoke", + "kind": "auth", + "required": true, + "command": "todos serve --port 0 && curl /v1/tasks", + "status": "pending", + "summary": "Unauthenticated non-health routes must fail closed before self-hosted readiness." + }, + { + "id": "redacted-output-scan", + "kind": "redaction", + "required": true, + "status": "pending", + "summary": "Health, readiness, logs, and CLI JSON must not expose credential values." + } + ] + } + ], "storage": { "mode": "cloud", "envPrefix": "HASNA_TODOS_", diff --git a/src/hasna.contract.schema.json b/src/hasna.contract.schema.json index 1b16673..5c6e1ba 100644 --- a/src/hasna.contract.schema.json +++ b/src/hasna.contract.schema.json @@ -5,7 +5,13 @@ "description": "Repo self-description (hasna.contract.json) for the Hasna Service Contract v1. Storage runtime enum is local|cloud ONLY per Amendment A1 (PURE REMOTE).", "type": "object", "additionalProperties": false, - "required": ["schema", "name", "class", "contractVersion", "kitVersion"], + "required": [ + "schema", + "name", + "class", + "contractVersion", + "kitVersion" + ], "properties": { "$schema": { "type": "string", @@ -20,7 +26,12 @@ "description": "Lowercase dashed app short-name, e.g. todos, mailery, loops." }, "class": { - "enum": ["library", "cli-with-store", "service", "saas"] + "enum": [ + "library", + "cli-with-store", + "service", + "saas" + ] }, "contractVersion": { "const": "v1" @@ -36,16 +47,247 @@ }, "bins": { "type": "array", - "items": { "type": "string", "minLength": 1 }, + "items": { + "type": "string", + "minLength": 1 + }, "description": "Declared bins. Allowlisted: , -cli, -mcp, -serve, -worker, -runner, -daemon, -migrate, -doctor." }, + "deploymentModes": { + "type": "array", + "items": { + "enum": [ + "local", + "self-hosted", + "cloud" + ] + }, + "description": "Supported deployment modes. local = this machine, self-hosted = Hasna-owned AWS, cloud = multi-tenant SaaS for outside users." + }, + "serviceSurfaces": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "name", + "status", + "authMode", + "deploymentModes" + ], + "properties": { + "name": { + "type": "string", + "minLength": 1 + }, + "status": { + "enum": [ + "supported", + "deferred", + "unsupported" + ] + }, + "bin": { + "type": "string", + "minLength": 1 + }, + "mcpBin": { + "type": "string", + "minLength": 1 + }, + "authMode": { + "enum": [ + "none", + "local-only", + "api-key", + "session", + "service-token", + "custom" + ] + }, + "deploymentModes": { + "type": "array", + "items": { + "enum": [ + "local", + "self-hosted", + "cloud" + ] + }, + "minItems": 1 + }, + "health": { + "type": "object", + "additionalProperties": false, + "required": [ + "method", + "path" + ], + "properties": { + "method": { + "enum": [ + "GET", + "POST", + "PUT", + "PATCH", + "DELETE" + ] + }, + "path": { + "type": "string", + "pattern": "^/[A-Za-z0-9_./:*-]*$" + }, + "public": { + "type": "boolean" + }, + "description": { + "type": "string", + "minLength": 1 + } + } + }, + "readiness": { + "type": "object", + "additionalProperties": false, + "required": [ + "method", + "path" + ], + "properties": { + "method": { + "enum": [ + "GET", + "POST", + "PUT", + "PATCH", + "DELETE" + ] + }, + "path": { + "type": "string", + "pattern": "^/[A-Za-z0-9_./:*-]*$" + }, + "public": { + "type": "boolean" + }, + "description": { + "type": "string", + "minLength": 1 + } + } + }, + "version": { + "type": "object", + "additionalProperties": false, + "required": [ + "method", + "path" + ], + "properties": { + "method": { + "enum": [ + "GET", + "POST", + "PUT", + "PATCH", + "DELETE" + ] + }, + "path": { + "type": "string", + "pattern": "^/[A-Za-z0-9_./:*-]*$" + }, + "public": { + "type": "boolean" + }, + "description": { + "type": "string", + "minLength": 1 + } + } + }, + "apiBasePath": { + "type": "string", + "pattern": "^/v[0-9]+$" + }, + "openApiPath": { + "type": "string", + "pattern": "^/[A-Za-z0-9_./:-]*$" + }, + "deferReason": { + "type": "string", + "minLength": 1 + }, + "readinessGates": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "kind" + ], + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "kind": { + "enum": [ + "auth", + "storage", + "secret-ref", + "migration", + "health", + "readiness", + "redaction", + "smoke", + "operator", + "other" + ] + }, + "required": { + "type": "boolean" + }, + "command": { + "type": "string", + "minLength": 1 + }, + "evidenceRef": { + "type": "object" + }, + "status": { + "enum": [ + "pending", + "passed", + "failed", + "blocked", + "deferred" + ] + }, + "summary": { + "type": "string", + "minLength": 1 + } + } + } + } + } + }, + "description": "Declared HTTP/MCP service surfaces. Supported surfaces name lifecycle endpoints; unsafe or unfinished surfaces use deferred/unsupported with a reason." + }, "storage": { "type": "object", "additionalProperties": false, - "required": ["mode"], + "required": [ + "mode" + ], "properties": { "mode": { - "enum": ["local", "cloud"], + "enum": [ + "local", + "cloud" + ], "description": "Runtime storage enum. local|cloud ONLY (Amendment A1: PURE REMOTE)." }, "envPrefix": { diff --git a/src/schemas.ts b/src/schemas.ts index 11a21ac..a7e5f30 100644 --- a/src/schemas.ts +++ b/src/schemas.ts @@ -1512,6 +1512,96 @@ export const SERVICE_CONTRACT_VERSION = "v1"; export const RepoClassSchema = z.enum(["library", "cli-with-store", "service", "saas"]); export type RepoClass = z.infer; +export const DEPLOYMENT_MODES = ["local", "self-hosted", "cloud"] as const; +export const DeploymentModeSchema = z.enum(DEPLOYMENT_MODES); +export type DeploymentMode = z.infer; + +export const ServiceSurfaceStatusSchema = z.enum(["supported", "deferred", "unsupported"]); +export type ServiceSurfaceStatus = z.infer; + +export const ServiceAuthModeSchema = z.enum(["none", "local-only", "api-key", "session", "service-token", "custom"]); +export type ServiceAuthMode = z.infer; + +export const ServiceEndpointSchema = z + .object({ + method: z.enum(["GET", "POST", "PUT", "PATCH", "DELETE"]), + path: z.string().regex(/^\/[A-Za-z0-9_./:*-]*$/, "Endpoint paths must be absolute HTTP paths"), + public: z.boolean().default(false), + description: z.string().min(1).optional() + }) + .strict(); +export type ServiceEndpoint = z.infer; + +export const DeploymentReadinessGateSchema = z + .object({ + id: z.string().min(1), + kind: z.enum(["auth", "storage", "secret-ref", "migration", "health", "readiness", "redaction", "smoke", "operator", "other"]), + required: z.boolean().default(true), + command: z.string().min(1).optional(), + evidenceRef: EvidencePointerSchema.optional(), + status: z.enum(["pending", "passed", "failed", "blocked", "deferred"]).default("pending"), + summary: z.string().min(1).optional() + }) + .strict() + .superRefine((value, ctx) => { + if ((value.status === "passed" || value.status === "failed" || value.status === "blocked") && !value.command && !value.evidenceRef && !value.summary) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Terminal readiness gates require command, evidenceRef, or summary", + path: ["status"] + }); + } + }); +export type DeploymentReadinessGate = z.infer; + +export const ServiceSurfaceSchema = z + .object({ + name: z.string().min(1), + status: ServiceSurfaceStatusSchema, + bin: z.string().min(1).optional(), + mcpBin: z.string().min(1).optional(), + authMode: ServiceAuthModeSchema, + deploymentModes: z.array(DeploymentModeSchema).min(1), + health: ServiceEndpointSchema.optional(), + readiness: ServiceEndpointSchema.optional(), + version: ServiceEndpointSchema.optional(), + apiBasePath: z.string().regex(/^\/v[0-9]+$/, "Stable API base path must be /vN").optional(), + openApiPath: z.string().regex(/^\/[A-Za-z0-9_./:-]*$/).optional(), + deferReason: z.string().min(1).optional(), + readinessGates: z.array(DeploymentReadinessGateSchema).default([]) + }) + .strict() + .superRefine((value, ctx) => { + if (value.status === "supported") { + if (!value.bin) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Supported service surfaces require a serve bin", path: ["bin"] }); + } + if (!value.health) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Supported service surfaces require a health endpoint", path: ["health"] }); + } + if (!value.version) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Supported service surfaces require a version endpoint", path: ["version"] }); + } + } + if ((value.status === "deferred" || value.status === "unsupported") && !value.deferReason) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Deferred or unsupported service surfaces require a deferReason", + path: ["deferReason"] + }); + } + if (value.health && value.health.path !== "/health") { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Health endpoint must be /health", path: ["health", "path"] }); + } + if (value.readiness && value.readiness.path !== "/ready") { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Readiness endpoint must be /ready", path: ["readiness", "path"] }); + } + if (value.version && value.version.path !== "/version") { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Version endpoint must be /version", path: ["version", "path"] }); + } + }); +export type ServiceSurface = z.infer; + /** Runtime storage enum. `local | cloud` ONLY (Amendment A1: PURE REMOTE). */ export const STORAGE_MODES = ["local", "cloud"] as const; export const StorageModeSchema = z.enum(STORAGE_MODES); @@ -1586,6 +1676,8 @@ export const ServiceContractManifestSchema = z description: z.string().min(1).optional(), bins: z.array(z.string().min(1)).default([]), storage: StorageContractSchema.optional(), + deploymentModes: z.array(DeploymentModeSchema).default(["local"]), + serviceSurfaces: z.array(ServiceSurfaceSchema).default([]), metadata: MetadataSchema.optional() }) .strict() @@ -1668,6 +1760,13 @@ export const ServiceContractManifestSchema = z if (!hasBin("-serve")) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: `service repos must ship the "${value.name}-serve" bin`, path: ["bins"] }); } + if (value.serviceSurfaces.length === 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "service repos must declare at least one service surface", + path: ["serviceSurfaces"] + }); + } } if (value.class === "saas") { @@ -1679,6 +1778,35 @@ export const ServiceContractManifestSchema = z if (!hasBin("-serve")) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: `saas repos must ship the "${value.name}-serve" bin`, path: ["bins"] }); } + if (value.serviceSurfaces.length === 0) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "saas repos must declare at least one service surface", path: ["serviceSurfaces"] }); + } + } + + for (const [index, surface] of value.serviceSurfaces.entries()) { + if (surface.bin && !seenBins.has(surface.bin)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Service surface bin "${surface.bin}" must be declared in bins`, + path: ["serviceSurfaces", index, "bin"] + }); + } + if (surface.mcpBin && !seenBins.has(surface.mcpBin)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Service surface MCP bin "${surface.mcpBin}" must be declared in bins`, + path: ["serviceSurfaces", index, "mcpBin"] + }); + } + for (const [modeIndex, deploymentMode] of surface.deploymentModes.entries()) { + if (!value.deploymentModes.includes(deploymentMode)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Service surface deployment mode "${deploymentMode}" must be declared in deploymentModes`, + path: ["serviceSurfaces", index, "deploymentModes", modeIndex] + }); + } + } } }); export type ServiceContractManifest = z.infer; diff --git a/src/service-contract.ts b/src/service-contract.ts index bd2583e..c0be4e3 100644 --- a/src/service-contract.ts +++ b/src/service-contract.ts @@ -53,6 +53,89 @@ export const SERVICE_CONTRACT_JSON_SCHEMA = { description: "Declared bins. Allowlisted: , -cli, -mcp, -serve, -worker, -runner, -daemon, -migrate, -doctor." }, + deploymentModes: { + type: "array", + items: { enum: ["local", "self-hosted", "cloud"] }, + description: + "Supported deployment modes. local = this machine, self-hosted = Hasna-owned AWS, cloud = multi-tenant SaaS for outside users." + }, + serviceSurfaces: { + type: "array", + items: { + type: "object", + additionalProperties: false, + required: ["name", "status", "authMode", "deploymentModes"], + properties: { + name: { type: "string", minLength: 1 }, + status: { enum: ["supported", "deferred", "unsupported"] }, + bin: { type: "string", minLength: 1 }, + mcpBin: { type: "string", minLength: 1 }, + authMode: { enum: ["none", "local-only", "api-key", "session", "service-token", "custom"] }, + deploymentModes: { + type: "array", + items: { enum: ["local", "self-hosted", "cloud"] }, + minItems: 1 + }, + health: { + type: "object", + additionalProperties: false, + required: ["method", "path"], + properties: { + method: { enum: ["GET", "POST", "PUT", "PATCH", "DELETE"] }, + path: { type: "string", pattern: "^/[A-Za-z0-9_./:*-]*$" }, + public: { type: "boolean" }, + description: { type: "string", minLength: 1 } + } + }, + readiness: { + type: "object", + additionalProperties: false, + required: ["method", "path"], + properties: { + method: { enum: ["GET", "POST", "PUT", "PATCH", "DELETE"] }, + path: { type: "string", pattern: "^/[A-Za-z0-9_./:*-]*$" }, + public: { type: "boolean" }, + description: { type: "string", minLength: 1 } + } + }, + version: { + type: "object", + additionalProperties: false, + required: ["method", "path"], + properties: { + method: { enum: ["GET", "POST", "PUT", "PATCH", "DELETE"] }, + path: { type: "string", pattern: "^/[A-Za-z0-9_./:*-]*$" }, + public: { type: "boolean" }, + description: { type: "string", minLength: 1 } + } + }, + apiBasePath: { type: "string", pattern: "^/v[0-9]+$" }, + openApiPath: { type: "string", pattern: "^/[A-Za-z0-9_./:-]*$" }, + deferReason: { type: "string", minLength: 1 }, + readinessGates: { + type: "array", + items: { + type: "object", + additionalProperties: false, + required: ["id", "kind"], + properties: { + id: { type: "string", minLength: 1 }, + kind: { + enum: ["auth", "storage", "secret-ref", "migration", "health", "readiness", "redaction", "smoke", "operator", "other"] + }, + required: { type: "boolean" }, + command: { type: "string", minLength: 1 }, + evidenceRef: { type: "object" }, + status: { enum: ["pending", "passed", "failed", "blocked", "deferred"] }, + summary: { type: "string", minLength: 1 } + } + } + } + } + }, + description: + "Declared HTTP/MCP service surfaces. Supported surfaces name lifecycle endpoints; unsafe or unfinished surfaces use deferred/unsupported with a reason." + }, storage: { type: "object", additionalProperties: false, diff --git a/tests/service-contract.test.ts b/tests/service-contract.test.ts index c0a90f3..bd291d8 100644 --- a/tests/service-contract.test.ts +++ b/tests/service-contract.test.ts @@ -96,10 +96,65 @@ describe("service contract manifest validation", () => { contractVersion: SERVICE_CONTRACT_VERSION, kitVersion: "0.3.0", bins: ["loops", "loops-serve"], - storage: { mode: "cloud", databaseUrlSecretRef: "hasna/oss/loops/database-url" } + storage: { mode: "cloud", databaseUrlSecretRef: "hasna/oss/loops/database-url" }, + deploymentModes: ["local", "self-hosted"], + serviceSurfaces: [ + { + name: "http", + status: "supported", + bin: "loops-serve", + authMode: "api-key", + deploymentModes: ["local", "self-hosted"], + health: { method: "GET", path: "/health", public: true }, + readiness: { method: "GET", path: "/ready", public: false }, + version: { method: "GET", path: "/version", public: true }, + apiBasePath: "/v1", + readinessGates: [ + { + id: "redaction", + kind: "redaction", + status: "pending" + } + ] + } + ] }; expect(validateServiceContractManifest(svc).success).toBe(true); expect(validateServiceContractManifest({ ...svc, bins: ["loops"] }).success).toBe(false); + expect(validateServiceContractManifest({ ...svc, serviceSurfaces: [] }).success).toBe(false); + }); + + test("service surfaces require lifecycle endpoints or explicit defer reasons", () => { + const badSupported = { + ...baseCliWithStore, + class: "service", + bins: ["todos", "todos-serve"], + storage: { mode: "cloud", databaseUrlSecretRef: "hasna/oss/todos/database-url" }, + serviceSurfaces: [ + { + name: "http", + status: "supported", + bin: "todos-serve", + authMode: "api-key", + deploymentModes: ["local"] + } + ] + }; + expect(validateServiceContractManifest(badSupported).success).toBe(false); + + const deferred = { + ...badSupported, + serviceSurfaces: [ + { + name: "http", + status: "deferred", + authMode: "api-key", + deploymentModes: ["local"], + deferReason: "Hosted service boundary still returns raw secret values." + } + ] + }; + expect(validateServiceContractManifest(deferred).success).toBe(true); }); test("saas class must be cloud mode", () => {