SaaS Pro Max
Sign inStart free
Documentation menu

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#

  1. 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.
  2. Every console request carries Authorization: Bearer <EdDSA JWT> signed with that private key, valid for 60 seconds, with a unique jti.
  3. Your handler verifies signature, iss, aud, nbf, exp and jti reuse, then enforces the acting user's permissions.
JSON
{
  "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#

  1. 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.
  2. Store it in your app:
Text
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#

TypeScript
// 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#

TypeScript
import { createConnectorHandler, toNodeListener } from "@saaspro/node";

app.use("/api/spm/connector", toNodeListener(handler));

Anything with Request / Response (Hono, Bun, Deno, workers)#

TypeScript
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#

RouteMethodPermissionHandler
/healthGETany signed tokenhealth?
/overviewGETpeople.readoverview
/usersGETpeople.readlistUsers
/users/{id}GETpeople.readgetUser
/users/{id}/controlsPOSTpeople.controls.writeapplyControl
/promptsGETevals.readlistPrompts
/prompts/{slug}/gatePOSTevals.runrecordGate
/agent-contextPOSTevals.readagentContext
/communications/sendPOSTcommunications.writesendCommunications

Implement only what you need. A route with no handler answers 501, and GET /health reports the capabilities it can infer:

JSON
{ "ok": true, "version": "0.1.0", "capabilities": ["overview", "users", "controls"] }

Implementing the handlers#

TypeScript
// 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:

TypeScript
{
  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:

TypeScript
async listUsers(query, ctx) {
  const rows = await db.users.search({ ...query, includeEmail: ctx.canReadPii });
  …
}

The same helper is exported if you need it elsewhere:

TypeScript
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#

TypeScript
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:

TypeScript
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 /health returns 200 with the expected capabilities (use the console's health check button)
  • Public key and app id stored as environment variables, not committed
  • applyControl is idempotent on requestId
  • Control changes are written to your own audit log with ctx.claims.sub