@providame/backend
v0.0.15
Published
Providame backend SDK — token verification, webhook signature verification, and typed access to the Providame Backend API from your own server.
Downloads
555
Maintainers
Readme
@providame/backend
Server-side SDK for Providame. Verify end-user access tokens, verify webhook signatures, and read your environment's end-users from your own backend.
This package must never run in a browser — it takes a secret key. For frontend code use
@providame/sdk-js,@providame/react, or@providame/vue.
Install
npm install @providame/backendSetup
import { createProvidameBackend } from '@providame/backend'
export const providame = createProvidameBackend({
secretKey: process.env.PROVIDAME_SECRET_KEY!,
})Config discovery works from the secret key alone. Pass publishableKey only if
you would rather discovery use the public endpoint, or supply jwksUri,
issuer, and audience explicitly to skip discovery entirely.
Authenticating requests
export async function GET(request: Request) {
const { isAuthenticated, userId } = await providame.authenticateRequest(request)
if (!isAuthenticated) {
return Response.json({ error: 'Unauthorized' }, { status: 401 })
}
return Response.json({ userId })
}authenticateRequest never throws — every failure returns
isAuthenticated: false plus a machine-readable reason
(no-authorization-header, malformed-authorization-header, token-invalid,
token-expired, config-unavailable).
It reads Authorization: Bearer only, never cookies.
Use providame.verifyToken(token) instead when you already hold a token and
want it validated or an error thrown.
Authorizing on roles, permissions, and organizations
The result carries the caller's active organization and a has() gate:
const auth = await providame.authenticateRequest(request)
auth.orgId // active organization id, or null
auth.orgSlug
auth.orgRoles // roles in the ACTIVE organization, both tiers
auth.orgPermissions // permissions resolved from those roles
if (!auth.has({ orgPermission: 'billing:manage' })) {
return Response.json({ error: 'Forbidden' }, { status: 403 })
}has() ANDs every constraint you supply — role, permission, orgRole,
orgPermission — and ignores the ones you omit. has({}) therefore means
"any authenticated session", not deny-all.
Two distinctions worth getting right:
orgRoles/orgPermissionscover the active organization only. Environment-level grants live inpermission/roleand do not appear there. A user who is an admin of organization B sees no trace of that while organization A is active.- Every field is read from the verified claims. Nothing here decodes the token unverified, which is what stops the bearer forging their own organization.
Only the active organization is in the token. To ask about a user's other
memberships, use providame.organizations.forUser(userId) below.
Verifying without a client
verifyToken is also exported standalone, for edge middleware, API gateways,
or anything that authenticates requests but never calls the Backend API.
import { verifyToken } from '@providame/backend'
const { userId, claims } = await verifyToken(token, {
secretKey: process.env.PROVIDAME_SECRET_KEY,
})How verification works, and what a key is actually for
Tokens are RS256. Providame signs with a private key; you verify against the matching public key from the deployment's JWKS, which is served unauthenticated. Nothing is verified "against" your secret key — it is an API credential, not a signing key.
A key is needed only to discover one value: the audience (your environment's project ID). Issuer and JWKS URI are the same for the whole deployment, but the audience is per-environment, and checking it is what stops a token minted for one environment from validating against another.
So any of these work:
// Secret key — discovery via the Backend API. One env var.
await verifyToken(token, { secretKey: process.env.PROVIDAME_SECRET_KEY })
// Publishable key — discovery via the public FAPI endpoint. Preferred for
// edge/gateway deployments, which should not hold a secret at all.
await verifyToken(token, { publishableKey: process.env.PROVIDAME_PUBLISHABLE_KEY })
// No credential at all — supply the three values yourself, no discovery call.
await verifyToken(token, {
jwksUri: 'https://api.providame.com/fapi/v1/.well-known/jwks.json',
issuer: 'https://api.providame.com',
audience: 'your-project-id',
})If you pass both keys, the publishable one is used, so the secret never goes on the wire.
This throws on an invalid, expired, or wrongly-audienced token. It resolves
config per call — construct a client with createProvidameBackend() instead if
you want discovery memoized across many verifications.
Verifying webhooks
import { verifyWebhook } from '@providame/backend/webhooks'
export async function POST(request: Request) {
try {
const event = await verifyWebhook(request, {
secret: process.env.PROVIDAME_WEBHOOK_SECRET!,
})
// event: { id, type, environment_id, data }
return Response.json({ received: true })
} catch {
return Response.json({ error: 'Invalid signature' }, { status: 400 })
}
}⚠️ The raw body is required
The signature covers the exact bytes Providame sent. If you parse the JSON and re-serialize it, key order, whitespace, and unicode escaping all change, and verification will fail every time.
- Next.js App Router —
verifyWebhook(request, …)reads the body itself. Do not callrequest.json()first. - Express — mount
express.raw({ type: 'application/json' })on the route and pass the resultingBufferas a string toverifyWebhookPayload. - Already read the body? Use
verifyWebhookPayload(rawBody, signatureHeader, options).
import { verifyWebhookPayload, SIGNATURE_HEADER } from '@providame/backend/webhooks'
const event = await verifyWebhookPayload(
rawBodyString,
req.header(SIGNATURE_HEADER)!,
{ secret: process.env.PROVIDAME_WEBHOOK_SECRET! },
)Signatures older than 5 minutes are rejected (replay protection). Override with
toleranceSeconds. Deliveries retry with a stable id, so dedupe on it.
Failures throw WebhookVerificationError, which carries a machine-readable
reason (missing-signature, malformed-signature,
timestamp-out-of-tolerance, signature-mismatch, invalid-payload) so you
can distinguish a misconfiguration from a forgery attempt.
Note: the webhook event is returned exactly as signed, so its fields are snake_case (
environment_id). Every other response in this package is camelCase. This is deliberate — it lets you compare the event byte-for-byte against the signed payload and the dashboard's event log.
End-users
const { users, total } = await providame.users.list({
limit: 25,
search: 'ada@',
sort: '-created_at', // an unrecognised sort is rejected, not ignored
})
const user = await providame.users.get('u_123') // includes `active` (ban status)
const metadata = await providame.users.getMetadata('u_123') // all 3 tiers, incl. private
const { roleKeys } = await providame.users.getRoles('u_123')getMetadata returns privateMetadata — this is a trusted server-to-server
surface, unlike the frontend SDKs, which never expose that tier.
Note that list() enriches each row with avatarUrl and scimProvisioned,
while get() does not return those two fields.
Writing
The same operations the dashboard performs, so anything you can do by clicking you can also do from a deploy script or an admin tool of your own.
const { id } = await providame.users.create({
email: '[email protected]',
givenName: 'Ada',
password: process.env.TEMP_PASSWORD,
username: 'ada_lovelace', // optional sign-in handle, for migrations
})
// Partial: an omitted field is left unchanged, names included.
await providame.users.update(id, { givenName: 'Ada King', username: 'alovelace' })
await providame.users.ban(id) // blocks sign-in, keeps the account
await providame.users.unban(id) // also how you approve a waitlisted sign-up
await providame.users.delete(id) // permanent, and clears all our records of them
await providame.users.setMetadata(id, { publicMetadata: { tier: 'gold' } })
await providame.users.deleteMetadataKey(id, 'public', 'tier')
await providame.users.setRoles(id, ['editor']) // replaces, does not merge
await providame.users.resetPassword(id) // generates one, emails it
await providame.users.resendVerification(id) // unverified accounts only
await providame.users.createPasskeyRegistrationLink(id)
const { permissionKeys } = await providame.users.getPermissions(id)Three things worth knowing before you use these:
update({ password })revokes every live session, so a stolen refresh token stops working immediately rather than continuing to mint access tokens.resetPassword()does the same, but generates the password itself and emails it to the account owner — the plaintext never comes back to you.setRoles()andsetMetadata()differ.setRolesreplaces the whole grant (an empty array revokes everything);setMetadatareplaces only the tiers you supply, and within one, only the keys you name.create()defaults toemailVerified: true. Passfalseif you want the user to verify — and noteresendVerification()only works for an account that has not verified yet.
Sessions and export
const { sessions } = await providame.users.listSessions(id)
await providame.users.revokeSession(sessions[0].id) // a SESSION id, not a user id
const csv = await providame.users.exportCsv()revokeSession() signs one device out. That is a different action from
ban() (blocks every future sign-in) and from changing the password (revokes
every session at once).
exportCsv() returns the whole directory as a string, one
<tier>_metadata.<key> column per metadata key. Your data is always yours to
take out, and from a backend you can put that on a schedule.
Sign-up invitations
An invitation admits one specific address into your environment's user pool.
It is an allowance, honoured under every sign-up policy mode rather than
only invite_only: naming one exact address is a more specific decision than
a general rule, so it also overrides a blocklist and skips a waitlist.
const invite = await providame.signupInvites.create({
email: '[email protected]',
sendEmail: false, // you are delivering it yourself
})
invite.token // single-use, expires in 7 days — treat it as a credential
const { invites, total } = await providame.signupInvites.list()
await providame.signupInvites.revoke(invite.id)Creation returns the plaintext token so you can deliver the invitation
through your own email, Slack, or SMS. The dashboard's equivalent never
returns it, because that response lands in a browser. Pair it with
sendEmail: false so we don't send a second invitation alongside yours.
Creating one returns 409 if the address is already a user here, or already
holds a live invitation — revoke the old one rather than issuing a second.
App audit events
Audit events your apps send through FAPI's POST /me/audit-events (for
example with @providame/core's sendAuditEvents()), read back for export
or review. Read-only; newest first.
const { events, total } = await providame.auditEvents.list({
filter: { action: "trip.viewed", occurred_at: ">=2026-09-01" },
sort: "-occurred_at",
limit: 100,
})
// events[i].submittedBy — the verified user whose session sent it
// events[i].userId — the user your app recorded (can differ on a shared device)Organizations
Organizations are Providame's B2B tenancy layer: your customers are companies with members and roles. Unlike end-users, this surface is full read/write.
const { organizations, total } = await providame.organizations.list({ limit: 25 })
const org = await providame.organizations.create({
slug: 'acme',
name: 'Acme Inc',
createdBy: 'u_123', // optional: seat this user as the first member
})
await providame.organizations.update(org.id, { name: 'Acme Corp', maxMembers: 50 })
await providame.organizations.delete(org.id)
// Organizations a user belongs to, with roles and resolved permissions.
// Useful when you hold a user id but no token to decode.
const memberships = await providame.organizations.forUser('u_123')Members
const { members } = await providame.organizations.memberships.list(orgId)
// Omit `roles` to grant the environment's configured default member roles.
await providame.organizations.memberships.add(orgId, { userId: 'u_123' })
// Replaces the member's FULL role set across both tiers. `[]` revokes all.
await providame.organizations.memberships.setRoles(orgId, 'u_123', [
{ key: 'org:admin', scope: 'environment' },
{ key: 'billing-owner', scope: 'organization' },
])
await providame.organizations.memberships.remove(orgId, 'u_123')A role carries a scope. "environment" roles come from the environment's
shared catalog and are reusable across every organization; "organization"
roles exist only inside one. Both may be granted to the same member, which is
why setRoles replaces across both tiers at once — a partial update would
silently strip the other tier.
add() is idempotent, so re-adding an existing member is safe.
Org-local roles
const roles = await providame.organizations.roles.list(orgId)
await providame.organizations.roles.create(orgId, {
roleKey: 'billing-owner',
displayName: 'Billing Owner',
permissionKeys: ['billing:manage'],
})
// Omitted fields are left unchanged; an explicit [] revokes every permission.
await providame.organizations.roles.update(orgId, 'billing-owner', {
permissionKeys: ['billing:manage', 'invoices:read'],
})
await providame.organizations.roles.delete(orgId, 'billing-owner')Org-local roles draw their permissions from the environment's catalog, so permission keys stay flat and enumerable per environment.
Invitations and verified domains
const invite = await providame.organizations.invitations.create('org_1', {
email: '[email protected]',
roles: [{ key: 'org:admin', scope: 'environment' }],
sendEmail: false,
})
invite.token // as above: returned only here, never to a browser
await providame.organizations.invitations.list('org_1')
await providame.organizations.invitations.revoke('org_1', invite.id)
const domain = await providame.organizations.domains.create('org_1', {
domain: 'acme.com',
})
domain.challengeRecord // publish this DNS TXT record to verify ownershipenrollmentMode defaults to manual_invitation. The automatic modes start
admitting people the moment DNS resolves, which is not a thing to switch on by
accident — so you have to ask for it.
This surface is fully trusted
Unlike the end-user-facing API in @providame/core, nothing here checks
org:sys_* permissions, and creation ignores the self-serve caps
(allow_end_user_creation, max_orgs_per_user) — those bound what an
end-user may do, not a server-to-server call. That is exactly what lets you
run an invite-only model: self-serve off, your own backend creating
organizations. Tenant isolation still applies in full; the secret key scopes
every call to its own environment.
Authorization
Fine-grained, per-object authorization on top of the roles/permissions above —
"can this user edit this specific document", not just "does this user have
the posts:write permission". Every question is a user, a
permission (e.g. "edit"), and an optional resource ("type:id",
e.g. "document:42"). Omit resource to check the environment's global
permission catalog instead — the same posts:write-style keys your roles
already grant.
This is the trusted, server-side surface — you name the user. The
client-side packages (@providame/core/vue/react/sdk-js) have a
narrower counterpart, providame.authz.can()/listResources() (plus a
<Can> component in Vue/React), which answers only for the signed-in
caller and never accepts a user id — appropriate for a frontend authenticated
by a publishable key, which is public by design.
Use forRequest() inside a request handler, not can() directly — every
check issued in the same tick collapses into one HTTP call instead of one
round trip per check:
app.get('/documents', async (req, res) => {
const authz = providame.authz.forRequest()
const documents = await loadDocuments()
const withAccess = await Promise.all(
documents.map(async (doc) => ({
...doc,
canEdit: await authz.can({ user: req.userId, permission: 'edit', resource: `document:${doc.id}` }),
})),
)
res.json(withAccess)
})Reach for a single can() only when you genuinely have just one question to
ask:
const allowed = await providame.authz.can({
user: userId,
permission: 'edit',
resource: 'document:42',
})Filter a list without checking every row — one round trip instead of N.
listResources returns one ResourceAccess per resource type asked about,
whose scopes is a UNION of shapes a caller compiles into SQL: "all" (every
record), "organization" (every record in one named organization),
"instances" (a specific enumerable set of ids), or "subtree" (a self-linked
hierarchy's root — "this and everything beneath it", never expanded, at any
size). A subject can hold several at once:
const [access] = await providame.authz.listResources({
user: userId,
permission: 'edit',
resourceType: 'document',
})
const editableDocumentIds = access.scopes.some((s) => s.kind === 'all')
? allDocumentIds
: access.scopes.flatMap((s) => s.instances ?? [])See the Authorization Lists guide (in the dashboard docs) for compiling every scope kind into SQL and for materialising access into your own database via the sync feed.
An organization can also be named, the same way can() accepts one — it
ADDS a place to look, never narrows what an environment-wide role/permission
already grants:
await providame.authz.can({
user: userId,
permission: 'edit',
resource: 'document:42',
organizationId: orgId,
})Conditions
A relation's subject can be authored (in the dashboard, or via authz.conditions
below) to require a named CEL condition — "editor, but only during business
hours." A check against a conditioned relation needs the parameter values
that condition's expression references, passed as context:
const allowed = await providame.authz.can({
user: userId,
permission: 'edit',
resource: 'document:42',
context: { current_hour: new Date().getHours() },
})context is checked, not merely accepted — omitting a parameter the
condition's expression actually needs is an error, not a silent false.
That's deliberate: a bug that drops a context value should fail loudly rather
than look identical to "access denied." can(), canEach() (via
forRequest()), and listResources() all accept context; on a batch, each
check's own context applies only to that check — one shared context is not
spread across the batch.
Resource types, their relations, and the permissions computed from them can be authored from the dashboard's Authorization section, or from your own backend — see Authoring the schema below. Once a type exists, grant and remove access on its records at any time — there's no separate registration step per record. A grant gives a role (or a single permission) on one resource, to a user or to everyone holding a role:
// Alice can edit document 42.
await providame.authz.assign({ subject: { type: 'user', id: 'alice' }, resource: 'document:42', role: 'editor' })
// Everyone who holds the "reviewer" role — now or later — can view it.
await providame.authz.assign({ subject: { type: 'role', key: 'reviewer' }, resource: 'document:42', permission: 'view' })
await providame.authz.unassign({ subject: { type: 'user', id: 'alice' }, resource: 'document:42', role: 'editor' })
// What was granted directly on this record (not what anyone inherits —
// use can() for an access decision).
const grants = await providame.authz.assignments({ resource: 'document:42' })
// Every member of an organization, or holders of one organization's own role.
await providame.authz.assign({ subject: { type: 'organization', id: orgId }, resource: 'document:42', role: 'viewer' })
await providame.authz.assign({ subject: { type: 'role', key: 'billing', organizationId: orgId }, resource: 'document:42', permission: 'view' })
// A permission handed straight to a user, no role in between
// (environment-wide, or within one organization with organizationId).
await providame.authz.grantPermission({ user: 'alice', permission: 'invoices:export' })
await providame.authz.revokePermission({ user: 'alice', permission: 'invoices:export' })A link places one record under another of the same type (nested folders), so a grant on the parent reaches its children. It is structural, never a grant itself:
await providame.authz.link({ resource: 'folder:child', link: 'parent', target: 'folder:root' })
await providame.authz.unlink({ resource: 'folder:child', link: 'parent', target: 'folder:root' })
const parents = await providame.authz.links({ resource: 'folder:child' })The type names user, group, role, permission, organization,
environment and resource are reserved by Providame and cannot be used
for your own resource types.
Authoring the schema
Resource types, permissions, roles, and conditions can all be authored from
your own backend via authz.resourceTypes/authz.permissions/authz.roles/
authz.conditions — the same model the dashboard's Authorization section
edits, so a CI pipeline can apply it the same way it applies a database
migration.
Resource types — your own nouns. hasInstances defaults to true and is
fixed at creation; a resource with no records of its own (billing, settings)
sets it false, and can carry permissions but never links. get returns the
type's permissions, links, and a read-only preview of what it compiles to;
list returns names and descriptions only.
const types = await providame.authz.resourceTypes.list()
const document = await providame.authz.resourceTypes.get('document')
await providame.authz.resourceTypes.create({
name: 'document',
displayName: 'Document',
description: 'A document in the workspace',
// hasInstances: false, // a resource with no records of its own
})
await providame.authz.resourceTypes.update('document', { displayName: 'Doc' })
await providame.authz.resourceTypes.delete('document')Links are declared per type, named from the argument rather than a body field. A self-link — a type linking to ITSELF — is the only shape that carries inherited access (nested folders, any parent/child hierarchy of one type). A link between two DIFFERENT types is structural only, useful for your own navigation, since a permission can never be shared across two types under this design.
await providame.authz.resourceTypes.upsertLink('document', 'folder', { targetType: 'document' })
await providame.authz.resourceTypes.deleteLink('document', 'folder')Permissions — a permission belongs to exactly ONE resource type, fixed at
creation. Omit resourceType for the built-in app-wide resource. The key and
resource type are both immutable afterwards; only name and description update.
const permissions = await providame.authz.permissions.list()
await providame.authz.permissions.create({
permissionKey: 'document:edit',
name: 'Edit',
resourceType: 'document',
})
await providame.authz.permissions.update('document:edit', { name: 'Edit document' })
await providame.authz.permissions.delete('document:edit')Roles — a named bundle of permissions from the catalog. Only a permission
that's also applied to a resource type turns into a real relation in the
compiled model, so a role holding only catalog-scoped permissions is valid but
inert. update replaces permissionKeys wholesale when the field is present
(including [], which clears it) — omit it to leave the assignment untouched.
const roles = await providame.authz.roles.list()
await providame.authz.roles.create({
roleKey: 'editor',
displayName: 'Editor',
permissionKeys: ['document:edit'],
})
await providame.authz.roles.update('editor', { displayName: 'Content editor' }) // permissions unchanged
await providame.authz.roles.update('editor', { permissionKeys: [] }) // revokes every permission
await providame.authz.roles.delete('editor')Conditions — named CEL expressions a role or link grant may carry. params
declares the TYPES the expression references; the values are supplied
per-check, as context on can(). Writing one recompiles the model, and the
compiler is the only authority on validity — an invalid expression is rejected
and never stored.
const conditions = await providame.authz.conditions.list()
await providame.authz.conditions.upsert('office_hours', {
expression: 'current_hour >= 9 && current_hour < 17',
params: { current_hour: 'int' },
})
await providame.authz.conditions.delete('office_hours')A permission or role Providame seeds itself (the six org:sys_* permissions,
org:admin/org:member) cannot be deleted through this surface, same as the
dashboard. Deleting a resource type is refused while another type links to it
or permissions still belong to it; deleting a condition is refused while a
grant still references it.
JWKS
const { keys } = await providame.jwks.get()You rarely need this. verifyToken and authenticateRequest already fetch and
cache these keys. It exists for tooling that wants to inspect or pre-warm them.
Runtime support
Node 18+, Vercel Edge, and Cloudflare Workers. Uses Web Crypto and fetch
only — no Node built-ins.
Not yet supported
Impersonation. Minting an access token that acts as one of your users is
deliberately dashboard-only, behind the users:impersonate role. A secret key
carries no roles at all, so exposing it here would mean a leaked key can act as
any end-user in your environment.
License
MIT
