Solo CRM

Mobile & JSON API

One registry of operations, served to the browser and to mobile clients from the same definitions.

Every capability this app has is declared once, in apps/web/src/api/modules/, and served from two mounts. The browser calls one; a mobile app or a script calls the other. No handler is written twice, so an operation cannot exist on one surface and be missing from the other, and a permission added to one is added to both.

BrowserMobile and scripts
Path/api/v1/{workspace}/{module}/{operation}/api/mobile/v1/{workspace}/{module}/{operation}
CredentialSession cookieAuthorization: Bearer …
CSRF checkYes, on writesNot needed — see below
ResponseThe bare value, or 204Always { ok, … }

Shape of a call

Reads are GET with query parameters. Writes are POST with a JSON body. There is no PATCH or DELETE: an operation is named by what it does, so the id travels in the body rather than in a path segment.

curl https://your-host/api/mobile/v1/acme/companies/list \
  -H "Authorization: Bearer sk_live_…"
{ "ok": true, "data": [{ "id": "…", "name": "Jayam Housing" }] }
curl -X POST https://your-host/api/mobile/v1/acme/notes/add \
  -H "Authorization: Bearer sk_live_…" \
  -H "Content-Type: application/json" \
  -d '{"resourceType":"company","resourceId":"…","body":"Chased the renewal."}'

Every response has the same shape

{ "ok": true,  "data":  }
{ "ok": false, "error": { "code": "forbidden", "message": "Your role cannot do that." } }

One shape means one decoder. The browser API returns the bare value and a 204 when there is nothing to say, which is fine for TypeScript talking to itself and poor for a typed client in another language — two response shapes are two decoders, and an empty body is a third case. On this mount an operation with nothing to return sends data: null.

Branch on code, never on message. Messages are written for people and get reworded; a code is a contract.

codeStatusMeaning
unauthenticated401No credential, or it is no longer valid
forbidden403Authenticated, but the role lacks the operation's permission
not_found404No such workspace — or one you are not a member of
unknown_module404The module segment is not a module
unknown_operation404The module has no such operation for this method
invalid_request400The body failed validation; the message names the field
internal500Something went wrong that is not yours to fix

A 404 for a workspace you cannot reach is deliberate and not a mistake: a probe must not be able to learn that a workspace exists but is not yours.

Authenticating

Two kinds of bearer credential, and neither is the session cookie. A cookie rides along on any request a browser makes to this origin, so accepting one here would let any page on the internet drive this API as whoever is signed in. A bearer token has to be supplied deliberately — which is also why this mount needs no CSRF check and the browser's does.

  • API keys — Settings → Connect to Agents. For a client someone configures by hand. A key names its own workspace, so the {workspace} segment must match it or the call is refused. Only a hash is stored and the token is shown once.
  • OAuth — for a client that cannot be handed a secret, like an app shipped to a store. The grant is to a person, never to a workspace, so the segment selects one and membership authorizes it.

Either way the credential ends at the same membership join the web app uses. A token carries its owner's access and cannot exceed it: demote them and the next call is narrower; remove them and it stops working.

Discovering what a build can do

Unauthenticated on purpose

The manifest names capabilities, not data. A client has to be able to tell "my token expired" from "this server is too old for that feature", and it cannot do that if discovery needs a token.

curl https://your-host/api/mobile/v1/manifest
{
  "ok": true,
  "data": {
    "api": "v1",
    "app": { "version": "0.1.28", "commit": "a1f41f6", "dev": false },
    "modules": [
      {
        "name": "notes",
        "operations": [
          { "name": "add",  "method": "POST", "path": "notes/add",  "permission": "record:write", "takesBody": true },
          { "name": "list", "method": "GET",  "path": "notes/list", "permission": "record:read",  "takesBody": false }
        ]
      }
    ]
  }
}

It is generated from the registry rather than maintained by hand, so it cannot describe an endpoint that does not exist or miss one that does.

The endpoint reference

Every operation, with its parameters, request body and responses, is under Reference — one page per endpoint, generated from the same registry. The bodies there are the actual zod schemas the server validates with, so a uuid or a minimum length in the reference is a constraint the API will really enforce.

This page is the part that does not fit an endpoint list: what the envelope is, how to authenticate, and why the version is in the path.

Versioning

v1 is in the path because a mobile build stays on someone's phone for months. Every other client here updates when the server does; that one does not, so the surface it was compiled against has to keep answering. A breaking change gets a new segment rather than a flag.

Permissions

Each operation declares the permission it needs, and both mounts read the same declaration — so neither can be more permissive than the other.

owneradminmemberviewer
record:read
record:write
record:delete
assistant:use
schema:write
member:manage
workspace:manage

workspace:manage is the only difference between an owner and an admin, and it gates every stored credential.

On this page