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.
{
  "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:
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 /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