Docs Casual Sheets

Customization

Admin panel walkthrough, secret handling, token issuance, webhook verification.

Casual Sheets ships with an admin panel at /admin for runtime customization — branding, storage backend, networking, room limits, auth providers, webhooks, base path. No restart needed for most changes; the on-disk config is reloaded on every read.

Enabling the admin panel

Three env vars to set:

CASUAL_ADMIN_USERNAME=admin
CASUAL_ADMIN_PASSWORD=$(openssl rand -hex 16)
CASUAL_JWT_SECRET=$(openssl rand -hex 32)

(The JWT secret is what the panel signs your session token with — must be ≥ 16 chars; 32 random bytes is a reasonable production floor.)

Then https://your-host/admin shows a login form. Credentials are constant-time compared against the env values; on success, you get a short-lived admin-role JWT (1 hour default, configurable via CASUAL_ADMIN_SESSION_TTL).

If any of the three env vars are unset, /admin shows a “not configured” hint listing exactly which env vars are missing.

Section reference

The panel has seven sections. Each is documented in detail:

  • Branding — app name, accent colour, logo.
  • Storage — workbook persistence backend selection + per-backend creds.
  • Networking — public origin, CORS, trust proxy, HSTS.
  • Base path — reverse-proxy sub-path mount.
  • Room limits — max rooms / file size / TTL / users per room.
  • Auth providers — JWT (live), OIDC + SAML (stubs for v0.2).
  • Webhooks — event-driven HTTP POSTs with HMAC signing.

How config layers

defaults (compiled into the image)
   ↓ overridden by
env vars (CASUAL_*)
   ↓ overridden by
admin-panel JSON config (CASUAL_ADMIN_CONFIG_PATH)

Operators bootstrap a deployment with env (good for config-as-code, secrets-manager integration, etc.); the admin panel writes are the runtime override. Setting an env var after the panel has overridden it does not revert — the on-disk JSON wins until an admin panel save overwrites it.

To force-reset the panel: delete the on-disk JSON file + restart. The server re-creates it with defaults.

Secret handling

The admin panel never re-displays secrets it’s been given. Once saved, the S3 secret key + OIDC client secret + webhook signing secrets all return as *** from GET /api/admin/config. The panel sends *** back verbatim on unchanged fields; the patch endpoint detects the sentinel and preserves the prior verbatim value.

To rotate a secret: type the new value over the ***. To delete: type an empty string + save.

Issuing access tokens

Once CASUAL_JWT_SECRET is set, every /wopi/files/* request requires a signed JWT. Admin-role tokens can mint subordinate tokens via:

curl -X POST http://localhost:3000/api/tokens \
  -H "Authorization: Bearer $ADMIN_TOK" \
  -H "content-type: application/json" \
  -d '{
    "sub": "alice@acme.example",
    "file_id": "wb-q3-budget",
    "role": "editor",
    "features": { "ai": false, "exportFiles": true },
    "ttl_seconds": 3600
  }'

Returns:

{
  "token": "eyJhbGciOiJIUzI1NiIs...",
  "ttl_seconds": 3600,
  "claims": { ... },
  "resolved_permissions": { "read": true, "write": true, ... },
  "resolved_features": { ... }
}

Drop the returned token into the WOPI request:

curl https://your-host/wopi/files/wb-q3-budget/contents \
  -H "Authorization: Bearer $TOK"

(Or as the standard WOPI placement, ?access_token=<JWT> query string — Casual Sheets accepts both.)

See auth.md for the full claim model + role permissions matrix.

Webhook signature verification

When you set a signing secret on a webhook subscription, every dispatch carries X-Casual-Signature: sha256=<hex>. Verify with:

// Node.js — Fastify route receiving Casual webhooks
import { createHmac, timingSafeEqual } from 'node:crypto';

function verify(req, raw, secret) {
  const sig = req.headers['x-casual-signature'];
  if (!sig?.startsWith('sha256=')) return false;
  const provided = sig.slice('sha256='.length);
  const expected = createHmac('sha256', secret).update(raw).digest('hex');
  // Constant-time compare — protects against timing oracles.
  return (
    provided.length === expected.length &&
    timingSafeEqual(Buffer.from(provided, 'hex'), Buffer.from(expected, 'hex'))
  );
}
# Python — Flask route
import hmac, hashlib
def verify(req, raw, secret):
    sig = req.headers.get("X-Casual-Signature", "")
    if not sig.startswith("sha256="):
        return False
    provided = bytes.fromhex(sig[len("sha256="):])
    expected = hmac.new(secret.encode(), raw, hashlib.sha256).digest()
    return hmac.compare_digest(provided, expected)

The raw argument is the raw request body bytes (not the parsed JSON object). Some web frameworks consume the stream before your handler runs — make sure you’re capturing the bytes before the body parser does.

Where to go next


Synced from docs/customization/overview.md in schnsrw/sheets. To update: edit upstream and re-run npm run sync-docs.