Backend Client

Learn how to connect other trusted machines to Jazz through your main backend, for machine-to-machine access.

Your main backend is the one that runs createJazz: browsers and apps sync through it, and its authenticate decides who each user is. A backend client is any other trusted machine that reads and writes the same data, such as a CI job, a worker on another cloud, a script or a partner's service. It connects to your main backend's /jazz like a browser does, but proves who it is with its own machine-to-machine credential, not a user's session.

A backend client is one of two kinds:

  • Trusted. It acts as the system, with your main backend's own authority: your permissions are not checked, and its writes carry the system identity. It can also act for any user. Use it for system work such as imports, migrations and scheduled jobs.
  • Scoped. It acts as one user, with that user's permissions, exactly as if authenticate had named the user. Use it for an agent or service that should only touch one account's data.

Trust the machine in your main backend

Most platforms give their workloads an OIDC token: GitHub Actions, Vercel, Google Cloud, Azure and Kubernetes all do. List each machine you trust in backendClients, naming it by the token's issuer, audience and the claims only that machine has:

jazz.server.ts
import { createJazz, sqlite, trustWorkload } from "jazz-tools/backend";
import { app } from "./schema";
import permissions from "./permissions";
​
export const jazz = createJazz({
app,
permissions,
store: sqlite({ path: "./data/jazz" }),
authenticate: async (request) => /* your users' sessions */ null,
backendClients: [
// The nightly import job in this repo's production environment: a trusted machine.
trustWorkload({
issuer: "https://token.actions.githubusercontent.com",
audience: "jazz:my-app",
claims: { sub: "repo:acme/my-app:environment:production" },
as: "backend",
}),
// A support agent that acts as its own user.
trustWorkload({
issuer: "https://token.actions.githubusercontent.com",
audience: "jazz:my-app",
claims: { sub: "repo:acme/support-agent:environment:production" },
as: () => ({ userId: "support-agent", claims: { role: "agent" } }),
}),
],
});
  • issuer is where Jazz finds the token's signing keys, through its OpenID discovery document.
  • audience must match the token's aud, so tokens minted for other services are refused.
  • claims must all match exactly. Name the workload down to its environment: every workload of the same issuer gets tokens signed by the same keys, so claims can't be empty.
  • as decides the kind: "backend" for a trusted machine, or a function from the token's claims to a user ({ userId, claims? }, or null to refuse) for a scoped one.

A token from a listed issuer that fails any check is refused. It never reaches authenticate.

Connect from the machine

On the machine, open a Jazz session against your main backend and pass a function that returns a fresh token. Jazz calls it for every request and socket, so short-lived tokens just work:

import-job.ts
import { createJazzClient } from "jazz-tools/backend";
import { app } from "./schema";
import permissions from "./permissions";
​
const session = await createJazzClient({
app,
permissions,
serverUrl: "https://my-app.com/jazz",
driver: { type: "memory" },
credential: githubOidcToken,
});
​
const { client } = session.getSnapshot();
await client!.db.insert(app.imports, { source: "nightly" }).wait({ tier: "global" });
await client!.db.shutdown({ waitForSync: true }); // let queued writes reach the backend
await session.close();
​
// GitHub Actions hands out OIDC tokens to jobs with `permissions: id-token: write`.
async function githubOidcToken() {
const url = `${process.env.ACTIONS_ID_TOKEN_REQUEST_URL}&audience=jazz:my-app`;
const response = await fetch(url, {
headers: {
Authorization: `Bearer ${process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN}`,
},
});
return (await response.json()).value as string;
}

session.close() on its own discards writes that haven't reached the backend yet, and so does exiting the process. Before a script ends, await db.shutdown({ waitForSync: true }). Each token needs at least 60 seconds of validity left when Jazz uses it; one closer to expiry is refused. Each identity can hold at most 4 sync sockets at a time for now, so keep to four workers per trusted identity. While your main backend is unreachable, createJazzClient waits without logging anything, so give a job its own timeout.

The session opens as whatever your main backend trusts the token as: with backend authority for a trusted machine, or signed in as its user for a scoped one. Your main backend's API routes see the same identity, so jazz.forReqUser(request) and jazz.accountFor(request) work for requests that carry the token as Authorization: Bearer ….

A scoped client's user gets an account when the client first signs in, like any client sign-in. Before that, accountFor and forReqUser in your API routes find no account for that user; call jazz.createAccountFor(userId) in your main backend if a route needs it earlier. A trusted client uses the system identity and needs no user account.

Act for a user

A trusted client can work as one of your users with client.forUser(userId). It returns the database as that user, just like jazz.forReqUser(request) in your main backend: writes carry the user's identity and are checked against that user's permissions. userId is the id your main backend's authenticate names the user by.

digest-job.ts
import { isNoJazzAccountError } from "jazz-tools/backend";
​
const { client } = session.getSnapshot();
try {
const db = await client!.forUser("user_123", { claims: { org: "acme" } });
await db.insert(app.notifications, { text: "Your weekly digest" }).wait({ tier: "global" });
} catch (error) {
if (!isNoJazzAccountError(error)) throw error;
// This user hasn't signed in yet, so they have no account to act as.
}
  • forUser never creates an account. A user with no account yet rejects with a NoJazzAccountError (code: "no_account"). They get one when they first sign in, or when your main backend calls jazz.createAccountFor(userId).
  • Your authenticate isn't called, because there is no request from the user. Your declarative permissions see exactly the claims you pass as the user's claims, and {} when you pass none.
  • If your main backend refuses the identity, for example because it was revoked, forUser rejects with an error whose status is 403.
  • Only a trusted client has forUser. On a scoped client it throws, because that client already acts as its user. In your main backend itself, use jazz.forReqUser.

Machines without an OIDC token

Some machines can't get a platform OIDC token, such as a developer's laptop, an AWS workload signing with SigV4, or a Cloudflare Worker. Give those your app's own credential instead, such as an API key or a token your backend issues, and resolve it to a user in authenticate, like any other sign-in. They then act as that user, with that user's permissions.