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.", 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