The signed connector
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 } 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 }) {
const { rows, total } = await db.users.search({ q, page, pageSize, plan: filters.plan });
return {
users: rows.map(toConnectorUser),
total,
page,
pages: Math.max(1, Math.ceil(total / pageSize)),
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);
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