hcengineering/platform · error · ApiError

Forbidden

Error message

Forbidden

What it means

After decoding the request token, the export endpoint checks decodedToken.extra.readonly. If that flag is present (not undefined), the token is a read-only token and the (mutating/heavy) export operation is refused with 403 Forbidden. Presence of the flag — not its value — triggers the rejection.

Source

Thrown at services/export/pod-export/src/server.ts:311

      const format = parseExportFormat(req.query.format)

      const {
        _class,
        query,
        attributesOnly
      }: {
        _class: Ref<Class<Doc<Space>>>
        query?: DocumentQuery<Doc>
        attributesOnly: boolean
      } = req.body

      if (_class == null) {
        throw new ApiError(400, 'Missing required parameters')
      }

      const decodedToken = decodeToken(token)
      if (decodedToken.extra?.readonly !== undefined) {
        throw new ApiError(403, 'Forbidden')
      }
      const isAdmin: boolean = decodedToken.extra?.admin === 'true'

      const accountClient = getClient(envConfig.AccountsUrl, token)

      try {
        const info = await accountClient.getLoginWithWorkspaceInfo()
        const winfo = info.workspaces[decodedToken.workspace]
        if (!isAdmin) {
          if (winfo === undefined) {
            res.status(401).end('Invalid workspace')
            return
          } else {
            if (winfo.role !== AccountRole.Owner) {
              res.status(401).end('Not an owner of workspace')
              return
            }
          }

View on GitHub (pinned to 63e28dc964)

Solutions

  1. Request a non-read-only workspace token (one without extra.readonly) from the account/token service.
  2. Inspect your token payload (decode it) to confirm extra.readonly is set, and regenerate the token without that restriction.
  3. If read-only tokens should be allowed to export, change the server check — this is a policy decision, not a client fix.
  4. Use an admin-capable token (extra.admin) if the operation requires elevated rights downstream.

Example fix

// before
const token = issueToken(wsId, { extra: { readonly: 'true' } })
// after
const token = issueToken(wsId, { extra: {} })
Defensive patterns

Strategy: try-catch

Validate before calling

const decoded = decodeTokenSafe(token)
if (decoded?.extra?.readonly !== undefined) {
  throw new Error('Token is read-only; request a non-readonly token for exports')
}

Type guard

function isWritableToken(t: { extra?: { readonly?: string } }): boolean {
  return t.extra?.readonly === undefined
}

Try / catch

try {
  await exportWorkspace(params)
} catch (e) {
  if (e instanceof ApiError && e.status === 403 && e.message === 'Forbidden') {
    token = await issueWritableWorkspaceToken(wsId)
    return exportWorkspace({ ...params, token })
  }
  throw e
}

Prevention

When it happens

Trigger: Calling the export endpoint with a token that was issued with extra.readonly set (e.g. a restricted/read-only workspace token); the check runs after the 'Missing required parameters' 400, so the body must already contain _class.

Common situations: CI pipelines configured with a deliberately restricted token; sharing a read-only demo token in documentation; a recent platform change issuing tokens with extra.readonly for external integrations.

Understand the failure class

Related errors


AI-assisted analysis of hcengineering/platform@63e28dc964 (2026-08-29). Data as JSON: /api/errors/530d87923ecfc276. Report an issue: GitHub.