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
.vdpolicy 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.
Connecting to Verdict Server
Section titled “Connecting to Verdict Server”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/clientInitialize 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.
Mapping domain IDs to identities
Section titled “Mapping domain IDs to identities”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 policyconst actor = identity("User", "alice");
// Represents Document:handbook in a .vd policyconst 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.
Supplying and updating facts
Section titled “Supplying and updating facts”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);Choosing which facts to store
Section titled “Choosing which facts to store”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:
- 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. - 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.
Adding and removing facts
Section titled “Adding and removing facts”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 usersawait client.deleteFact(["shared_to", identity("Document", "handbook"), null]);Replacing facts atomically
Section titled “Replacing facts atomically”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:bobawait client.batchFacts({ deletes: [["is_owner", identity("User", "alice"), identity("Document", "handbook")]], inserts: [["is_owner", identity("User", "bob"), identity("Document", "handbook")]],});Enforcing decisions in request handlers
Section titled “Enforcing decisions in request handlers”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.
Querying policy results
Section titled “Querying policy results”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 orderconst 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.
Next steps
Section titled “Next steps”- Dive deeper into synchronizing domain data, storing grants, and cleaning up resources in Managing Facts.
- Learn how to query accessible resources and keep handler checks aligned with policy queries in Enforcing Decisions & Querying.
- Explore all client methods, wildcard patterns, and options in the TypeScript client reference and
VerdictClient. - Integrate from other languages using the REST API reference.