Core SDK (arkveil)
Call decisions from any JavaScript runtime — the core client, permission checks, read conditions, and write checks.
Install
npm install arkveilarkveil 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
| Option | Type | Default | Description |
|---|---|---|---|
serviceUrl | string | — | Required. Base URL of Arkveil Cloud or a self-hosted arkveil-runtime sidecar. |
apiKey | string | — | Required. Workspace API key, sent as the x-api-key header. |
version | "v1" | "v1" | API version. |
timeout | number | 5000 | Request timeout in milliseconds. |
retryAttempts | number | 3 | Retry attempts for 429/5xx/network errors. |
logger | Logger | — | 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)resolvesuser/contextattributes from a framework request viagetUserAttributes/getContextAttributes, returning aPermissionCheckRequest. This is used internally by@arkveil/nodeand@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. datasetCodeis the canonical dataset code,datasource.schema.table(see dataset codes).- Pass
aliaswhen 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.operationis 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:Mutation touchSql(pre-state)resultSql(post-state)CREATE absent after the insert, over the inserted ids UPDATE before the update, over idsafter the update, over the same ids DELETE before the delete, over idsabsent An absent field means the operation has no such phase — never "allow".
-
idsare required and non-empty for UPDATE and DELETE — sent as strings, inlined server-side as typed literals for the dataset's primary key. An emptyidsarray 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, soresultSqlcomes back with an{{ids}}template. The recipe: insert, collect the ids the insert returned, render the check withresolveCreateResultSql, execute it, roll back onfalse. -
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 FALSEin every field the operation has, withmode: "UNAVAILABLE", on any transport error. A malformeddatasetCode, an unknownoperation, oridsthat 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.operationis"UPDATE" | "DELETE"—CREATEhas no WHERE clause and throws. touchConditionis 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 callbuildWriteChecks({ operation: "UPDATE", ids })with the returned ids and execute onlyresultSql. Never runtouchSqlafter 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";| Export | Description |
|---|---|
normalizeDatasetCode(code: string): string | Validates and normalizes a dataset code. Throws on malformed input. |
resolveCreateResultSql(response, insertedIds): string | Completes 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)[]): string | Replaces 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.