The signed connector
On this page
SaaS Pro Max never touches your database. To manage your application's users from the console, you expose a small HTTP surface in your own backend and the console calls it with a short-lived, signed token.
@saaspro/node implements the whole tenant side: signature verification,
replay protection, permission enforcement and PII redaction. You supply the data.
How it works#
- The console generates an Ed25519 key pair per application. The private key stays in the SaaS Pro Max vault; you get the public key once.
- Every console request carries
Authorization: Bearer <EdDSA JWT>signed with that private key, valid for 60 seconds, with a uniquejti. - Your handler verifies signature,
iss,aud,nbf,expandjtireuse, then enforces the acting user's permissions.
{
"iss": "saaspromax",
"aud": "app_01HZX…",
"sub": "console_user_id",
"email": "ricky@saaspro.dev",
"orgId": "org_…",
"appId": "app_01HZX…",
"permissions": ["people.read", "people.pii.read", "people.controls.write"],
"iat": 1788000000,
"nbf": 1788000000,
"exp": 1788000060,
"jti": "4c1e…"
}
Setup#
- App → Settings → Connector in the console: set your connector base URL
(for example
https://app.example.com/api/spm/connector) and generate a key pair. Copy the public key — it is shown once. - Store it in your app:
SPM_CONNECTOR_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEA…\n-----END PUBLIC KEY-----"
SPM_APP_ID="app_01HZX…"
Both PEM (SPKI) and a bare base64 32-byte key are accepted.
Mount the handler#
Next.js App Router#
// app/api/spm/connector/[[...path]]/route.ts
import { createConnectorHandler, toNextRouteHandler } from "@saaspro/node";
import { handlers } from "@/lib/spm-connector";
const handler = createConnectorHandler({
publicKey: process.env.SPM_CONNECTOR_PUBLIC_KEY!,
audience: process.env.SPM_APP_ID!,
handlers,
});
export const { GET, POST } = toNextRouteHandler(handler);
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
Express, Fastify, node:http#
import { createConnectorHandler, toNodeListener } from "@saaspro/node";
app.use("/api/spm/connector", toNodeListener(handler));
Anything with Request / Response (Hono, Bun, Deno, workers)#
app.all("/api/spm/connector/*", (c) => handler(c.req.raw));
Routes are matched on the path suffix, so the handler works at any mount point without configuration.
Routes and permissions#
| Route | Method | Permission | Handler |
|---|---|---|---|
/health | GET | any signed token | health? |
/overview | GET | people.read | overview |
/users | GET | people.read | listUsers |
/users/{id} | GET | people.read | getUser |
/users/{id}/controls | POST | people.controls.write | applyControl |
/prompts | GET | evals.read | listPrompts |
/prompts/{slug}/gate | POST | evals.run | recordGate |
/agent-context | POST | evals.read | agentContext |
/communications/send | POST | communications.write | sendCommunications |
Implement only what you need. A route with no handler answers 501, and
GET /health reports the capabilities it can infer:
{ "ok": true, "version": "0.1.0", "capabilities": ["overview", "users", "controls"] }
Implementing the handlers#
// lib/spm-connector.ts
import type { ConnectorHandlers, ConnectorUsersSort } from "@saaspro/node";
import { db } from "./db";
export const handlers: ConnectorHandlers = {
async overview() {
const [users, mrr] = await Promise.all([db.users.count(), db.billing.mrr()]);
return {
kpis: [
{ key: "users", label: "Users", value: users, format: "number" },
{ key: "mrr", label: "MRR", value: mrr, format: "currency" },
],
recentUsers: await recentUsers(10),
};
},
async listUsers({ q, page, pageSize, filters, sort, dir }) {
const applied: ConnectorUsersSort = { column: sort ?? "created", dir: dir ?? "desc" };
const fields = { name: "name", email: "email", plan: "plan", status: "status", created: "createdAt" };
const { rows, total } = await db.users.search({
q, page, pageSize, plan: filters.plan,
orderBy: [
{ field: fields[applied.column], direction: applied.dir, nulls: "last" },
{ field: "id", direction: "asc" },
],
});
return {
users: rows.map(toConnectorUser),
total,
page,
pages: Math.max(1, Math.ceil(total / pageSize)),
sort: applied,
filters: [
{ key: "plan", label: "Plan", options: [
{ value: "free", label: "Free" },
{ value: "pro", label: "Pro" },
] },
],
};
},
async getUser(id) {
const user = await db.users.find(id);
if (!user) return null;
return {
...toConnectorUser(user),
sections: [
{ title: "Account", facts: [
{ label: "Plan", value: user.plan },
{ label: "Stripe customer", value: user.stripeId, mono: true },
] },
],
controls: await db.controls.forUser(id),
availableControls: [
{ type: "suspend", label: "Suspend", description: "Block sign-in", reversible: true, maxDays: 30 },
{ type: "reset_password", label: "Force password reset", description: "…", reversible: false,
requiresApproval: true },
],
activity: await db.activity.forUser(id, 50),
};
},
async applyControl(id, { type, action, reason, durationDays, requestId }, ctx) {
// requestId is idempotent — the console retries with the same value.
const control = await db.controls.apply({
userId: id, type, action, reason, durationDays, requestId,
actor: ctx.claims.sub, actorEmail: ctx.claims.email,
});
return control;
},
};
function toConnectorUser(user: DbUser) {
return {
id: user.id,
email: user.email, // stripped automatically without people.pii.read
name: user.name, // idem
createdAt: user.createdAt.toISOString(),
lastActiveAt: user.lastActiveAt?.toISOString(),
plan: user.plan,
status: user.status,
attributes: { seats: user.seats, verified: user.verified },
};
}
Every handler receives a context as its last argument:
{
claims, // the verified ServiceClaims — who is acting
request, url, // the raw Request and parsed URL
permissions, // claims.permissions as an array
canReadPii, // true when "people.pii.read" is present
}
PII redaction#
email and name are removed from every user in every response unless
the token carries people.pii.read. This happens after your handler returns, so
you cannot forget it. Use ctx.canReadPii when you want to change what you
fetch rather than what you return:
async listUsers(query, ctx) {
const rows = await db.users.search({ ...query, includeEmail: ctx.canReadPii });
…
}
The same helper is exported if you need it elsewhere:
import { redactUser } from "@saaspro/node";
const safe = redactUser(user, ctx.permissions);
Sorting the user directory#
Node SDK 0.5.0 / shared protocol 0.4.0 adds optional sort and dir
to ConnectorUsersQuery. GET /users?sort=plan&dir=asc passes typed options
to the existing listUsers(query, ctx) handler. Allowed columns are name,
email, plan, status and created; directions are asc and desc.
An omitted direction defaults to descending for created, ascending otherwise.
An unknown column or invalid direction drops both ordering fields; raw values
are never forwarded. Without people.pii.read, name and email ordering is also
dropped before the handler runs.
Your handler must apply the order to the full filtered set before LIMIT /
OFFSET or equivalent pagination. Use a code-owned field whitelist, nulls last,
and a unique user id tiebreak (ascending in the example). Translate the example's
orderBy objects into your own database adapter or ORM; never interpolate URL
input into SQL, and never sort only a fetched page.
Return sort: { column, dir } only after applying that order. The SDK echoes it
only when it is valid and matches the requested options. This response echo is
the sorting capability: the console enables clickable headers and a sort menu
only for a matching echo. No extra health capability is required. Sort changes
reset pagination; page links, search and filter changes preserve the applied order.
User/name and Email headers stay plain without PII permission.
Existing handlers can ignore the new options and omit the echo; their signature and responses remain valid. Older SDKs that ignore these query parameters keep serving the directory with plain headers. Upgrading the SDK alone does not claim that your handler sorts. The Python SDK does not implement the connector.
Errors#
| Status | When |
|---|---|
401 |
Missing, malformed, mis-signed, expired, not-yet-valid or replayed token, wrong iss/aud |
403 |
Valid token without the route's permission |
404 |
Unknown route, or getUser returned null |
405 |
Right path, wrong method |
400 |
Missing required body fields |
501 |
Route has no handler |
500 |
Your handler threw (also passed to onError) |
The body is always { "error": "…", "code": "…" }. The code on a 401 is one
of unauthorized, malformed, unsupported_alg, bad_signature,
bad_issuer, bad_audience, not_yet_valid, expired, replayed, bad_key —
which makes misconfiguration obvious in your logs.
Verifying tokens yourself#
import { ReplayGuard, ServiceTokenError, verifyServiceToken } from "@saaspro/node";
const guard = new ReplayGuard(120_000);
try {
const claims = await verifyServiceToken(token, {
publicKey: process.env.SPM_CONNECTOR_PUBLIC_KEY!,
issuer: "saaspromax",
audience: process.env.SPM_APP_ID!,
replayGuard: guard,
});
} catch (error) {
if (error instanceof ServiceTokenError) console.warn(error.code, error.message);
throw error;
}
ReplayGuard is an in-memory TTL store. It is per-process — behind several
instances a token can be replayed once per instance inside its 2-minute window.
Back it with Redis if that matters to you; tokens are single-use by design.
Key rotation#
Generating a new key pair in the console invalidates the old one immediately. To rotate without downtime, accept both for a moment:
const handlers = { … };
const current = createConnectorHandler({ publicKey: NEW_KEY, audience, handlers });
const previous = createConnectorHandler({ publicKey: OLD_KEY, audience, handlers });
export async function handler(request: Request): Promise<Response> {
const response = await current(request.clone());
return response.status === 401 ? previous(request) : response;
}
Checklist#
- Handler mounted and reachable from the public internet
-
GET /healthreturns200with the expected capabilities (use the console's health check button) - Public key and app id stored as environment variables, not committed
-
applyControlis idempotent onrequestId - Control changes are written to your own audit log with
ctx.claims.sub