{"record":{"id":"cfdb28674ddd9edb","repo":"odysseus-dev/odysseus","slug":"api-token-missing-required-scope-and-join-so","errorCode":null,"errorMessage":"API token missing required scope: {' and '.join(sorted(missing))}","messagePattern":"API token missing required scope: (.+?)","errorType":"http","errorClass":"HTTPException","httpStatus":403,"severity":"error","filePath":"routes/codex_routes.py","lineNumber":105,"sourceCode":"    if getattr(request.state, \"api_token\", False):\n        scopes = set(getattr(request.state, \"api_token_scopes\", []) or [])\n        if not scopes.intersection(allowed):\n            required = \" or \".join(sorted(allowed))\n            raise HTTPException(403, f\"API token missing required scope: {required}\")\n        owner = getattr(request.state, \"api_token_owner\", None)\n        if not owner:\n            raise HTTPException(403, \"API token has no owner\")\n        return owner\n    return require_user(request)\n\n\ndef _scope_owner_all(request: Request, required: set[str]) -> str:\n    \"\"\"Return owner only when an API token has every required scope.\"\"\"\n    if getattr(request.state, \"api_token\", False):\n        scopes = set(getattr(request.state, \"api_token_scopes\", []) or [])\n        missing = required - scopes\n        if missing:\n            raise HTTPException(403, f\"API token missing required scope: {' and '.join(sorted(missing))}\")\n        owner = getattr(request.state, \"api_token_owner\", None)\n        if not owner:\n            raise HTTPException(403, \"API token has no owner\")\n        return owner\n    return require_user(request)\n\n\ndef _require_cookbook_scope(request: Request, allowed: set[str]) -> str:\n    \"\"\"Authorize a Codex cookbook route.\n\n    For API-token callers, enforce the given scope set.\n    For cookie-session callers, additionally require admin privileges\n    because cookbook surfaces expose host topology, task logs, tmux\n    commands, and model-serving controls.\n    \"\"\"\n    owner = _scope_owner(request, allowed)\n    if not getattr(request.state, \"api_token\", False):\n        require_admin(request)","sourceCodeStart":87,"sourceCodeEnd":123,"githubUrl":"https://github.com/odysseus-dev/odysseus/blob/f9235ebbf13f693a6fd29ce70b097f6ec83705bf/routes/codex_routes.py#L87-L123","documentation":"Raised as HTTP 403 by _scope_owner_all when an API-token caller is missing one or more of a route's required scopes — this variant demands ALL scopes (set difference), and the message interpolates the missing ones joined by 'and'. It appears on routes combining multiple integrations, such as POST /api/codex/emails/draft-document which needs both email-draft and documents-write scopes.","triggerScenarios":"Calling a multi-scope route (e.g. codex_email_draft_document) with a token that has email:write but not the DOCS_WRITE_SCOPES entries; a token provisioned before a route gained an additional required scope.","commonSituations":"Route's scope requirements expanded in a new release so previously-working tokens now 403; token minted from a scope template that omits one integration.","solutions":["Parse the missing list from the message and re-issue the token adding exactly those scopes.","Verify with the token introspection endpoint that all required scopes are present before calling combined routes.","Keep tokens per-purpose: mint a dedicated token with the full scope set for cross-integration automation rather than reusing a narrow one."],"exampleFix":"# before\ncurl -H 'Authorization: Bearer $EMAIL_ONLY_TOKEN' -X POST .../api/codex/emails/draft-document  # 403\n\n# after\nTOKEN=$(create_token --scope email:write --scope documents:write)\ncurl -H \"Authorization: Bearer $TOKEN\" -X POST .../api/codex/emails/draft-document","handlingStrategy":"validation","validationCode":"const REQUIRED = ['email:write', 'documents:write']; // draft-document route\nconst {scopes} = await introspect(token);\nconst missing = REQUIRED.filter(s => !scopes.includes(s));\nif (missing.length) await reissueToken([...scopes, ...missing]);","typeGuard":"function hasAllScopes(have: string[], need: string[]): boolean {\n  return need.every(s => have.includes(s));\n}","tryCatchPattern":"try { r = await draftDocument() } catch (e) { if (e.status === 403 && e.detail.includes('missing required scope')) { reissue with missing scopes; retry once } else throw }","preventionTips":["For multi-integration routes, provision one token carrying the union of all required scopes.","On app upgrades, diff route scope requirements and re-issue tokens before deploying clients.","Parse 403 detail messages programmatically to extract the exact missing scope list."],"tags":["http-403","authorization","api-token","scopes","multi-scope"],"backgroundTag":null,"analyzedSha":"f9235ebbf13f693a6fd29ce70b097f6ec83705bf","analyzedAt":"2026-08-14T21:47:48.359Z","schemaVersion":2},"datasetVersion":"2026-08-15T17:31:12.345Z"}