Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,16 @@
# ADMIN_KEY= # enable multi-tenant projects (manage via /v1/admin)
# CORS_ORIGIN= # comma-separated browser origin allowlist (unset = open)

# Verifiable end-user identity (JWT/OIDC). When set, a caller may present a token
# (Authorization: Bearer <jwt>, X-User-Token, or ?token=) → verified as req.user,
# echoed at /v1/me, used as a trusted realtime presence identity, and (on an
# API_KEY-locked instance) accepted in place of the key. Use a shared secret OR a
# JWKS URL — not both. Unset = identity disabled.
# AUTH_JWT_SECRET= # HS256 shared secret
# AUTH_JWKS_URL= # OIDC JWKS endpoint (RS/ES), e.g. https://issuer/.well-known/jwks.json
# AUTH_JWT_ISSUER= # required `iss` (optional but recommended)
# AUTH_JWT_AUDIENCE= # required `aud` (optional but recommended)

# --- limits ---
# MAX_UPLOAD_BYTES=52428800 # 50 MB
# RATE_LIMIT_MAX=600 # requests per window per IP (0 disables)
Expand Down
37 changes: 31 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -406,6 +406,25 @@ secret and comparing to `X-Zero-Signature`. Management is key-gated (and per-pro
in multi-tenant); since webhooks make outbound requests, run with a key and restrict
egress if the instance is internet-exposed.

### Verifiable identity (JWT / OIDC)

Optionally verify an end-user token so calls carry a **trusted identity**. Configure
either a shared HS256 secret (`AUTH_JWT_SECRET`) or an OIDC JWKS endpoint
(`AUTH_JWKS_URL`, with `AUTH_JWT_ISSUER` / `AUTH_JWT_AUDIENCE`). A caller presents the
token as `Authorization: Bearer <jwt>`, an `X-User-Token` header (so it can sit
beside a project key), or `?token=` (for websockets):

```bash
curl localhost:3737/v1/me -H "Authorization: Bearer $JWT" # -> { user: { sub, claims } }
```

A verified token is **echoed at `/v1/me`**, becomes the **trusted realtime presence
identity** (overriding `?as=`), and on an `API_KEY`-locked instance **stands in for
the key** — so end-users authenticate with their own IdP tokens instead of sharing a
secret. `Zero(base, { token })` sends it for you. An invalid/expired token is a
`401`; identity is off until configured. _(Row-level per-user authorization is the
natural next step and isn't included yet.)_

## Multi-tenant projects

By default the server runs in **open mode**: one implicit project, no auth — exactly
Expand Down Expand Up @@ -475,6 +494,9 @@ curl -X POST localhost:3737/v1/import \
| `API_KEY` | _(unset)_ | If set, locks the whole instance with one bearer key |
| `ADMIN_KEY` | _(unset)_ | If set, enables multi-tenant projects (manage via `/v1/admin`) |
| `CORS_ORIGIN` | _(unset → `*`)_ | Comma-separated browser origin allowlist; unset = open CORS |
| `AUTH_JWT_SECRET` | _(unset)_ | HS256 secret to verify end-user identity tokens (`req.user`, `/v1/me`) |
| `AUTH_JWKS_URL` | _(unset)_ | OIDC JWKS endpoint (RS/ES) — alternative to `AUTH_JWT_SECRET` |
| `AUTH_JWT_ISSUER` / `AUTH_JWT_AUDIENCE` | _(unset)_ | Required `iss` / `aud` enforced on tokens (recommended) |
| `LOG_LEVEL` | `info` | Log verbosity: `trace`…`fatal`, or `silent` |

### Data store — SQLite (default) or Postgres
Expand Down Expand Up @@ -615,9 +637,12 @@ alongside the SQLite + disk defaults; **declared indexed fields** (built
JSON Schema** validation (opt-in); **cursor pagination** for stable iteration;
**per-project public collections + live feeds** via a non-secret project id;
realtime **presence** (join/leave/typing) with an optional **Redis** fan-out,
**Redis-backed rate limiting**, and presence TTLs for multi-process scale; and
**observability** — Prometheus `/metrics` + a `/ready` probe.

**Next (larger):** verifiable identity (JWT/OIDC) for per-user ACL + trusted
presence; outbound webhooks on data changes; per-project AI provider config;
publish to npm + GHCR (the release workflow is wired; the first tag publishes).
**Redis-backed rate limiting**, and presence TTLs for multi-process scale;
**observability** — Prometheus `/metrics` + a `/ready` probe; **outbound webhooks**
(persisted, retried, HMAC-signed); **per-project AI provider** config; and
**verifiable identity** (JWT/OIDC) — trusted `req.user` / `/v1/me`, trusted presence,
and token auth for locked instances.

**Next:** row-level per-user authorization (own-your-rows ACL) built on the new
verified identity; publish to npm + GHCR (the release workflow is wired; the first
tag publishes).
10 changes: 10 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@
"better-sqlite3": "^12.10.0",
"fastify": "^5.8.5",
"ioredis": "^5.11.1",
"jose": "^5.10.0",
"pg": "^8.21.0",
"prom-client": "^15.1.3"
},
Expand Down
3 changes: 3 additions & 0 deletions public/docs.html
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ <h2 id="auth">Auth &amp; modes</h2>
</tbody>
</table>
<p class="note"><code>GET /v1</code> reports the active <code>mode</code> and capability map; <code>GET /v1/stats</code> adds version, uptime, usage counts, and the AI provider.</p>
<p class="note"><b>Verifiable identity (optional).</b> Configure <code>AUTH_JWT_SECRET</code> (HS256) or <code>AUTH_JWKS_URL</code> (OIDC) and a caller may present a token — <code>Authorization: Bearer &lt;jwt&gt;</code>, <code>X-User-Token</code>, or <code>?token=</code> — verified into <code>req.user</code>, echoed at <code>/v1/me</code>, used as the trusted realtime presence identity, and accepted in place of <code>API_KEY</code> on a locked instance. <code>Zero(base, { token })</code> sends it.</p>

<h2 id="headers">Request &amp; response headers</h2>
<table>
Expand Down Expand Up @@ -323,6 +324,7 @@ <h2 id="rest-meta">Meta &amp; health</h2>
<tr><td class="ep"><span class="m">GET</span>/metrics</td><td>Prometheus exposition — request rate/latency, memory, realtime connections. No per-tenant labels; restrict at the proxy if needed.</td></tr>
<tr><td class="ep"><span class="m">GET</span>/v1</td><td>Capabilities + endpoint map + mode + AI provider.</td></tr>
<tr><td class="ep"><span class="m">GET</span>/v1/stats</td><td>Version, uptime, usage counts, AI provider readiness.</td></tr>
<tr><td class="ep"><span class="m">GET</span>/v1/me</td><td>The verified end-user identity <code>{ user: { sub, claims } | null }</code> (when JWT/OIDC auth is configured).</td></tr>
<tr><td class="ep"><span class="m">GET</span>/sdk.js · /sdk.d.ts · /llms.txt</td><td>The browser client, its types, and the AI-readable guide.</td></tr>
</tbody>
</table>
Expand Down Expand Up @@ -356,6 +358,7 @@ <h3>Core &amp; access</h3>
<tr><td class="ep">ADMIN_KEY</td><td>—</td><td>Enable multi-tenant projects</td></tr>
<tr><td class="ep">CORS_ORIGIN</td><td>* (open)</td><td>Comma-separated browser origin allowlist</td></tr>
<tr><td class="ep">REDIS_URL</td><td>—</td><td>Horizontal scale: cross-process realtime (messages + presence) + a shared per-IP rate-limit window</td></tr>
<tr><td class="ep">AUTH_JWT_SECRET / AUTH_JWKS_URL</td><td>—</td><td>Verify end-user identity tokens (HS256 secret, or OIDC JWKS) → <code>req.user</code> / <code>/v1/me</code></td></tr>
<tr><td class="ep">LOG_LEVEL</td><td>info</td><td>trace … fatal, or silent</td></tr>
</tbody>
</table>
Expand Down
12 changes: 12 additions & 0 deletions public/sdk.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,16 @@ declare namespace Zero {
* the `X-Project-Id` header (and `?project=` on file URLs).
*/
project?: string;
/**
* A verified end-user identity token (JWT) for an instance with identity
* configured. Sent as `X-User-Token` (and `?token=` on websockets).
*/
token?: string;
}

interface Identity {
sub: string;
claims: Record<string, unknown>;
}

type Operator = 'eq' | 'ne' | 'gt' | 'gte' | 'lt' | 'lte' | 'like' | 'in' | 'nin';
Expand Down Expand Up @@ -299,6 +309,8 @@ declare namespace Zero {
interface Client {
baseUrl: string;
health(): Promise<{ ok: boolean; uptime: number }>;
/** The verified end-user identity for the configured token, or null. */
me(): Promise<Identity | null>;
info(): Promise<Record<string, unknown>>;
stats(): Promise<Stats>;
collections(): Promise<{ name: string; count: number }[]>;
Expand Down
7 changes: 7 additions & 0 deletions public/sdk.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,15 @@
// A non-secret project id for anonymous access to a project's PUBLIC
// collections in multi-tenant mode — no secret key embedded in the page.
var project = opts.project || null;
// A verified end-user identity token (JWT) — sent as X-User-Token so it can
// coexist with a project key, and as ?token= on websockets.
var token = opts.token || null;

function headers(extra) {
var h = Object.assign({}, extra || {});
if (apiKey) h['Authorization'] = 'Bearer ' + apiKey;
if (project) h['X-Project-Id'] = project;
if (token) h['X-User-Token'] = token;
return h;
}

Expand Down Expand Up @@ -301,6 +305,7 @@
var params = [];
if (apiKey) params.push('key=' + encodeURIComponent(apiKey));
else if (project) params.push('project=' + encodeURIComponent(project));
if (token) params.push('token=' + encodeURIComponent(token)); // verified identity
if (opts.as != null) {
params.push('as=' + encodeURIComponent(typeof opts.as === 'string' ? opts.as : JSON.stringify(opts.as)));
}
Expand Down Expand Up @@ -385,6 +390,8 @@
return {
baseUrl: base,
health: function () { return request('GET', '/health'); },
// The verified end-user identity for the configured token, or null.
me: function () { return request('GET', '/v1/me').then(function (r) { return r.user; }); },
info: function () { return request('GET', '/v1'); },
// Operational stats: version, uptime, and usage counts for your project.
stats: function () { return request('GET', '/v1/stats'); },
Expand Down
67 changes: 67 additions & 0 deletions src/auth/identity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { jwtVerify, createRemoteJWKSet, type JWTPayload } from 'jose';
import { config } from '../config.js';

/** A verified caller identity from a JWT. */
export interface Identity {
sub: string;
claims: JWTPayload;
}

export type AuthConfig = typeof config.auth;

/** Verifies a token, returning the identity, or null if it's invalid/expired. */
export type Verifier = (token: string) => Promise<Identity | null>;

/** Whether end-user identity is configured (a secret or a JWKS URL is set). */
export function identityEnabled(auth: AuthConfig = config.auth): boolean {
return !!(auth.jwtSecret || auth.jwksUrl);
}

function toIdentity(payload: JWTPayload): Identity | null {
return typeof payload.sub === 'string' && payload.sub.length > 0 ? { sub: payload.sub, claims: payload } : null;
}

/**
* Build a token verifier from the auth config. Prefers a JWKS URL (OIDC, RS/ES)
* when set, else a shared HS256 secret. Issuer/audience are enforced when
* configured. Returns a verifier that resolves null for any invalid token
* (bad signature, wrong iss/aud, expired, or missing `sub`) — never throws.
*/
export function createVerifier(auth: AuthConfig = config.auth): Verifier {
const opts = {
...(auth.issuer ? { issuer: auth.issuer } : {}),
...(auth.audience ? { audience: auth.audience } : {}),
};

if (auth.jwksUrl) {
const jwks = createRemoteJWKSet(new URL(auth.jwksUrl));
return async (token) => {
try {
const { payload } = await jwtVerify(token, jwks, opts);
return toIdentity(payload);
} catch {
return null;
}
};
}

if (auth.jwtSecret) {
const secret = new TextEncoder().encode(auth.jwtSecret);
return async (token) => {
try {
const { payload } = await jwtVerify(token, secret, opts);
return toIdentity(payload);
} catch {
return null;
}
};
}

return async () => null; // identity disabled
}

/** Looks like a compact JWS/JWT: three non-empty dot-separated segments. */
export function looksLikeJwt(token: string): boolean {
const parts = token.split('.');
return parts.length === 3 && parts.every((p) => p.length > 0);
}
11 changes: 11 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,17 @@ export const config = {
// (authenticated with this key). Unset = open single-tenant mode (default).
adminKey: process.env.ADMIN_KEY ?? null,

// Optional verifiable end-user identity (JWT/OIDC). When configured, a caller
// may present a token (Authorization: Bearer <jwt>, X-User-Token, or ?token=)
// that the server verifies → req.user. Either a shared HS256 secret, or a
// JWKS URL for an OIDC issuer (RS/ES). Unset = identity disabled.
auth: {
jwtSecret: process.env.AUTH_JWT_SECRET ?? null,
jwksUrl: process.env.AUTH_JWKS_URL ?? null,
issuer: process.env.AUTH_JWT_ISSUER ?? null,
audience: process.env.AUTH_JWT_AUDIENCE ?? null,
},

// Allowed CORS origins. Unset = open (`*`), so arbitrary AI-generated sites
// can call the API cross-origin — the zero-config default. In production, set
// a comma-separated allowlist (e.g. "https://app.example,https://admin.example")
Expand Down
5 changes: 4 additions & 1 deletion src/modules/realtime/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,11 @@ export async function realtimeRoutes(app: FastifyInstance): Promise<void> {
app.get('/v1/realtime/:channel', { websocket: true }, (socket: WebSocket, req) => {
const { channel } = req.params as { channel: string };
const ch = key(req.projectId, channel);
// A verified end-user identity (JWT) is a trusted presence label and wins
// over the client-supplied ?as=; otherwise fall back to ?as=.
const asRaw = (req.query as { as?: string } | undefined)?.as;
const member = { cid: newId(), identity: typeof asRaw === 'string' && asRaw ? asRaw : null };
const identity = req.user?.sub ?? (typeof asRaw === 'string' && asRaw ? asRaw : null);
const member = { cid: newId(), identity };

hub.join(ch, socket);

Expand Down
48 changes: 46 additions & 2 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { webhookRoutes } from './modules/webhooks/routes.js';
import { AclStore } from './modules/acl/store.js';
import { SchemaStore, SchemaValidationError } from './modules/data/schema.js';
import { AiConfigStore } from './modules/ai/config-store.js';
import { createVerifier, identityEnabled, looksLikeJwt, type AuthConfig } from './auth/identity.js';
import { getBlobStore } from './modules/files/blob.js';
import { newId } from './lib/id.js';

Expand Down Expand Up @@ -86,6 +87,8 @@ export interface BuildOptions {
corsOrigin?: string | null;
/** Override the webhook max delivery attempts (used by the test suite). */
webhookMaxAttempts?: number;
/** Override the JWT/OIDC identity config (used by the test suite). */
auth?: AuthConfig;
}

/** Resolve the @fastify/cors `origin` option: an allowlist array, or `true` (open). */
Expand Down Expand Up @@ -144,6 +147,13 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
// True unless the request reached a collection anonymously via a public ACL;
// read routes use this to redact `hidden` fields from anonymous callers.
app.decorateRequest('authed', true);
// The verified end-user identity (JWT), when one is presented and valid.
app.decorateRequest('user', null);

// Verifiable identity (JWT/OIDC): env by default, overridable for tests.
const auth = opts.auth ?? config.auth;
const idEnabled = identityEnabled(auth);
const verifyUser = createVerifier(auth);

// Open CORS by default: arbitrary AI-generated sites need to call this API
// cross-origin. Set CORS_ORIGIN to a comma-separated allowlist to lock it down.
Expand Down Expand Up @@ -175,11 +185,39 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
return typeof q === 'string' && q ? q : null;
};

// The end-user identity token (JWT): from Authorization: Bearer <jwt> (only when
// it looks like a JWT, so it never clashes with an api/project key), the
// X-User-Token header, or ?token= (websockets can't set headers).
const userToken = (req: { headers: Record<string, unknown>; query: unknown }): string | null => {
const authz = req.headers['authorization'];
if (typeof authz === 'string' && authz.startsWith('Bearer ')) {
const t = authz.slice(7);
if (looksLikeJwt(t)) return t;
}
const h = req.headers['x-user-token'];
if (typeof h === 'string' && h) return h;
const q = (req.query as { token?: string } | undefined)?.token;
return typeof q === 'string' && q ? q : null;
};

app.addHook('onRequest', async (req, reply) => {
req.projectId = DEFAULT_PROJECT;
const url = req.url.split('?')[0] ?? '';
if (url === '/health') return;

// Verify an end-user identity token if one is presented (any mode). A
// presented-but-invalid token is a 401; a valid one populates req.user.
if (idEnabled) {
const token = userToken(req);
if (token) {
const identity = await verifyUser(token);
if (!identity) return reply.code(401).send({ error: 'invalid_token', message: 'Invalid or expired identity token.' });
req.user = identity;
}
}
// The identity echo endpoint is always reachable (reports req.user or null).
if (url === '/v1/me') return;

if (url === '/v1/admin' || url.startsWith('/v1/admin/')) {
if (!adminKey) {
return reply.code(403).send({ error: 'admin_disabled', message: 'Set ADMIN_KEY to manage projects.' });
Expand Down Expand Up @@ -233,9 +271,12 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
return reply.code(401).send({ error: 'unauthorized', message: 'Provide a project key.' });
}

// Open mode: optional instance-wide lock. A locked instance still serves
// collections whose ACL marks the requested operation public (anonymous).
// Open mode: optional instance-wide lock. A locked instance is also opened by
// a verified end-user identity (so users authenticate with their own IdP
// token instead of sharing API_KEY), and still serves collections whose ACL
// marks the requested operation public (anonymous).
if (apiKey && bearer(req) !== apiKey) {
if (req.user) return; // authenticated as an end-user via a verified JWT
const access = dataAccess(req.method, url);
if (access && (await app.acl.get(DEFAULT_PROJECT, access.collection))[access.op] === 'public') {
req.authed = false; // anonymous: read routes will redact hidden fields
Expand Down Expand Up @@ -269,6 +310,9 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta

app.get('/health', async () => ({ ok: true, uptime: process.uptime() }));

// Echo the verified end-user identity (or null). Reachable in every mode.
app.get('/v1/me', async (req) => ({ user: req.user }));

// /metrics (Prometheus) + /ready (readiness probe) for scaled deployments.
registerObservability(app);

Expand Down
3 changes: 3 additions & 0 deletions src/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type { AiConfigStore } from './modules/ai/config-store.js';
import type { WebhookStore } from './modules/webhooks/store.js';
import type { WebhookDispatcher } from './modules/webhooks/dispatcher.js';
import type { BlobStore } from './modules/files/blob.js';
import type { Identity } from './auth/identity.js';

declare module 'fastify' {
interface FastifyInstance {
Expand Down Expand Up @@ -36,5 +37,7 @@ declare module 'fastify' {
projectId: string;
/** False when the request reached a collection anonymously via a public ACL. */
authed: boolean;
/** The verified end-user identity (JWT), or null when none was presented. */
user: Identity | null;
}
}
Loading
Loading