Skip to content

Custom API instances

The official Web app accepts a custom origin only when the Pinchana project has issued an unexpired Ed25519 certificate for that exact origin. This prevents the settings field from becoming an arbitrary server-side request proxy.

The API returns this public envelope from GET /web/identity:

{"payload":"BASE64URL_JSON_CLAIMS","signature":"BASE64URL_ED25519_SIGNATURE"}

Decoded claims must contain:

Claim Requirement
issuer exactly pinchana-project
protocol exactly 1
origin exact normalized API origin
turnstile_site_key non-empty public widget key for this Web hostname
issued_at Unix seconds; no more than five minutes in the future
expires_at Unix seconds; must be in the future

Production origins must use HTTPS and contain no credentials, path, query, or fragment. Development permits HTTP. The Web deployment pins the project public key in server-only PINCHANA_INSTANCE_PUBLIC_KEY and verifies Ed25519 over the raw payload bytes.

  1. A user enters an origin in Settings.
  2. Browser code fetches ${origin}/web/identity with CORS.
  3. It posts the origin and envelope to same-origin POST /api/instance.
  4. The BFF verifies signature, claims, exact origin, and time bounds.
  5. It stores payload.signature in the HttpOnly pinchana_instance cookie until certificate expiry, clears the old session, and starts Turnstile with the certified site key.

After approval, an operator receives the one-line envelope—not the signing key. For the standard Docker Compose deployment, copy the envelope into the API repository’s .env and quote the complete JSON value:

PINCHANA_INSTANCE_CERTIFICATE='{"payload":"BASE64URL_CLAIMS","signature":"BASE64URL_SIGNATURE"}'
PINCHANA_INSTANCE_CERTIFICATE_FILE=

The API deployment also needs the private Turnstile secret matching the public site key embedded in the certificate, plus the Web hostname and action:

TURNSTILE_SECRET_KEY=REPLACE_WITH_PRIVATE_TURNSTILE_SECRET
TURNSTILE_EXPECTED_HOSTNAME=pinchana.example.com
TURNSTILE_EXPECTED_ACTION=turnstile-spin-v1
TURNSTILE_SESSION_SECRET=REPLACE_WITH_AT_LEAST_32_RANDOM_CHARACTERS

Apply and verify the configuration through the final public HTTPS origin:

Terminal window
docker compose up -d --force-recreate server
curl --fail --silent https://api.example.com/web/identity

The response must contain the installed payload and signature. A 503 means the certificate was not available inside the server container. The API’s /web/identity response is public, cached for five minutes, and allows all origins because the certificate is already public and cryptographically verified.

To use a file instead, mount it read-only into the server container and set PINCHANA_INSTANCE_CERTIFICATE_FILE to the container path. Leave the inline variable empty:

services:
server:
volumes:
- ./secrets/instance-certificate.json:/run/secrets/pinchana-instance-certificate.json:ro
environment:
PINCHANA_INSTANCE_CERTIFICATE_FILE: /run/secrets/pinchana-instance-certificate.json

The inline value takes precedence when both sources are configured.

The private Ed25519 key stays offline and outside every repository and deployment. A maintainer can generate a key pair and use the API repository’s signing script after reviewing an operator:

Terminal window
openssl genpkey -algorithm Ed25519 -out /secure/pinchana-instance-private.pem
openssl pkey -in /secure/pinchana-instance-private.pem -pubout -out pinchana-instance-public.pem
node scripts/sign-instance-certificate.mjs \
/secure/pinchana-instance-private.pem \
https://api.example.com \
REPLACE_WITH_TURNSTILE_SITE_KEY \
90

The command prints the one-line envelope to standard output. Its second argument is the exact final HTTPS API origin, with no path, query, credentials, or fragment. Its third argument is the public Turnstile site key for the Web hostname—not the private Siteverify secret. Validity may be 1 through 366 days.

Issue and install a replacement before expires_at, then recreate the API server container. Once an old certificate expires, the Web app rejects it and users must select the instance again after renewal.