Skip to content

Commit f286d45

Browse files
Merge pull request #186 from InstaNode-dev/fix/api-openapi-stack-schema-and-anon-rate-limit-msg-2026-05-30
fix(api): OpenAPI StackRequest additionalProperties + generic 404 hint cleanup (DOG-30 + BUG-API-105)
2 parents 4759b67 + d09c91d commit f286d45

3 files changed

Lines changed: 55 additions & 4 deletions

File tree

internal/handlers/helpers.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -343,7 +343,14 @@ var codeToAgentAction = map[string]errorCodeMeta{
343343
// StatusNotFound / StatusMethodNotAllowed / StatusRequestEntityTooLarge
344344
// / StatusUnsupportedMediaType.
345345
"not_found": {
346-
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.",
346+
// BUG-API-105 (QA 2026-05-29): the agent_action used to tack on
347+
// "anon resources also auto-expire after 24h" — irrelevant when
348+
// the 404 is a typo'd internal path or unknown route, and
349+
// actively misleading on authenticated misroutes. Hint kept
350+
// behind a "see /docs" pointer; the auto-expire footnote now
351+
// only fires when the surface-specific 404 (resource_not_found
352+
// / webhook_not_found / etc.) emits it.
353+
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.",
347354
},
348355
"method_not_allowed": {
349356
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.",

internal/handlers/openapi.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2909,12 +2909,12 @@ const openAPISpec = `{
29092909
},
29102910
"StackRequest": {
29112911
"type": "object",
2912-
"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.",
2912+
"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).",
29132913
"properties": {
29142914
"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 }" },
2915-
"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." },
2916-
"<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." }
2915+
"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." }
29172916
},
2917+
"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." },
29182918
"required": ["manifest", "name"]
29192919
},
29202920
"StackResponse": {

internal/handlers/openapi_test.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,50 @@ func TestOpenAPI_StacksEndpointsDocumented(t *testing.T) {
138138
}
139139
}
140140

141+
// TestOpenAPI_StackRequestUsesAdditionalProperties pins DOG-30 (QA 2026-05-29):
142+
// the dynamic per-service multipart field used to be modeled with a literal
143+
// "<service-name>" property in the StackRequest schema, which codegen clients
144+
// (Postman, OpenAPI-generator, etc.) would interpret as "a property literally
145+
// named <service-name>", emitting broken upload code. The contract is now
146+
// expressed with additionalProperties — the OpenAPI-3 idiom for "any
147+
// additional field with this shape".
148+
func TestOpenAPI_StackRequestUsesAdditionalProperties(t *testing.T) {
149+
var v map[string]any
150+
if err := json.Unmarshal([]byte(openAPISpec), &v); err != nil {
151+
t.Fatalf("openAPISpec parse: %v", err)
152+
}
153+
schema, ok := digMap(v, "components", "schemas", "StackRequest")
154+
if !ok {
155+
t.Fatal("DOG-30: StackRequest schema missing entirely")
156+
}
157+
158+
// The literal placeholder must NOT appear as a property — it's the
159+
// regression marker for the original bug. Codegen clients emitted a
160+
// real field literally named "<service-name>" when this was present.
161+
props, _ := schema["properties"].(map[string]any)
162+
if _, has := props["<service-name>"]; has {
163+
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.")
164+
}
165+
166+
// additionalProperties must be present with the binary-upload shape so
167+
// codegen clients understand "for every service in manifest.services,
168+
// emit one upload field named after the service".
169+
ap, has := schema["additionalProperties"]
170+
if !has {
171+
t.Fatal("DOG-30: StackRequest schema missing additionalProperties — the dynamic per-service multipart field has no machine-readable contract")
172+
}
173+
apMap, ok := ap.(map[string]any)
174+
if !ok {
175+
t.Fatalf("DOG-30: StackRequest.additionalProperties must be an object schema, got %T", ap)
176+
}
177+
if apMap["type"] != "string" {
178+
t.Errorf("DOG-30: StackRequest.additionalProperties.type must be 'string' (multipart upload), got %v", apMap["type"])
179+
}
180+
if apMap["format"] != "binary" {
181+
t.Errorf("DOG-30: StackRequest.additionalProperties.format must be 'binary' (multipart binary upload), got %v", apMap["format"])
182+
}
183+
}
184+
141185
// TestOpenAPI_MultiEnvEndpointsDocumented guards RETRO-2026-05-12 §10.17:
142186
// the env-promotion endpoints (POST /api/v1/stacks/:slug/promote and
143187
// POST /api/v1/vault/copy) must be discoverable in the spec, and both must

0 commit comments

Comments
 (0)