Skip to content

Workspace Store

The store is the optional service that gives a team shared documents. It keeps them encrypted, so it holds ciphertext and wrapped keys and can read none of it. See How your work is protected for what that means in practice.

Without a store, the editor works exactly as it does today: documents live in the browser, sessions start from a link, and nothing leaves the machine.

What you need

  • PostgreSQL 16 or later. The store applies its own schema at startup; there is no separate migration step.
  • An identity provider. Any OIDC provider works - Keycloak, Entra ID, Okta, Auth0, GitLab, Google - and GitHub is supported through an adapter.
  • HTTPS, unless everything is on localhost. See Origins and HTTPS.
  • A recovery keypair, generated before the first document is stored.

The image

The store is published alongside the editor and the relay, on each release:

ghcr.io/jbraunsmajr/system-design-store:latest
ghcr.io/jbraunsmajr/system-design-store:<yyyy-mm-dd>

Each release is tested against PostgreSQL and started once before it is published. A complete example - editor, relay, store, PostgreSQL and Keycloak - lives in docker/store/compose.yaml in the repository; it builds the images from source, and swapping each build: for the matching image: above runs the published ones instead.

yaml
store:
  image: ghcr.io/jbraunsmajr/system-design-store:latest
  environment:
    PUBLIC_URL: https://store.example.gov
    DATABASE_URL: postgresql://store:${POSTGRES_PASSWORD}@postgres:5432/store
    # …the settings below

Before the first document

Generate the organization's recovery pair once, on a machine that is not the server.

bash
docker run --rm \
  -u $(id -u):$(id -g) \
  -v ./keys:/keys \
  ghcr.io/jbraunsmajr/system-design-store:latest \
  generate-recovery-key --out /keys/recovery

From source

bash
npx tsx scripts/generate-recovery-key.ts --out ./recovery

Give the store the public half (recovery-public.pem). Keep the private half (recovery-private.pem) offline, and somewhere other than your database backups - it is the last route into a document when workspace keys are gone, and it is useless to an attacker who has only the database.

A store with encryption on and no recovery key refuses documents.

That is deliberate. The alternative is a workspace quietly accumulating content that nobody, including you, could ever recover.

Settings

The store reads its configuration from the environment, prints what it is at startup, and refuses to start on anything unusable rather than falling back to a quiet default.

VariableRequiredPurpose
PUBLIC_URLyesWhere people reach the store. Sign-in returns here.
DATABASE_URLnoPostgreSQL. Without it everything is kept in memory - demonstrations only.
PORTnoDefault 8080.
ALLOWED_ORIGINSwhere the editor is elsewhereExact origins the editor is served from. No wildcards.
AFTER_LOGIN_URLnoWhere people land after signing in. Defaults to the first allowed origin.
AUTH_PROVIDERSyesoidc, github, or both.
OIDC_ISSUERwith oidcThe issuer URL as the browser sees it.
OIDC_INTERNAL_URLin a container networkThe address the store uses, when it differs.
OIDC_CLIENT_ID, OIDC_CLIENT_SECRETwith oidcThis store's client. The secret stays on the server.
GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRETwith githubA GitHub OAuth app.
RECOVERY_PUBLIC_KEY_FILEwith encryption onPEM file holding the recovery public key.
ADMIN_SUBJECTSnoissuer#subject per administrator. With none set, holds and purges are refused to everyone.
RETENTION_PERIODnoimmediate, a duration (7d, 6m, 7y), or indefinite. Default 30d.
RELAY_TOKEN_SECRETnoShared with the relay to require membership for sessions.
CRYPTO_MODEnowebcrypto (default) or passthrough.
ALLOW_UNAUTHENTICATEDnotrue runs with no sign-in at all. Development only.

The editor needs one setting of its own: STORE_URL, the store's public address. Without it, the editor shows no workspace.

Identity provider setup

Register the store as a confidential client:

SettingValue
Redirect URIExactly <PUBLIC_URL>/v1/auth/callback
Client typeConfidential - the secret stays on the server
FlowAuthorization Code with PKCE
Scopesopenid profile - only the subject and a display name are read

Two things catch people out:

  • The redirect URI must match PUBLIC_URL exactly, including the port. A mismatch is refused by the provider before the store is involved.
  • A user with no email address may not be able to sign in. Keycloak 26 interrupts the sign-in to ask them to complete their profile. Give test and service accounts an email, marked verified.

Two addresses for one provider

Inside a container, localhost is that container. A store told only about http://localhost:8081 will send the browser to the right place and then fail to reach the provider itself:

yaml
OIDC_ISSUER: http://localhost:8081/realms/system-design   # the browser's
OIDC_INTERNAL_URL: http://keycloak:8080                   # the store's

Only the token exchange, discovery and key fetch use the internal address. The provider must also know its own public address (KC_HOSTNAME for Keycloak) or it will advertise the internal one and the two stop matching.

Origins and HTTPS

A browser only sends a session cookie across origins when it is marked SameSite=None, and only accepts that over HTTPS.

  • Different origins - serve both over HTTPS. The store refuses to start with a cross-origin editor over plain HTTP rather than appearing to work and then having every request look unauthenticated.
  • Same origin, behind one reverse proxy - nothing special is needed.
  • Localhost - plain HTTP works, because browsers treat it as secure.

Sessions and the relay

By default the relay admits anyone who can reach it: knowing a room name is the whole of the access control. To require membership, set the same RELAY_TOKEN_SECRET on the store and the relay, and run the relay from scripts/relay-server.ts. The store then issues short-lived tokens for one room to people it has signed in.

The relay never holds a document key either way.

RETENTION_PERIOD decides what deleting a document means: purged at once, restorable for a period, or kept until an administrator purges it. A legal hold prevents purge in every mode, including an explicit purge, until an administrator releases it. Holds and purges need ADMIN_SUBJECTS.

Set the period your records schedule requires before the first document is stored; changing it later affects only documents deleted afterwards.

Running more than one instance

Sessions live in PostgreSQL, so a restart does not sign everyone out and two instances behind a load balancer share them - no sticky sessions needed.

Backups and disaster recovery

Back up PostgreSQL as usual. Its contents are encrypted, so a backup is useless to an attacker - and equally useless to you without the offline recovery key. Store that key separately from the backups it would be used to recover.

To restore an escrowed document package offline using the recovery private key:

bash
docker run --rm \
  -u $(id -u):$(id -g) \
  -v ./keys:/keys \
  -v ./backups:/backups \
  ghcr.io/jbraunsmajr/system-design-store:latest \
  recover-document \
    --key /keys/recovery-private.pem \
    --package /backups/document.json \
    --out /backups/recovered.json

Or from source:

bash
npx tsx scripts/recover-document.ts \
  --key recovery-private.pem \
  --package document.json \
  --out recovered.json

System Design Editor Documentation