SaaS Pro Max
Sign inStart free
Documentation menu

App user management (the signed connector)

SaaS Pro Max manages the users of your application without ever holding them. There is no shared database, no export, no sync job. The console calls a small HTTP surface you mount inside your own backend, signs every request with a key pair that exists for exactly one application, and stores only its own record of what an operator asked for.

The tenant half of the contract is @saaspro/node and is documented in docs/sdk/connector.md. This page is the platform half: what the console sends, what it stores, and what an operator can do.

Security model#

Every call carries Authorization: Bearer <EdDSA JWT>:

JSON
{
  "iss": "saaspromax",
  "aud": "<application id>",
  "sub": "<console user id>",
  "email": "operator@example.com",
  "orgId": "<organization id>",
  "appId": "<application id>",
  "permissions": ["people.read", "people.pii.read"],
  "iat": 1788000000,
  "nbf": 1787999995,
  "exp": 1788000060,
  "jti": "1f0c…"
}

Five properties make this safe to point at someone else's production system:

  1. The token is scoped to one route. permissions is not the caller's whole role — it is the intersection of what the operator holds with what the route needs. A prompt sync carries evals.read and nothing else.
  2. Asserting an unheld permission is an error, not a downgrade. If the acting member does not hold the permission a route needs, the request is refused before it leaves the platform (ConnectorError with kind forbidden).
  3. PII is opt-in per route. people.pii.read is forwarded only on routes that carry user records (/overview, /users, /users/{id}, /users/{id}/controls, /communications/send) and only when the operator holds it. Without it the tenant handler strips email and name from every user in every response.
  4. Tokens live 60 seconds and are single-use. jti is a fresh UUID and the tenant handler keeps a two-minute replay window.
  5. The private key never leaves the vault. It is generated in the console, envelope-encrypted immediately, and decrypted server-side for the duration of one signature.

The token identifies a person (sub, email), so your own audit log can record who acted, not just "the platform".

Key lifecycle#

StepWhat happensWhere the material lives
GenerateEd25519 key pair created in the consoleprivate key → vault (provider_credentials, provider connector), public key → app_connectors.public_key
InstallOperator copies the public key into the applicationyour environment: SPM_CONNECTOR_PUBLIC_KEY, SPM_APP_ID
HealthGET /health probes the handler and records its capabilitiesapp_connectors.capabilities, status, last_health_at, last_error
RotateA new pair is generated; the old one moves to previous_* with a 24 h expiryboth private keys in the vault
ExpireAfter 24 h the superseded credential is revoked by the workervault row status='revoked'

Rotation is a 24-hour window, not a swap. The tenant handler only ever knows one public key. During the grace window the console signs with the new key, and if the tenant answers 401 it retries the same request once with the superseded key. Update SPM_CONNECTOR_PUBLIC_KEY in your application inside that window; once it lapses the old vault credential is revoked and the fallback stops working — every request then fails with unauthorized until the tenant has the current key.

Environment variables the tenant sets:

Text
SPM_CONNECTOR_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEA…\n-----END PUBLIC KEY-----"
SPM_APP_ID="<application id>"

Both PEM (SPKI) and a bare base64 32-byte key are accepted by the SDK. The settings page shows the exact block to paste, plus a .pem download.

Permissions forwarded per route#

Console callRoutePermission assertedPII forwarded
connectorHealthGET /health(none)no
connectorOverviewGET /overviewpeople.readwhen held
connectorUsersGET /userspeople.readwhen held
connectorUserGET /users/{id}people.readwhen held
applyControlPOST /users/{id}/controlspeople.controls.writewhen held
connectorPromptsGET /promptsevals.readno
reportGatePOST /prompts/{slug}/gateevals.runno
agentContextPOST /agent-contextevals.readno
sendCommunicationPOST /communications/sendcommunications.writewhen held

Console permissions for the module itself: the directory and dossier need people.read; unmasked emails need people.pii.read; controls need people.controls.write; everything on Settings › Connector needs apps.write and a passkey verified in the last 30 minutes.

Directory ordering#

The console sends whitelisted sort=name|email|plan|status|created and dir=asc|desc on /users. A matching sort: { column, dir } response confirms that the tenant applied ordering before pagination, with nulls last and a unique user ID tiebreak. Only then do User, Email, Plan, Status and Created headers become sortable, with a sort menu for phone cards. Last active and Attributes retain plain headers. Changing the column or direction resets the page; page links, searches and filter changes preserve the applied ordering.

Name and email ordering require people.pii.read; both the console and Node SDK gate them, while the existing masking remains in place. Unknown URL columns fall back to Created descending; the SDK independently drops invalid ordering values. Legacy SDKs and handlers without a matching echo keep plain headers without an additional notice. See the tenant implementation.

Controls and approvals#

An account control is something the console asks the application to do to one user — suspend it, force a password reset, erase its data. The tenant decides what exists by returning availableControls on the dossier:

TypeScript
{ type: "suspend", label: "Suspend account", description: "Blocks sign-in",
  reversible: true, maxDays: 30 }
{ type: "delete_data", label: "Erase account data", description: "…",
  reversible: false, requiresApproval: true }

The console validates the submitted control against that list server-side — unknown type, a duration over maxDays, or removing an irreversible control are all refused before anything is sent. Every request needs a reason of at least ten characters and a fresh passkey, and is written to the audit ledger.

requiresApproval turns the request into a four-eyes flow:

Text
operator submits          → connector_controls row (pending_approval)
                          + approval_requests row (action "connector.control")
second operator approves  → operations page records the decision
executeApprovedControl()  → tenant call with the ORIGINAL requestId
                          → connector_controls row (applied)  + audit event

The requestId is created when the request is recorded and reused when it finally executes, so a tenant that keys on it is idempotent even when the approval lands hours later. executeApprovedControl(approvalId) claims approval_requests.executed_at before calling out, so two callers racing on the same approval produce exactly one tenant call. A rejected or expired approval turns the ledger row into rejected.

Two things drive the executor: the operations approval handler (once it calls executeApprovedControl) and the recurring connector.controls job, which is the safety net for a decision whose request died before dispatch.

Storage#

Table Holds
app_connectors one row per application: url, key_id, public_key, private_key_credential_id, issuer, status, capabilities, version, last_health_at, last_error, rotated_at, and the previous_* columns of the rotation grace
connector_controls the platform ledger: tenant user, type, action, reason, duration, request_id, approval_request_id, status, the tenant's response, the acting operator

status on a connector moves unconfigured → configured → healthy | unhealthy (disabled switches it off). No tenant user record is ever stored. The directory and dossier are rendered from a live response and nothing is cached; a control's ledger row keeps the tenant user id, which is the only identifier the platform retains.

Jobs#

KindEveryDoes
connector.health10 minprobes every configured connector, records status and capabilities, revokes rotated keys past their grace window
connector.controls1 minexecutes approved controls that have not reached the tenant, marks rejected ones

Neither job notifies anyone: a connector going quiet is shown on the settings page and in the directory, not paged on.

Using the connector from another module#

src/lib/connector/client.ts is the only entry point. It takes a context, not a request, so it works from a page, an API route and the worker alike:

TypeScript
type ConnectorContext = {
  org: { id: string };
  app: { id: string };
  user: { id: string; email: string };
  permissions: readonly string[];
};

connectorFetch<T>(ctx, path, { scope, includePii?, timeoutMs?, ...RequestInit }): Promise<T>

An AppContext from requireApp satisfies ConnectorContext structurally. Outside a request, build one with connectorContextForApp(appId, userId), which re-reads the actor's membership so a removed member cannot keep acting through a background job.

  • Evals (W11) call connectorPrompts(ctx), reportGate(ctx, slug, payload) and agentContext(ctx, input). None of them forward PII.
  • Communications (W12b) call sendCommunication(ctx, payload) for the in_app channel. It is user-bearing, so PII is forwarded when the acting member holds people.pii.read.

Failures are ConnectorError with a kind of unconfigured, network, unauthorized, forbidden or upstream, plus the HTTP status when there was one. Pages render the kind as a sentence through connectorErrorHint; a module that cannot recover should surface the message rather than swallow it.

Operator checklist#

  • Settings › Connector: URL saved (https outside localhost, no query string)
  • Key pair generated, public key and app id installed in the application
  • Health check returns the capabilities you expect
  • applyControl is idempotent on requestId in your handler
  • Control changes are written to your own audit log with ctx.claims.sub
  • After a rotation, the application's environment is updated within 24 hours

What the assistant can do here#

Nothing, deliberately. The connector's controls — suspending an account, resetting a password, forcing a sign-out — are console actions guarded by the key pair and the approvals described above, and they act inside your backend rather than on data SaaS Pro Max holds. There is no management API surface for them, so the assistant has no tool for them and will say so.

What it can do is explain: it reads this documentation directly, so "what does the connector sign, and what does it store?" is answered from the page you are reading rather than from memory.