Skip to content

Integration guide — the Liquida case

This guide walks the Liquida integration end to end. Liquida is the reference domain the isolation model was designed for: one wrapper project, two client apps, and a two-level identity hierarchy (Master → Ruta).

App Kind Who signs in Sees
Sensei internal web company staff all Masters; provisions and assigns quota
Liquida Master web a Master own files + all of its Rutas
Liquida Pro mobile a worker (a Ruta) only that Ruta

The project registers two integrations:

  • Master — key embedded in the Liquida Master web build. Mints user tokens.
  • Pro — key embedded in the Liquida Pro mobile build. Mints space tokens (spaces:login).

Because both keys live in app builds and are extractable, neither key is the isolation boundary. The boundary is the token minted at login. See the isolation model for why.

The default is SCOPE_ENFORCEMENT=enforce, so an integration receives 403 for any operation whose scope it did not declare. Declare scopes when you create the integration (POST /project/integrations) or edit them later (PUT /project/integrations/:id).

Scope Grants
users:read read a user; required to mint a user token
users:write create / update users (provisioning)
files:read list and read files
files:write upload files and images
files:delete delete files
plans:read read plan definitions
plans:assign assign a plan to a user
stats:read read stats
spaces:login exchange space credentials for a space token

A minted user token inherits the caller’s scopes by default (or a validated subset). So the Master integration’s own scope set is the ceiling for every user token it mints. The reference demo integration is seeded with [files:read, files:write, files:delete, spaces:login, stats:read, users:read, users:write].

Terminal window
BASE="http://api.wsw.local/api/v1" # or http://localhost:3000/api/v1
PROJECT_KEY="pak_...." # PROJECT_API_KEY — Sensei / owner authority
MASTER_KEY="...." # Master integration key (mints user tokens)
PRO_KEY="...." # Pro integration key (mints space tokens)

Sensei (or any provisioner) creates the Master as a user. Requires users:write (or the project key).

Terminal window
curl -X POST "$BASE/project/users" \
-H "Authorization: Bearer $PROJECT_KEY" \
-H 'Content-Type: application/json' \
-d '{ "external_id": "master_42" }'
# → 201 { "user": { "id": "MASTER_UUID", "external_id": "master_42", ... } }

Step 2 — Master logs in → mint a user token

Section titled “Step 2 — Master logs in → mint a user token”

When a Master signs into the Liquida Master app, the app’s server exchanges the Master integration key for a short-lived user token bound to that Master. Requires users:read.

Terminal window
curl -X POST "$BASE/project/users/MASTER_UUID/tokens" \
-H "Authorization: Bearer $MASTER_KEY" \
-H 'Content-Type: application/json' \
-d '{ "scopes": ["files:read", "files:write", "files:delete"] }'
# → 201 {
# "token": "eyJhbGciOi...", # ← what the Master's client uses from here on
# "jti": "…", # ← pass to /tokens/revoke to revoke
# "user_id": "MASTER_UUID",
# "scopes": ["files:read","files:write","files:delete"],
# "expires_at": "…"
# }
  • The scopes body is optional; omit it to inherit all of the caller’s scopes.
  • Requested scopes must be a subset of the caller’s — you cannot escalate.
  • A user token cannot mint further tokens (no lateral escalation) — 403.

The Master’s client now calls file endpoints with Authorization: Bearer MASTER_TOKEN. The subject guard confines it to MASTER_UUID regardless of the path it tries.

Also available by external id: POST /project/users/ext/master_42/tokens.

Revoke early with:

Terminal window
curl -X POST "$BASE/project/users/MASTER_UUID/tokens/revoke" \
-H "Authorization: Bearer $MASTER_KEY" \
-H 'Content-Type: application/json' \
-d '{ "jti": "THE_JTI" }'
# → the token is blocklisted and rejected on its next use

The Master (with its user token) — or Sensei with the project key — creates a Ruta under itself. This is require-space-manager territory: integration and space tokens are refused.

Terminal window
curl -X POST "$BASE/project/users/MASTER_UUID/spaces" \
-H "Authorization: Bearer $MASTER_TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"external_id": "ruta_001",
"name": "Ruta Norte",
"scopes": ["files:read", "files:write"],
"password": "worker-pw",
"limit_bytes": 5368709120
}'
# → 201 { "space": { "id": "SPACE_UUID", "external_id": "ruta_001", "scopes": [...] } }
# (the password hash is NEVER returned)

scopes here is the permission subset that Ruta’s token will carry. limit_bytes is an optional per-space sub-limit within the Master’s total quota.

Step 4 — Worker signs into the Ruta (space login)

Section titled “Step 4 — Worker signs into the Ruta (space login)”

The Liquida Pro mobile app authenticates with the Pro integration key (spaces:login) and the Ruta’s external_id + password. The wrapper verifies the password (Bun.password.verify, constant-time on the miss path) and mints a space token.

Terminal window
curl -X POST "$BASE/project/spaces/login" \
-H "Authorization: Bearer $PRO_KEY" \
-H 'Content-Type: application/json' \
-d '{ "external_id": "ruta_001", "password": "worker-pw" }'
# → 201 {
# "token": "SPACE_TOKEN",
# "jti": "…",
# "space_id": "SPACE_UUID",
# "user_id": "MASTER_UUID", # owner (Master)
# "scopes": ["files:read","files:write"],
# "expires_at": "…"
# }

Single-device (last-wins): every successful login blocklists the space’s previous token and records the new one. A worker signing in on a second device silently signs the first one out. Revoke explicitly with:

Terminal window
curl -X POST "$BASE/project/spaces/token/revoke" \
-H "Authorization: Bearer $PRO_KEY" \
-H 'Content-Type: application/json' \
-d '{ "jti": "THE_JTI" }'

Step 5 — Upload and list, confined by the token

Section titled “Step 5 — Upload and list, confined by the token”

Both the Master and a Ruta call the same file endpoints under the Master’s path — the token decides what happens.

A Ruta uploads — the row is stamped space_id = SPACE_UUID automatically:

Terminal window
curl -X POST "$BASE/project/users/MASTER_UUID/files" \
-H "Authorization: Bearer $SPACE_TOKEN" \
-F file=@invoice.pdf -F meta='{"visibility":"private"}'
# → 201; file lands INSIDE the space

A Ruta lists — sees only its own space (sibling Rutas are invisible):

Terminal window
curl "$BASE/project/users/MASTER_UUID/files" \
-H "Authorization: Bearer $SPACE_TOKEN"
# → only files with space_id = SPACE_UUID

The Master lists — sees own files (space_id IS NULL) plus all its spaces; it can also filter by Ruta with ?space_id=<uuid|none>:

Terminal window
curl "$BASE/project/users/MASTER_UUID/files?space_id=SPACE_UUID" \
-H "Authorization: Bearer $MASTER_TOKEN"

For images, use POST /project/users/MASTER_UUID/images instead — it returns 202 and compresses asynchronously. See the image compression guide.

Every request under /project/* passes through integration-policy, which applies the caller integration’s own policy with a hierarchical fallback:

  1. dev_mode on the integration → no rate limit, CORS open (Origin reflected).
  2. Else the integration’s own rate_limit_max / rate_limit_window.
  3. Else the project’s rate_limit_max / rate_limit_window.
  4. Else the global RATE_LIMIT_MAX / RATE_LIMIT_WINDOW_MS env fallback.

Behavior to expect:

  • Responses carry X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset.
  • Exceeding the limit returns 429 with Retry-After.
  • A request whose Origin is not in the integration’s allowed origins is 403.
  • Requests without an Origin header are allowed (server-to-server).
  • Because browsers preflight cookie/credentialed cross-origin requests, the wrapper answers OPTIONS permissively and sets Access-Control-Allow-Credentials: true on both preflight and the real response.
Action Project key Master integration Master (user token) Ruta (space token)
Manage keys / integrations ✅ ❌ ❌ ❌
Create / delete users ✅ ✅ ❌ ❌
Mint a user token ✅ ✅ ❌ ❌
Manage its own Rutas ✅ ❌ ✅ ❌
Space login (mint space token) ✅ — — via Pro key
Upload / list own files ✅ ✅ ✅ (own) ✅ (its space)
See another user’s / Ruta’s files ✅ ✅ ❌ ❌

The columns map directly to the authorization axes: actor-type guards drive the top rows, subject/space guards drive the bottom rows.