From ad0a2810bba23fa475a1865c371d0c88a1795e7e Mon Sep 17 00:00:00 2001 From: Manas Srivastava Date: Sat, 30 May 2026 10:53:04 +0530 Subject: [PATCH 1/2] fix(openapi): StackRequest uses additionalProperties for per-service tarballs (DOG-30) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit StackRequest used to model the dynamic per-service multipart field as a literal property named "". OpenAPI-3 codegen clients (Postman, openapi-generator, etc.) read this as "a property literally named " — so generated upload code emits a field with that exact literal name and the deploy fails. Now: the dynamic field is expressed via additionalProperties (the OpenAPI-3 idiom for "any additional property with this shape"). The description spells out the contract — for every service in manifest.services, emit one multipart field named after the real service name whose value is the gzipped tar of that service's build context. Codegen clients now produce correct upload loops. Existing CLI/dashboard/MCP clients build the multipart body imperatively from manifest.services (not from the OpenAPI schema) so this is purely a docs/contract improvement — no runtime behaviour change. Coverage block: Symptom: StackRequest schema property literally named "" Enumeration: rg -F '' internal/handlers/openapi.go Sites found: 2 (StackRequest property + StackResponse.services.url description, where it's a real placeholder in human prose) Sites touched: 1 (StackRequest property — the schema bug). The StackResponse description text is human prose, not a property name. Coverage test: TestOpenAPI_StackRequestUsesAdditionalProperties — asserts the literal '' property is gone AND additionalProperties is present with the binary- upload shape. Fails today before the schema change. Live verified: pending merge + auto-deploy + curl /openapi.json | jq '.components.schemas.StackRequest' Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/handlers/openapi.go | 6 ++--- internal/handlers/openapi_test.go | 44 +++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/internal/handlers/openapi.go b/internal/handlers/openapi.go index 0974a73..ed94413 100644 --- a/internal/handlers/openapi.go +++ b/internal/handlers/openapi.go @@ -2909,12 +2909,12 @@ const openAPISpec = `{ }, "StackRequest": { "type": "object", - "description": "Multipart form. The 'manifest' field is the YAML instant.yaml text; each service declared under services: must have a matching multipart field named after the service whose content is a gzipped tar archive of that service's build context.", + "description": "Multipart form. The 'manifest' field is the YAML instant.yaml text; each service declared under services: must have a matching multipart field named after the service whose content is a gzipped tar archive of that service's build context. Codegen note: the dynamic per-service field is expressed via additionalProperties (OpenAPI cannot model literal-named fields whose names come from another field at runtime). Treat additionalProperties as: 'for every service S in manifest.services, send a multipart field named S whose value is the gzipped tar of S's build context.' DOG-30 (QA 2026-05-29).", "properties": { "manifest": { "type": "string", "description": "instant.yaml contents. Example: services:\\n api:\\n build: ./api\\n port: 8080\\n web:\\n build: ./web\\n port: 8080\\n expose: true\\n env: { API_URL: service://api }" }, - "name": { "type": "string", "minLength": 1, "maxLength": 64, "pattern": "^[A-Za-z0-9][A-Za-z0-9 _-]*$", "description": "REQUIRED. Short human-readable label for this stack (1-64 chars after trimming; must start with a letter or digit, then letters/digits/spaces/underscores/hyphens). Missing/empty → 400 name_required. Bad format/length → 400 invalid_name." }, - "": { "type": "string", "format": "binary", "description": "One field per service declared in the manifest, named after the service. Value is a gzipped tar archive containing that service's Dockerfile + source. Total request body cap is 200 MB." } + "name": { "type": "string", "minLength": 1, "maxLength": 64, "pattern": "^[A-Za-z0-9][A-Za-z0-9 _-]*$", "description": "REQUIRED. Short human-readable label for this stack (1-64 chars after trimming; must start with a letter or digit, then letters/digits/spaces/underscores/hyphens). Missing/empty → 400 name_required. Bad format/length → 400 invalid_name." } }, + "additionalProperties": { "type": "string", "format": "binary", "description": "One multipart field per service declared in the manifest, with the field NAME equal to the real service name (e.g. 'api' or 'web', NOT the literal placeholder '') and the VALUE a gzipped tar archive (≤50 MiB) containing that service's Dockerfile + source. Codegen clients should emit one upload field per manifest entry." }, "required": ["manifest", "name"] }, "StackResponse": { diff --git a/internal/handlers/openapi_test.go b/internal/handlers/openapi_test.go index a377e0e..2d8e583 100644 --- a/internal/handlers/openapi_test.go +++ b/internal/handlers/openapi_test.go @@ -138,6 +138,50 @@ func TestOpenAPI_StacksEndpointsDocumented(t *testing.T) { } } +// TestOpenAPI_StackRequestUsesAdditionalProperties pins DOG-30 (QA 2026-05-29): +// the dynamic per-service multipart field used to be modeled with a literal +// "" property in the StackRequest schema, which codegen clients +// (Postman, OpenAPI-generator, etc.) would interpret as "a property literally +// named ", emitting broken upload code. The contract is now +// expressed with additionalProperties — the OpenAPI-3 idiom for "any +// additional field with this shape". +func TestOpenAPI_StackRequestUsesAdditionalProperties(t *testing.T) { + var v map[string]any + if err := json.Unmarshal([]byte(openAPISpec), &v); err != nil { + t.Fatalf("openAPISpec parse: %v", err) + } + schema, ok := digMap(v, "components", "schemas", "StackRequest") + if !ok { + t.Fatal("DOG-30: StackRequest schema missing entirely") + } + + // The literal placeholder must NOT appear as a property — it's the + // regression marker for the original bug. Codegen clients emitted a + // real field literally named "" when this was present. + props, _ := schema["properties"].(map[string]any) + if _, has := props[""]; has { + t.Error("DOG-30: StackRequest.properties still contains the literal '' placeholder — codegen clients will emit a broken field with that literal name. Use additionalProperties to express the dynamic per-service shape instead.") + } + + // additionalProperties must be present with the binary-upload shape so + // codegen clients understand "for every service in manifest.services, + // emit one upload field named after the service". + ap, has := schema["additionalProperties"] + if !has { + t.Fatal("DOG-30: StackRequest schema missing additionalProperties — the dynamic per-service multipart field has no machine-readable contract") + } + apMap, ok := ap.(map[string]any) + if !ok { + t.Fatalf("DOG-30: StackRequest.additionalProperties must be an object schema, got %T", ap) + } + if apMap["type"] != "string" { + t.Errorf("DOG-30: StackRequest.additionalProperties.type must be 'string' (multipart upload), got %v", apMap["type"]) + } + if apMap["format"] != "binary" { + t.Errorf("DOG-30: StackRequest.additionalProperties.format must be 'binary' (multipart binary upload), got %v", apMap["format"]) + } +} + // TestOpenAPI_MultiEnvEndpointsDocumented guards RETRO-2026-05-12 §10.17: // the env-promotion endpoints (POST /api/v1/stacks/:slug/promote and // POST /api/v1/vault/copy) must be discoverable in the spec, and both must From d09c91dec418e17a3ec45efd60faeb911a6e88df Mon Sep 17 00:00:00 2001 From: Manas Srivastava Date: Sat, 30 May 2026 10:55:27 +0530 Subject: [PATCH 2/2] fix(api): drop contextually-wrong "anon resources auto-expire" hint from generic 404 (BUG-API-105) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The generic "not_found" agent_action used to tack on "anon resources also auto-expire after 24h" — irrelevant when the 404 is a typo'd path, unknown internal endpoint, or authenticated misroute. Hint is kept on the surface-specific 404s (webhook_not_found etc.) where the auto- expire footnote IS actionable. Coverage block: Symptom: 404 agent_action says "anon resources auto-expire" on any 404 including internal/typo'd routes Enumeration: rg 'anon resources' internal/ --type go Sites found: 2 — webhook_not_found (kept; correct context) + generic not_found (changed) Sites touched: 1 (generic not_found) Coverage test: N/A — agent_action contract is the prose itself; no test pins the exact wording for the generic 404 today. Live verified: pending merge + auto-deploy + curl unknown path Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/handlers/helpers.go | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/internal/handlers/helpers.go b/internal/handlers/helpers.go index d88e7cb..04cd2bb 100644 --- a/internal/handlers/helpers.go +++ b/internal/handlers/helpers.go @@ -343,7 +343,14 @@ var codeToAgentAction = map[string]errorCodeMeta{ // StatusNotFound / StatusMethodNotAllowed / StatusRequestEntityTooLarge // / StatusUnsupportedMediaType. "not_found": { - AgentAction: "Tell the user the URL is wrong or the resource no longer exists. Have them check the path against https://instanode.dev/docs — anon resources also auto-expire after 24h, so re-provision if needed.", + // BUG-API-105 (QA 2026-05-29): the agent_action used to tack on + // "anon resources also auto-expire after 24h" — irrelevant when + // the 404 is a typo'd internal path or unknown route, and + // actively misleading on authenticated misroutes. Hint kept + // behind a "see /docs" pointer; the auto-expire footnote now + // only fires when the surface-specific 404 (resource_not_found + // / webhook_not_found / etc.) emits it. + AgentAction: "Tell the user the URL is wrong or the resource no longer exists. Have them check the path against https://instanode.dev/docs — if they were calling a token-keyed URL the token may have expired or been mistyped.", }, "method_not_allowed": { AgentAction: "Tell the user the HTTP method is wrong for this URL. Have them check the Allow response header (or https://instanode.dev/docs) for the supported methods.",