@aventara/access
v1.0.0-rc.2
Published
A policy engine for Aventara: role-first rules typed from your schema, compiled to a passive policies file.
Readme
@aventara/access
Access extends Better Auth with policies for Aventara apps and the services beside them. Better Auth signs users in and issues their tokens. Access decides what each user may do with each row and each field.
You write role-first rules, typed from your app's schema. Access compiles them to a policies file: plain JSON in the
aventara-access/policies@1 format. Its engine answers the framework's policy questions from that file: who may read,
create, update or delete which rows, and which fields they may see.
This is a release candidate. It holds:
| Part | What it does |
|---|---|
| @aventara/access: definePolicies, createAccessEngine | The rules and the engine. They need no Better Auth and no server. |
| @aventara/access/auth: createAccess | Better Auth with Access beside it: tokens with claims, the engine, the request's principal. |
| @aventara/access/better-auth: aventaraAccess, accessOf, aventaraAccessClient | Access as a plugin of the Better Auth you build yourself, and its client plugin. |
| @aventara/access/standalone | serve(access): Access as its own HTTP server. |
| The registry | Namespaces, keys and the org's guardrails, stored in Better Auth's database. |
| avaccess | The command line: push, pull, verify, status, diff, keys, generate, schema, serve. |
| The generated client | Two TypeScript files (avAccess.ts and avAccess.internal.ts) that import nothing else and decide for a service that does not run on Aventara. |
Contents: Install · AI agents · Write the rules · createAccess ·
Mount the routes · Wire it into Aventara ·
The Better Auth plugin · The engine alone · The audit log ·
Users and roles as Resources · The registry and avaccess
· Guardrails · Services not on Aventara ·
Running avaccess serve · File formats and hashes ·
Error codes
Install
Access goes into an Aventara app. To start one, create it with the Aventara CLI (here NestJS, Prisma 7 and PostgreSQL), then install Access in it:
npx @aventara/[email protected] new <app> --orm prisma7 --db postgresql
cd <app>
npm install --save-exact @aventara/access@rc better-auth@~1.7.12
# or, with pnpm:
pnpm add --save-exact @aventara/access@rc better-auth@~1.7.12On a terminal, aventara new asks for the package manager; anywhere else, add --package-manager npm (or pnpm)
or --yes. Then set DATABASE_URL in .env, as the CLI says, and add Better Auth's secret and the app's origin.
npx auth@~1.7.12 secret prints a new secret. Without BETTER_AUTH_URL, createAccess refuses to start
(ACCESS_OPTIONS_INVALID).
# .env
BETTER_AUTH_SECRET="<the value npx auth@~1.7.12 secret prints>"
BETTER_AUTH_URL="http://localhost:3000"Then, in this order:
- Add your models to
prisma/schema.prismaand runnpm run aventara:prepare(Write the rules). IfUser.idthere is anInt, make it aStringfirst (User ids). - Write the rules in
src/access.policies.ts. - Write
src/access.tswithcreateAccess. - Generate Better Auth's tables, push them and run
npm run aventara:prepareagain (Better Auth's tables). - Mount the routes and wire it into Aventara.
Access runs on Better Auth ~1.7.12: 1.7.12 or any later 1.7 patch. npm installs the newest patch, and
--save-exact records it. Another version (an older one, 1.8, a prerelease) is refused when createAccess runs
(ACCESS_BETTER_AUTH_VERSION).
better-auth is an optional peer. The engine alone (definePolicies, createAccessEngine, from @aventara/access)
works without it, and type-checks without it with skipLibCheck: false. Only createAccess loads it, and it is
imported from @aventara/access/auth.
Access works with @aventara/core 1.0.0-rc.10 or later. It has no dependency on the framework: its engine and its
instance fit the framework's types by shape. Node.js ^22.23.4 || >=24.22.1. On Node.js 22, createAccess needs
@node-rs/argon2 to hash passwords (Passwords).
AI agents
The package ships an agent skill at node_modules/@aventara/access/skill/: a short SKILL.md and reference pages an
agent loads when a task needs them (setup and wiring, the rule language, recipes, signed-out callers and cached reads,
error codes). Its TypeScript examples compile against this release. Copy the folder into your agent's skills folder:
mkdir -p .claude/skills
cp -R node_modules/@aventara/access/skill .claude/skills/aventara-accessOr point your agent at node_modules/@aventara/access/skill/SKILL.md. Copy it again after you upgrade Access.
Write the rules
definePolicies takes your schema. In an Aventara app that is the discovery artifact's model, which
aventara:prepare writes. Every name in a rule is checked against it. A Resource, field, relation or enum value that
does not exist is a type error at the key that names it.
The rules below use Campaign, Member and Note, which the scaffold does not have. Add them to
prisma/schema.prisma first, with the two relation lines in the scaffold's User, then run npm run aventara:prepare
so the model has them:
// prisma/schema.prisma: the scaffold's User, with `members` and `notes` added
model User {
id String @id @default(cuid())
email String @unique
name String?
members Member[]
notes Note[]
}
enum CampaignRole {
DM
PLAYER
}
enum Visibility {
PARTY
PRIVATE
DM_ONLY
}
model Campaign {
id String @id @default(cuid())
name String
members Member[]
notes Note[]
}
model Member {
id String @id @default(cuid())
campaignId String
userId String
role CampaignRole
campaign Campaign @relation(fields: [campaignId], references: [id])
user User @relation(fields: [userId], references: [id])
@@unique([campaignId, userId])
}
model Note {
id String @id @default(cuid())
campaignId String
authorId String
body String
visibility Visibility
campaign Campaign @relation(fields: [campaignId], references: [id])
author User @relation(fields: [authorId], references: [id])
}Better Auth's generate (Better Auth's tables) adds its own fields to this User later and
keeps the ones above.
// src/access.policies.ts
import { definePolicies } from "@aventara/access";
import { model } from "./generated/aventara/discovery.artifact.js";
/** What your app derives for a request: the signed-in user, or null. */
export type Principal = {
id: string;
claims: { role?: "admin"; banned?: boolean };
};
export const policies = definePolicies(model)
.principal<Principal>()
.roles(({ me }) => ({
// A global role, read from the principal's claims.
admin: { global: { claims: { role: "admin" } } },
// Roles held on a row: a member of a campaign, as DM or player.
dm: {
on: "Campaign",
where: { members: { some: { userId: me.id, role: "DM" } } },
},
player: {
on: "Campaign",
where: { members: { some: { userId: me.id, role: "PLAYER" } } },
},
}))
// Governed fields: only a grant that names one opens it. The list governs reads
// and writes: out of every default projection, written by no grant that does not
// name it.
.govern("User", ["email"])
// "write": read as any other field, written only by a grant that names it. The
// row roles read a member's role, so no grant hands one out without naming it.
.govern("Member", { role: "write" })
.grants(({ grant, me }) => [
grant("signedIn", "read", "User"),
grant("signedIn", "read", "User", {
where: { id: me.id },
fields: ["User.email"],
}),
grant("admin", "read", "*", { fields: ["User.email"] }),
grant("signedIn", "create", "Campaign"),
grant(["dm", "player"], "read", "Campaign"),
grant("player", "read", "Note", {
in: "campaign",
where: { visibility: "PARTY" },
}),
grant("dm", ["read", "create", "update", "delete"], "Note", {
in: "campaign",
}),
// The DM changes a seat's role, never who sits in it: the grant names the role.
grant("dm", "update", "Member", {
in: "campaign",
write: { except: ["userId", "campaignId"] },
fields: ["Member.role"],
}),
])
.denies(({ deny }) => [
deny("*", "*", { if: { claims: { banned: true } } }),
deny("delete", "*", { if: { origin: "agent" } }),
]);In Next.js, aventara:prepare writes the artifact to src/aventara/generated/discovery.artifact.ts. Import the model
there as:
// src/access.policies.ts (Next.js)
import { model } from "@/aventara/generated/discovery.artifact";How the rules combine:
- Nothing is allowed until a grant allows it.
- Any grant that matches allows. Any deny that matches wins over every grant. The order of rules never matters.
signedInis any non-null principal.anyonealso includes the anonymous caller (null). The principal is an object, an instance of a class (an entity) included; an array, a function or a primitive is anonymous. Its values are read from its own properties, never from its prototype.in: "campaign"means the subject holds its role on the row'scampaign.- A rule reads
mevalues the principal may lack: an anonymous principal lacks them all, and a missing ornullproperty, an empty string or a number JSON cannot carry counts as missing. A grant that reads a missing value never matches. A deny that reads one in itswherematches, whatevernotorNOTsurrounds it: it fails closed. - A claim the principal lacks fails closed the same way: a global role that compares it is not held, and a deny's
ifthat compares it holds, whatever value it compares with. A token Access issues carries every declared boolean claim,falsewhen the user's is unset, sodeny("*", "*", { if: { claims: { banned: true } } })leaves alone a user whose ban is unset and denies a token that lacksbanned(one from another issuer, or from before the claim was declared). The anonymous caller has no claims and no token: a comparison withtruenever holds for it, so a ban does not close ananyonegrant, and any other comparison holds for a deny. if: { origin: "agent" }matches an AI agent's request, through the framework's MCP endpoint. The other origins areclient(a user's request) andapplication(your server's own).- A rule compares a value by its field's type: a
decimalby value (1.5,"1.50"and a decimal object are equal), adatetimeby instant (aDate, or an ISO-8601 date or instant with its offset that reads back as written: no February 30th, no hour 24, nothing past the millisecond; the compiled file holdstoISOString()'s form), and abigintexactly. A row value of another type than its field's, or than the rule'smevalue, is unknown: no grant matches it, and a deny that compares it matches. - Through Aventara, a rule that compares a
datetime,decimalorbigintfield cannot be used yet: the framework validates a policy'swherewith aDate, a decimal object or abigintwhere the file holds text, so a scope or anexistsquestion on such a field answersA3000(the request is refused). It fails closed; the framework's seam is being changed.canon a row, and a generated client, compare these fields as above. - Writing a reference needs read on its target. When a create or an update sends a reference (a
Member'sprofileId) to a Resource the app serves, Aventara holds it to the rows the caller may read there, and refuses the write (A4002) otherwise. When Aventara hosts the engine (@aventara/core1.0.0-rc.10or later) it tells the engine which Resources it holds references to (engine.hosted({ held })), and the engine warns (ACCESS_REFERENCE_UNREADABLE, a Node.js warning) about a role that may write a reference to one of them no read grant reaches for it. When that Resource is a Better Auth tablecreateAccesskeeps closed (Userwithoutresources: { users: true }), the warning names the key that opens it. Compiling the rules warns about nothing, and the engine decides as before. A reference to a foreign Resource is the exception: no caller reads one, so the engine lists the rules' foreign Resources inunheldand Aventara does not hold a reference to them (Namespaces and foreign Resources). - Strings compare exactly in
can, code unit by code unit. A database column with a case- or accent-insensitive collation (MySQL's default, PostgreSQL'scitextor a nondeterministic collation) can find rowscanwould not keep, soscopeOfandcandisagree on them. Give the fields your rules compare a binary or deterministic collation.
Updates
An update is checked twice. Its scope (scopeOf(update)) picks the rows a grant's where covers and no update deny
matches. Then the row each update leaves must still be one an update grant covers, by that grant's where and its
after together, and no update deny may match it. A grant's where is checked on the new row by default: a grant
where: { authorId: me.id } does not let the author hand the row to someone else. after adds what the new row must
also meet:
grant("signedIn", "update", "Character", { where: { ownerId: me.id }, after: { campaign: { $role: "player" } } })lets a player move a character only to a campaign they play in.
The grant that covers the row after must also be the one that covered the row before. can then gets both rows
(row, and before, the row as it was before the update), and one update grant must cover both: its where the row
before, its where and after the row after. No update moves a row from the rows one grant reaches to another's.
When two update grants with different conditions cover a Resource, a row that only one of them covers before the
update and only the other covers after it is refused. Update denies are asked about the row after only.
The engine lists in afterChecks every Resource whose update grants read the row (a where, an after, a role held
on a row) or whose update denies have a where. Aventara asks can about each row such an update changed, with the
row before it, inside the update's transaction, and rolls the update back when one fails; where it has no interactive
transaction, it refuses those updates. The row before needs @aventara/core 1.0.0-rc.10 or later. An older core
hands can the row after only, and can then lets an update in when any update grant covers that row.
Which grants covered the row before is decided before the write. Aventara asks the engine's grantsOf about each row
such an update will change, after it reads the row and before any of your stages or the write itself runs, and hands
the answer back to can as beforeGrants. The grant that covers the row after must be one of those. A grant whose
where reads a relation (tags: { some: { name: "shared" } }, a role held through a membership) is therefore judged
on what the database held before the update: tagging a row "shared" in the same update that hands it to someone else
does not let a grant on shared rows cover the row before. This also needs @aventara/core 1.0.0-rc.10 or later.
Without beforeGrants, can asks a relation condition on the row before through exists at the after check, when
the update has already written, so a nested write in the same request can make it hold. With before too, can
still checks each listed grant's where on the row before in memory, so a key list replayed from another row or
another principal fails on a scalar condition; only a relation condition is left to grantsOf's answer. The keys
grantsOf answers are opaque: a short hash of the policies file's grants, then a grant's place in it, never its
conditions, so a list from another policies file covers nothing. grantsOf answers no key on a row an update deny
already refuses in memory. Its answer is what the database held when it was asked: a relation fact your own stages,
or another transaction committing in between, change before the write is not in it. Aventara may ask it about several
rows at once, so your exists can be called concurrently in one transaction. It decides nothing, so it is neither
reported to the decision observer nor explained; the can after it is.
A generated client's can({ action: "update", row, before }) answers the same question about the rows you pass.
Without before it checks only the row the update leaves. Ask access.grantsOf({ action: "update", row }) about the
row before you write it, in the update's transaction and with the same exists, and pass its answer to can as
beforeGrants with the row the update leaves; without it, can asks the row before's relation conditions when you
ask can. The caller must also limit the update itself to the rows scopeOf({ action: "update" }) returns, or an
update could start from a row no grant covers.
A row handed to can is a Resource's own scalar fields. A relation on it (author: { … }, tags: [ … ]) is never
read: a relation condition is always asked through exists, on what the database holds. Still, never put relation
data a client sent on a row. A to-one step whose reference fields the row carries (campaign through campaignId) is
asked on the row those values name, so a question asked before the write sees where the row is moving; anything else
is asked about the row itself, by its identifier.
write: { except: [...] } keeps fields out of what a create or an update may set. A relation and its reference fields
are locked together: except: ["author"] locks authorId too, and except: ["authorId"] locks author.
Governed fields
A governed field is opened only by a grant that names it (fields: ["User.email"]). Each governed field has a mode:
| Form | Reads | Writes |
|---|---|---|
| .govern("User", ["email"]), or { email: "both" } | Out of every default projection; a read grant that names it opens it, when the request selects it | Only a create or update grant that names it sets it |
| .govern("Member", { role: "write" }) | As any other field | Only a create or update grant that names it sets it |
| .govern("Citizen", { nationalId: "read" }) | As "both" | As any other field |
Govern every field your roles, claims or principal read, usually as "write". A role held on a Member row reads
Member.role; governed as "write", an update grant on Member that does not name it cannot change it, while every
reader still sees it. "read" fits a field people write and almost nobody reads back: a government id, a stored
secret.
Selecting a field governed for reading without a grant that opens it is refused with 403 A4002. The field is not
quietly dropped from the result. Setting a field governed for writing without a grant that names it is refused the
same way. A field governed twice with two modes is a type error, and refused when the rules compile.
A relation and its reference fields tell each other (author.id is authorId), so they are governed together, with
one mode: .govern("Post", ["author"]) without "authorId", or the reverse, is refused when the rules compile.
A field governed as "read" that is unique (one of its Resource's identifiers) still tells writers which values
exist: a create or update that sends a value another row holds fails on the duplicate. Compiling the rules or building
the engine warns ACCESS_READ_GOVERNED_UNIQUE (a Node.js warning) for each such field. Govern it as "both" so only a
grant that names it writes it, or accept the leak.
In the policies file, govern lists every governed field, and governModes gives the fields governed for one access
only: "governModes": { "Member": { "role": "write" } }. A field without an entry is governed for both, so the list
form compiles to the same file, with the same hash, as "both".
fields on a Resource with no governed field is a type error that says what to do:
no field of "Note" is governed; govern it first, .govern("Note", [<field>]), and a grant can then open it.
The principal type
.principal<…>() types me in every callback. There are two ways to use it.
- Your own type, as above. With
createAccess, you then pass aprincipalfunction that maps the verified token to it. - The token's identity.
AccessIdentity<typeof claims>is{ sub, claims }, exactly what a verified token carries. Pass the sameclaimsobject tocreateAccessand leaveprincipalout.
// src/identity.policies.ts
import { definePolicies } from "@aventara/access";
import type { AccessIdentity } from "@aventara/access/auth";
import { model } from "./generated/aventara/discovery.artifact.js";
/** The claims a token carries beside `sub`, by type. */
export const claims = { role: "string", banned: "boolean" } as const;
export const identityPolicies = definePolicies(model)
.principal<AccessIdentity<typeof claims>>()
.grants(({ grant, me }) => [
grant("signedIn", "read", "User", { where: { id: me.sub } }),
grant("signedIn", "create", "Note", { where: { authorId: me.sub } }),
])
.denies(({ deny }) => [deny("*", "*", { if: { claims: { banned: true } } })]);Without .principal<…>() the rules have no principal type, so me.sub in a where and claims in a role are type
errors.
Namespaces and foreign Resources
When several services share one Access server, each service owns a namespace and writes the rules for its own
Resources. definePolicies(model, { namespace: "chat" }) names it. A namespace name matches
^[a-z][a-z0-9_-]{0,62}$.
A Resource another namespace owns is marked { foreign: "<namespace>" } in the schema. The rules can read it in
conditions, but cannot grant, deny or govern on it. That is a type error, and the engine and the registry refuse it
too. A foreign Resource is only readable while both services share one database: the ownership beacon
(below) checks that at push, at pull and at startup.
// src/chat.policies.ts
import { definePolicies } from "@aventara/access";
import { model } from "./generated/aventara/discovery.artifact.js";
/** The schema with Campaign marked as the campaign namespace's. */
const schema = {
...model,
resources: {
...model.resources,
Campaign: { ...model.resources.Campaign, foreign: "campaign" },
},
} as const;
export const chatPolicies = definePolicies(schema, { namespace: "chat" })
.principal<{ id: string }>()
.grants(({ grant, me }) => [
// Read through Campaign in a condition: allowed.
grant("signedIn", "read", "Note", {
where: { campaign: { members: { some: { userId: me.id } } } },
}),
// grant("signedIn", "read", "Campaign") would be a type error here.
]);A row of your own can hold a reference to a foreign Resource (a chat Room's campaignId). No caller reads a foreign
Resource, so Aventara cannot hold such a reference to the rows the caller may read, as it does for your own Resources:
the engine lists the rules' foreign Resources in unheld (@aventara/core 1.0.0-rc.10 or later), and the write grant
alone decides which row may be linked. Constrain it through the relation, with a where on a create grant and an
after on an update grant (an update grant's where is checked on the row it leaves too):
grant("signedIn", "create", "Room", { where: { campaign: { members: { some: { userId: me.id } } } } }),
grant("signedIn", "update", "Room", {
where: { ownerId: me.id },
after: { campaign: { members: { some: { userId: me.id } } } },
}),Without such a condition the caller can link any row of the foreign Resource by its identifier. When Aventara hosts the
engine, it warns about each role whose create or update grant may write a reference to a foreign Resource with no
condition through that relation (ACCESS_FOREIGN_REFERENCE_UNCHECKED, a Node.js warning, once per process and
namespace). Reads are unchanged: through Aventara a read of a foreign Resource, or an include of one, is still refused
(A4002).
createAccess
createAccess builds Better Auth with Access beside it. It opens no port and mounts nothing. Better Auth's options sit
in the betterAuth block, exactly as betterAuth({...}) takes them, so you can paste them from an existing Better Auth
setup. Access's own concerns sit in their own blocks beside it.
// src/access.ts
import { createAccess } from "@aventara/access/auth";
import { PrismaPg } from "@prisma/adapter-pg";
import { prismaAdapter } from "better-auth/adapters/prisma";
import { policies } from "./access.policies.js";
import { PrismaClient } from "./generated/prisma/client.js";
/** Better Auth's own Prisma client: it reads and writes its tables through it. */
const prisma = new PrismaClient({
adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL ?? "" }),
});
/** What every token carries beside `sub`. */
const claims = { role: "string", banned: "boolean" } as const;
export const access = createAccess({
// Better Auth's options, as betterAuth({...}) takes them.
betterAuth: {
database: prismaAdapter(prisma, { provider: "postgresql" }),
emailAndPassword: { enabled: true },
user: {
additionalFields: {
role: { type: "string", required: false, input: false },
banned: { type: "boolean", required: false, input: false },
},
},
},
// The rules, and the claims they read. Each claim is read from the user's field
// of the same name.
policies: { rules: policies, claims },
// The rules are typed with your own Principal, so map the token to it.
principal: ({ sub, claims }) => ({
id: sub,
claims: {
...(claims.role === "admin" ? { role: "admin" as const } : {}),
...(claims.banned === undefined ? {} : { banned: claims.banned }),
},
}),
// The rules read User, one of Better Auth's tables: open it explicitly.
resources: { users: true },
});
export default access;Better Auth reads BETTER_AUTH_SECRET and BETTER_AUTH_URL from the environment, as it always does, or takes
secret and baseURL in the block. Access needs a base URL it can fix when the instance is built: its tokens' issuer
and audience are that URL's origin (baseURL, else BETTER_AUTH_URL, or a dynamic baseURL's fallback). With none,
createAccess refuses with ACCESS_OPTIONS_INVALID. The issuer is the origin alone, not the path: two apps on one
origin that share a database (and so Better Auth's key set) accept each other's tokens. Give each app its own origin,
or its own database.
Each declared claim is read from the user's field of the same name, so that field needs input: false, as role and
banned have above. A field without it is one the user sets themselves, at sign-up or with /update-user, and a
claim read from it would be the user's own choice: createAccess refuses it (ACCESS_OPTIONS_INVALID), for fields a
plugin adds too. To read a claim from such a field anyway, return it from policies.claimsOf.
policies.claimsOf must return every declared boolean claim, as banned: user.banned === true: an answer without
one is a type error (its return type, AccessClaimsAnswer, requires each boolean claim's key), and at run time it is
refused (ACCESS_OPTIONS_INVALID, naming the claim) and no token is issued, so a flag it forgot never reads as unset. null or undefined there is false. Beyond that, Access does not check what policies.claimsOf returns. A
claim it reads from a field the user can set (an email, a
username, an additional field without input: false) is again the user's own choice, so read claims there only from
fields your server alone writes.
User ids
Better Auth's ids are strings, and so is a token's sub. aventara new 1.0.0-rc.12 writes
model User { id String @id @default(cuid()) … }, which is what Better Auth needs. 1.0.0-rc.11 and earlier write
model User { id Int @id @default(autoincrement()) … }: make it id String @id @default(cuid()) before you generate
Better Auth's tables, or generate writes String foreign keys (Session.userId, Account.userId) against that Int
and prisma validate fails. The scaffold's User is then Better Auth's user table, so a rule on User needs
resources: { users: true } (Users and roles as Resources).
Better Auth's numeric ids (advanced.database.generateId: "serial") are refused (ACCESS_OPTIONS_INVALID). Under
them Better Auth does not enforce its single-use checks (private_key_jwt client assertions, DPoP proofs, SAML
assertion ids, the magic-link, email-OTP and SIWE locks), and ids written differently ("01", "1e0") name the same
user. Numeric ids the database makes (generateId: false on Int id columns) have the same problem and are not
supported either.
Better Auth's tables
Better Auth's tables go into your schema the way Better Auth's docs say. With Prisma:
npx auth@~1.7.12 generate --config src/access.ts --yes
npx prisma validate
npx prisma db push
npm run aventara:preparegenerate writes Better Auth's models (and Access's own tables, when audit or registry is on) into
prisma/schema.prisma. It reads the module's default export. Without --yes it asks before it writes the file, and
where it cannot ask (a script, CI) it writes nothing and still exits 0. Run aventara:prepare again so the
framework's model has the new tables. Access closes them to every request until you open them (see
Users and roles as Resources).
On its first run, generate may print a Babel note that an exported identifier such as AvComputedFields
"is not declared in Babel's scope tracker". It comes from a type the generated discovery artifact re-exports, and
changes nothing: the schema is written all the same.
The options
| Option | Takes | Default |
|---|---|---|
| betterAuth | Better Auth's options | required |
| policies.rules | The builder (definePolicies(…)), its .contract(), or a pulled policies file as JSON | required, except on a registry-only server |
| policies.claims | The claims a token carries beside sub, by type: { role: "string", banned: "boolean" } | required with rules |
| policies.claimsOf | ({ user, session }) => claims, when the claims are not the user's fields of the same name | each claim from the user's field |
| policies.guardrails | The org's guardrails, as pulled (Guardrails) | none |
| policies.lock | The project's avAccess.lock.json (The lock at startup) | none |
| policies.requireLock | true: refuse to start without policies.lock; recommended in production | false |
| policies.tokenTtl | How long a token lives, in whole seconds from 300 to 900 | 900 |
| principal | (identity) => principal \| null: maps { sub, claims } to your principal | me is the identity |
| profile | { kind: "web" }, { kind: "service" }, or { kind: "kiosk", canEnrollDevice, idleTimeout?, insecureLan? } (The kiosk profile) | web |
| registry | { enabled: true } turns on the registry (The registry) | { enabled: false } |
| registry.ownership | "first-push" or "assigned": how a Resource gets its owner (Who owns a Resource) | "first-push" |
| audit | true, false or { sink } (The audit log) | off |
| resources | { users?: boolean, members?: boolean, authTables? } (Users and roles) | both off; authTables required off Prisma's adapter |
| passwords | { argon2id: "owasp-minimum" \| "rfc9106-low-memory", legacyScrypt?: boolean } (Passwords) | "owasp-minimum", unless betterAuth.emailAndPassword.password is set |
principalis required when the rules are typed with your own principal, and optional when they are typed withAccessIdentity<typeof claims>. Returnnullto leave the caller anonymous. The principal must carry the token's claims (identity.claims) as they are: a boolean claim it drops is unknown to the rules, so a deny such asbanned: truerefuses it. Return a new object for each user (one cached per user is fine): the audit log names the user each principal stands for, so an objectprincipalalready returned for another user is refused withACCESS_OPTIONS_INVALID. Only the objectprincipalreturns is checked; aMap,Dateor other object inside it is not.meis a frozen copy of its plain data, so your object itself is never frozen.- A claim the rules read that
policies.claimsdoes not declare is a type error, and is refused at construction (ACCESS_CLAIMS_UNDECLARED). A token carriessuband the declared claims only. Every declared boolean claim is in it: one the user's field leaves unset (absent,nullorundefined), orclaimsOfreturns asnullorundefined, isfalse. profile: { kind: "service" }hands no caller a new session, and reads the principal from bearer tokens only. The routes that start a session answer404:/sign-in/*,/sign-up/*, the OAuth callbacks, and the plugins' routes that sign a user in (/phone-number/verify,/magic-link/verify,/two-factor/verify-*,/device/token,/admin/impersonate-userand the rest). They are matched by Better Auth's endpoint path, whatever the URL's spelling,basePathorbaseURL. Any other route cannot create a session either: Better Auth's session hook refuses the row, so none exists even while the route runs, and the answer is a bare404, its cookies and headers dropped. So is an answer that sets the session cookie of a session that already exists, and no session cookie (nor amultiSession()cookie) leavesaccess.handlerat all: it strips them itself, after Better Auth and every plugin'sonResponse, so a route that returns its ownResponseor a plugin that answers last is covered too. A route that puts a session token in a JSON body is not covered; no built-in plugin does. Your server's own calls to Better Auth's API with no request still sign users in; a call that forwards the host'srequest(auth.api.signInEmail({ body, request })) is treated as that request, and refused withNOT_FOUND. A plugin route Access does not know may answer500with an empty body when its session is refused.webis Better Auth as you configured it.tokenTtlbounds how long a changed claim takes to reach every service. A user you ban or suspend gets the new claim with their next token.- An option
createAccessdoes not take, or a block it cannot read, is refused withACCESS_OPTIONS_INVALIDand a sentence that says what it takes.
The plugins createAccess installs
createAccess installs Better Auth's jwt() plugin and its own Access plugin (plugin id aventara-access, the one
aventaraAccess() returns). Putting either in betterAuth.plugins is a type error at that plugin, which reads
createAccess installs jwt() itself: remove it from betterAuth.plugins. At runtime it is refused with
ACCESS_PLUGIN_DUPLICATE, under any case or spacing of its id ("JWT", "jwt "). So is a plugin that defines one of
jwt()'s endpoints (getJwks, getToken, signJWT, verifyJWT, or a route at /jwks or /token): it would answer
for Access's key set or its tokens. Every other Better Auth plugin passes through as it is.
Passwords
Better Auth hashes passwords with scrypt. createAccess hashes them with argon2id instead, in one of three ways. The
Better Auth plugin, aventaraAccess(), takes the same passwords and does the same
(The Better Auth plugin):
- The default. Set neither
passwordsnorbetterAuth.emailAndPassword.password, and passwords are hashed with argon2id at the"owasp-minimum"profile. - A named profile.
passwords: { argon2id: "rfc9106-low-memory" }besidebetterAuth. A misspelled profile is a type error, and is refused at runtime withACCESS_OPTIONS_INVALID. AddlegacyScrypt: trueto accept passwords Better Auth already stored with scrypt (see below). - Your own hook. Set Better Auth's
emailAndPassword.password(hashandverify) in thebetterAuthblock, and it is used as you wrote it: Access installs nothing. To keep Better Auth's scrypt, pass its own functions.
// src/access.passwords.ts
import type { AccessPasswordsOptions } from "@aventara/access/auth";
import type { BetterAuthOptions } from "better-auth";
import { hashPassword, verifyPassword } from "better-auth/crypto";
// 1. The default: leave `passwords` out, and `emailAndPassword.password` too.
export const byDefault = {
enabled: true,
} satisfies BetterAuthOptions["emailAndPassword"];
// 2. A named profile: createAccess({ betterAuth, policies, passwords }).
export const passwords = {
argon2id: "rfc9106-low-memory",
} satisfies AccessPasswordsOptions;
// 2b. The same, for an app whose users Better Auth signed up with scrypt.
export const movingOffScrypt = {
argon2id: "owasp-minimum",
legacyScrypt: true,
} satisfies AccessPasswordsOptions;
// 3. Your own hook, here Better Auth's scrypt: betterAuth: { emailAndPassword }, and no `passwords`.
export const emailAndPassword = {
enabled: true,
password: {
hash: hashPassword,
verify: verifyPassword,
},
} satisfies BetterAuthOptions["emailAndPassword"];Setting both passwords and betterAuth.emailAndPassword.password is refused with ACCESS_OPTIONS_INVALID, so it is
never in doubt which one hashes.
| Profile | Memory | Passes (t) | Lanes (p) | Source |
|---|---|---|---|---|
| "owasp-minimum" (the default) | 19 MiB (m=19456) | 2 | 1 | OWASP Password Storage Cheat Sheet, Argon2id: "m=19456 (19 MiB), t=2, p=1" |
| "rfc9106-low-memory" | 64 MiB (m=65536) | 3 | 4 | RFC 9106 section 4, the second recommended option |
Both use a 16-byte random salt (RFC 9106: "16 bytes is RECOMMENDED for password hashing") and a 32-byte tag. RFC 9106's first recommended option (2 GiB of memory per hash) is not offered: a server that signs users in concurrently cannot spend 2 GiB on each.
- A hash is a PHC string,
$argon2id$v=19$m=19456,t=2,p=1$<salt>$<hash>. Verifying reads the parameters from the stored string, so you can move to another profile later and every older hash still verifies. The hash is compared in constant time. - A sign-in whose password matches a hash stored at other parameters than your profile's (an older profile, a cheaper
or a costlier one) stores the password again at your profile, so active users' hashes follow a profile change. The
hook checks the password against the stored hash itself first, and writes only while the account still holds that
hash, so a password changed or reset meanwhile is never overwritten. A hash already at your profile costs one read
of the credential account per password sign-in, and no write. The one that moves costs two argon2id computations
more (the check again, and the new hash) and one write. The write goes through Better Auth's adapter, past its
accountupdate hooks: it is the same password, stored anew. A failure is logged (never the password or a hash), and the sign-in stands. An account with two-factor on may not be rehashed by a password sign-in its second step holds back. Kiosk PINs move the same way at the next switch. - Hashes Better Auth stored with scrypt (
<salt>:<key>in hex) fail the check by default, with a warning in Better Auth's log. WithlegacyScrypt: truethey verify, through Better Auth's ownverifyPassword, so an existing Better Auth app can move tocreateAccesswithout resetting passwords, and a sign-in that matches one (/sign-in/email,/sign-in/username, any/sign-in/*route that takes a password) stores the password again with argon2id. Each such account leaves scrypt at its next sign-in. Once none is left, turnlegacyScryptoff. - The time a check takes does not say what an account holds. Every check costs one argon2id computation at your
profile, as Better Auth's hash of the guess costs for an unknown user, whether the stored value is an argon2id hash,
a scrypt hash or garbage. With
legacyScrypt: true, every hash and check also costs one scrypt, so sign-up and sign-in are about twice as slow while it is on. One exception: after you move to another profile, a hash stored at the old one is checked at the old one's cost, so the time a sign-in takes can tell such an account apart (and that it exists) until its user next signs in, which rehashes it at your profile. Active users' hashes move; an account nobody signs in to keeps the old cost until its password is set again. - A stored hash that costs more than the largest profile (
mover 65536,tover 3,pover 4) is refused without being computed, with a warning in Better Auth's log, so a tampered row cannot make a sign-in allocate gigabytes. A corrupted or tampered value, or a failure inside the argon2 computation (logged as an error), fails the check like a wrong password. A check never throws. - A password longer than Better Auth's
emailAndPassword.maxPasswordLength(128 by default) is never hashed: hashing it is refused with Better Auth'sPASSWORD_TOO_LONG(400), and checking it fails. Passwords are NFKC-normalised before hashing, as Better Auth's scrypt does. - Better Auth's plugins that hash or check a password (
admin,username,phone-number,email-otp, two-factor) use the same hook, so the profile applies to them too, whether or notemailAndPassword.enabledis set.
Node.js 22
argon2id runs on Node.js's own crypto.argon2, which Node.js 24.7.0 added, with no native dependency. Node.js 22 has
none, so there Access uses @node-rs/argon2, an optional peer you install yourself, at the exact version Access is
tested against:
npm install --save-exact @node-rs/[email protected]It computes the same function, and Access writes and reads the PHC strings itself, so a hash made on Node.js 22 verifies
on Node.js 24 and back. On Node.js 24 it is never loaded, installed or not. On Node.js 22 without it, createAccess
refuses with ACCESS_OPTIONS_INVALID, unless you hash passwords yourself (betterAuth.emailAndPassword.password).
What the instance holds
| Member | What it is |
|---|---|
| access.handler | Better Auth's request handler, (Request) => Promise<Response>, with Access's routes |
| access.api | Better Auth's server API, as auth.api |
| access.options | The options Better Auth runs with: your block plus the installed plugins (what npx auth@~1.7.12 generate --yes reads). Your block's plain data (its plain objects and arrays) is a frozen copy taken when createAccess ran, so changing it in your block afterwards does not reach Better Auth. Plugin objects, functions and class instances (the adapter, a database client or pool) are kept as you gave them and not frozen |
| access.$Infer | Types only: Session, Me (the principal) and Claims |
| access.policy | The policy engine. It is built on first read and is not enumerable. |
| access.state({ headers }) | The request's state, { readonly me }: from a bearer token checked against Better Auth's key set, or (profiles web and kiosk) the session cookie; null when neither. me is a copy of the plain data principal returned (its plain objects and arrays, all the way down), frozen, and the state holds it read-only, so no code that runs during the request can replace or change the principal the policy decides for. A class instance, Map, Set, Date, typed array or getter is kept as you returned it and is not frozen: return plain data for the fields the rules read. Your own objects are never frozen. Under an OAuth access token, return a plain object: a Map or Set principal is not supported there, and a class instance loses its #private fields |
| access.registry | The registry's operator API, present only with registry: { enabled: true } |
| access.client | Where Access is mounted and the $access verbs it serves, for the framework's clients (below); on the kiosk profile, the kiosk's verbs too |
| access.kiosk | The kiosk profile's operator API: listDevices() and revokeDevice(deviceId) (The kiosk profile) |
A bearer token that does not verify gives me: null, so the policies then refuse the request.
Tokens and sessions
state takes the request's Authorization header first. When the header is there, the session cookie is never read:
a valid Bearer token gives its identity, and anything else (another scheme, a malformed token, an empty header) gives
me: null.
A token is accepted when all of this holds:
- it is signed by a key of the instance's key set (
GET /api/auth/jwks). Access reads the set once, reads it again every 5 minutes, and at once (no more often than every 5 seconds) for a token signed with a key it does not hold. A key removed from the set stops verifying within 5 minutes. When a read fails, the last set read is kept; - its
issand itsaudare both the instance's issuer, the base URL's origin, fixed when the instance was built. An audience list that merely includes it is refused; - it has
sub,expandiat, it has not expired, and it was issued no longer ago thantokenTtland not in the future. 5 seconds of clock skew are allowed; - it carries no claim beyond the declared ones.
The session cookie is read from Better Auth's store, never from its cookie cache, so a session you revoke (a sign-out,
a revoked session, a deleted user) gives me: null at once. With session.cookieCache on, GET /token and the
set-auth-jwt header issue a token only for a session the store still has.
On a request that may change something (any method but GET, HEAD and OPTIONS, and a request whose method the
host did not pass), the cookie counts only when the request comes from a site Better Auth trusts: its Origin is your
base URL's or one of trustedOrigins, or, with no Origin, its Sec-Fetch-Site is same-origin or none. A request
with neither header is no browser's, and its cookie is read.
A token is not revoked. One issued before a sign-out, a revoked session or a deleted user keeps verifying until it
expires, for at most tokenTtl (15 minutes by default). Keep tokenTtl short when that matters; a service that must
see a sign-out at once reads the session instead.
The session cookie is SameSite=Lax, Better Auth's default: a browser sends it on another site's links, never on
another site's POST or fetch. Better Auth checks the Origin of its own routes that carry a cookie, and the
framework's API takes JSON, which a cross-site HTML form cannot send. Keep it that way: an endpoint of your own that
acts on a GET, or takes a form body, with the cookie as its credential, is open to cross-site requests. A session
cookie set to sameSite: "none" (in advanced.defaultCookieAttributes or advanced.cookies.session_token) is
refused with ACCESS_OPTIONS_INVALID unless trustedOrigins names the origins your app is served from. With them,
state holds every request to the trusted-site rule above, GET included.
Mount the routes
Access's routes are Better Auth's, plus its own, and they mount the way Better Auth's do. Better Auth's own helpers take the Access instance as it is. The framework's hosts mount no Access route.
Next.js
The catch-all route file, as in Better Auth's Next.js docs:
// src/app/api/auth/[...all]/route.ts
import { toNextJsHandler } from "better-auth/next-js";
import { access } from "@/access";
export const { GET, POST } = toNextJsHandler(access);A Next.js server bundle carries Better Auth (Turbopack or webpack, and output: "standalone" too). Next.js evaluates
your access.ts once per bundle, so one process can hold several Access instances. That is expected: each one reads
Better Auth's key set from your database once.
Better Auth's rate limit counts each client by its X-Forwarded-For, and Next.js keeps one the client sent (it
fills the header from the socket only when it is missing). On Vercel there is nothing to do: the platform sets it.
Self-hosted (next start), run behind a proxy that overwrites X-Forwarded-For with the client's address, or one that
appends to it and is listed in Better Auth's advanced.ipAddress.trustedProxies. Never let clients reach next start
directly: each could pick the address it is counted under.
NestJS, Express and Fastify
Mount Better Auth's Node.js handler before the body parser. In NestJS, that is app.use() between
NestFactory.create() and listen(), with a path test. It works on Express and Fastify alike. This is the scaffold's
src/main.ts with the mount added after its CORS block, so Access's routes answer the same origins:
// src/main.ts
import type { IncomingMessage, ServerResponse } from "node:http";
import { NestFactory } from "@nestjs/core";
import { toNodeHandler } from "better-auth/node";
import { access } from "./access.js";
import { AppModule } from "./app.module.js";
import { clientAddressed } from "./client-address.js";
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// The browser origins allowed to call this server: CORS_ORIGINS in .env,
// comma-separated. Unset or empty, CORS stays off.
const corsOrigins = (process.env.CORS_ORIGINS ?? "")
.split(",")
.map((origin) => origin.trim())
.filter(Boolean);
app.enableCors({ origin: corsOrigins.length > 0 ? corsOrigins : false });
// Rate limiting: @nestjs/throttler is a Nest guard and does not run on Aventara's
// routes; a platform-level limiter does. `npm i express-rate-limit`, import
// { rateLimit } from 'express-rate-limit', pick your limits and uncomment below.
// See: https://aventara.razor34.dev/framework/docs/limits-and-safety#rate-limiting
// app.use(rateLimit({ windowMs: 60_000, limit: 100 }));
// Access's routes, under /api/auth: after CORS, before Nest's body parser
// (listen() adds it), each request with its client's address.
const auth = toNodeHandler(access);
app.use(
(request: IncomingMessage, response: ServerResponse, next: () => void) => {
if (!request.url?.startsWith("/api/auth/")) {
return next();
}
clientAddressed(request);
return auth(request, response);
},
);
await app.listen(process.env.PORT ?? 3000);
}
await bootstrap();Node.js's handler hands Better Auth no client address, and Better Auth reads one only from a header
(X-Forwarded-For unless advanced.ipAddress.ipAddressHeaders names others). Without one, in production its rate
limit puts every client in one bucket per route, so three wrong sign-ins lock everyone out of signing in; and a header
the client sent itself would buy it a fresh bucket each time. clientAddressed does what serve does:
// src/client-address.ts
import type { IncomingMessage } from "node:http";
import { BlockList, isIP } from "node:net";
import type { BetterAuthOptions } from "better-auth";
import { access } from "./access.js";
const options: BetterAuthOptions = access.options;
const ip = options.advanced?.ipAddress;
/** Where Better Auth reads the client's address: its first `ipAddressHeaders`, `x-forwarded-for` by default. */
const headers = (ip?.ipAddressHeaders ?? ["x-forwarded-for"]).map((header) =>
header.toLowerCase(),
);
/** Better Auth's `trustedProxies`: the proxies whose forwarded headers are kept. */
const proxies = new BlockList();
for (const entry of ip?.trustedProxies ?? []) {
const [address = "", prefix] = entry.split("/");
const family = isIP(address);
const type = family === 4 ? "ipv4" : "ipv6";
if (family !== 0 && prefix === undefined) {
proxies.addAddress(address, type);
} else if (family !== 0 && /^\d+$/.test(prefix ?? "")) {
proxies.addSubnet(address, Number(prefix), type);
}
}
/**
* Gives a request its client's address, as `serve` does: the socket's, in the header
* Better Auth reads. A client's own `X-Forwarded-For` (and `-Proto`, `-Host`) is
* dropped first, unless the socket is a proxy `trustedProxies` lists: then the
* proxy's headers are kept, and Better Auth reads the client from them.
*/
export function clientAddressed(request: IncomingMessage): void {
if (ip?.disableIpTracking === true) {
return;
}
// An IPv4 client on a dual-stack socket reads as ::ffff:203.0.113.7.
const socket = request.socket.remoteAddress?.replace(
/^::ffff:(?=\d{1,3}(?:\.\d{1,3}){3}$)/i,
"",
);
const family = socket === undefined ? 0 : isIP(socket);
const proxy =
socket !== undefined &&
family !== 0 &&
proxies.check(socket, family === 4 ? "ipv4" : "ipv6");
if (!proxy) {
for (const header of headers) {
delete request.headers[header];
}
delete request.headers["x-forwarded-proto"];
delete request.headers["x-forwarded-host"];
}
const first = headers[0];
if (socket !== undefined && first !== undefined && !(first in request.headers)) {
request.headers[first] = socket;
}
}- On a server clients reach directly, a client's own
X-Forwarded-Foris replaced with its socket's address, so no client picks the address it is counted under. - Behind a proxy (a load balancer, a TLS terminator), list it in Better Auth's
advanced.ipAddress.trustedProxies(["10.0.0.2"], or a CIDR range): itsX-Forwarded-Foris kept, and Better Auth reads the client from the rightmost address in it that is not a trusted proxy. Without it, every client counts as the proxy's address. - Run production with
NODE_ENV=production: Better Auth's rate limit, and Access's limit onaccess.client's calls, are on only under it, and the scaffold'sstart:proddoes not set it.
On Fastify, app.use("/api/auth", …) hands Better Auth the URL without its base path, so use the path test above. Your
own controllers keep Nest's body parser. In a plain Express app, mount it the same way, before express.json():
clientAddressed(request), then toNodeHandler(access).
Standalone
serve runs Access as its own HTTP server around the same handler, with GET /health answering
200 {"status":"ok"}:
// src/access.server.ts
import { serve } from "@aventara/access/standalone";
import access from "../access.config.js";
const server = await serve(access, { port: 4000 });
console.log(`Access listens on ${server.url}`);In production, run it with NODE_ENV=production (NODE_ENV=production node dist/access.server.js).
port defaults to 3000, and 0 picks a free one. hostname defaults to "127.0.0.1", this host only; pass
"0.0.0.0" to listen on every interface. serve resolves to { port, url, close() } once the server listens.
avaccess serve runs the same server from a configuration file (Running avaccess serve).
serve speaks plain HTTP. Sessions, tokens and registry keys travel in headers, so put it behind a proxy that
terminates TLS before anything outside the host reaches it. What it does for the handler behind it:
- A request body over
maxBodyBytes(default 1 MiB,1048576) is refused with413before it is handed on: by itsContent-Lengthwithout reading it, or as soon as a streamed body passes the cap. A push to the registry has its key checked before its body is read. - The client's address is the socket's.
X-Forwarded-For(or the first of Better Auth'sadvanced.ipAddress.ipAddressHeaders) is replaced with it, unless the socket is a proxy listed in Better Auth'sadvanced.ipAddress.trustedProxies: then the proxy's header is kept, and Better Auth reads the client from it. Better Auth's rate limit counts each client on its own, and no client picks its own address. close()stops accepting connections, lets the requests in flight finish for up to 10 seconds, then ends the rest.
Sign up and sign in
Better Auth's routes are under /api/auth. With emailAndPassword on, from a terminal:
# Sign up: the user is created and signed in.
curl -X POST http://localhost:3000/api/auth/sign-up/email \
-H 'Content-Type: application/json' -H 'Origin: http://localhost:3000' \
-d '{ "email": "[email protected]", "password": "correct-horse-battery", "name": "Ada" }'
# Sign in: the session cookie goes into cookies.txt.
curl -c cookies.txt -X POST http://localhost:3000/api/auth/sign-in/email \
-H 'Content-Type: application/json' -H 'Origin: http://localhost:3000' \
-d '{ "email": "[email protected]", "password": "correct-horse-battery" }'
# A token for a service: the session cookie traded for a JWT, sent as a bearer token.
curl -b cookies.txt http://localhost:3000/api/auth/tokenA browser's fetch to the same routes sends Origin itself.
With emailAndPassword on, anyone who reaches the server can sign up. A server whose users are created another way
(an admin, an invitation, a social provider) sets emailAndPassword: { enabled: true, disableSignUp: true }, and a
server that signs in no one, such as one that only runs the registry, leaves emailAndPassword out.
Send an Origin header that matches Better Auth's base URL (BETTER_AUTH_URL) or one of its trustedOrigins. A
request that carries a cookie is refused 403 MISSING_OR_NULL_ORIGIN without one, and 403 INVALID_ORIGIN with
another. The Aventara API then reads the user from the session cookie (profiles web and kiosk) or from
Authorization: Bearer <token>.
Wire it into Aventara
The instance fits the framework's access option (AvAccessProvider) by shape: it brings the policy engine and each
request's me. With access, the configuration has no policy and the host has no state option.
Next.js
Put access in aventaraConfig, in src/aventara/config.ts, not in server.ts:
// src/aventara/config.ts
import { access } from "../access";
export const aventaraConfig = {
api: "/api",
exposed: true,
// The rest of the configuration `aventara new` wrote stays as it is.
access,
} as const;The build (aventara:prepare, which next build runs first) reads config.ts to compile the Contracts. With access
there, the prebuilt Contracts match what the server runs, and the governed fields reach the generated client. Given
only in server.ts, the framework warns AVENTARA_PREBUILT_STALE and compiles the Contracts at startup. server.ts
needs no change: it already spreads aventaraConfig into Aventara().
aventara-build runs config.ts, and so your access.ts, at build time. It reads the project's .env files first,
as avclient generate does (a variable already set wins, then .env.<mode>.local, .env.<mode>, .env.local and
.env), so a database client that reads DATABASE_URL at import finds it.
NestJS
Put access beside adapter in the config that the scaffold's forRootAsync factory returns. This is the scaffold's
src/app.module.ts with the two access lines added:
// src/app.module.ts
import { Module } from '@nestjs/common';
import { AppController } from './app.controller.js';
import { AppService } from './app.service.js';
import { AventaraModule } from '@aventara/nest';
import { createPrismaAdapter } from '@aventara/prisma7-adapter';
import adapter from './aventara.adapter.js';
import { aventaraConfig } from './aventara.config.js';
import { PrismaModule, PrismaService } from './prisma.service.js';
import { access } from './access.js';
@Module({
imports: [
PrismaModule,
AventaraModule.forRootAsync({
imports: [PrismaModule],
inject: [PrismaService],
useFactory: async (prisma: PrismaService) => ({
config: {
...aventaraConfig,
adapter: await createPrismaAdapter({
...adapter,
client: prisma,
}),
access,
},
}),
}),
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}Here aventaraConfig is the configuration without policy, since Access brings the engine. Nest compiles its
Contracts at startup, so nothing goes stale.
For the MCP endpoint, see Agents over MCP: mcp: { path: "/mcp", authorization: access.mcp }.
Anywhere else
createFramework({ ...config, access }).
With the Better Auth plugin, accessOf(auth) is the access value, in each of the places above.
The $access verbs
access.client is what the framework's $access reads (the provider's client member): where Access is mounted, and
the verbs this instance serves. url is Better Auth's base URL with its base path (http://localhost:3000/api/auth),
or the base path alone (/api/auth) when baseURL is dynamic and follows the request's host. Each verb has a method,
a path under url, the JSON Schema of its arguments and of its result, and call:
| Verb | Route | Arguments | Result |
|---|---|---|---|
| signIn | POST /sign-in/email | email, password, optional callbackURL, rememberMe | redirect, url, user: { id } |
| signUp | POST /sign-up/email | name, email, password, optional image, callbackURL, rememberMe | user: { id } |
| signOut | POST /sign-out | none | success |
| resetPassword | POST /reset-password | newPassword, token | status |
| me | GET /me | none | { sub, claims }, or null when no one is signed in |
The arguments are the ones Better Auth's client sends on each route. A result names the user's id and Better Auth's own
flags, never the session token or a field of the user. me is the token identity: sub and the claims
policies.claims declares, the same as the token carries, whatever principal maps the request to. GET /api/auth/me
is Access's own route, served by access.handler; a plugin of yours with an endpoint at /me (or named accessMe) is
refused with ACCESS_PLUGIN_DUPLICATE.
A verb the instance does not serve is left out: signIn, signUp and resetPassword need emailAndPassword,
signUp is left out with disableSignUp, a path in disabledPaths takes its verb out, and the service profile has
no signIn or signUp.
call({ args, headers }) runs the verb on the server, in the same process, through Better Auth's API with the
request's headers. That is how a Server Action signs a user in: Better Auth's nextCookies() plugin, last in
betterAuth.plugins, sets the browser's cookie from the answer. The call is checked as the route would be: the
arguments against the verb's schema (400 naming the key, never the value), Better Auth's origin check (a cookie from
a site Better Auth does not trust, or a callbackURL outside trustedOrigins, is refused 403), every plugin's
onRequest in the order Better Auth's router runs them (so captcha() refuses a call without its answer, as it refuses
the route; a plugin's refusal is thrown as Better Auth's APIError with the plugin's status and body, and a plugin's new
request is the one the route runs with), the profile's refusals, and the audit log, which records the sign-in with
origin client.
Better Auth's rate limit runs in its router, which a call to Better Auth's API skips, so Access limits the calls of
signIn, signUp and resetPassword itself, with Better Auth's own rateLimit options: on when they turn it on
(enabled, production by default), with the rule Better Auth's router applies to the route (3 tries in 10 seconds on
/sign-in/* and /sign-up/* by default, a plugin's rules, customRules, else window and max), in the storage they
name (customStorage, secondaryStorage, the database, or memory). Each call is counted in two buckets, and refused
when either is full:
- by client address, read from the request's headers as Better Auth reads it (
advanced.ipAddress: itsipAddressHeaders,trustedProxiesandipv6Subnet), under the key Better Auth's router uses for the route. With the database, a secondary or a custom storage, a client's calls and its requests to the route spend one budget; in memory they do not, since Better Auth's memory store is its own. WithdisableIpTrackingthere is no address bucket; - by target: the email the call names (normalized as
/sign-in/emailnormalizes it), or a password reset's token, as its HMAC, so guesses on one account from many addresses count together. It counts failures: 10, each within 5 minutes of the one before, refuse the account's next try, the right password too, until 5 minutes have passed. A success clears the count, so a few failures by someone else never keep the owner out. A try is counted when it is let through, so parallel guesses cannot all pass on one reading. WithcustomStorage, which can count b
