HeyPuter/puter · error · HttpError

forbidden

forbidden

Error message

Forbidden

What it means

`POST /turn/ingest-usage` is an internal-only endpoint authenticated via a shared secret (`config.peers.internal_auth_secret`). If that config value is missing, the endpoint immediately returns 403 — the feature is not set up, so no request can authenticate. This is a configuration guard, not a credential check failure.

Source

Thrown at src/backend/controllers/peer/PeerController.ts:231

            throw new HttpError(500, 'TURN credential generation failed', {
                legacyCode: 'internal_error',
            });
        }

        const data = (await cfRes.json()) as { iceServers?: unknown };
        res.json({ ttl, iceServers: data.iceServers });
    };

    /**
     * POST /turn/ingest-usage — internal-only TURN egress metering. an external
     * service that knows the usage information from cloudflare will send it to
     * us here. Meters each record directly against the owning user via
     * `services.metering.incrementUsage` multiplied by turn:egress-bytes cost.
     */
    #ingestUsage = async (req: Request, res: Response): Promise<void> => {
        const cfg = this.config.peers;
        if (!cfg || !cfg.internal_auth_secret) {
            throw new HttpError(403, 'Forbidden', { legacyCode: 'forbidden' });
        }
        const expectedSecret = cfg.internal_auth_secret;
        const header = req.headers['x-puter-internal-auth'];
        if (
            !expectedSecret ||
            typeof header !== 'string' ||
            !secretsEqual(header, expectedSecret)
        ) {
            throw new HttpError(403, 'Forbidden', { legacyCode: 'forbidden' });
        }

        const { records } = req.body ?? {};
        if (!Array.isArray(records)) {
            throw new HttpError(400, 'Missing `records` array', {
                legacyCode: 'bad_request',
            });
        }

View on GitHub (pinned to 908ec23eda)

Solutions

  1. Set `peers.internal_auth_secret` to a strong random string shared between the server and the external usage-ingestion service.
  2. Ensure the external service sends the same value in the `x-puter-internal-auth` header.
  3. Restart the backend after updating config.
  4. If you don't run the usage-ingestion service, this endpoint returning 403 is expected and harmless.

Example fix

// before (config.json)
{ "peers": { "signaller_url": "..." } }

// after
{
  "peers": {
    "signaller_url": "...",
    "internal_auth_secret": "<strong-random-secret>"
  }
}
Defensive patterns

Strategy: try-catch

Try / catch

// Internal service — catch 403 and alert ops that config is missing
try {
  await fetch('/turn/ingest-usage', {
    method: 'POST',
    headers: { 'x-puter-internal-auth': secret },
    body: JSON.stringify({ records }),
  });
} catch (e) {
  if (e.code === 'forbidden') {
    console.error('internal_auth_secret not configured on server. Alert ops.');
  }
}

Prevention

When it happens

Trigger: The internal usage-ingestion service calls the endpoint, but `config.peers` or `config.peers.internal_auth_secret` is not configured on the server. The first guard (`!cfg || !cfg.internal_auth_secret`) fires before the header is even checked.

Common situations: Self-hosting without configuring the internal auth secret; the external metering service is deployed but the server config was never updated; a new deployment that copied a minimal config template.

Understand the failure class

Related errors


AI-assisted analysis of HeyPuter/puter@908ec23eda (2026-08-12). Data as JSON: /api/errors/2692252091c994c1. Report an issue: GitHub.