Data isolation
D1 is SQLite. It has no row-level security and no database roles, and a Worker’s D1 binding can read every row. gymgym gets the same guarantees from several layers, each of which catches a different kind of mistake.
- One way into the database. Resolvers receive
ScopedData, built per request from the session. Every read filters on the caller’s user id and every insert sets it from the session. Clients never send a user id. - System access is explicit and audited. Code that must read across users (cron reminders, admin screens) goes through
SystemData, which writes an audit row with a reason. - The database refuses cross-user links. Child tables carry the owner’s id, and triggers in
0001_owner_guards.sqlreject a child whose owner differs from its parent’s, and any update that changes an owner. - Ids can’t be probed. Ids are UUIDv7. Someone else’s row and a missing row both return
NOT_FOUND. - No shared caches. GraphQL responses carry
Cache-Control: private, no-store, so no browser or edge cache keeps or shares them. - Other storage follows the same rule. The rest timer Durable Object is addressed by the session’s user id, never by a client argument.
- Sharing is a snapshot. A shared plan is a sanitized copy read by token. It never contains workouts, weigh-ins or identity.
Columns
Section titled “Columns”- The schema is the allowlist. Only fields declared in the GraphQL schema exist for clients. Session tokens, OAuth tokens and passkey keys are never exposed.
- Field scopes. Every root field declares who may call it (
public,vieweroradmin), and sensitive fields carry their own scope. The access column in the GraphQL reference is generated from these declarations. - Explicit inputs. Each mutation has its own input type. Owner ids, roles, ban flags and computed values are never accepted.
- Quiet errors. Production masks unexpected error messages.
Remote MCP and connected apps
Section titled “Remote MCP and connected apps”Assistants on the remote MCP server (/mcp) get OAuth access tokens from gymgym’s own authorization server after the user signs in, picks an account and allows them. Each request is checked before any tool runs: the token’s signature, issuer, expiry and audience (it must be issued for /mcp, so a token for something else is refused), its DPoP binding when it has one, that the account exists and is not disabled, and that the user still allows that app. The tools then reach the data the same way the web app does, through GraphQL and the row rules above, as that one user: the Worker vouches for the user id in-process and never forwards the assistant’s token. Removing an app in Settings → Connected apps deletes its consent and tokens, and its next request is refused.
Personal API keys
Section titled “Personal API keys”API keys let scripts call /api/graphql as their owner. The Better Auth api-key plugin stores only a hash and the first characters, checks expiry, and limits each key to 120 requests a minute; its own HTTP endpoints are disabled, so keys are created and revoked only through GraphQL. A key resolves to the same ScopedData as a session, so the row rules above apply unchanged. On top of them:
- a read-only key is refused for every mutation before any resolver runs;
- managing keys, signed-in devices and connected apps needs a real browser or CLI session, and admin scopes are never granted to a key;
- a key is accepted only by GraphQL: Better Auth’s endpoints and the MCP server do not treat it as a session;
- a trigger deletes a user’s keys with the user.
The signed-in devices list reads sessions through ScopedData too, and returns no session tokens.
How it is tested
Section titled “How it is tested”- An isolation test runs every operation in
packages/sdk/operationsas user B against user A’s data and expectsNOT_FOUNDor nothing. New operations are covered without editing the test. - A schema contract test fails if any root field lacks an access scope or a description.
- Database tests insert cross-user children and change owners directly in SQL and expect the triggers to refuse.
Stronger options
Section titled “Stronger options”If the data ever has to be isolated by the engine itself, two paths stay open: one SQLite database per user in a Durable Object, or Postgres with real row-level security through Hyperdrive. Both trade away some simplicity of D1.