Arkveil
SDK

Core SDK (arkveil)

Call decisions from any JavaScript runtime — the core client, permission checks, read conditions, and write checks.

Install

npm install arkveil

arkveil has zero runtime dependencies and ships dual ESM/CJS builds with type declarations.

Creating a client

import { Arkveil } from "arkveil";

const arkveil = new Arkveil({
  serviceUrl: "https://api.arkveil.com",
  apiKey: "your-api-key",
});

Constructor options

OptionTypeDefaultDescription
serviceUrlstring—Required. Base URL of Arkveil Cloud or a self-hosted arkveil-runtime sidecar.
apiKeystring—Required. Workspace API key, sent as the x-api-key header.
version"v1""v1"API version.
timeoutnumber5000Request timeout in milliseconds.
retryAttemptsnumber3Retry attempts for 429/5xx/network errors.
loggerLogger—Logger with error/warn methods, used for fail-closed diagnostics.
getUserAttributes(req: any) => TUser | Promise<TUser>—Extracts user attributes from a framework request object. Used by platform SDKs.
getContextAttributes(req: any) => TContext | Promise<TContext>—Extracts context attributes from a framework request object.
onDenied(req, res, reason?) => void | Promise<void>—Custom handler invoked on denial by platform SDKs.

Arkveil is generic over <TCode, TUser, TContext>. Leave these unset to fall back to string/Record<string, any>, or bind them to generated types — see Typed codes & attributes.

Permission checks

const result = await arkveil.checkPermission({
  actionCode: "articles:delete",
  user: { id: "user-123", role: "admin" },
  context: { region: "EU" },
});
// result: { granted: boolean }
  • checkPermission(request) POSTs to ${serviceUrl}/api/${version}/abac/permissions/check.
  • It is fail-closed: any network error, timeout, or non-OK response is caught, logged, and resolved to { granted: false }. It never throws.
  • buildPermissionRequest(code, req) resolves user/context attributes from a framework request via getUserAttributes/getContextAttributes, returning a PermissionCheckRequest. This is used internally by @arkveil/node and @arkveil/nest.

Row-level data protection

Three methods turn data policies into SQL you execute directly against your own database: buildReadCondition filters reads, buildWriteChecks validates a mutation over named rows, and buildTouchCondition scopes a bulk mutation by predicate. The SDK never sees your data — only the rendered condition or check SQL. The model behind them — READ, TOUCH and RESULT policies and their per-operation grants — is described in Data policies.

buildReadCondition

const { readCondition, mode } = await arkveil.buildReadCondition({
  datasetCode: "billing.public.payments",
  user: { id: "user-123", role: "manager" },
  context: {},
  alias: "p",
});

const rows = await db.query(
  `SELECT * FROM payments p WHERE p.tenant_id = $1 AND (${readCondition})`,
  [tenantId],
);
  • POSTs to .../abac/conditions/read.
  • datasetCode is the canonical dataset code, datasource.schema.table (see dataset codes).
  • Pass alias when the table is aliased or joined, so the condition qualifies columns as "alias"."column" instead of "schema"."table"."column".
  • readCondition: "FALSE" is a normal response meaning "no policy grants access to any rows" — not an error.
  • Fails closed to { readCondition: "FALSE", mode: "UNAVAILABLE" } on any error.

buildWriteChecks

One call per mutation over named rows. The response carries one SQL field per phase the operation has: touchSql is the pre-state check (TOUCH policies), resultSql the post-state check (RESULT policies).

const { touchSql, resultSql } = await arkveil.buildWriteChecks({
  datasetCode: "billing.public.payments",
  user,
  context: {},
  operation: "UPDATE",
  ids: [42, 7],
});

const [{ allowed }] = await tx.query(`${touchSql} AS allowed`);
if (!allowed) throw rollback();
// ...run the UPDATE...
const [{ ok }] = await tx.query(`${resultSql} AS ok`);
if (!ok) throw rollback();
  • POSTs to .../abac/conditions/write. operation is required: "CREATE" | "UPDATE" | "DELETE".

  • Each field is a single statement returning one boolean. Run it inside the mutation's transaction and roll back the whole mutation on false — no partial success, no silent narrowing on named rows. Which fields arrive, and when each runs:

    MutationtouchSql (pre-state)resultSql (post-state)
    CREATEabsentafter the insert, over the inserted ids
    UPDATEbefore the update, over idsafter the update, over the same ids
    DELETEbefore the delete, over idsabsent

    An absent field means the operation has no such phase — never "allow".

  • ids are required and non-empty for UPDATE and DELETE — sent as strings, inlined server-side as typed literals for the dataset's primary key. An empty ids array targets nothing: the SDK skips the request and returns { mode: "NO_OP" } — run no checks and no mutation.

  • CREATE takes no ids — the inserted rows' ids exist only after the insert, so resultSql comes back with an {{ids}} template. The recipe: insert, collect the ids the insert returned, render the check with resolveCreateResultSql, execute it, roll back on false.

  • reason: "METADATA_MISSING" means the dataset isn't registered in Arkveil. reason: "CONTRACT_VIOLATION" is synthesized by the SDK when a response is missing a field its operation requires — it denies rather than proceeding unchecked. Both name configuration gaps, logged distinctly from a policy deny.

  • Fails closed to SELECT FALSE in every field the operation has, with mode: "UNAVAILABLE", on any transport error. A malformed datasetCode, an unknown operation, or ids that contradict the operation throw instead — those are programming errors, not runtime conditions.

buildTouchCondition

The bulk-mutation counterpart. A statement like UPDATE … WHERE status = 'stale' names a predicate, not ids — so instead of checking named rows, you compose the operation's TOUCH union into the statement itself:

const { touchCondition } = await arkveil.buildTouchCondition({
  datasetCode: "billing.public.payments",
  user,
  context: {},
  operation: "DELETE",
  alias: "p",
});

await tx.query(
  `DELETE FROM payments p WHERE p.status = 'stale' AND (${touchCondition})`,
);
  • POSTs to .../abac/conditions/touch. operation is "UPDATE" | "DELETE" — CREATE has no WHERE clause and throws.
  • touchCondition is a bare boolean fragment, aliased like a read condition — AND it into the bulk statement's WHERE clause. Rows outside the subject's touch union are simply not touched: that narrowing is deliberate and visible in the query. "FALSE" is a normal response — an empty union means the statement affects zero rows.
  • A composed bulk UPDATE still owes the RESULT check over the rows it actually changed: add RETURNING <pk>, then call buildWriteChecks({ operation: "UPDATE", ids }) with the returned ids and execute only resultSql. Never run touchSql after the fact — the pre-state it checks no longer exists. A bulk DELETE is complete with the filter alone.
  • When the ids are known upfront, prefer buildWriteChecks — deny-whole over the enumerated rows is the stricter promise.
  • Fails closed to { touchCondition: "FALSE", mode: "UNAVAILABLE" } on any transport error.

Dataset codes

Dataset codes identify a table for row-level protection and follow a strict shape: datasource.schema.table, three lowercase, dot-separated identifier segments.

import { normalizeDatasetCode } from "arkveil";

normalizeDatasetCode("Billing.Public.Payments");
// "billing.public.payments"

normalizeDatasetCode trims and lowercases a dataset code, and throws if it isn't exactly three dot-separated identifier segments. A malformed code is a configuration bug, not a runtime access decision — the same rule the data methods apply to an impossible request, like an operation the call cannot take.

Helper exports

import {
  normalizeDatasetCode,
  resolveCreateResultSql,
  substituteIds,
  IDS_PLACEHOLDER,
  DENY_SQL,
  DENY_CONDITION,
  METADATA_MISSING,
  CONTRACT_VIOLATION,
  MODE_UNAVAILABLE,
  MODE_NO_OP,
} from "arkveil";
ExportDescription
normalizeDatasetCode(code: string): stringValidates and normalizes a dataset code. Throws on malformed input.
resolveCreateResultSql(response, insertedIds): stringCompletes the CREATE path: renders the executable post-state check from the response's resultSql template and the ids the insert produced. Returns DENY_SQL when the response cannot be completed. Throws if insertedIds is empty — an insert that produced no rows has nothing to check.
substituteIds(sql: string, ids: readonly (string | number | bigint)[]): stringReplaces the {{ids}} placeholder with escaped SQL literals. Only a CREATE resultSql carries the template. Throws if ids is empty or no placeholder is present.
IDS_PLACEHOLDER"{{ids}}"
DENY_SQL"SELECT FALSE" — the statement substituted whenever the SDK must deny
DENY_CONDITION"FALSE" — the condition substituted when a read or touch condition must deny
METADATA_MISSING"METADATA_MISSING"
CONTRACT_VIOLATION"CONTRACT_VIOLATION"
MODE_UNAVAILABLE"UNAVAILABLE"
MODE_NO_OP"NO_OP"

Types

import type {
  ArkveilParams,
  ArkveilCode,
  ArkveilCodeRegistry,
  ArkveilUser,
  ArkveilUserRegistry,
  ArkveilContext,
  ArkveilContextRegistry,
  PermissionCheckRequest,
  PermissionCheckResponse,
  ReadConditionRequest,
  ReadConditionResponse,
  WriteChecksRequest,
  WriteChecksResponse,
  WriteCheckId,
  WriteOperation,
  TouchConditionRequest,
  TouchConditionResponse,
  TouchOperation,
} from "arkveil";

The *Registry interfaces are empty by default and are augmented by generated code — see Typed codes & attributes.

On this page