diff --git a/internal/handlers/openapi.go b/internal/handlers/openapi.go index 5d265f2d..7d731110 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 ", @@ -3090,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 aa6bb1f5..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": { @@ -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.",