Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion internal/handlers/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
6 changes: 3 additions & 3 deletions internal/handlers/openapi.go
Original file line number Diff line number Diff line change
Expand Up @@ -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." },
"<service-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 '<service-name>') 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": {
Expand Down
44 changes: 44 additions & 0 deletions internal/handlers/openapi_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
// "<service-name>" property in the StackRequest schema, which codegen clients
// (Postman, OpenAPI-generator, etc.) would interpret as "a property literally
// named <service-name>", 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 "<service-name>" when this was present.
props, _ := schema["properties"].(map[string]any)
if _, has := props["<service-name>"]; has {
t.Error("DOG-30: StackRequest.properties still contains the literal '<service-name>' 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
Expand Down
Loading