Skip to content

Integrate with Verdict Server

When running Verdict Server, your application and Verdict Server split responsibilities cleanly:

  • Your application authenticates incoming requests, manages domain data, and enforces HTTP or RPC responses.
  • Verdict Server stores authorization facts and evaluates your .vd policy whenever your backend asks for a decision.

This guide assumes you have a running Verdict Server with a published policy. See the Quickstart to start a local server and Policy Fundamentals for how .vd policies work.

Your backend communicates with Verdict Server over Connect RPC using the TypeScript client, or over HTTP using the /v1 REST API from any language.

Install the client in a TypeScript or JavaScript backend:

npm install @useverdict/client

Initialize VerdictClient with your server URL and API key:

import { VerdictClient, identity } from "@useverdict/client";
export const client = new VerdictClient({
instanceUrl: process.env.VERDICT_INSTANCE_URL ?? "http://localhost:4891",
apiKey: process.env.VERDICT_API_KEY ?? "",
});

For a local server started with verdict serve without authentication, apiKey can be an empty string. If your backend is written in another language, send JSON requests to the corresponding /v1 endpoints documented in the REST API reference.

In a .vd policy, entities such as users, documents, and organizations are represented as typed identities (User:alice, Document:handbook, Organization:acme).

In your application code, wrap raw database IDs with the identity helper before passing them to Verdict:

// Represents User:alice in a .vd policy
const actor = identity("User", "alice");
// Represents Document:handbook in a .vd policy
const resource = identity("Document", "handbook");

Over the REST API, the same identity is passed as {"type": "User", "id": "alice"}.

In Verdict, a plain string is a literal string constant (such as the action "read" or role "member"), whereas identity("User", "alice") pairs the PascalCase policy type with the ID so User:alice and Organization:alice never collide.

Suppose your server is running this document access policy:

declare is_owner(User, Document);
declare shared_to(Document, User);
allow(user: User, "read", document: Document) if
is_owner(user, document);
allow(user: User, "edit", document: Document) if
is_owner(user, document);
allow(user: User, "read", document: Document) if
shared_to(document, user);

Look at the declare declarations and rules in your policy to see which relationships and attributes your backend needs to supply to Verdict Server. You do not need to copy full database rows (such as document titles, body text, or timestamps) into Verdict; only supply the relationships and attributes that your policy rules actually inspect.

In practice, facts come from two patterns:

  1. Domain relationships synchronized on write: When your application creates or deletes a domain record that affects access (for example, creating a document owned by User:alice), write the corresponding fact (is_owner(User:alice, Document:handbook)) alongside your database change.
  2. Access grants stored directly in Verdict: When a fact exists purely to grant access (such as shared_to(Document:handbook, User:bob)), store and remove that grant directly in Verdict Server without maintaining a separate table in your application database.

Use insertFact (POST /v1/facts) to record a fact and deleteFact (POST /v1/facts/delete) to retract it. Pass each fact as a [predicate, ...terms] tuple matching the term types in your rules:

// Record ownership when a document is created:
// is_owner(User:alice, Document:handbook)
await client.insertFact(["is_owner", identity("User", "alice"), identity("Document", "handbook")]);
// Grant and later revoke read access for a collaborator:
// shared_to(Document:handbook, User:bob)
await client.insertFact(["shared_to", identity("Document", "handbook"), identity("User", "bob")]);
await client.deleteFact(["shared_to", identity("Document", "handbook"), identity("User", "bob")]);

deleteFact also accepts null as a wildcard in any term position to delete all matching facts at once:

// Remove shared_to(Document:handbook, _) for all users
await client.deleteFact(["shared_to", identity("Document", "handbook"), null]);

Facts in Verdict are independent statements in a set: inserting is_owner(User:bob, Document:handbook) without removing is_owner(User:alice, Document:handbook) leaves both users as owners.

When a state transition replaces an existing relationship or single-valued attribute (such as transferring document ownership or changing a user’s role), use batchFacts (POST /v1/facts/batch). Verdict Server applies deletes before inserts atomically in a single mutation:

// Transfer ownership of Document:handbook from User:alice to User:bob
await client.batchFacts({
deletes: [["is_owner", identity("User", "alice"), identity("Document", "handbook")]],
inserts: [["is_owner", identity("User", "bob"), identity("Document", "handbook")]],
});

When a request arrives at your backend, authenticate the caller using your application’s session or auth middleware, then call client.authorize(actor, action, resource) (POST /v1/authorize) before returning or modifying the protected resource:

async function getDocumentHandler(currentUserId: string, documentId: string): Promise<Response> {
const allowed = await client.authorize(
identity("User", currentUserId),
"read",
identity("Document", documentId),
);
if (!allowed) {
return new Response("Forbidden", { status: 403 });
}
const document = await db.documents.findById(documentId);
return Response.json(document);
}

client.authorize checks for a matching allow(actor, action, resource) and no matching deny(actor, action, resource) against your active policy and stored facts and resolves to a boolean (true when allowed, false when denied). A denied check resolves to false rather than throwing an exception; thrown errors indicate transport, authentication, or request validation failures.

While authorize checks one specific (actor, action, resource) triple, some endpoints need to ask open-ended questions such as “which documents can User:alice read?” or “which users can edit Document:handbook?”.

Use client.query (POST /v1/query) with null or a typed wildcard in the unbound positions to evaluate a predicate across your policy rules:

// Find every Document that User:alice is allowed to "read"
const rows = await client.query([
"allow",
identity("User", "alice"),
"read",
{ wildcard: { type: "Document" } },
]);
// Each row contains the matched values for the wildcard positions in order
const readableDocumentIds = rows.map(([doc]) => (typeof doc === "object" ? doc.id : String(doc)));

Unlike listFacts, which inspects only raw stored facts, query evaluates your policy rules and returns derived answers (here, combining both is_owner and shared_to rules for allow).

If your policy defines deny, raw allow queries still report grants independently. Check each candidate with client.authorize before treating it as an effective permission.