Skip to content

Commit 78a7d60

Browse files
docs(openapi): add /auth/exchange — closes the gap that hid 2026-05-30 outage (#203)
* docs(openapi): add /auth/exchange — closes contract gap that hid 2026-05-30 outage The 2026-05-30 prod-login outage chained 3 failures along /auth/exchange (client missing, preflight rejected, ACAC missing). Even with the new contract-CI gate (api #202), the bug class wasn't catchable because /auth/exchange was literally NOT in the OpenAPI spec — agents and the typed-client codegen pipeline had no contract to enforce. Adds the path with the full CORS contract documented in the description so any future regression to the headers / preflight rules is visible in the diff that breaks the snapshot-drift gate. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(openapi): drop redeploy_requires_name from DeployRequest description That error code was emitted by an unreachable defence-in-depth arm that landed in api#201 and was removed before merge (requireName already rejects empty/whitespace name upstream of the redeploy branch). The DeployRequest description still mentioned it; tidied + clarified the upstream-rejection path. --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 00865ab commit 78a7d60

2 files changed

Lines changed: 59 additions & 2 deletions

File tree

internal/handlers/openapi.go

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1623,6 +1623,19 @@ const openAPISpec = `{
16231623
}
16241624
}
16251625
},
1626+
"/auth/exchange": {
1627+
"post": {
1628+
"summary": "Exchange the AUTH-004 bridge cookie for a session JWT",
1629+
"description": "Final leg of the AUTH-004 cross-origin sign-in handshake. The /auth/email/callback and /auth/github/callback handlers set a short-lived HttpOnly auth_exchange_cookie and 302 to https://instanode.dev/login/callback?signed_in=1. The dashboard then makes a credentials:include POST to this endpoint. CORS contract: response MUST include Access-Control-Allow-Origin: https://instanode.dev AND Access-Control-Allow-Credentials: true — the browser blocks the read otherwise. Request MUST be a CORS-simple POST (no custom headers like Accept: application/json), since adding one forces a preflight that PreflightAllowlist may reject. Returns 200 + the bearer JWT (24h, HS256, aud=https://api.instanode.dev) on success. The 2026-05-29 to 2026-05-30 prod-login outage chained three failures along this exact endpoint — documenting it here so any future regression is catchable by the cross-stack contract gate (api PR #202).",
1630+
"requestBody": { "required": false, "description": "No body. The bridge cookie travels in the Cookie header via credentials:include." },
1631+
"responses": {
1632+
"200": { "description": "Cookie verified; JWT minted", "content": { "application/json": { "schema": { "type": "object", "required": ["ok", "token"], "properties": { "ok": { "type": "boolean" }, "token": { "type": "string", "description": "Session JWT — store in localStorage and send as Authorization: Bearer for /api/v1/* calls" } } } } } },
1633+
"400": { "description": "Bridge cookie missing / expired (canonical envelope with error code cookie_missing_or_expired)" },
1634+
"401": { "description": "Cookie present but signature invalid or aud mismatch" },
1635+
"503": { "description": "JWT signing failed (downstream)" }
1636+
}
1637+
}
1638+
},
16261639
"/auth/email/callback": {
16271640
"get": {
16281641
"summary": "Consume a magic link, mint a session JWT, 302 to <return_to>",
@@ -3090,7 +3103,7 @@ const openAPISpec = `{
30903103
"notify_webhook": { "type": "string", "description": "Optional https:// URL fired by POST when the deploy reaches a terminal state (status='healthy' or 'failed'). Lets callers subscribe instead of polling GET /deploy/:id. Rejected with 400 + agent_action if the URL is not https, the hostname is unresolvable, or resolves to a private/loopback/link-local/CGNAT IP (SSRF protection). Payload shape: { event: 'deploy.healthy' | 'deploy.failed', deploy_id, app_id, url, commit_id, build_time, duration_s, error_message? }. 2xx → notify_state='sent'; 4xx → 'failed' (no retry — user URL is broken); 5xx/network → up to 3 retries, then 'failed'." },
30913104
"notify_webhook_secret": { "type": "string", "description": "Optional HMAC-SHA256 signing key. When set, every dispatch includes an X-InstaNode-Signature: sha256=<hex(hmac(secret, body))> header. Stored AES-256-GCM encrypted; plaintext never leaves the request. Omit to dispatch without a signature header." },
30923105
"ttl_policy": { "type": "string", "enum": ["auto_24h", "permanent"], "description": "Wave FIX-J. Sets the deploy's lifecycle. 'auto_24h' (default for new deploys) means the deploy auto-expires 24h from creation; the response's agent_action sentence tells the LLM the three explicit routes to keep it permanent. 'permanent' opts the deploy out of TTL up front — useful for production deploys where the agent already knows the user wants it kept. Anonymous tier is FORCED to auto_24h regardless of caller intent. Team-wide default can be flipped via PATCH /api/v1/team/settings." },
3093-
"redeploy": { "type": "boolean", "default": false, "description": "When true with a matching 'name', replace the existing deployment in place (same app_id + URL, same provider_id) instead of minting a fresh one. The platform looks up the team's most-recent non-terminal deployment whose env_vars._name matches the supplied 'name' (scoped to the resolved 'env'), then routes through the same compute path as POST /deploy/:id/redeploy. Closes the agent-UX gap (2026-05-30): multiple /deploy/new calls for the same logical app used to fan out into N distinct URLs because there was no way to upsert by name. Truthy values: 'true', '1', 'yes' (case-insensitive); anything else is false. Errors: 400 redeploy_requires_name when 'name' is empty; 404 no_existing_deployment_to_redeploy when no live row matches (omit 'redeploy' to create a new deployment, or call GET /api/v1/deployments first to discover the id); 409 not_ready when the matching row exists but has no provider_id yet (initial build still running). Default false: leaving the field absent keeps the legacy fan-out behaviour." }
3106+
"redeploy": { "type": "boolean", "default": false, "description": "When true with a matching 'name', replace the existing deployment in place (same app_id + URL, same provider_id) instead of minting a fresh one. The platform looks up the team's most-recent non-terminal deployment whose env_vars._name matches the supplied 'name' (scoped to the resolved 'env'), then routes through the same compute path as POST /deploy/:id/redeploy. Closes the agent-UX gap (2026-05-30): multiple /deploy/new calls for the same logical app used to fan out into N distinct URLs because there was no way to upsert by name. Truthy values: 'true', '1', 'yes' (case-insensitive); anything else is false. Errors: 404 no_existing_deployment_to_redeploy when no live row matches (note: an empty 'name' is rejected upstream by the standard name_required check, before this flag is even consulted) (omit 'redeploy' to create a new deployment, or call GET /api/v1/deployments first to discover the id); 409 not_ready when the matching row exists but has no provider_id yet (initial build still running). Default false: leaving the field absent keeps the legacy fan-out behaviour." }
30943107
},
30953108
"required": ["tarball", "name"]
30963109
},

openapi.snapshot.json

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -839,7 +839,7 @@
839839
},
840840
"redeploy": {
841841
"default": false,
842-
"description": "When true with a matching 'name', replace the existing deployment in place (same app_id + URL, same provider_id) instead of minting a fresh one. The platform looks up the team's most-recent non-terminal deployment whose env_vars._name matches the supplied 'name' (scoped to the resolved 'env'), then routes through the same compute path as POST /deploy/:id/redeploy. Closes the agent-UX gap (2026-05-30): multiple /deploy/new calls for the same logical app used to fan out into N distinct URLs because there was no way to upsert by name. Truthy values: 'true', '1', 'yes' (case-insensitive); anything else is false. Errors: 400 redeploy_requires_name when 'name' is empty; 404 no_existing_deployment_to_redeploy when no live row matches (omit 'redeploy' to create a new deployment, or call GET /api/v1/deployments first to discover the id); 409 not_ready when the matching row exists but has no provider_id yet (initial build still running). Default false: leaving the field absent keeps the legacy fan-out behaviour.",
842+
"description": "When true with a matching 'name', replace the existing deployment in place (same app_id + URL, same provider_id) instead of minting a fresh one. The platform looks up the team's most-recent non-terminal deployment whose env_vars._name matches the supplied 'name' (scoped to the resolved 'env'), then routes through the same compute path as POST /deploy/:id/redeploy. Closes the agent-UX gap (2026-05-30): multiple /deploy/new calls for the same logical app used to fan out into N distinct URLs because there was no way to upsert by name. Truthy values: 'true', '1', 'yes' (case-insensitive); anything else is false. Errors: 404 no_existing_deployment_to_redeploy when no live row matches (note: an empty 'name' is rejected upstream by the standard name_required check, before this flag is even consulted) (omit 'redeploy' to create a new deployment, or call GET /api/v1/deployments first to discover the id); 409 not_ready when the matching row exists but has no provider_id yet (initial build still running). Default false: leaving the field absent keeps the legacy fan-out behaviour.",
843843
"type": "boolean"
844844
},
845845
"resource_bindings": {
@@ -8084,6 +8084,50 @@
80848084
"summary": "Send a passwordless magic-link sign-in email"
80858085
}
80868086
},
8087+
"/auth/exchange": {
8088+
"post": {
8089+
"description": "Final leg of the AUTH-004 cross-origin sign-in handshake. The /auth/email/callback and /auth/github/callback handlers set a short-lived HttpOnly auth_exchange_cookie and 302 to https://instanode.dev/login/callback?signed_in=1. The dashboard then makes a credentials:include POST to this endpoint. CORS contract: response MUST include Access-Control-Allow-Origin: https://instanode.dev AND Access-Control-Allow-Credentials: true — the browser blocks the read otherwise. Request MUST be a CORS-simple POST (no custom headers like Accept: application/json), since adding one forces a preflight that PreflightAllowlist may reject. Returns 200 + the bearer JWT (24h, HS256, aud=https://api.instanode.dev) on success. The 2026-05-29 to 2026-05-30 prod-login outage chained three failures along this exact endpoint — documenting it here so any future regression is catchable by the cross-stack contract gate (api PR #202).",
8090+
"requestBody": {
8091+
"description": "No body. The bridge cookie travels in the Cookie header via credentials:include.",
8092+
"required": false
8093+
},
8094+
"responses": {
8095+
"200": {
8096+
"content": {
8097+
"application/json": {
8098+
"schema": {
8099+
"properties": {
8100+
"ok": {
8101+
"type": "boolean"
8102+
},
8103+
"token": {
8104+
"description": "Session JWT — store in localStorage and send as Authorization: Bearer for /api/v1/* calls",
8105+
"type": "string"
8106+
}
8107+
},
8108+
"required": [
8109+
"ok",
8110+
"token"
8111+
],
8112+
"type": "object"
8113+
}
8114+
}
8115+
},
8116+
"description": "Cookie verified; JWT minted"
8117+
},
8118+
"400": {
8119+
"description": "Bridge cookie missing / expired (canonical envelope with error code cookie_missing_or_expired)"
8120+
},
8121+
"401": {
8122+
"description": "Cookie present but signature invalid or aud mismatch"
8123+
},
8124+
"503": {
8125+
"description": "JWT signing failed (downstream)"
8126+
}
8127+
},
8128+
"summary": "Exchange the AUTH-004 bridge cookie for a session JWT"
8129+
}
8130+
},
80878131
"/auth/github": {
80888132
"post": {
80898133
"description": "Programmatic / SPA flow. Body: {\"code\":\"<github-oauth-code>\"}. Returns 200 with a 24h session JWT plus user/team ids. Returns 503 oauth_not_configured when GITHUB_CLIENT_ID / GITHUB_CLIENT_SECRET are not set in the environment.",

0 commit comments

Comments
 (0)