{"record":{"id":"ac52d041a6b60e64","repo":"thedotmack/claude-mem","slug":"forbidden-ac52d0","errorCode":"Forbidden","errorMessage":"`include=payload` requires admin scope","messagePattern":"`include=payload` requires admin scope","errorType":"http","errorClass":null,"httpStatus":403,"severity":"error","filePath":"src/server/routes/v1/ServerV1PostgresRoutes.ts","lineNumber":622,"sourceCode":"    // project's jobs; team-scoped key sees the team's jobs. Filters: status,\n    // source_type, limit, offset, since (ISO timestamp on created_at). The\n    // BullMQ payload column is NEVER returned by default — even with admin\n    // scope, the caller MUST opt in via `?include=payload`. This anti-pattern\n    // guard prevents accidental exfil of sensitive event payloads.\n    app.get('/v1/jobs', readAuth, this.asyncHandler(async (req, res) => {\n      const teamId = this.requireTeamId(req, res);\n      if (!teamId) return;\n      const callerProjectId = req.authContext?.projectId ?? null;\n      const includeRaw = typeof req.query.include === 'string' ? req.query.include : '';\n      const includePayload = includeRaw.split(',').map(p => p.trim()).includes('payload');\n      const callerScopes = req.authContext?.scopes ?? [];\n      const isAdmin = callerScopes.includes('*') || callerScopes.includes('admin')\n        || callerScopes.includes('memories:admin');\n      if (includePayload && !isAdmin) {\n        // Anti-pattern guard: refuse the include=payload elevation without\n        // admin scope. Returning 403 (not silently stripping) makes the\n        // attempted privilege escalation visible in the audit chain.\n        res.status(403).json({\n          error: 'Forbidden',\n          message: '`include=payload` requires admin scope',\n        });\n        return;\n      }\n      const { status, sourceType, limit, offset, since } = parseGenericJobListingQuery(req);\n      let jobs: JobListRow[] = [];\n      let total = 0;\n      try {\n        ({ jobs, total } = await this.listJobsForScope({\n          teamId, projectId: callerProjectId, status, sourceType, limit, offset, since,\n        }));\n      } catch (error) {\n        const err = error instanceof Error ? error : new Error(String(error));\n        logger.warn('SYSTEM', 'jobs.list query failed', { requestId: req.requestId ?? null }, err);\n        this.handleDbError(err, res, 'jobs.list');\n        return;\n      }","sourceCodeStart":604,"sourceCodeEnd":640,"githubUrl":"https://github.com/thedotmack/claude-mem/blob/e2d1df569a8f04075d40e92461128ece7cf04c82/src/server/routes/v1/ServerV1PostgresRoutes.ts#L604-L640","documentation":"GET /v1/jobs supports an include=payload query parameter that returns each job's full BullMQ payload, which can contain sensitive generation request data. The route refuses the call with 403 when the API key's scopes do not include '*', 'admin', or 'memories:admin'. The refusal is deliberate: rather than silently stripping the include, it keeps the attempted privilege elevation visible in the audit chain.","triggerScenarios":"GET /v1/jobs?include=payload (or include=status,payload) issued with a key whose scopes are e.g. ['memories:read'] or any list without '*', 'admin', or 'memories:admin'.","commonSituations":"Ops dashboards or debug scripts copied from an admin-key environment onto a production read-only key; a newly minted key provisioned without admin scopes; tooling migrated from a team-admin key to a least-privilege key.","solutions":["Remove 'payload' from the include query parameter and re-issue the request","If job payload data is genuinely required, issue the API key with the 'memories:admin' (or 'admin'/'*') scope","Verify the key's effective scopes via key introspection before switching dashboards to include=payload"],"exampleFix":"// before\nconst res = await fetch(`${base}/v1/jobs?include=payload`, { headers: { Authorization: `Bearer ${key}` } });\n\n// after — only request payload when the key is known to be admin-scoped\nconst isAdminKey = keyScopes.some(s => s === '*' || s === 'admin' || s === 'memories:admin');\nconst include = isAdminKey ? 'status,payload' : 'status';\nconst res = await fetch(`${base}/v1/jobs?include=${include}`, { headers: { Authorization: `Bearer ${key}` } });","handlingStrategy":"validation","validationCode":"// Only ask for payloads when the key is known to carry an admin-ish scope\nconst ADMIN_SCOPES = new Set(['*', 'admin', 'memories:admin']);\nconst canIncludePayload = (keyScopes: string[]) => keyScopes.some(s => ADMIN_SCOPES.has(s));\nconst include = canIncludePayload(keyScopes) ? 'status,payload' : 'status';","typeGuard":"const isAdminScope = (scopes: string[] | undefined): boolean =>\n  !!scopes && scopes.some(s => s === '*' || s === 'admin' || s === 'memories:admin');","tryCatchPattern":null,"preventionTips":["Store the key's scopes alongside the key in your client config and derive include params from them","Never hardcode include=payload in shared dashboards; make it an explicit admin-only toggle","Treat a 403 from this route as a scope problem first — check scopes before debugging the URL"],"tags":["authorization","http-403","scopes","query-params","jobs"],"backgroundTag":"insufficient-permissions","analyzedSha":"e2d1df569a8f04075d40e92461128ece7cf04c82","analyzedAt":"2026-08-20T23:58:13.836Z","schemaVersion":2},"datasetVersion":"2026-08-29T17:17:51.833Z"}