Docs Casual Sheets

Environment variables

Every runtime + build-time knob, with defaults + accepted values.

The single source of truth for every runtime + build-time knob Casual Sheets reads. The admin panel (lands in v0.1.0) reads this doc to render its config UI; docs/DOCKERHUB.md and the site self-hosting section both link here.

Two flavours:

  • Runtime — read by the Node server at startup or per-request. Settable via docker run -e, the environment: block in docker-compose.yml, or --env-file.
  • Build-time — read by Vite during pnpm --filter @sheet/web build and baked into the frontend bundle. Setting these at runtime does nothing; pass them as --build-arg on docker build or via the args: block of docker-compose.yml to bake your own image.

Server (runtime)

VarDefaultDescription
PORT3000HTTP + WebSocket listen port. The single port the image exposes.
HOST0.0.0.0Bind address. 0.0.0.0 exposes to the network; 127.0.0.1 keeps it localhost-only.
REDIS_URLunsetRedis connection string for Y.Doc persistence (rooms survive server restarts with a 7-day TTL). Unset → in-memory only; rooms vanish on restart. Compose pattern: redis://redis:6379.
ROOM_TTL_MIN15Minutes a room stays in memory after the last client disconnects. Lower → quicker eviction (less idle memory); higher → friendlier reconnect after a short drop.
MAX_UPLOAD_MB100Hard cap on multipart + raw-binary uploads. Bounds the share-room seed (.xlsx) and snapshot (gzipped JSON) upload paths. Raise for users with workbooks above this — and bump VITE_MAX_OPEN_MB to match so the browser doesn’t post something the server will 413.
NODE_ENVproduction (in image)Standard Node mode. Set by the Dockerfile; rarely overridden.

Storage (runtime · landing in v0.1.0)

Reserved keys for the WOPI host-integration MVP. Currently no-op on main; documented here so operators can plan ahead.

VarAcceptedDescription
CASUAL_STORAGEmemory (default) · local · s3 · postgresSelects the WOPI backend. memory keeps today’s no-DB shape. The other three persist workbooks across restarts.
CASUAL_LOCAL_PATH/dataFilesystem root when CASUAL_STORAGE=local. Mount with -v ./workbooks:/data.
CASUAL_S3_ENDPOINTunsetS3-API endpoint when CASUAL_STORAGE=s3. Examples: https://s3.amazonaws.com, http://minio:9000, https://<account>.r2.cloudflarestorage.com.
CASUAL_S3_REGIONus-east-1S3 region. Required by AWS S3; safe to keep at default for MinIO / R2 / B2.
CASUAL_S3_BUCKETunsetBucket name.
CASUAL_S3_ACCESS_KEYunsetS3 access key. Treat as secret.
CASUAL_S3_SECRET_KEYunsetS3 secret key. Treat as secret.
CASUAL_S3_FORCE_PATH_STYLEfalseSet true for MinIO and some self-hosted S3 implementations that require path-style addressing.
CASUAL_PG_URLunsetPostgres connection string when CASUAL_STORAGE=postgres. Format: postgres://user:pass@host:port/db.

Networking (runtime · landing in v0.1.0)

Reserved keys for the admin-panel networking surface. Currently no-op on main.

VarDefaultDescription
CASUAL_PUBLIC_ORIGINdetectedThe public URL the server should report in redirects, WOPI BaseFileName, share-link generation, OG canonical URLs. Example: https://sheets.acme.example.com.
CASUAL_CORS_ORIGINSempty (same-origin only)Comma-separated origins that may call the API. Empty → same-origin only. Example: https://app.acme.example.com,https://staging.acme.example.com.
CASUAL_TRUST_PROXYloopbackWhich proxy hops we accept X-Forwarded-* from. false to disable; true to trust the immediate upstream; a list of IPs / subnets for explicit allowlisting.
CASUAL_HSTS_MAX_AGEunsetEmit Strict-Transport-Security: max-age=<value> when set. Only set if HTTPS terminates upstream — sending HSTS over HTTP locks users out.

Admin (runtime · v0.1.0)

The admin panel at /admin is gated by env-driven credentials. Set CASUAL_ADMIN_USERNAME + CASUAL_ADMIN_PASSWORD to enable; the panel POSTs them to /api/admin/login which constant-time-compares against env and mints a short-lived admin-role JWT for the session. CASUAL_JWT_SECRET must also be set (≥ 16 chars) so the session token can be signed.

VarDefaultDescription
CASUAL_ADMIN_USERNAMEunset (panel disabled)Operator login username for the admin panel. Compared constant-time against /api/admin/login body.
CASUAL_ADMIN_PASSWORDunset (panel disabled)Operator login password. Treat as secret.
CASUAL_ADMIN_SESSION_TTL3600Admin session JWT lifetime in seconds.
CASUAL_ADMIN_CONFIG_PATH/data/casual-admin.jsonFilesystem path where the admin panel persists its JSON config (branding, storage, networking, room limits, auth-hook config, base path, webhook subscriptions). Atomically written with mode 0600 — treat the file as a secret.

Admin REST endpoints

PathMethodAuthDescription
/api/admin/statusGETpublic{ configured: bool } — bootstrap probe for the panel.
/api/admin/loginPOSTenv credsReturns { token, ttl_seconds, username }. Token is an admin-role JWT.
/api/admin/configGETadmin JWTCurrent config; secret fields redacted to ***.
/api/admin/configPUTadmin JWTPatch-merge the on-disk config. Secret fields sent as *** keep the prior value.

Admin config — runtime overrides

The on-disk config persisted at CASUAL_ADMIN_CONFIG_PATH overrides env at runtime. Env provides the bootstrap floor; the admin panel writes win once set. Fields:

  • brandingappName, accentColor, logoUrl
  • basePath — reverse-proxy mount path (e.g. /sheets); empty means served at root. Affects Fastify route prefix + the SPA’s asset base.
  • storagebackend (memory | local | s3 | postgres)
    • per-backend creds. Mirrors the CASUAL_STORAGE env above.
  • networkingpublicOrigin, corsOrigins, trustProxy, hstsMaxAge. Mirrors the networking env vars.
  • limitsmaxRooms, maxFileSizeMb, roomTtlMin, maxUsersPerRoom.
  • auth — OIDC / SAML / JWT provider config (OIDC + SAML stub in v0.1; JWT actively wired).
  • webhooks — array of { name, url, events, secret, enabled } subscriptions. See Webhooks below.

Webhooks (runtime · v0.1.0)

Server-side events fire HTTP POSTs to operator-configured URLs. HMAC-SHA256 signs the JSON body when a subscription has a secret — receivers verify via the X-Casual-Signature: sha256=<hex> header.

Subscriptions live in admin-config.webhooks (writable via the admin panel). Each subscription:

{
  "name": "audit-log",
  "url": "https://example.com/hooks/casual",
  "events": ["file.saved", "admin.login"],
  "secret": "shh",
  "enabled": true
}

Empty events array = subscribed to every event.

Events

EventFired when
room.createdPOST /api/rooms creates a new room
room.droppedLast client leaves + GC ticks (after roomTtlMin)
file.uploadedPOST /api/rooms/:id/seed succeeds
file.savedPOST /wopi/files/:id/contents succeeds (download → edit → save flow)
file.deletedDELETE /wopi/files/:id (admin only)
user.joinedNew client joins a room
user.leftClient disconnects from a room
admin.loginSuccessful /api/admin/login
admin.login_failedFailed /api/admin/login

Payload shape

{
  "event": "file.saved",
  "timestamp": "2026-06-01T14:23:09.123Z",
  "payload": {
    "fileId": "wb-q3-budget",
    "size": 12345,
    "version": "1748872989123-abc12345",
    "user": "alice@acme.example"
  }
}

Headers on every dispatch:

HeaderDescription
Content-Typeapplication/json
User-AgentCasualSheets-Webhook/0.1
X-Casual-EventThe event name
X-Casual-Attempt1 or 2 — see retry policy below
X-Casual-Signaturesha256=<hex> when subscription has a secret. Compute the same way to verify: hmac-sha256(secret, raw_body).

Retry policy (v0.1)

  • Single retry on non-2xx response or network error.
  • Retry fires after 5 s.
  • After 2 attempts, the dispatch is logged + dropped. v0.2 adds a proper queue with exponential back-off + a dead-letter store.

Auth (runtime · JWT access tokens · v0.1.0)

When CASUAL_JWT_SECRET is set, every /wopi/files/* request must carry a valid JWT (in Authorization: Bearer … or via the ?access_token=… query string — WOPI’s standard placement). When unset, WOPI routes fall through to v0.0.x anonymous-by-URL behaviour — operators opt in to auth by setting the secret.

VarDefaultDescription
CASUAL_JWT_SECRETunset (auth disabled)HMAC-SHA256 shared secret used to sign + verify access tokens. Minimum 16 chars; recommend ≥ 32 random bytes (e.g. openssl rand -hex 32). Treat as secret.
CASUAL_JWT_DEFAULT_TTL3600Default token lifetime in seconds when ttl_seconds is omitted from the POST /api/tokens body.

Token claims (signed payload)

ClaimTypeDescription
substringUsername, email, or any stable user identifier. Surfaces as UserId in CheckFileInfo.
file_idstringThe single file this token authorises. WOPI routes reject when the URL :id ≠ this claim.
role'admin' | 'editor' | 'commenter' | 'viewer'Coarse role. Default permission map applied unless permissions overrides.
permissionsobject (optional)Per-flag override: { read, write, comment, download, share, admin }.
featuresobject (optional)Feature toggles consumed by the client UI: { charts, pivots, conditionalFormatting, sharing, exportFiles, collab, ai }.
password_requiredboolean (optional)When true, the legacy x-room-password header gate also applies on top of the JWT.
display_namestring (optional)Human label for presence + cursor markers. Falls back to sub.
audstring (optional)Audience — typically the deployment’s public origin.
issstring (optional)Issuer — useful when downstream SSO mints tokens.
exp, iatnumberStandard JWT expiry + issued-at, set by the signer.

Endpoints exposed when JWT is configured

PathMethodAuthDescription
/api/meGEToptionalDecodes the token + returns resolved role / permissions / features / passwordRequired / exp. Returns { anonymous: true } without a token.
/api/tokensPOSTadmin role requiredMint a new token. Body: { sub, file_id, role, permissions?, features?, password_required?, display_name?, ttl_seconds?, aud?, iss? }. Returns the signed JWT + the resolved claim summary.
/api/filesGETadmin role requiredList every file id the host backend knows about.

Bootstrapping the first admin token

Tokens are minted by tokens — chicken-and-egg. Sign the first admin token manually using the secret:

docker compose exec app node -e '
  import("jsonwebtoken").then(({ default: jwt }) => {
    const tok = jwt.sign(
      { sub: "owner", file_id: "*", role: "admin" },
      process.env.CASUAL_JWT_SECRET,
      { algorithm: "HS256", expiresIn: "8h" },
    );
    console.log(tok);
  });
'

After that, the admin panel (v0.1.0) issues subsequent tokens through its UI; the bootstrap step happens once per deployment.


Web build (Vite — bake-time only)

These are read at pnpm --filter @sheet/web build and bundled into the JS. Override via --build-arg. Setting them at runtime does nothing.

VarDefaultDescription
VITE_COLLAB_ENABLED1Ship co-editing in the bundle. Off in the GitHub Pages demo build; on in the Docker image.
VITE_COLLAB_WS_URLsame-origin /yjsWebSocket URL the collab driver dials. Override when running Vite dev (:5273) against a standalone server (:3000).
VITE_MAX_OPEN_MB100Hard reject for File → Open / drag-drop. Larger files freeze and eventually OOM-crash the tab during the ExcelJS parse. The supported sweet spot is 25–50 MB.
VITE_SOFT_WARN_MB25Threshold above which the loading overlay shows the up-front “this is a large workbook, may take 10+ s” hint. Should be ≤ VITE_MAX_OPEN_MB.

OCI image-label build args

Passed by .github/workflows/docker-publish.yml at tag-time. Surface as org.opencontainers.image.* labels on the published image so operators can docker inspect provenance.

Build argSets labelNotes
CASUAL_VERSIONimage.versionThe git tag, e.g. v0.1.0.
CASUAL_GIT_SHAimage.revisionFull commit SHA at the tag.
CASUAL_BUILD_DATEimage.createdRFC 3339 UTC timestamp at build time.

Inspect with:

docker inspect schnsrw/casual-sheets:latest \
  | jq '.[0].Config.Labels | with_entries(select(.key | startswith("org.opencontainers")))'

Discovery convention

  • Server keys use snake-case (PORT, ROOM_TTL_MIN) for backwards compatibility with the v0.0.x release line.
  • Storage / networking / admin keys (v0.1.0+) all carry the CASUAL_ prefix so they’re greppable and don’t collide with generic env vars on a shared host.

If you’re adding a new runtime knob, follow the CASUAL_* convention and update this file in the same commit. The admin panel auto-renders its config UI from this table.


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