From 2590fe5d0c8dca0252dbda1e9c44f6a75cefa2d7 Mon Sep 17 00:00:00 2001 From: Manas Srivastava Date: Sat, 30 May 2026 19:28:00 +0530 Subject: [PATCH 1/2] =?UTF-8?q?docs(openapi):=20add=20/auth/exchange=20?= =?UTF-8?q?=E2=80=94=20closes=20contract=20gap=20that=20hid=202026-05-30?= =?UTF-8?q?=20outage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- internal/handlers/openapi.go | 13 +++++++++++ openapi.snapshot.json | 44 ++++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/internal/handlers/openapi.go b/internal/handlers/openapi.go index 5d265f2d..19155b81 100644 --- a/internal/handlers/openapi.go +++ b/internal/handlers/openapi.go @@ -1623,6 +1623,19 @@ const openAPISpec = `{ } } }, + "/auth/exchange": { + "post": { + "summary": "Exchange the AUTH-004 bridge cookie for a session JWT", + "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).", + "requestBody": { "required": false, "description": "No body. The bridge cookie travels in the Cookie header via credentials:include." }, + "responses": { + "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" } } } } } }, + "400": { "description": "Bridge cookie missing / expired (canonical envelope with error code cookie_missing_or_expired)" }, + "401": { "description": "Cookie present but signature invalid or aud mismatch" }, + "503": { "description": "JWT signing failed (downstream)" } + } + } + }, "/auth/email/callback": { "get": { "summary": "Consume a magic link, mint a session JWT, 302 to ", diff --git a/openapi.snapshot.json b/openapi.snapshot.json index aa6bb1f5..789c92ad 100644 --- a/openapi.snapshot.json +++ b/openapi.snapshot.json @@ -8084,6 +8084,50 @@ "summary": "Send a passwordless magic-link sign-in email" } }, + "/auth/exchange": { + "post": { + "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).", + "requestBody": { + "description": "No body. The bridge cookie travels in the Cookie header via credentials:include.", + "required": false + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "ok": { + "type": "boolean" + }, + "token": { + "description": "Session JWT — store in localStorage and send as Authorization: Bearer for /api/v1/* calls", + "type": "string" + } + }, + "required": [ + "ok", + "token" + ], + "type": "object" + } + } + }, + "description": "Cookie verified; JWT minted" + }, + "400": { + "description": "Bridge cookie missing / expired (canonical envelope with error code cookie_missing_or_expired)" + }, + "401": { + "description": "Cookie present but signature invalid or aud mismatch" + }, + "503": { + "description": "JWT signing failed (downstream)" + } + }, + "summary": "Exchange the AUTH-004 bridge cookie for a session JWT" + } + }, "/auth/github": { "post": { "description": "Programmatic / SPA flow. Body: {\"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.", From ea446ecbe96707c5f412598ccd181ee5aed2caa5 Mon Sep 17 00:00:00 2001 From: Manas Srivastava Date: Sat, 30 May 2026 19:39:14 +0530 Subject: [PATCH 2/2] 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. --- internal/handlers/openapi.go | 2 +- openapi.snapshot.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/handlers/openapi.go b/internal/handlers/openapi.go index 19155b81..7d731110 100644 --- a/internal/handlers/openapi.go +++ b/internal/handlers/openapi.go @@ -3103,7 +3103,7 @@ const openAPISpec = `{ "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'." }, "notify_webhook_secret": { "type": "string", "description": "Optional HMAC-SHA256 signing key. When set, every dispatch includes an X-InstaNode-Signature: sha256= header. Stored AES-256-GCM encrypted; plaintext never leaves the request. Omit to dispatch without a signature header." }, "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." }, - "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." } + "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." } }, "required": ["tarball", "name"] }, diff --git a/openapi.snapshot.json b/openapi.snapshot.json index 789c92ad..20aa4b8d 100644 --- a/openapi.snapshot.json +++ b/openapi.snapshot.json @@ -839,7 +839,7 @@ }, "redeploy": { "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.", + "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.", "type": "boolean" }, "resource_bindings": {