skills/powersync/references/custom-backend.md

388 lines
14 KiB
Markdown

---
name: custom-backend
description: Building a custom backend for PowerSync — server-side API for uploadData, custom JWT auth, JWKS endpoints, and client-side connector implementation
metadata:
tags: backend, custom, jwt, auth, express, fastify, uploadData, api, non-supabase
---
# Custom Backend for PowerSync
> **Load this when** building a PowerSync integration without Supabase — custom auth, custom backend API, or any non-Supabase database.
## Table of Contents
- [Architecture Recap](#architecture-recap)
- [1. Custom JWT Auth](#1-custom-jwt-auth)
- [2. Backend API for uploadData](#2-backend-api-for-uploaddata)
- [3. Client-Side Connector](#3-client-side-connector-custom-backend)
- [Common Pitfalls](#common-pitfalls)
Use this file when building a PowerSync integration **without Supabase** — your own auth and a backend API that receives writes from the client's upload queue.
For **source database setup** (Postgres replication, MongoDB replica set, MySQL binlog, MSSQL CDC), see `references/powersync-service.md` § "Source Database Setup".
For **service.yaml configuration** (Cloud or self-hosted templates), see `references/powersync-service.md`.
| Resource | Description |
|----------|-------------|
| [App Backend Setup](https://docs.powersync.com/configuration/app-backend/setup.md) | Overview of setting up the app backend for PowerSync. |
| [Client-Side Integration](https://docs.powersync.com/configuration/app-backend/client-side-integration.md) | How to implement a backend connector. |
| [Writing Client-Side Changes](https://docs.powersync.com/usage/writing-client-side-changes-to-your-backend.md) | Detailed guide on the upload queue and backend write flow. |
| [Custom Auth](https://docs.powersync.com/configuration/auth/custom.md) | JWT auth setup for non-Supabase backends. |
| [Development Tokens](https://docs.powersync.com/configuration/auth/development-tokens.md) | Generate tokens for local development and testing. |
## Architecture Recap
```
Client App Your Backend API Source Database
| | |
|-- uploadData() POST ---------->|--- INSERT/UPDATE/DELETE -->|
| |<-- 2xx response -----------|
| |
PowerSync Service <-------------- CDC / logical replication ---|
|
|-- streams synced data -------> Client App (local SQLite)
```
Key rule: **client writes never go through PowerSync**. The upload queue sends writes to YOUR backend API. PowerSync only handles the read/sync path.
## 1. Custom JWT Auth
PowerSync verifies JWTs from client apps. Without Supabase, you must generate and serve your own JWTs and JWKS.
### Supported Algorithms
| Algorithm | Type | Recommendation |
|-----------|------|----------------|
| RS256, RS384, RS512 | Asymmetric (RSA) | Recommended for production |
| ES256, ES384, ES512 | Asymmetric (ECDSA) | Recommended for production |
| EdDSA (Ed25519, Ed448) | Asymmetric | Recommended for production |
| HS256 | Symmetric | Development only |
### Required JWT Claims
| Claim | Required | Description |
|-------|----------|-------------|
| `sub` | Yes | User ID — returned by `auth.user_id()` in sync config queries |
| `aud` | Yes | Must match the `audience` configured in PowerSync service config |
| `iat` | Yes | Issued-at timestamp (seconds since epoch) |
| `exp` | Yes | Expiry timestamp — must be at most 86400 seconds (24h) after `iat` |
| `kid` | Yes (for JWKS) | Key ID — must match a key in the JWKS |
### Generate RSA Key Pair
```bash
# Generate a 2048-bit RSA private key
openssl genpkey -algorithm RSA -out private.pem -pkeyopt rsa_keygen_bits:2048
# Extract the public key
openssl rsa -in private.pem -pubout -out public.pem
```
### Implement a JWKS Endpoint
Your backend must serve a `/.well-known/jwks.json` endpoint. PowerSync fetches this every few minutes to get the public keys for token verification.
```ts
// Using jose library: npm install jose
import { exportJWK, importPKCS8 } from 'jose';
import { readFileSync } from 'fs';
const privateKeyPem = readFileSync('./private.pem', 'utf-8');
const KID = 'powersync-key-1'; // Stable key identifier
let cachedJwk: any = null;
export async function getJWKS() {
if (!cachedJwk) {
const privateKey = await importPKCS8(privateKeyPem, 'RS256');
const jwk = await exportJWK(privateKey);
// Only include the public key components
cachedJwk = {
kty: jwk.kty,
n: jwk.n,
e: jwk.e,
alg: 'RS256',
kid: KID,
use: 'sig',
};
}
return { keys: [cachedJwk] };
}
```
### Generate JWTs (Token Endpoint)
The token endpoint generates a PowerSync JWT for an already-authenticated user. It does **not** handle user login — your app authenticates users separately via sessions, OAuth, or whatever mechanism you use.
```ts
import { SignJWT, importPKCS8 } from 'jose';
import { readFileSync } from 'fs';
const privateKeyPem = readFileSync('./private.pem', 'utf-8');
const KID = 'powersync-key-1';
const POWERSYNC_URL = process.env.POWERSYNC_URL || 'http://localhost:8080';
export async function generateToken(userId: string): Promise<string> {
const privateKey = await importPKCS8(privateKeyPem, 'RS256');
return new SignJWT({})
.setProtectedHeader({ alg: 'RS256', kid: KID })
.setSubject(userId)
.setIssuedAt()
.setIssuer('your-app') // Must match issuer config if set
.setAudience(POWERSYNC_URL) // Must match audience config
.setExpirationTime('5m') // Short-lived; max 24h, PowerSync refreshes automatically
.sign(privateKey);
}
```
### Service Config for Custom Auth
See `references/powersync-service.md` § "Minimal Cloud service.yaml Examples" for the Cloud + Custom Auth template, or § "Complete service.yaml Example" for self-hosted.
For local development with `host.docker.internal`, set `block_local_jwks: false` in service config when the JWKS URI resolves to a private IP.
### Development Tokens
For quick development without full auth, configure a signing key in `service.yaml` and use the CLI:
```bash
powersync generate token --user-id "test-user-123"
```
This requires `client_auth` to be configured with at least one key. See [Development Tokens](https://docs.powersync.com/configuration/auth/development-tokens.md).
### Key Rotation
When using a JWKS URI:
1. Add the new key to the JWKS endpoint (keep the old key).
2. Wait 5 minutes for PowerSync to refresh its key cache.
3. Start signing tokens with the new key.
4. Wait for all old tokens to expire (up to their `exp`).
5. Remove the old key from the JWKS endpoint.
## 2. Backend API for uploadData
The client's `uploadData()` sends pending writes to your backend API. Your backend must:
1. Accept the write operations.
2. Apply them to the database **synchronously** (do not queue for later processing).
3. Return 2xx — even for validation errors.
### Request/Response Contract
| Scenario | HTTP Status | Effect on Upload Queue |
|----------|-------------|----------------------|
| Success | 2xx | `transaction.complete()` advances the queue |
| Validation error | 2xx (with error details in body) | Queue advances — surface errors via a synced table |
| Transient error (DB down) | 5xx | PowerSync retries with backoff |
| Auth error / permanent failure | 4xx | **Blocks the queue permanently** — never return 4xx for data errors |
### CrudEntry Format (What the Client Sends)
Each operation in the upload queue has this shape:
```ts
interface CrudEntry {
id: string; // Row ID (UUID)
op: 'PUT' | 'PATCH' | 'DELETE';
table: string; // Table name
opData?: Record<string, any>; // Column values (undefined for DELETE)
transactionId?: number; // Groups ops from the same writeTransaction()
}
```
- `PUT` = full insert or replace (new row or complete overwrite)
- `PATCH` = partial update (opData contains only changed columns)
- `DELETE` = deletion (opData is undefined)
### Example: Express Backend
```ts
import express from 'express';
import pg from 'pg';
const app = express();
app.use(express.json());
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
// Token endpoint — client calls this in fetchCredentials()
app.get('/api/auth/token', async (req, res) => {
const userId = req.query.user_id as string;
if (!userId) return res.status(400).json({ error: 'user_id is required' });
const token = await generateToken(userId);
res.json({
token,
powersync_url: process.env.POWERSYNC_URL,
});
});
// JWKS endpoint — PowerSync fetches this to verify tokens
app.get('/.well-known/jwks.json', async (_req, res) => {
const jwks = await getJWKS();
res.json(jwks);
});
// Upload endpoint — client's uploadData() calls this
app.post('/api/powersync/upload', async (req, res) => {
const { operations } = req.body;
const client = await pool.connect();
try {
await client.query('BEGIN');
for (const op of operations) {
switch (op.op) {
case 'PUT': {
const columns = Object.keys(op.opData);
const values = Object.values(op.opData);
const placeholders = columns.map((_, i) => `$${i + 2}`).join(', ');
const updateSet = columns.map((col, i) => `${col} = $${i + 2}`).join(', ');
await client.query(
`INSERT INTO ${op.table} (id, ${columns.join(', ')})
VALUES ($1, ${placeholders})
ON CONFLICT (id) DO UPDATE SET ${updateSet}`,
[op.id, ...values]
);
break;
}
case 'PATCH': {
const columns = Object.keys(op.opData!);
const values = Object.values(op.opData!);
const setClause = columns.map((col, i) => `${col} = $${i + 2}`).join(', ');
await client.query(
`UPDATE ${op.table} SET ${setClause} WHERE id = $1`,
[op.id, ...values]
);
break;
}
case 'DELETE': {
await client.query(
`DELETE FROM ${op.table} WHERE id = $1`,
[op.id]
);
break;
}
}
}
await client.query('COMMIT');
res.json({ success: true });
} catch (err) {
await client.query('ROLLBACK');
console.error('Upload error:', err);
// Return 2xx with error details — do NOT return 4xx
res.json({ success: false, error: (err as Error).message });
} finally {
client.release();
}
});
app.listen(3001, () => console.log('Backend running on :3001'));
```
**IMPORTANT:** The upload endpoint example above uses string interpolation for table names. In production, validate `op.table` against an allowlist:
```ts
const ALLOWED_TABLES = new Set(['posts', 'comments', 'users']);
if (!ALLOWED_TABLES.has(op.table)) {
return res.json({ success: false, error: `Unknown table: ${op.table}` });
}
```
### Boolean Conversion
PowerSync stores booleans as integers (0/1) in SQLite. If your database uses native `boolean` columns, convert before writing:
```ts
if (op.table === 'posts' && op.opData?.is_published !== undefined) {
op.opData.is_published = Boolean(op.opData.is_published);
}
```
## 3. Client-Side Connector (Custom Backend)
### fetchCredentials
```ts
import type { PowerSyncBackendConnector, PowerSyncCredentials } from '@powersync/web';
const BACKEND_URL = import.meta.env.VITE_BACKEND_URL; // e.g. http://localhost:3001
const POWERSYNC_URL = import.meta.env.VITE_POWERSYNC_URL; // e.g. http://localhost:8080
export class CustomConnector implements PowerSyncBackendConnector {
private userId: string;
private token: string | null = null;
constructor(userId: string) {
this.userId = userId;
}
async fetchCredentials(): Promise<PowerSyncCredentials> {
const res = await fetch(
`${BACKEND_URL}/api/auth/token?user_id=${encodeURIComponent(this.userId)}`
);
if (!res.ok) throw new Error('Failed to get PowerSync token');
const { token, powersync_url } = await res.json();
this.token = token;
return {
endpoint: powersync_url || POWERSYNC_URL,
token,
};
}
```
### uploadData
```ts
async uploadData(database: AbstractPowerSyncDatabase): Promise<void> {
const transaction = await database.getNextCrudTransaction();
if (!transaction) return;
try {
const operations = transaction.crud.map((op) => ({
id: op.id,
op: op.op,
table: op.table,
opData: op.opData,
}));
const res = await fetch(`${BACKEND_URL}/api/powersync/upload`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.token}`,
},
body: JSON.stringify({ operations }),
});
if (!res.ok) throw new Error(`Upload failed: ${res.status}`);
const result = await res.json();
if (!result.success) {
console.warn('Upload had errors:', result.error);
}
// MUST call complete() — without this the queue stalls permanently
await transaction.complete();
} catch (ex) {
throw ex;
}
}
}
```
## Common Pitfalls
1. **4xx from upload endpoint** — Blocks the upload queue **permanently**. Always return 2xx, even for validation errors.
2. **Async processing of writes** — PowerSync expects writes reflected in the database immediately. Do not queue writes.
3. **Token expiry > 24h** — PowerSync rejects tokens with `exp - iat > 86400`. Use short-lived tokens (1h production, max 24h dev).
4. **`kid` mismatch** — JWT header `kid` must match a key in your JWKS. Causes `PSYNC_S2101`.
5. **`block_local_jwks` not set** — JWKS URIs resolving to private IPs are blocked by default. Set `block_local_jwks: false` for local dev.
6. **Wrong `endpoint` in `fetchCredentials()`** — Must be the PowerSync URL, not your backend URL. Causes 404 on `/sync/stream`.