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).
The Liquida topology
Section titled “The Liquida topology”| 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.
Scopes an integration must declare
Section titled “Scopes an integration must declare”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].
Base URL and credentials
Section titled “Base URL and credentials”BASE="http://api.wsw.local/api/v1" # or http://localhost:3000/api/v1PROJECT_KEY="pak_...." # PROJECT_API_KEY — Sensei / owner authorityMASTER_KEY="...." # Master integration key (mints user tokens)PRO_KEY="...." # Pro integration key (mints space tokens)Step 1 — Provision a Master (user)
Section titled “Step 1 — Provision a Master (user)”Sensei (or any provisioner) creates the Master as a user. Requires
users:write (or the project key).
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.
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
scopesbody 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:
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 useStep 3 — Master creates a Ruta (space)
Section titled “Step 3 — Master creates a Ruta (space)”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.
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.
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:
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:
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 spaceA Ruta lists — sees only its own space (sibling Rutas are invisible):
curl "$BASE/project/users/MASTER_UUID/files" \ -H "Authorization: Bearer $SPACE_TOKEN"# → only files with space_id = SPACE_UUIDThe Master lists — sees own files (space_id IS NULL) plus all its spaces;
it can also filter by Ruta with ?space_id=<uuid|none>:
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.
Rate limiting & CORS per integration
Section titled “Rate limiting & CORS per integration”Every request under /project/* passes through integration-policy, which
applies the caller integration’s own policy with a hierarchical fallback:
dev_modeon the integration → no rate limit, CORS open (Origin reflected).- Else the integration’s own
rate_limit_max/rate_limit_window. - Else the project’s
rate_limit_max/rate_limit_window. - Else the global
RATE_LIMIT_MAX/RATE_LIMIT_WINDOW_MSenv fallback.
Behavior to expect:
- Responses carry
X-RateLimit-Limit,X-RateLimit-Remaining,X-RateLimit-Reset. - Exceeding the limit returns
429withRetry-After. - A request whose
Originis not in the integration’s allowed origins is403. - Requests without an
Originheader are allowed (server-to-server). - Because browsers preflight cookie/credentialed cross-origin requests, the
wrapper answers
OPTIONSpermissively and setsAccess-Control-Allow-Credentials: trueon both preflight and the real response.
What each actor can and cannot do
Section titled “What each actor can and cannot do”| 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.