@aventara/access 0.0.0-stage → 1.0.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (248) hide show
  1. package/CHANGELOG.md +877 -0
  2. package/LICENSE +91 -0
  3. package/LICENSE-ADDITIONAL-PERMISSION.md +9 -0
  4. package/README.md +2536 -2
  5. package/dist/access/access-options.d.ts +74 -0
  6. package/dist/access/access-options.js +5 -0
  7. package/dist/access/access.error.d.ts +35 -0
  8. package/dist/access/access.error.js +11 -0
  9. package/dist/access/access.factory.d.ts +275 -0
  10. package/dist/access/access.factory.js +440 -0
  11. package/dist/access/better-auth.hardening.d.ts +120 -0
  12. package/dist/access/better-auth.hardening.js +420 -0
  13. package/dist/access/better-auth.loader.d.ts +67 -0
  14. package/dist/access/better-auth.loader.js +65 -0
  15. package/dist/access/better-auth.range.d.ts +9 -0
  16. package/dist/access/better-auth.range.js +28 -0
  17. package/dist/access/better-auth.require.d.ts +90 -0
  18. package/dist/access/better-auth.require.js +6 -0
  19. package/dist/access/better-auth.require.node.d.ts +7 -0
  20. package/dist/access/better-auth.require.node.js +52 -0
  21. package/dist/access/claims.mapper.d.ts +79 -0
  22. package/dist/access/claims.mapper.js +152 -0
  23. package/dist/access/key-set.cache.d.ts +10 -0
  24. package/dist/access/key-set.cache.js +63 -0
  25. package/dist/access/policies-block.checker.d.ts +64 -0
  26. package/dist/access/policies-block.checker.js +138 -0
  27. package/dist/access/principal.subject.d.ts +22 -0
  28. package/dist/access/principal.subject.js +89 -0
  29. package/dist/access/profile.policy.d.ts +74 -0
  30. package/dist/access/profile.policy.js +110 -0
  31. package/dist/access/request.state.d.ts +89 -0
  32. package/dist/access/request.state.js +266 -0
  33. package/dist/access/session.hooks.d.ts +114 -0
  34. package/dist/access/session.hooks.js +258 -0
  35. package/dist/access/token.verifier.d.ts +35 -0
  36. package/dist/access/token.verifier.js +69 -0
  37. package/dist/access-schema.d.ts +48 -0
  38. package/dist/access-schema.js +5 -0
  39. package/dist/access-warning.emitter.d.ts +4 -0
  40. package/dist/access-warning.emitter.js +24 -0
  41. package/dist/access.builder.d.ts +340 -0
  42. package/dist/access.builder.js +75 -0
  43. package/dist/access.engine.d.ts +155 -0
  44. package/dist/access.engine.js +477 -0
  45. package/dist/audit/audit-event.d.ts +135 -0
  46. package/dist/audit/audit-event.js +8 -0
  47. package/dist/audit/audit.log.d.ts +122 -0
  48. package/dist/audit/audit.log.js +345 -0
  49. package/dist/audit/audit.schema.d.ts +143 -0
  50. package/dist/audit/audit.schema.js +50 -0
  51. package/dist/auth/index.d.ts +12 -0
  52. package/dist/auth/index.js +2 -0
  53. package/dist/aventara-where.translator.d.ts +19 -0
  54. package/dist/aventara-where.translator.js +87 -0
  55. package/dist/beacon/beacon.check.d.ts +59 -0
  56. package/dist/beacon/beacon.check.js +128 -0
  57. package/dist/beacon/beacon.schema.d.ts +41 -0
  58. package/dist/beacon/beacon.schema.js +48 -0
  59. package/dist/beacon/beacon.store.d.ts +29 -0
  60. package/dist/beacon/beacon.store.js +78 -0
  61. package/dist/better-auth/access.client-plugin.d.ts +75 -0
  62. package/dist/better-auth/access.client-plugin.js +133 -0
  63. package/dist/better-auth/access.plugin.d.ts +164 -0
  64. package/dist/better-auth/access.plugin.js +348 -0
  65. package/dist/better-auth/access.provider.d.ts +80 -0
  66. package/dist/better-auth/access.provider.js +100 -0
  67. package/dist/better-auth/index.d.ts +7 -0
  68. package/dist/better-auth/index.js +4 -0
  69. package/dist/better-auth/organization.bridge.d.ts +66 -0
  70. package/dist/better-auth/organization.bridge.js +144 -0
  71. package/dist/canonical-json.writer.d.ts +12 -0
  72. package/dist/canonical-json.writer.js +30 -0
  73. package/dist/cli/avaccess.bin.d.ts +2 -0
  74. package/dist/cli/avaccess.bin.js +26 -0
  75. package/dist/cli/avaccess.program.d.ts +15 -0
  76. package/dist/cli/avaccess.program.js +166 -0
  77. package/dist/cli/beacon-pins.file.d.ts +14 -0
  78. package/dist/cli/beacon-pins.file.js +62 -0
  79. package/dist/cli/beacon.loader.d.ts +20 -0
  80. package/dist/cli/beacon.loader.js +71 -0
  81. package/dist/cli/cli.context.d.ts +49 -0
  82. package/dist/cli/cli.context.js +66 -0
  83. package/dist/cli/diff.command.d.ts +2 -0
  84. package/dist/cli/diff.command.js +66 -0
  85. package/dist/cli/generate.command.d.ts +2 -0
  86. package/dist/cli/generate.command.js +82 -0
  87. package/dist/cli/keys.command.d.ts +2 -0
  88. package/dist/cli/keys.command.js +96 -0
  89. package/dist/cli/lock.file.d.ts +31 -0
  90. package/dist/cli/lock.file.js +181 -0
  91. package/dist/cli/module.loader.d.ts +13 -0
  92. package/dist/cli/module.loader.js +63 -0
  93. package/dist/cli/policies.diff.d.ts +16 -0
  94. package/dist/cli/policies.diff.js +101 -0
  95. package/dist/cli/pull.command.d.ts +2 -0
  96. package/dist/cli/pull.command.js +234 -0
  97. package/dist/cli/push.command.d.ts +2 -0
  98. package/dist/cli/push.command.js +59 -0
  99. package/dist/cli/registry.client.d.ts +63 -0
  100. package/dist/cli/registry.client.js +287 -0
  101. package/dist/cli/schema.command.d.ts +2 -0
  102. package/dist/cli/schema.command.js +60 -0
  103. package/dist/cli/serve.command.d.ts +6 -0
  104. package/dist/cli/serve.command.js +200 -0
  105. package/dist/cli/status.command.d.ts +2 -0
  106. package/dist/cli/status.command.js +31 -0
  107. package/dist/cli/translator.flag.d.ts +6 -0
  108. package/dist/cli/translator.flag.js +24 -0
  109. package/dist/cli/verify.command.d.ts +2 -0
  110. package/dist/cli/verify.command.js +40 -0
  111. package/dist/client/access-client.factory.d.ts +92 -0
  112. package/dist/client/access-client.factory.js +269 -0
  113. package/dist/client/access-verbs.schema.d.ts +66 -0
  114. package/dist/client/access-verbs.schema.js +159 -0
  115. package/dist/client/verb-rate.limiter.d.ts +116 -0
  116. package/dist/client/verb-rate.limiter.js +358 -0
  117. package/dist/foreign/foreign-facts.d.ts +30 -0
  118. package/dist/foreign/foreign-facts.js +75 -0
  119. package/dist/foreign/foreign.merger.d.ts +10 -0
  120. package/dist/foreign/foreign.merger.js +52 -0
  121. package/dist/foreign-references.check.d.ts +6 -0
  122. package/dist/foreign-references.check.js +131 -0
  123. package/dist/generator/client.emitter.d.ts +28 -0
  124. package/dist/generator/client.emitter.js +411 -0
  125. package/dist/generator/copied-modules.reader.d.ts +14 -0
  126. package/dist/generator/copied-modules.reader.js +80 -0
  127. package/dist/generator/namespace.facts.d.ts +28 -0
  128. package/dist/generator/namespace.facts.js +32 -0
  129. package/dist/generator/orm.translator.d.ts +48 -0
  130. package/dist/generator/orm.translator.js +29 -0
  131. package/dist/generator/prisma7/prisma-schema.reader.d.ts +9 -0
  132. package/dist/generator/prisma7/prisma-schema.reader.js +394 -0
  133. package/dist/generator/prisma7/prisma-where.translator.d.ts +73 -0
  134. package/dist/generator/prisma7/prisma-where.translator.js +138 -0
  135. package/dist/generator/prisma7/prisma7.translator.d.ts +2 -0
  136. package/dist/generator/prisma7/prisma7.translator.js +87 -0
  137. package/dist/generator/schema.emitter.d.ts +14 -0
  138. package/dist/generator/schema.emitter.js +23 -0
  139. package/dist/generator/tsconfig.reader.d.ts +44 -0
  140. package/dist/generator/tsconfig.reader.js +110 -0
  141. package/dist/generator/typescript-literal.writer.d.ts +5 -0
  142. package/dist/generator/typescript-literal.writer.js +42 -0
  143. package/dist/guardrails/guardrails-contract.d.ts +45 -0
  144. package/dist/guardrails/guardrails-contract.js +12 -0
  145. package/dist/guardrails/guardrails.builder.d.ts +48 -0
  146. package/dist/guardrails/guardrails.builder.js +32 -0
  147. package/dist/guardrails/guardrails.compiler.d.ts +10 -0
  148. package/dist/guardrails/guardrails.compiler.js +254 -0
  149. package/dist/guardrails/guardrails.composer.d.ts +7 -0
  150. package/dist/guardrails/guardrails.composer.js +95 -0
  151. package/dist/guardrails/guardrails.hash.d.ts +10 -0
  152. package/dist/guardrails/guardrails.hash.js +38 -0
  153. package/dist/index.d.ts +16 -0
  154. package/dist/index.js +6 -0
  155. package/dist/kiosk/kiosk-verbs.schema.d.ts +19 -0
  156. package/dist/kiosk/kiosk-verbs.schema.js +61 -0
  157. package/dist/kiosk/kiosk.plugin.d.ts +217 -0
  158. package/dist/kiosk/kiosk.plugin.js +877 -0
  159. package/dist/kiosk/kiosk.schema.d.ts +176 -0
  160. package/dist/kiosk/kiosk.schema.js +101 -0
  161. package/dist/kiosk/kiosk.store.d.ts +171 -0
  162. package/dist/kiosk/kiosk.store.js +478 -0
  163. package/dist/kiosk/lan-range.policy.d.ts +31 -0
  164. package/dist/kiosk/lan-range.policy.js +113 -0
  165. package/dist/kiosk/pin.policy.d.ts +9 -0
  166. package/dist/kiosk/pin.policy.js +143 -0
  167. package/dist/kiosk/session-use.marker.d.ts +4 -0
  168. package/dist/kiosk/session-use.marker.js +16 -0
  169. package/dist/lock/lock-contract.d.ts +38 -0
  170. package/dist/lock/lock-contract.js +67 -0
  171. package/dist/lock/lock.verifier.d.ts +27 -0
  172. package/dist/lock/lock.verifier.js +62 -0
  173. package/dist/mcp/agent-scope.gate.d.ts +13 -0
  174. package/dist/mcp/agent-scope.gate.js +75 -0
  175. package/dist/mcp/agent.grant.d.ts +40 -0
  176. package/dist/mcp/agent.grant.js +92 -0
  177. package/dist/mcp/mcp-options.d.ts +60 -0
  178. package/dist/mcp/mcp-options.js +72 -0
  179. package/dist/oauth/access-token.verifier.d.ts +66 -0
  180. package/dist/oauth/access-token.verifier.js +201 -0
  181. package/dist/oauth/oauth-provider.hardening.d.ts +62 -0
  182. package/dist/oauth/oauth-provider.hardening.js +261 -0
  183. package/dist/passwords/argon2id.hasher.d.ts +97 -0
  184. package/dist/passwords/argon2id.hasher.js +256 -0
  185. package/dist/passwords/node-rs-argon2.adapter.d.ts +25 -0
  186. package/dist/passwords/node-rs-argon2.adapter.js +42 -0
  187. package/dist/passwords/passwords.options.d.ts +79 -0
  188. package/dist/passwords/passwords.options.js +212 -0
  189. package/dist/policies-contract.d.ts +219 -0
  190. package/dist/policies-contract.js +8 -0
  191. package/dist/policies.compiler.d.ts +38 -0
  192. package/dist/policies.compiler.js +1433 -0
  193. package/dist/policies.decider.d.ts +103 -0
  194. package/dist/policies.decider.js +390 -0
  195. package/dist/policies.hash.d.ts +10 -0
  196. package/dist/policies.hash.js +54 -0
  197. package/dist/predicate.evaluator.d.ts +62 -0
  198. package/dist/predicate.evaluator.js +378 -0
  199. package/dist/random-secret.generator.d.ts +2 -0
  200. package/dist/random-secret.generator.js +15 -0
  201. package/dist/read-governed-unique.check.d.ts +3 -0
  202. package/dist/read-governed-unique.check.js +21 -0
  203. package/dist/reference-reads.check.d.ts +16 -0
  204. package/dist/reference-reads.check.js +158 -0
  205. package/dist/registry/access.registry.d.ts +173 -0
  206. package/dist/registry/access.registry.js +865 -0
  207. package/dist/registry/beacon.ledger.d.ts +30 -0
  208. package/dist/registry/beacon.ledger.js +93 -0
  209. package/dist/registry/ownership.check.d.ts +54 -0
  210. package/dist/registry/ownership.check.js +221 -0
  211. package/dist/registry/registry.endpoints.d.ts +50 -0
  212. package/dist/registry/registry.endpoints.js +193 -0
  213. package/dist/registry/registry.key.d.ts +7 -0
  214. package/dist/registry/registry.key.js +13 -0
  215. package/dist/registry/registry.options.d.ts +13 -0
  216. package/dist/registry/registry.options.js +28 -0
  217. package/dist/registry/registry.refusal.d.ts +6 -0
  218. package/dist/registry/registry.refusal.js +11 -0
  219. package/dist/registry/registry.schema.d.ts +238 -0
  220. package/dist/registry/registry.schema.js +127 -0
  221. package/dist/resources/auth-tables.catalog.d.ts +96 -0
  222. package/dist/resources/auth-tables.catalog.js +110 -0
  223. package/dist/resources/auth-tables.guard.d.ts +106 -0
  224. package/dist/resources/auth-tables.guard.js +462 -0
  225. package/dist/scope.resolver.d.ts +144 -0
  226. package/dist/scope.resolver.js +529 -0
  227. package/dist/sha256.digest.d.ts +2 -0
  228. package/dist/sha256.digest.js +81 -0
  229. package/dist/standalone/access.server.d.ts +41 -0
  230. package/dist/standalone/access.server.js +301 -0
  231. package/dist/standalone/index.d.ts +1 -0
  232. package/dist/standalone/index.js +1 -0
  233. package/package.json +102 -3
  234. package/skill/SKILL.md +170 -0
  235. package/skill/reference/callers.md +99 -0
  236. package/skill/reference/errors.md +89 -0
  237. package/skill/reference/recipes.md +281 -0
  238. package/skill/reference/rules.md +153 -0
  239. package/skill/reference/setup.md +334 -0
  240. package/src/aventara-where.translator.ts +116 -0
  241. package/src/beacon/beacon.check.ts +204 -0
  242. package/src/canonical-json.writer.ts +51 -0
  243. package/src/generator/prisma7/prisma-where.translator.ts +243 -0
  244. package/src/policies-contract.ts +215 -0
  245. package/src/policies.decider.ts +662 -0
  246. package/src/predicate.evaluator.ts +574 -0
  247. package/src/scope.resolver.ts +819 -0
  248. package/src/sha256.digest.ts +85 -0
package/README.md CHANGED
@@ -1,3 +1,2537 @@
1
- # Temporary Holding Version
1
+ # @aventara/access
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Access extends [Better Auth](https://www.better-auth.com) with policies for
4
+ [Aventara](https://aventara.razor34.dev/framework) apps and the services beside them. Better Auth signs users in and
5
+ issues their tokens. Access decides what each user may do with each row and each field.
6
+
7
+ You write role-first rules, typed from your app's schema. Access compiles them to a policies file: plain JSON in the
8
+ `aventara-access/policies@1` format. Its engine answers the framework's policy questions from that file: who may read,
9
+ create, update or delete which rows, and which fields they may see.
10
+
11
+ This is a release candidate. It holds:
12
+
13
+ | Part | What it does |
14
+ |---|---|
15
+ | `@aventara/access`: `definePolicies`, `createAccessEngine` | The rules and the engine. They need no Better Auth and no server. |
16
+ | `@aventara/access/auth`: `createAccess` | Better Auth with Access beside it: tokens with claims, the engine, the request's principal. |
17
+ | `@aventara/access/better-auth`: `aventaraAccess`, `accessOf`, `aventaraAccessClient` | Access as a plugin of the Better Auth you build yourself, and its client plugin. |
18
+ | `@aventara/access/standalone` | `serve(access)`: Access as its own HTTP server. |
19
+ | The registry | Namespaces, keys and the org's guardrails, stored in Better Auth's database. |
20
+ | `avaccess` | The command line: push, pull, verify, status, diff, keys, generate, schema, serve. |
21
+ | 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. |
22
+
23
+ Contents: [Install](#install) · [AI agents](#ai-agents) · [Write the rules](#write-the-rules) · [createAccess](#createaccess) ·
24
+ [Mount the routes](#mount-the-routes) · [Wire it into Aventara](#wire-it-into-aventara) ·
25
+ [The Better Auth plugin](#the-better-auth-plugin) · [The engine alone](#the-engine-alone) · [The audit log](#the-audit-log) ·
26
+ [Users and roles as Resources](#users-and-roles-as-resources) · [The registry and avaccess](#the-registry-and-avaccess)
27
+ · [Guardrails](#guardrails) · [Services not on Aventara](#services-not-on-aventara) ·
28
+ [Running `avaccess serve`](#running-avaccess-serve) · [File formats and hashes](#file-formats-and-hashes) ·
29
+ [Error codes](#error-codes)
30
+
31
+ ## Install
32
+
33
+ Access goes into an Aventara app. To start one, create it with the Aventara CLI (here NestJS, Prisma 7 and
34
+ PostgreSQL), then install Access in it:
35
+
36
+ ```sh
37
+ npx @aventara/cli@1.0.0-rc.11 new <app> --orm prisma7 --db postgresql
38
+ cd <app>
39
+ npm install --save-exact @aventara/access@rc better-auth@~1.7.12
40
+ # or, with pnpm:
41
+ pnpm add --save-exact @aventara/access@rc better-auth@~1.7.12
42
+ ```
43
+
44
+ On a terminal, `aventara new` asks for the package manager; anywhere else, add `--package-manager npm` (or `pnpm`)
45
+ or `--yes`. Then set `DATABASE_URL` in `.env`, as the CLI says, and add Better Auth's secret and the app's origin.
46
+ `npx auth@~1.7.12 secret` prints a new secret. Without `BETTER_AUTH_URL`, `createAccess` refuses to start
47
+ (`ACCESS_OPTIONS_INVALID`).
48
+
49
+ ```sh
50
+ # .env
51
+ BETTER_AUTH_SECRET="<the value npx auth@~1.7.12 secret prints>"
52
+ BETTER_AUTH_URL="http://localhost:3000"
53
+ ```
54
+
55
+ Then, in this order:
56
+
57
+ 1. Add your models to `prisma/schema.prisma` and run `npm run aventara:prepare`
58
+ ([Write the rules](#write-the-rules)).
59
+ 2. Write the rules in `src/access.policies.ts`.
60
+ 3. Write `src/access.ts` with `createAccess`, keeping the scaffold's numeric ids
61
+ ([The scaffold's numeric ids](#the-scaffolds-numeric-ids)).
62
+ 4. Generate Better Auth's tables, push them and run `npm run aventara:prepare` again
63
+ ([Better Auth's tables](#better-auths-tables)).
64
+ 5. [Mount the routes](#mount-the-routes) and [wire it into Aventara](#wire-it-into-aventara).
65
+
66
+ Access runs on Better Auth `~1.7.12`: 1.7.12 or any later 1.7 patch. npm installs the newest patch, and
67
+ `--save-exact` records it. Another version (an older one, 1.8, a prerelease) is refused when `createAccess` runs
68
+ (`ACCESS_BETTER_AUTH_VERSION`).
69
+
70
+ `better-auth` is an optional peer. The engine alone (`definePolicies`, `createAccessEngine`, from `@aventara/access`)
71
+ works without it, and type-checks without it with `skipLibCheck: false`. Only `createAccess` loads it, and it is
72
+ imported from `@aventara/access/auth`.
73
+
74
+ Access works with `@aventara/core` `1.0.0-rc.10` or later. It has no dependency on the framework: its engine and its
75
+ instance fit the framework's types by shape. Node.js `^22.23.4 || >=24.22.1`. On Node.js 22, `createAccess` needs
76
+ `@node-rs/argon2` to hash passwords ([Passwords](#passwords)).
77
+
78
+ ## AI agents
79
+
80
+ The package ships an agent skill at `node_modules/@aventara/access/skill/`: a short `SKILL.md` and reference pages an
81
+ agent loads when a task needs them (setup and wiring, the rule language, recipes, signed-out callers and cached reads,
82
+ error codes). Its TypeScript examples compile against this release. Copy the folder into your agent's skills folder:
83
+
84
+ ```sh
85
+ mkdir -p .claude/skills
86
+ cp -R node_modules/@aventara/access/skill .claude/skills/aventara-access
87
+ ```
88
+
89
+ Or point your agent at `node_modules/@aventara/access/skill/SKILL.md`. Copy it again after you upgrade Access.
90
+
91
+ ## Write the rules
92
+
93
+ `definePolicies` takes your schema. In an Aventara app that is the discovery artifact's `model`, which
94
+ `aventara:prepare` writes. Every name in a rule is checked against it. A Resource, field, relation or enum value that
95
+ does not exist is a type error at the key that names it.
96
+
97
+ The rules below use `Campaign`, `Member` and `Note`, which the scaffold does not have. Add your own models to
98
+ `prisma/schema.prisma` first, then run `npm run aventara:prepare` so the model has them.
99
+
100
+ The examples in this README type the principal's `id` as `string`, for a project whose `User.id` is a `String`. On
101
+ the scaffold's `Int` ids, type it as `number` and map it with `Number(sub)`
102
+ ([The scaffold's numeric ids](#the-scaffolds-numeric-ids)).
103
+
104
+ ```ts
105
+ // src/access.policies.ts
106
+ import { definePolicies } from "@aventara/access";
107
+ import { model } from "./generated/aventara/discovery.artifact.js";
108
+
109
+ /** What your app derives for a request: the signed-in user, or null. */
110
+ export type Principal = {
111
+ id: string;
112
+ claims: { role?: "admin"; banned?: boolean };
113
+ };
114
+
115
+ export const policies = definePolicies(model)
116
+ .principal<Principal>()
117
+
118
+ .roles(({ me }) => ({
119
+ // A global role, read from the principal's claims.
120
+ admin: { global: { claims: { role: "admin" } } },
121
+ // Roles held on a row: a member of a campaign, as DM or player.
122
+ dm: {
123
+ on: "Campaign",
124
+ where: { members: { some: { userId: me.id, role: "DM" } } },
125
+ },
126
+ player: {
127
+ on: "Campaign",
128
+ where: { members: { some: { userId: me.id, role: "PLAYER" } } },
129
+ },
130
+ }))
131
+
132
+ // Governed fields: only a grant that names one opens it. The list governs reads
133
+ // and writes: out of every default projection, written by no grant that does not
134
+ // name it.
135
+ .govern("User", ["email"])
136
+ // "write": read as any other field, written only by a grant that names it. The
137
+ // row roles read a member's role, so no grant hands one out without naming it.
138
+ .govern("Member", { role: "write" })
139
+
140
+ .grants(({ grant, me }) => [
141
+ grant("signedIn", "read", "User"),
142
+ grant("signedIn", "read", "User", {
143
+ where: { id: me.id },
144
+ fields: ["User.email"],
145
+ }),
146
+ grant("admin", "read", "*", { fields: ["User.email"] }),
147
+ grant("signedIn", "create", "Campaign"),
148
+ grant(["dm", "player"], "read", "Campaign"),
149
+ grant("player", "read", "Note", {
150
+ in: "campaign",
151
+ where: { visibility: "PARTY" },
152
+ }),
153
+ grant("dm", ["read", "create", "update", "delete"], "Note", {
154
+ in: "campaign",
155
+ }),
156
+ // The DM changes a seat's role, never who sits in it: the grant names the role.
157
+ grant("dm", "update", "Member", {
158
+ in: "campaign",
159
+ write: { except: ["userId", "campaignId"] },
160
+ fields: ["Member.role"],
161
+ }),
162
+ ])
163
+
164
+ .denies(({ deny }) => [
165
+ deny("*", "*", { if: { claims: { banned: true } } }),
166
+ deny("delete", "*", { if: { origin: "agent" } }),
167
+ ]);
168
+ ```
169
+
170
+ In Next.js, `aventara:prepare` writes the artifact to `src/aventara/generated/discovery.artifact.ts`. Import the model
171
+ there as:
172
+
173
+ ```ts
174
+ // src/access.policies.ts (Next.js)
175
+ import { model } from "@/aventara/generated/discovery.artifact";
176
+ ```
177
+
178
+ How the rules combine:
179
+
180
+ - Nothing is allowed until a grant allows it.
181
+ - Any grant that matches allows. Any deny that matches wins over every grant. The order of rules never matters.
182
+ - `signedIn` is any non-null principal. `anyone` also includes the anonymous caller (`null`). The principal is an
183
+ object, an instance of a class (an entity) included; an array, a function or a primitive is anonymous. Its values
184
+ are read from its own properties, never from its prototype.
185
+ - `in: "campaign"` means the subject holds its role on the row's `campaign`.
186
+ - A rule reads `me` values the principal may lack: an anonymous principal lacks them all, and a missing or `null`
187
+ property, an empty string or a number JSON cannot carry counts as missing. A grant that reads a missing value
188
+ never matches. A deny that reads one in its `where` matches, whatever `not` or `NOT` surrounds it: it fails
189
+ closed.
190
+ - A claim the principal lacks fails closed the same way: a global role that compares it is not held, and a deny's
191
+ `if` that compares it holds, whatever value it compares with. A token Access issues carries every declared
192
+ boolean claim, `false` when the user's is unset, so `deny("*", "*", { if: { claims: { banned: true } } })` leaves
193
+ alone a user whose ban is unset and denies a token that lacks `banned` (one from another issuer, or from before
194
+ the claim was declared). The anonymous caller has no claims and no token: a comparison with `true` never holds for
195
+ it, so a ban does not close an `anyone` grant, and any other comparison holds for a deny.
196
+ - `if: { origin: "agent" }` matches an AI agent's request, through the framework's MCP endpoint. The other origins are
197
+ `client` (a user's request) and `application` (your server's own).
198
+ - A rule compares a value by its field's type: a `decimal` by value (`1.5`, `"1.50"` and a decimal object are equal),
199
+ a `datetime` by instant (a `Date`, or an ISO-8601 date or instant with its offset that reads back as written: no
200
+ February 30th, no hour 24, nothing past the millisecond; the compiled file holds `toISOString()`'s form), and a
201
+ `bigint` exactly. A row value of another type than its field's, or than the rule's `me` value, is unknown: no grant
202
+ matches it, and a deny that compares it matches.
203
+ - Through Aventara, a rule that compares a `datetime`, `decimal` or `bigint` field cannot be used yet: the framework
204
+ validates a policy's `where` with a `Date`, a decimal object or a `bigint` where the file holds text, so a scope or
205
+ an `exists` question on such a field answers `A3000` (the request is refused). It fails closed; the framework's
206
+ seam is being changed. `can` on a row, and a generated client, compare these fields as above.
207
+ - Writing a reference needs read on its target. When a create or an update sends a reference (a `Member`'s
208
+ `profileId`) to a Resource the app serves, Aventara holds it to the rows the caller may read there, and refuses the
209
+ write (`A4002`) otherwise. When Aventara hosts the engine (`@aventara/core` `1.0.0-rc.10` or later) it tells the
210
+ engine which Resources it holds references to (`engine.hosted({ held })`), and the engine warns
211
+ (`ACCESS_REFERENCE_UNREADABLE`, a Node.js warning) about a role that may write a reference to one of them no read
212
+ grant reaches for it. When that Resource is a Better Auth table `createAccess` keeps closed (`User` without
213
+ `resources: { users: true }`), the warning names the key that opens it. Compiling the rules warns about nothing, and
214
+ the engine decides as before. A reference to a
215
+ foreign Resource is the exception: no caller reads one, so the engine lists the rules' foreign Resources in
216
+ `unheld` and Aventara does not hold a reference to them ([Namespaces and foreign
217
+ Resources](#namespaces-and-foreign-resources)).
218
+ - Strings compare exactly in `can`, code unit by code unit. A database column with a case- or accent-insensitive
219
+ collation (MySQL's default, PostgreSQL's `citext` or a nondeterministic collation) can find rows `can` would not
220
+ keep, so `scopeOf` and `can` disagree on them. Give the fields your rules compare a binary or deterministic
221
+ collation.
222
+
223
+ ### Updates
224
+
225
+ An update is checked twice. Its scope (`scopeOf(update)`) picks the rows a grant's `where` covers and no update deny
226
+ matches. Then the row each update leaves must still be one an update grant covers, by that grant's `where` and its
227
+ `after` together, and no update deny may match it. A grant's `where` is checked on the new row by default: a grant
228
+ `where: { authorId: me.id }` does not let the author hand the row to someone else. `after` adds what the new row must
229
+ also meet:
230
+
231
+ - `grant("signedIn", "update", "Character", { where: { ownerId: me.id }, after: { campaign: { $role: "player" } } })`
232
+ lets a player move a character only to a campaign they play in.
233
+
234
+ The grant that covers the row after must also be the one that covered the row before. `can` then gets both rows
235
+ (`row`, and `before`, the row as it was before the update), and one update grant must cover both: its `where` the row
236
+ before, its `where` and `after` the row after. No update moves a row from the rows one grant reaches to another's.
237
+ When two update grants with different conditions cover a Resource, a row that only one of them covers before the
238
+ update and only the other covers after it is refused. Update denies are asked about the row after only.
239
+
240
+ The engine lists in `afterChecks` every Resource whose update grants read the row (a `where`, an `after`, a role held
241
+ on a row) or whose update denies have a `where`. Aventara asks `can` about each row such an update changed, with the
242
+ row before it, inside the update's transaction, and rolls the update back when one fails; where it has no interactive
243
+ transaction, it refuses those updates. The row before needs `@aventara/core` `1.0.0-rc.10` or later. An older core
244
+ hands `can` the row after only, and `can` then lets an update in when any update grant covers that row.
245
+
246
+ Which grants covered the row before is decided before the write. Aventara asks the engine's `grantsOf` about each row
247
+ such an update will change, after it reads the row and before any of your stages or the write itself runs, and hands
248
+ the answer back to `can` as `beforeGrants`. The grant that covers the row after must be one of those. A grant whose
249
+ `where` reads a relation (`tags: { some: { name: "shared" } }`, a role held through a membership) is therefore judged
250
+ on what the database held before the update: tagging a row "shared" in the same update that hands it to someone else
251
+ does not let a grant on shared rows cover the row before. This also needs `@aventara/core` `1.0.0-rc.10` or later.
252
+ Without `beforeGrants`, `can` asks a relation condition on the row before through `exists` at the after check, when
253
+ the update has already written, so a nested write in the same request can make it hold. With `before` too, `can`
254
+ still checks each listed grant's `where` on the row before in memory, so a key list replayed from another row or
255
+ another principal fails on a scalar condition; only a relation condition is left to `grantsOf`'s answer. The keys
256
+ `grantsOf` answers are opaque: a short hash of the policies file's grants, then a grant's place in it, never its
257
+ conditions, so a list from another policies file covers nothing. `grantsOf` answers no key on a row an update deny
258
+ already refuses in memory. Its answer is what the database held when it was asked: a relation fact your own stages,
259
+ or another transaction committing in between, change before the write is not in it. Aventara may ask it about several
260
+ rows at once, so your `exists` can be called concurrently in one transaction. It decides nothing, so it is neither
261
+ reported to the decision observer nor explained; the `can` after it is.
262
+
263
+ A generated client's `can({ action: "update", row, before })` answers the same question about the rows you pass.
264
+ Without `before` it checks only the row the update leaves. Ask `access.grantsOf({ action: "update", row })` about the
265
+ row before you write it, in the update's transaction and with the same `exists`, and pass its answer to `can` as
266
+ `beforeGrants` with the row the update leaves; without it, `can` asks the row before's relation conditions when you
267
+ ask `can`. The caller must also limit the update itself to the rows `scopeOf({ action: "update" })` returns, or an
268
+ update could start from a row no grant covers.
269
+
270
+ A row handed to `can` is a Resource's own scalar fields. A relation on it (`author: { … }`, `tags: [ … ]`) is never
271
+ read: a relation condition is always asked through `exists`, on what the database holds. Still, never put relation
272
+ data a client sent on a row. A to-one step whose reference fields the row carries (`campaign` through `campaignId`) is
273
+ asked on the row those values name, so a question asked before the write sees where the row is moving; anything else
274
+ is asked about the row itself, by its identifier.
275
+
276
+ `write: { except: [...] }` keeps fields out of what a create or an update may set. A relation and its reference fields
277
+ are locked together: `except: ["author"]` locks `authorId` too, and `except: ["authorId"]` locks `author`.
278
+
279
+ ### Governed fields
280
+
281
+ A governed field is opened only by a grant that names it (`fields: ["User.email"]`). Each governed field has a mode:
282
+
283
+ | Form | Reads | Writes |
284
+ |---|---|---|
285
+ | `.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 |
286
+ | `.govern("Member", { role: "write" })` | As any other field | Only a create or update grant that names it sets it |
287
+ | `.govern("Citizen", { nationalId: "read" })` | As `"both"` | As any other field |
288
+
289
+ Govern every field your roles, claims or principal read, usually as `"write"`. A role held on a `Member` row reads
290
+ `Member.role`; governed as `"write"`, an update grant on `Member` that does not name it cannot change it, while every
291
+ reader still sees it. `"read"` fits a field people write and almost nobody reads back: a government id, a stored
292
+ secret.
293
+
294
+ Selecting a field governed for reading without a grant that opens it is refused with `403 A4002`. The field is not
295
+ quietly dropped from the result. Setting a field governed for writing without a grant that names it is refused the
296
+ same way. A field governed twice with two modes is a type error, and refused when the rules compile.
297
+
298
+ A relation and its reference fields tell each other (`author.id` is `authorId`), so they are governed together, with
299
+ one mode: `.govern("Post", ["author"])` without `"authorId"`, or the reverse, is refused when the rules compile.
300
+
301
+ A field governed as `"read"` that is unique (one of its Resource's identifiers) still tells writers which values
302
+ exist: a create or update that sends a value another row holds fails on the duplicate. Compiling the rules or building
303
+ the engine warns `ACCESS_READ_GOVERNED_UNIQUE` (a Node.js warning) for each such field. Govern it as `"both"` so only a
304
+ grant that names it writes it, or accept the leak.
305
+
306
+ In the policies file, `govern` lists every governed field, and `governModes` gives the fields governed for one access
307
+ only: `"governModes": { "Member": { "role": "write" } }`. A field without an entry is governed for both, so the list
308
+ form compiles to the same file, with the same hash, as `"both"`.
309
+
310
+ `fields` on a Resource with no governed field is a type error that says what to do:
311
+ `no field of "Note" is governed; govern it first, .govern("Note", [<field>]), and a grant can then open it`.
312
+
313
+ ### The principal type
314
+
315
+ `.principal<…>()` types `me` in every callback. There are two ways to use it.
316
+
317
+ - **Your own type**, as above. With `createAccess`, you then pass a `principal` function that maps the verified token to
318
+ it.
319
+ - **The token's identity.** `AccessIdentity<typeof claims>` is `{ sub, claims }`, exactly what a verified token
320
+ carries. Pass the same `claims` object to `createAccess` and leave `principal` out.
321
+
322
+ ```ts
323
+ // src/identity.policies.ts
324
+ import { definePolicies } from "@aventara/access";
325
+ import type { AccessIdentity } from "@aventara/access/auth";
326
+ import { model } from "./generated/aventara/discovery.artifact.js";
327
+
328
+ /** The claims a token carries beside `sub`, by type. */
329
+ export const claims = { role: "string", banned: "boolean" } as const;
330
+
331
+ export const identityPolicies = definePolicies(model)
332
+ .principal<AccessIdentity<typeof claims>>()
333
+ .grants(({ grant, me }) => [
334
+ grant("signedIn", "read", "User", { where: { id: me.sub } }),
335
+ grant("signedIn", "create", "Note", { where: { authorId: me.sub } }),
336
+ ])
337
+ .denies(({ deny }) => [deny("*", "*", { if: { claims: { banned: true } } })]);
338
+ ```
339
+
340
+ Without `.principal<…>()` the rules have no principal type, so `me.sub` in a `where` and `claims` in a role are type
341
+ errors.
342
+
343
+ ### Namespaces and foreign Resources
344
+
345
+ When several services share one Access server, each service owns a namespace and writes the rules for its own
346
+ Resources. `definePolicies(model, { namespace: "chat" })` names it. A namespace name matches
347
+ `^[a-z][a-z0-9_-]{0,62}$`.
348
+
349
+ A Resource another namespace owns is marked `{ foreign: "<namespace>" }` in the schema. The rules can read it in
350
+ conditions, but cannot grant, deny or govern on it. That is a type error, and the engine and the registry refuse it
351
+ too. A foreign Resource is only readable while both services share one database: the ownership beacon
352
+ ([below](#the-ownership-beacon)) checks that at push, at pull and at startup.
353
+
354
+ ```ts
355
+ // src/chat.policies.ts
356
+ import { definePolicies } from "@aventara/access";
357
+ import { model } from "./generated/aventara/discovery.artifact.js";
358
+
359
+ /** The schema with Campaign marked as the campaign namespace's. */
360
+ const schema = {
361
+ ...model,
362
+ resources: {
363
+ ...model.resources,
364
+ Campaign: { ...model.resources.Campaign, foreign: "campaign" },
365
+ },
366
+ } as const;
367
+
368
+ export const chatPolicies = definePolicies(schema, { namespace: "chat" })
369
+ .principal<{ id: string }>()
370
+ .grants(({ grant, me }) => [
371
+ // Read through Campaign in a condition: allowed.
372
+ grant("signedIn", "read", "Note", {
373
+ where: { campaign: { members: { some: { userId: me.id } } } },
374
+ }),
375
+ // grant("signedIn", "read", "Campaign") would be a type error here.
376
+ ]);
377
+ ```
378
+
379
+ A row of your own can hold a reference to a foreign Resource (a chat `Room`'s `campaignId`). No caller reads a foreign
380
+ Resource, so Aventara cannot hold such a reference to the rows the caller may read, as it does for your own Resources:
381
+ the engine lists the rules' foreign Resources in `unheld` (`@aventara/core` `1.0.0-rc.10` or later), and the write grant
382
+ alone decides which row may be linked. Constrain it through the relation, with a `where` on a create grant and an
383
+ `after` on an update grant (an update grant's `where` is checked on the row it leaves too):
384
+
385
+ ```text
386
+ grant("signedIn", "create", "Room", { where: { campaign: { members: { some: { userId: me.id } } } } }),
387
+ grant("signedIn", "update", "Room", {
388
+ where: { ownerId: me.id },
389
+ after: { campaign: { members: { some: { userId: me.id } } } },
390
+ }),
391
+ ```
392
+
393
+ Without such a condition the caller can link any row of the foreign Resource by its identifier. When Aventara hosts the
394
+ engine, it warns about each role whose create or update grant may write a reference to a foreign Resource with no
395
+ condition through that relation (`ACCESS_FOREIGN_REFERENCE_UNCHECKED`, a Node.js warning, once per process and
396
+ namespace). Reads are unchanged: through Aventara a read of a foreign Resource, or an include of one, is still refused
397
+ (`A4002`).
398
+
399
+ ## createAccess
400
+
401
+ `createAccess` builds Better Auth with Access beside it. It opens no port and mounts nothing. Better Auth's options sit
402
+ in the `betterAuth` block, exactly as `betterAuth({...})` takes them, so you can paste them from an existing Better Auth
403
+ setup. Access's own concerns sit in their own blocks beside it.
404
+
405
+ ```ts
406
+ // src/access.ts
407
+ import { createAccess } from "@aventara/access/auth";
408
+ import { PrismaPg } from "@prisma/adapter-pg";
409
+ import { prismaAdapter } from "better-auth/adapters/prisma";
410
+ import { policies } from "./access.policies.js";
411
+ import { PrismaClient } from "./generated/prisma/client.js";
412
+
413
+ /** Better Auth's own Prisma client: it reads and writes its tables through it. */
414
+ const prisma = new PrismaClient({
415
+ adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL ?? "" }),
416
+ });
417
+
418
+ /** What every token carries beside `sub`. */
419
+ const claims = { role: "string", banned: "boolean" } as const;
420
+
421
+ export const access = createAccess({
422
+ // Better Auth's options, as betterAuth({...}) takes them.
423
+ betterAuth: {
424
+ database: prismaAdapter(prisma, { provider: "postgresql" }),
425
+ emailAndPassword: { enabled: true },
426
+ user: {
427
+ additionalFields: {
428
+ role: { type: "string", required: false, input: false },
429
+ banned: { type: "boolean", required: false, input: false },
430
+ },
431
+ },
432
+ },
433
+ // The rules, and the claims they read. Each claim is read from the user's field
434
+ // of the same name.
435
+ policies: { rules: policies, claims },
436
+ // The rules are typed with your own Principal, so map the token to it.
437
+ principal: ({ sub, claims }) => ({
438
+ id: sub,
439
+ claims: {
440
+ ...(claims.role === "admin" ? { role: "admin" as const } : {}),
441
+ ...(claims.banned === undefined ? {} : { banned: claims.banned }),
442
+ },
443
+ }),
444
+ });
445
+
446
+ export default access;
447
+ ```
448
+
449
+ Better Auth reads `BETTER_AUTH_SECRET` and `BETTER_AUTH_URL` from the environment, as it always does, or takes
450
+ `secret` and `baseURL` in the block. Access needs a base URL it can fix when the instance is built: its tokens' issuer
451
+ and audience are that URL's origin (`baseURL`, else `BETTER_AUTH_URL`, or a dynamic `baseURL`'s `fallback`). With none,
452
+ `createAccess` refuses with `ACCESS_OPTIONS_INVALID`. The issuer is the origin alone, not the path: two apps on one
453
+ origin that share a database (and so Better Auth's key set) accept each other's tokens. Give each app its own origin,
454
+ or its own database.
455
+
456
+ Each declared claim is read from the user's field of the same name, so that field needs `input: false`, as `role` and
457
+ `banned` have above. A field without it is one the user sets themselves, at sign-up or with `/update-user`, and a
458
+ claim read from it would be the user's own choice: `createAccess` refuses it (`ACCESS_OPTIONS_INVALID`), for fields a
459
+ plugin adds too. To read a claim from such a field anyway, return it from `policies.claimsOf`.
460
+
461
+ `policies.claimsOf` must return every declared boolean claim, as `banned: user.banned === true`: an answer without
462
+ one is a type error (its return type, `AccessClaimsAnswer`, requires each boolean claim's key), and at run time it is
463
+ 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
464
+ claim it reads from a field the user can set (an `email`, a
465
+ `username`, an additional field without `input: false`) is again the user's own choice, so read claims there only from
466
+ fields your server alone writes.
467
+
468
+ ### The scaffold's numeric ids
469
+
470
+ The project `aventara new` creates has `model User { id Int @id @default(autoincrement()) … }`. Better Auth's ids are
471
+ strings by default, so without the option below `npx auth@~1.7.12 generate --yes` writes `String` foreign keys
472
+ (`Session.userId`, `Account.userId`) against that `Int`, and `prisma validate` fails. Before you generate Better Auth's
473
+ tables, put Better Auth's option for numeric ids in the `betterAuth` block of `src/access.ts`, beside `database`, and
474
+ map the principal's `id` to a number:
475
+
476
+ ```text
477
+ betterAuth: { …, advanced: { database: { generateId: "serial" } } },
478
+ principal: ({ sub, claims }) => ({ id: Number(sub), claims: { … } }),
479
+ ```
480
+
481
+ with `id: number` in the `Principal` type of `src/access.policies.ts`. Every Better Auth table, and Access's own, then
482
+ gets `Int @id @default(autoincrement())`, and its references are `Int`. `generate` only adds what the schema lacks: on
483
+ a schema that already has Better Auth's models with `String` ids, it answers "Your schema is already up to date" and
484
+ changes nothing. Delete the models it wrote (and Access's), then run it again.
485
+
486
+ - Better Auth hands an id out as a string (`"1"`), so a token's `sub` is `"1"`, and `Number(sub)` is the user's id. A
487
+ rule that compares `User.id` with a string, such as `where: { id: me.id }` with `id: string` or `me.sub` from
488
+ `AccessIdentity`, is a type error.
489
+ - Better Auth warns that its single-use checks (SAML assertion ids, DPoP proofs, `private_key_jwt` assertions) need
490
+ string ids and are not enforced with `"serial"`. If you use those, change `User.id` to `String` instead and leave
491
+ `generateId` out.
492
+ - The scaffold's `User` is now Better Auth's `user` table, so a rule on `User` needs `resources: { users: true }`
493
+ ([Users and roles as Resources](#users-and-roles-as-resources)).
494
+
495
+ ### Better Auth's tables
496
+
497
+ Better Auth's tables go into your schema the way Better Auth's docs say. With Prisma:
498
+
499
+ ```sh
500
+ npx auth@~1.7.12 generate --config src/access.ts --yes
501
+ npx prisma validate
502
+ npx prisma db push
503
+ npm run aventara:prepare
504
+ ```
505
+
506
+ `generate` writes Better Auth's models (and Access's own tables, when `audit` or `registry` is on) into
507
+ `prisma/schema.prisma`. It reads the module's default export. Without `--yes` it asks before it writes the file, and
508
+ where it cannot ask (a script, CI) it writes nothing and still exits 0. Run `aventara:prepare` again so the
509
+ framework's model has the new tables. Access closes them to every request until you open them (see
510
+ [Users and roles as Resources](#users-and-roles-as-resources)).
511
+
512
+ On its first run, `generate` may print a Babel note that an exported identifier such as `AvComputedFields`
513
+ "is not declared in Babel's scope tracker". It comes from a type the generated discovery artifact re-exports, and
514
+ changes nothing: the schema is written all the same.
515
+
516
+ ### The options
517
+
518
+ | Option | Takes | Default |
519
+ |---|---|---|
520
+ | `betterAuth` | Better Auth's options | required |
521
+ | `policies.rules` | The builder (`definePolicies(…)`), its `.contract()`, or a pulled policies file as JSON | required, except on a registry-only server |
522
+ | `policies.claims` | The claims a token carries beside `sub`, by type: `{ role: "string", banned: "boolean" }` | required with `rules` |
523
+ | `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 |
524
+ | `policies.guardrails` | The org's guardrails, as pulled ([Guardrails](#guardrails)) | none |
525
+ | `policies.lock` | The project's `avAccess.lock.json` ([The lock at startup](#the-lock-at-startup)) | none |
526
+ | `policies.requireLock` | `true`: refuse to start without `policies.lock`; recommended in production | `false` |
527
+ | `policies.tokenTtl` | How long a token lives, in whole seconds from 300 to 900 | 900 |
528
+ | `principal` | `(identity) => principal \| null`: maps `{ sub, claims }` to your principal | `me` is the identity |
529
+ | `profile` | `{ kind: "web" }`, `{ kind: "service" }`, or `{ kind: "kiosk", canEnrollDevice, idleTimeout?, insecureLan? }` ([The kiosk profile](#the-kiosk-profile)) | `web` |
530
+ | `registry` | `{ enabled: true }` turns on the registry ([The registry](#the-registry-and-avaccess)) | `{ enabled: false }` |
531
+ | `registry.ownership` | `"first-push"` or `"assigned"`: how a Resource gets its owner ([Who owns a Resource](#who-owns-a-resource)) | `"first-push"` |
532
+ | `audit` | `true`, `false` or `{ sink }` ([The audit log](#the-audit-log)) | off |
533
+ | `resources` | `{ users?: boolean, members?: boolean, authTables? }` ([Users and roles](#users-and-roles-as-resources)) | both off; `authTables` required off Prisma's adapter |
534
+ | `passwords` | `{ argon2id: "owasp-minimum" \| "rfc9106-low-memory", legacyScrypt?: boolean }` ([Passwords](#passwords)) | `"owasp-minimum"`, unless `betterAuth.emailAndPassword.password` is set |
535
+
536
+ - `principal` is required when the rules are typed with your own principal, and optional when they are typed with
537
+ `AccessIdentity<typeof claims>`. Return `null` to leave the caller anonymous. The principal must carry the token's
538
+ claims (`identity.claims`) as they are: a boolean claim it drops is unknown to the rules, so a deny such as
539
+ `banned: true` refuses it. Return a new object for each user (one
540
+ cached per user is fine): the audit log names the user each principal stands for, so an object `principal` already
541
+ returned for another user is refused with `ACCESS_OPTIONS_INVALID`. Only the object `principal` returns is checked;
542
+ a `Map`, `Date` or other object inside it is not. `me` is a frozen copy of its plain data, so your object itself is
543
+ never frozen.
544
+ - A claim the rules read that `policies.claims` does not declare is a type error, and is refused at construction
545
+ (`ACCESS_CLAIMS_UNDECLARED`). A token carries `sub` and the declared claims only. Every declared boolean claim is
546
+ in it: one the user's field leaves unset (absent, `null` or `undefined`), or `claimsOf` returns as `null` or
547
+ `undefined`, is `false`.
548
+ - `profile: { kind: "service" }` hands no caller a new session, and reads the principal from bearer tokens only. The
549
+ routes that start a session answer `404`: `/sign-in/*`, `/sign-up/*`, the OAuth callbacks, and the plugins' routes
550
+ that sign a user in (`/phone-number/verify`, `/magic-link/verify`, `/two-factor/verify-*`, `/device/token`,
551
+ `/admin/impersonate-user` and the rest). They are matched by Better Auth's endpoint path, whatever the URL's spelling,
552
+ `basePath` or `baseURL`. Any other route cannot create a session either: Better Auth's session hook refuses the row,
553
+ so none exists even while the route runs, and the answer is a bare `404`, its cookies and headers dropped. So is an
554
+ answer that sets the session cookie of a session that already exists, and no session cookie (nor a `multiSession()`
555
+ cookie) leaves `access.handler` at all: it strips them itself, after Better Auth and every plugin's `onResponse`, so
556
+ a route that returns its own `Response` or a plugin that answers last is covered too. A route that puts a session token in a JSON body is not
557
+ covered; no built-in plugin does. Your server's own calls to Better Auth's API with no request still sign users in;
558
+ a call that forwards the host's `request` (`auth.api.signInEmail({ body, request })`) is treated as that request,
559
+ and refused with `NOT_FOUND`. A plugin route Access does not know may answer `500` with an empty body when its
560
+ session is refused. `web` is Better Auth as you configured it.
561
+ - `tokenTtl` bounds how long a changed claim takes to reach every service. A user you ban or suspend gets the new claim
562
+ with their next token.
563
+ - An option `createAccess` does not take, or a block it cannot read, is refused with `ACCESS_OPTIONS_INVALID` and a
564
+ sentence that says what it takes.
565
+
566
+ ### The plugins createAccess installs
567
+
568
+ `createAccess` installs Better Auth's `jwt()` plugin and its own Access plugin (plugin id `aventara-access`, the one
569
+ `aventaraAccess()` returns). Putting either in `betterAuth.plugins` is a type error at that plugin, which reads
570
+ `createAccess installs jwt() itself: remove it from betterAuth.plugins`. At runtime it is refused with
571
+ `ACCESS_PLUGIN_DUPLICATE`, under any case or spacing of its id (`"JWT"`, `"jwt "`). So is a plugin that defines one of
572
+ `jwt()`'s endpoints (`getJwks`, `getToken`, `signJWT`, `verifyJWT`, or a route at `/jwks` or `/token`): it would answer
573
+ for Access's key set or its tokens. Every other Better Auth plugin passes through as it is.
574
+
575
+ ### Passwords
576
+
577
+ Better Auth hashes passwords with scrypt. `createAccess` hashes them with argon2id instead, in one of three ways. The
578
+ Better Auth plugin, `aventaraAccess()`, takes the same `passwords` and does the same
579
+ ([The Better Auth plugin](#the-better-auth-plugin)):
580
+
581
+ 1. **The default.** Set neither `passwords` nor `betterAuth.emailAndPassword.password`, and passwords are hashed with
582
+ argon2id at the `"owasp-minimum"` profile.
583
+ 2. **A named profile.** `passwords: { argon2id: "rfc9106-low-memory" }` beside `betterAuth`. A misspelled profile is a
584
+ type error, and is refused at runtime with `ACCESS_OPTIONS_INVALID`. Add `legacyScrypt: true` to accept passwords
585
+ Better Auth already stored with scrypt (see below).
586
+ 3. **Your own hook.** Set Better Auth's `emailAndPassword.password` (`hash` and `verify`) in the `betterAuth` block,
587
+ and it is used as you wrote it: Access installs nothing. To keep Better Auth's scrypt, pass its own functions.
588
+
589
+ ```ts
590
+ // src/access.passwords.ts
591
+ import type { AccessPasswordsOptions } from "@aventara/access/auth";
592
+ import type { BetterAuthOptions } from "better-auth";
593
+ import { hashPassword, verifyPassword } from "better-auth/crypto";
594
+
595
+ // 1. The default: leave `passwords` out, and `emailAndPassword.password` too.
596
+ export const byDefault = {
597
+ enabled: true,
598
+ } satisfies BetterAuthOptions["emailAndPassword"];
599
+
600
+ // 2. A named profile: createAccess({ betterAuth, policies, passwords }).
601
+ export const passwords = {
602
+ argon2id: "rfc9106-low-memory",
603
+ } satisfies AccessPasswordsOptions;
604
+
605
+ // 2b. The same, for an app whose users Better Auth signed up with scrypt.
606
+ export const movingOffScrypt = {
607
+ argon2id: "owasp-minimum",
608
+ legacyScrypt: true,
609
+ } satisfies AccessPasswordsOptions;
610
+
611
+ // 3. Your own hook, here Better Auth's scrypt: betterAuth: { emailAndPassword }, and no `passwords`.
612
+ export const emailAndPassword = {
613
+ enabled: true,
614
+ password: {
615
+ hash: hashPassword,
616
+ verify: verifyPassword,
617
+ },
618
+ } satisfies BetterAuthOptions["emailAndPassword"];
619
+ ```
620
+
621
+ Setting both `passwords` and `betterAuth.emailAndPassword.password` is refused with `ACCESS_OPTIONS_INVALID`, so it is
622
+ never in doubt which one hashes.
623
+
624
+ | Profile | Memory | Passes (`t`) | Lanes (`p`) | Source |
625
+ |---|---|---|---|---|
626
+ | `"owasp-minimum"` (the default) | 19 MiB (`m=19456`) | 2 | 1 | [OWASP Password Storage Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html), Argon2id: "m=19456 (19 MiB), t=2, p=1" |
627
+ | `"rfc9106-low-memory"` | 64 MiB (`m=65536`) | 3 | 4 | [RFC 9106](https://www.rfc-editor.org/rfc/rfc9106.html) section 4, the second recommended option |
628
+
629
+ Both use a 16-byte random salt (RFC 9106: "16 bytes is RECOMMENDED for password hashing") and a 32-byte tag. RFC 9106's
630
+ first recommended option (2 GiB of memory per hash) is not offered: a server that signs users in concurrently cannot
631
+ spend 2 GiB on each.
632
+
633
+ - A hash is a PHC string, `$argon2id$v=19$m=19456,t=2,p=1$<salt>$<hash>`. Verifying reads the parameters from the
634
+ stored string, so you can move to another profile later and every older hash still verifies. The hash is compared in
635
+ constant time.
636
+ - A sign-in whose password matches a hash stored at other parameters than your profile's (an older profile, a cheaper
637
+ or a costlier one) stores the password again at your profile, so active users' hashes follow a profile change. The
638
+ hook checks the password against the stored hash itself first, and writes only while the account still holds that
639
+ hash, so a password changed or reset meanwhile is never overwritten. A hash already at your profile costs one read
640
+ of the credential account per password sign-in, and no write. The one that moves costs two argon2id computations
641
+ more (the check again, and the new hash) and one write. The write goes through Better Auth's adapter, past its
642
+ `account` update hooks: it is the same password, stored anew. A failure is logged (never the password or a hash),
643
+ and the sign-in stands. An account with two-factor on may not be rehashed by a password sign-in its second step holds
644
+ back. Kiosk PINs move the same way at the next switch.
645
+ - Hashes Better Auth stored with scrypt (`<salt>:<key>` in hex) fail the check by default, with a warning in Better
646
+ Auth's log. With `legacyScrypt: true` they verify, through Better Auth's own `verifyPassword`, so an existing Better
647
+ Auth app can move to `createAccess` without resetting passwords, and a sign-in that matches one (`/sign-in/email`,
648
+ `/sign-in/username`, any `/sign-in/*` route that takes a password) stores the password again with argon2id. Each
649
+ such account leaves scrypt at its next sign-in. Once none is left, turn `legacyScrypt` off.
650
+ - The time a check takes does not say what an account holds. Every check costs one argon2id computation at your
651
+ profile, as Better Auth's hash of the guess costs for an unknown user, whether the stored value is an argon2id hash,
652
+ a scrypt hash or garbage. With `legacyScrypt: true`, every hash and check also costs one scrypt, so sign-up and
653
+ sign-in are about twice as slow while it is on. One exception: after you move to another profile, a hash stored at
654
+ 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
655
+ exists) until its user next signs in, which rehashes it at your profile. Active users' hashes move; an account
656
+ nobody signs in to keeps the old cost until its password is set again.
657
+ - A stored hash that costs more than the largest profile (`m` over 65536, `t` over 3, `p` over 4) is refused without
658
+ being computed, with a warning in Better Auth's log, so a tampered row cannot make a sign-in allocate gigabytes. A
659
+ corrupted or tampered value, or a failure inside the argon2 computation (logged as an error), fails the check like a
660
+ wrong password. A check never throws.
661
+ - A password longer than Better Auth's `emailAndPassword.maxPasswordLength` (128 by default) is never hashed: hashing
662
+ it is refused with Better Auth's `PASSWORD_TOO_LONG` (`400`), and checking it fails. Passwords are NFKC-normalised
663
+ before hashing, as Better Auth's scrypt does.
664
+ - Better Auth's plugins that hash or check a password (`admin`, `username`, `phone-number`, `email-otp`, two-factor)
665
+ use the same hook, so the profile applies to them too, whether or not `emailAndPassword.enabled` is set.
666
+
667
+ #### Node.js 22
668
+
669
+ argon2id runs on Node.js's own `crypto.argon2`, which Node.js 24.7.0 added, with no native dependency. Node.js 22 has
670
+ none, so there Access uses `@node-rs/argon2`, an optional peer you install yourself, at the exact version Access is
671
+ tested against:
672
+
673
+ ```sh
674
+ npm install --save-exact @node-rs/argon2@2.2.2
675
+ ```
676
+
677
+ It computes the same function, and Access writes and reads the PHC strings itself, so a hash made on Node.js 22 verifies
678
+ on Node.js 24 and back. On Node.js 24 it is never loaded, installed or not. On Node.js 22 without it, `createAccess`
679
+ refuses with `ACCESS_OPTIONS_INVALID`, unless you hash passwords yourself (`betterAuth.emailAndPassword.password`).
680
+
681
+ ### What the instance holds
682
+
683
+ | Member | What it is |
684
+ |---|---|
685
+ | `access.handler` | Better Auth's request handler, `(Request) => Promise<Response>`, with Access's routes |
686
+ | `access.api` | Better Auth's server API, as `auth.api` |
687
+ | `access.options` | The options Better Auth runs with: your block plus the installed plugins (what `npx auth@~1.7.12 generate --yes` reads) |
688
+ | `access.$Infer` | Types only: `Session`, `Me` (the principal) and `Claims` |
689
+ | `access.policy` | The policy engine. It is built on first read and is not enumerable. |
690
+ | `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 |
691
+ | `access.registry` | The registry's operator API, present only with `registry: { enabled: true }` |
692
+ | `access.client` | Where Access is mounted and the `$access` verbs it serves, for the framework's clients ([below](#the-access-verbs)); on the kiosk profile, the kiosk's verbs too |
693
+ | `access.kiosk` | The kiosk profile's operator API: `listDevices()` and `revokeDevice(deviceId)` ([The kiosk profile](#the-kiosk-profile)) |
694
+
695
+ A bearer token that does not verify gives `me: null`, so the policies then refuse the request.
696
+
697
+ ### Tokens and sessions
698
+
699
+ `state` takes the request's `Authorization` header first. When the header is there, the session cookie is never read:
700
+ a valid `Bearer` token gives its identity, and anything else (another scheme, a malformed token, an empty header) gives
701
+ `me: null`.
702
+
703
+ A token is accepted when all of this holds:
704
+
705
+ - it is signed by a key of the instance's key set (`GET /api/auth/jwks`). Access reads the set once, reads it again
706
+ 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
707
+ key removed from the set stops verifying within 5 minutes. When a read fails, the last set read is kept;
708
+ - its `iss` and its `aud` are both the instance's issuer, the base URL's origin, fixed when the instance was built. An
709
+ audience list that merely includes it is refused;
710
+ - it has `sub`, `exp` and `iat`, it has not expired, and it was issued no longer ago than `tokenTtl` and not in the
711
+ future. 5 seconds of clock skew are allowed;
712
+ - it carries no claim beyond the declared ones.
713
+
714
+ The session cookie is read from Better Auth's store, never from its cookie cache, so a session you revoke (a sign-out,
715
+ a revoked session, a deleted user) gives `me: null` at once. With `session.cookieCache` on, `GET /token` and the
716
+ `set-auth-jwt` header issue a token only for a session the store still has.
717
+
718
+ On a request that may change something (any method but `GET`, `HEAD` and `OPTIONS`, and a request whose method the
719
+ host did not pass), the cookie counts only when the request comes from a site Better Auth trusts: its `Origin` is your
720
+ base URL's or one of `trustedOrigins`, or, with no `Origin`, its `Sec-Fetch-Site` is `same-origin` or `none`. A request
721
+ with neither header is no browser's, and its cookie is read.
722
+
723
+ A token is not revoked. One issued before a sign-out, a revoked session or a deleted user keeps verifying until it
724
+ expires, for at most `tokenTtl` (15 minutes by default). Keep `tokenTtl` short when that matters; a service that must
725
+ see a sign-out at once reads the session instead.
726
+
727
+ The session cookie is `SameSite=Lax`, Better Auth's default: a browser sends it on another site's links, never on
728
+ another site's `POST` or `fetch`. Better Auth checks the `Origin` of its own routes that carry a cookie, and the
729
+ framework's API takes JSON, which a cross-site HTML form cannot send. Keep it that way: an endpoint of your own that
730
+ acts on a `GET`, or takes a form body, with the cookie as its credential, is open to cross-site requests. A session
731
+ cookie set to `sameSite: "none"` (in `advanced.defaultCookieAttributes` or `advanced.cookies.session_token`) is
732
+ refused with `ACCESS_OPTIONS_INVALID` unless `trustedOrigins` names the origins your app is served from. With them,
733
+ `state` holds every request to the trusted-site rule above, `GET` included.
734
+
735
+ ## Mount the routes
736
+
737
+ 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
738
+ the Access instance as it is. The framework's hosts mount no Access route.
739
+
740
+ ### Next.js
741
+
742
+ The catch-all route file, as in Better Auth's Next.js docs:
743
+
744
+ ```ts
745
+ // src/app/api/auth/[...all]/route.ts
746
+ import { toNextJsHandler } from "better-auth/next-js";
747
+ import { access } from "@/access";
748
+
749
+ export const { GET, POST } = toNextJsHandler(access);
750
+ ```
751
+
752
+ A Next.js server bundle carries Better Auth (Turbopack or webpack, and `output: "standalone"` too). Next.js evaluates
753
+ your `access.ts` once per bundle, so one process can hold several Access instances. That is expected: each one reads
754
+ Better Auth's key set from your database once.
755
+
756
+ ### NestJS, Express and Fastify
757
+
758
+ Mount Better Auth's Node.js handler before the body parser. In NestJS, that is `app.use()` between
759
+ `NestFactory.create()` and `listen()`, with a path test. It works on Express and Fastify alike:
760
+
761
+ ```ts
762
+ // src/main.ts
763
+ import type { IncomingMessage, ServerResponse } from "node:http";
764
+ import { NestFactory } from "@nestjs/core";
765
+ import { toNodeHandler } from "better-auth/node";
766
+ import { access } from "./access.js";
767
+ import { AppModule } from "./app.module.js";
768
+
769
+ async function bootstrap() {
770
+ const app = await NestFactory.create(AppModule);
771
+ // Access's routes, under /api/auth, before Nest's body parser (listen() adds it).
772
+ const auth = toNodeHandler(access);
773
+ app.use(
774
+ (request: IncomingMessage, response: ServerResponse, next: () => void) =>
775
+ request.url?.startsWith("/api/auth/") ? auth(request, response) : next(),
776
+ );
777
+ await app.listen(process.env.PORT ?? 3000);
778
+ }
779
+ await bootstrap();
780
+ ```
781
+
782
+ On Fastify, `app.use("/api/auth", …)` hands Better Auth the URL without its base path, so use the path test above. Your
783
+ own controllers keep Nest's body parser. In a plain Express app, mount `toNodeHandler(access)` the same way, before
784
+ `express.json()`.
785
+
786
+ If you enable CORS, register it before this mount, so Access's routes answer the same origins.
787
+
788
+ ### Standalone
789
+
790
+ `serve` runs Access as its own HTTP server around the same handler, with `GET /health` answering
791
+ `200 {"status":"ok"}`:
792
+
793
+ ```ts
794
+ // src/access.server.ts
795
+ import { serve } from "@aventara/access/standalone";
796
+ import access from "../access.config.js";
797
+
798
+ const server = await serve(access, { port: 4000 });
799
+ console.log(`Access listens on ${server.url}`);
800
+ ```
801
+
802
+ `port` defaults to `3000`, and `0` picks a free one. `hostname` defaults to `"127.0.0.1"`, this host only; pass
803
+ `"0.0.0.0"` to listen on every interface. `serve` resolves to `{ port, url, close() }` once the server listens.
804
+ `avaccess serve` runs the same server from a configuration file ([Running `avaccess serve`](#running-avaccess-serve)).
805
+
806
+ `serve` speaks plain HTTP. Sessions, tokens and registry keys travel in headers, so put it behind a proxy that
807
+ terminates TLS before anything outside the host reaches it. What it does for the handler behind it:
808
+
809
+ - A request body over `maxBodyBytes` (default 1 MiB, `1048576`) is refused with `413` before it is handed on: by its
810
+ `Content-Length` without reading it, or as soon as a streamed body passes the cap. A push to the registry has its
811
+ key checked before its body is read.
812
+ - The client's address is the socket's. `X-Forwarded-For` (or the first of Better Auth's
813
+ `advanced.ipAddress.ipAddressHeaders`) is replaced with it, unless the socket is a proxy listed in Better Auth's
814
+ `advanced.ipAddress.trustedProxies`: then the proxy's header is kept, and Better Auth reads the client from it.
815
+ Better Auth's rate limit counts each client on its own, and no client picks its own address.
816
+ - `close()` stops accepting connections, lets the requests in flight finish for up to 10 seconds, then ends the rest.
817
+
818
+ ### Sign up and sign in
819
+
820
+ Better Auth's routes are under `/api/auth`. With `emailAndPassword` on, from a terminal:
821
+
822
+ ```sh
823
+ # Sign up: the user is created and signed in.
824
+ curl -X POST http://localhost:3000/api/auth/sign-up/email \
825
+ -H 'Content-Type: application/json' -H 'Origin: http://localhost:3000' \
826
+ -d '{ "email": "ada@example.com", "password": "correct-horse-battery", "name": "Ada" }'
827
+
828
+ # Sign in: the session cookie goes into cookies.txt.
829
+ curl -c cookies.txt -X POST http://localhost:3000/api/auth/sign-in/email \
830
+ -H 'Content-Type: application/json' -H 'Origin: http://localhost:3000' \
831
+ -d '{ "email": "ada@example.com", "password": "correct-horse-battery" }'
832
+
833
+ # A token for a service: the session cookie traded for a JWT, sent as a bearer token.
834
+ curl -b cookies.txt http://localhost:3000/api/auth/token
835
+ ```
836
+
837
+ A browser's `fetch` to the same routes sends `Origin` itself.
838
+
839
+ With `emailAndPassword` on, anyone who reaches the server can sign up. A server whose users are created another way
840
+ (an admin, an invitation, a social provider) sets `emailAndPassword: { enabled: true, disableSignUp: true }`, and a
841
+ server that signs in no one, such as one that only runs the registry, leaves `emailAndPassword` out.
842
+
843
+ Send an `Origin` header that matches Better Auth's base URL (`BETTER_AUTH_URL`) or one of its `trustedOrigins`. A
844
+ request that carries a cookie is refused `403 MISSING_OR_NULL_ORIGIN` without one, and `403 INVALID_ORIGIN` with
845
+ another. The Aventara API then reads the user from the session cookie (profiles `web` and `kiosk`) or from
846
+ `Authorization: Bearer <token>`.
847
+
848
+ ## Wire it into Aventara
849
+
850
+ The instance fits the framework's `access` option (`AvAccessProvider`) by shape: it brings the policy engine and each
851
+ request's `me`. With `access`, the configuration has no `policy` and the host has no `state` option.
852
+
853
+ ### Next.js
854
+
855
+ Put `access` in `aventaraConfig`, in `src/aventara/config.ts`, not in `server.ts`:
856
+
857
+ ```ts
858
+ // src/aventara/config.ts
859
+ import { access } from "../access";
860
+
861
+ export const aventaraConfig = {
862
+ api: "/api",
863
+ exposed: true,
864
+ // The rest of the configuration `aventara new` wrote stays as it is.
865
+ access,
866
+ } as const;
867
+ ```
868
+
869
+ The build (`aventara:prepare`, which `next build` runs first) reads `config.ts` to compile the Contracts. With `access`
870
+ there, the prebuilt Contracts match what the server runs, and the governed fields reach the generated client. Given
871
+ only in `server.ts`, the framework warns `AVENTARA_PREBUILT_STALE` and compiles the Contracts at startup. `server.ts`
872
+ needs no change: it already spreads `aventaraConfig` into `Aventara()`.
873
+
874
+ `aventara-build` runs `config.ts`, and so your `access.ts`, at build time. It reads the project's `.env` files first,
875
+ as `avclient generate` does (a variable already set wins, then `.env.<mode>.local`, `.env.<mode>`, `.env.local` and
876
+ `.env`), so a database client that reads `DATABASE_URL` at import finds it.
877
+
878
+ ### NestJS
879
+
880
+ Put `access` beside `adapter` in the `config` that the scaffold's `forRootAsync` factory returns. This is the scaffold's
881
+ `src/app.module.ts` with the two `access` lines added:
882
+
883
+ ```ts
884
+ // src/app.module.ts
885
+ import { Module } from '@nestjs/common';
886
+ import { AppController } from './app.controller.js';
887
+ import { AppService } from './app.service.js';
888
+ import { AventaraModule } from '@aventara/nest';
889
+ import { createPrismaAdapter } from '@aventara/prisma7-adapter';
890
+ import adapter from './aventara.adapter.js';
891
+ import { aventaraConfig } from './aventara.config.js';
892
+ import { PrismaModule, PrismaService } from './prisma.service.js';
893
+ import { access } from './access.js';
894
+
895
+ @Module({
896
+ imports: [
897
+ PrismaModule,
898
+ AventaraModule.forRootAsync({
899
+ imports: [PrismaModule],
900
+ inject: [PrismaService],
901
+ useFactory: async (prisma: PrismaService) => ({
902
+ config: {
903
+ ...aventaraConfig,
904
+ adapter: await createPrismaAdapter({
905
+ ...adapter,
906
+ client: prisma,
907
+ }),
908
+ access,
909
+ },
910
+ }),
911
+ }),
912
+ ],
913
+ controllers: [AppController],
914
+ providers: [AppService],
915
+ })
916
+ export class AppModule {}
917
+ ```
918
+
919
+ Here `aventaraConfig` is the configuration without `policy`, since Access brings the engine. Nest compiles its
920
+ Contracts at startup, so nothing goes stale.
921
+
922
+ For the MCP endpoint, see [Agents over MCP](#agents-over-mcp): `mcp: { path: "/mcp", authorization: access.mcp }`.
923
+
924
+ ### Anywhere else
925
+
926
+ `createFramework({ ...config, access })`.
927
+
928
+ With the Better Auth plugin, `accessOf(auth)` is the `access` value, in each of the places above.
929
+
930
+ ### The `$access` verbs
931
+
932
+ `access.client` is what the framework's `$access` reads (the provider's `client` member): where Access is mounted, and
933
+ the verbs this instance serves. `url` is Better Auth's base URL with its base path (`http://localhost:3000/api/auth`),
934
+ or the base path alone (`/api/auth`) when `baseURL` is dynamic and follows the request's host. Each verb has a method,
935
+ a path under `url`, the JSON Schema of its arguments and of its result, and `call`:
936
+
937
+ | Verb | Route | Arguments | Result |
938
+ |---|---|---|---|
939
+ | `signIn` | `POST /sign-in/email` | `email`, `password`, optional `callbackURL`, `rememberMe` | `redirect`, `url`, `user: { id }` |
940
+ | `signUp` | `POST /sign-up/email` | `name`, `email`, `password`, optional `image`, `callbackURL`, `rememberMe` | `user: { id }` |
941
+ | `signOut` | `POST /sign-out` | none | `success` |
942
+ | `resetPassword` | `POST /reset-password` | `newPassword`, `token` | `status` |
943
+ | `me` | `GET /me` | none | `{ sub, claims }`, or `null` when no one is signed in |
944
+
945
+ 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
946
+ flags, never the session token or a field of the user. `me` is the token identity: `sub` and the claims
947
+ `policies.claims` declares, the same as the token carries, whatever `principal` maps the request to. `GET /api/auth/me`
948
+ is Access's own route, served by `access.handler`; a plugin of yours with an endpoint at `/me` (or named `accessMe`) is
949
+ refused with `ACCESS_PLUGIN_DUPLICATE`.
950
+
951
+ A verb the instance does not serve is left out: `signIn`, `signUp` and `resetPassword` need `emailAndPassword`,
952
+ `signUp` is left out with `disableSignUp`, a path in `disabledPaths` takes its verb out, and the `service` profile has
953
+ no `signIn` or `signUp`.
954
+
955
+ `call({ args, headers })` runs the verb on the server, in the same process, through Better Auth's API with the
956
+ request's headers. That is how a Server Action signs a user in: Better Auth's `nextCookies()` plugin, last in
957
+ `betterAuth.plugins`, sets the browser's cookie from the answer. The call is checked as the route would be: the
958
+ arguments against the verb's schema (`400` naming the key, never the value), Better Auth's origin check (a cookie from
959
+ a site Better Auth does not trust, or a `callbackURL` outside `trustedOrigins`, is refused `403`), every plugin's
960
+ `onRequest` in the order Better Auth's router runs them (so `captcha()` refuses a call without its answer, as it refuses
961
+ 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
962
+ request is the one the route runs with), the profile's refusals, and the audit log, which records the sign-in with
963
+ origin `client`.
964
+
965
+ Better Auth's rate limit runs in its router, which a call to Better Auth's API skips, so Access limits the calls of
966
+ `signIn`, `signUp` and `resetPassword` itself, with Better Auth's own `rateLimit` options: on when they turn it on
967
+ (`enabled`, production by default), with the rule Better Auth's router applies to the route (3 tries in 10 seconds on
968
+ `/sign-in/*` and `/sign-up/*` by default, a plugin's rules, `customRules`, else `window` and `max`), in the storage they
969
+ name (`customStorage`, `secondaryStorage`, the database, or memory). Each call is counted in two buckets, and refused
970
+ when either is full:
971
+
972
+ - **by client address**, read from the request's headers as Better Auth reads it (`advanced.ipAddress`: its
973
+ `ipAddressHeaders`, `trustedProxies` and `ipv6Subnet`), under the key Better Auth's router uses for the route. With
974
+ the database, a secondary or a custom storage, a client's calls and its requests to the route spend one budget; in
975
+ memory they do not, since Better Auth's memory store is its own. With `disableIpTracking` there is no address bucket;
976
+ - **by target**: the email the call names (normalized as `/sign-in/email` normalizes it), or a password reset's token,
977
+ as its HMAC, so guesses on one account from many addresses count together. It counts failures: 10, each within 5
978
+ minutes of the one before, refuse the account's next try, the right password too, until 5 minutes have passed. A
979
+ success clears the count, so a few failures by someone else never keep the owner out. A try is counted when it is let
980
+ through, so parallel guesses cannot all pass on one reading. With `customStorage`, which can count but not clear, the
981
+ target's count is kept in the server's memory.
982
+
983
+ A refused call throws Better Auth's own `429` (`APIError` `TOO_MANY_REQUESTS`, with `X-Retry-After`), and the audit
984
+ log records a **sign-in-failed** with reason `rate-limited`. Give the server the client's address: behind a proxy, set
985
+ `trustedProxies`, or calls whose address cannot be read share one bucket per route, as Better Auth's requests do.
986
+
987
+ ### The kiosk profile
988
+
989
+ `profile: { kind: "kiosk", canEnrollDevice, idleTimeout?, insecureLan? }` is the web profile plus staff switching by PIN on shared
990
+ devices, such as the tills of a shop. A manager enrolls a device once; after that, anyone with a PIN switches the
991
+ device to themselves with their user id and PIN, and the session that opens is bound to that device and locks when
992
+ idle.
993
+
994
+ | Option | Takes | Default |
995
+ |---|---|---|
996
+ | `canEnrollDevice` | `(identity) => boolean`: who may enroll, list or revoke devices, asked with `{ sub, claims }` (typed from `policies.claims`) of a fresh session opened with full credentials. Anything but `true` refuses. | required |
997
+ | `idleTimeout` | Seconds without a request that may change something (any method but GET, HEAD or OPTIONS) after which a session on the device locks: whole seconds from 60 to 3600 | 300 |
998
+ | `insecureLan` | The private IPv4 ranges of a till network without TLS, on whose addresses a plain http: base URL is taken ([A till network without TLS](#a-till-network-without-tls)) | none |
999
+
1000
+ `profile` takes one kind. The kiosk's keys on `web` or `service`, or two kinds at once (`kind: ["kiosk",
1001
+ "service"]`), are a type error and are refused with `ACCESS_OPTIONS_INVALID`: a service hands no caller a session,
1002
+ and a kiosk is made of them. The kiosk also refuses, each because it is a way around the device binding:
1003
+
1004
+ - Better Auth's cookie cache (a locked session must end at once, and the cookie cache would keep answering it);
1005
+ - `secondaryStorage` without `session.storeSessionInDatabase: true` (the kiosk finds a device's sessions in the
1006
+ database to end them);
1007
+ - Better Auth's `bearer()` (it hands a session's token to page code and takes it back from an `Authorization` header,
1008
+ where the device's credential does not go). Under any other id, no answer carries its `set-auth-token` header;
1009
+ - Better Auth's `multiSession()` (it keeps the session before a switch in the browser and switches back to it);
1010
+ - `advanced.crossSubDomainCookies`, and `SameSite=None` in `advanced.defaultCookieAttributes` or on any cookie in
1011
+ `advanced.cookies` (the credential and the sessions would reach other hosts, or come from other sites);
1012
+ - a plain http: base URL, except on a loopback host (`localhost`, 127.0.0.0/8, `[::1]`) or on an address
1013
+ `insecureLan` lists: the credential and the PIN sessions would cross the network in the clear. A dynamic `baseURL`
1014
+ is read whole: it takes `protocol: "https"`, unless every allowed host is a loopback host.
1015
+
1016
+ On the web and service profiles there is no PIN route: each answers `404`.
1017
+
1018
+ The routes, under Better Auth's base path, and the verbs `access.client.verbs` publishes for them on this profile,
1019
+ after Better Auth's (the framework's `$access` takes them as optional verbs):
1020
+
1021
+ | Verb | Route | Arguments | Result | Who |
1022
+ |---|---|---|---|---|
1023
+ | `enrollDevice` | `POST /kiosk/enroll-device` | `name` | `device: { id, name }` | A fresh full-credential session `canEnrollDevice` allows |
1024
+ | `revokeDevice` | `POST /kiosk/revoke-device` | `deviceId` | `success` | The same |
1025
+ | `setPin` | `POST /kiosk/set-pin` | `pin` | `success` | A fresh full-credential session, for its own user |
1026
+ | `switchUser` | `POST /kiosk/switch-user` | `userId`, `pin` | `user: { id }` | An enrolled device |
1027
+ | `lock` | `POST /kiosk/lock` | none | `success` | An enrolled device |
1028
+
1029
+ `GET /kiosk/list-devices` answers a manager (the same as `enrollDevice`) with `devices: [{ id, name, enrolledBy,
1030
+ enrolledAt, lastSwitchAt? }]`, never a credential. It is a route only: the framework's set of verbs is closed. Call the
1031
+ verbs' routes from the device, or their `call` from your server. A `call` of `switchUser`, `lock` or `enrollDevice`
1032
+ sets the session cookie and the device's credential through your cookie plugin (Better Auth's `nextCookies()` in a
1033
+ Server Action), as `signIn`'s does. Without one the browser keeps the credential the switch replaced, and the device's
1034
+ next switch is refused as a copy: call the route from the device instead.
1035
+
1036
+ Your server has its own way in, which no route opens:
1037
+
1038
+ | Call | Does |
1039
+ |---|---|
1040
+ | `access.kiosk.listDevices()` | The enrolled devices, as `GET /kiosk/list-devices` answers them (dates as `Date`) |
1041
+ | `access.kiosk.revokeDevice(deviceId)` | Revokes a device, ending every session on it; `false` when there is none |
1042
+ | `access.api.kioskSetUserPin({ body: { userId, pin } })` | Sets anyone's PIN, so staff who have no password get one from a manager's screen your app guards. Better Auth's server-only endpoint: its router never serves it, and a call with a PIN session's headers is refused |
1043
+
1044
+ `access.kiosk` is there on the kiosk profile only.
1045
+
1046
+ - **A fresh full-credential session** is one opened with a password (or any sign-in but a PIN) within Better Auth's
1047
+ `session.freshAge`, one day by default. A PIN session never enrolls, lists, revokes or sets a PIN.
1048
+ - **Enrolling** gives the device its credential, 32 random bytes in an HTTP-only cookie (`better-auth.kiosk_device`,
1049
+ kept 400 days, always `SameSite=Lax`; on an https base URL, `__Host-better-auth.kiosk_device`, `Secure`, at `/` and
1050
+ with no `Domain`, whatever Better Auth's cookie options say; under `insecureLan`, `better-auth.kiosk_device`, not
1051
+ `Secure`, at `/` and with no `Domain`). A Strict device cookie would not be sent on a link the
1052
+ till opens from another site (a magic link from webmail), and the session it opens would not be bound. Enrolling
1053
+ ends the session that enrolled it, so no manager's session stays on
1054
+ a shared device: the device starts locked. Enrolling a device that already has a credential revokes the old
1055
+ enrollment first.
1056
+ - **A PIN** is 6 to 12 digits, refused when it is set (`400 PIN_TOO_WEAK`) if it is one a guesser tries first: the
1057
+ blocklist Apple's iOS uses for 6-digit passcodes (as Markert et al. extracted it, IEEE S&P 2020), widened to every
1058
+ length (one digit or a block repeated, `000000`, `121212`, `123123`; two digits alternating; a palindrome,
1059
+ `123321`; a straight run, `123456`; doubled digits, `112233`; lines of the keypad, `147258`, `159753`), and the
1060
+ most common 6-digit PINs of leaked passwords (`789456`, `246810`, `520131`). Dates are not refused. It is hashed with
1061
+ argon2id at the `passwords` profile, whatever hashes your users' passwords, and never stored, logged or answered.
1062
+ Setting a PIN clears its user's failed PINs.
1063
+ - **A switch** checks the device's credential, then the PIN. A right one ends every session on the device, and any
1064
+ other session the request carried, and opens a new one for the user named, with that user's own claims: nothing of
1065
+ the session before it carries over. The device's credential is replaced in the same answer, so a copy of the old one
1066
+ is spent. The audit log records a `kiosk-switch` naming the user before (`previousUserId`) and after. A session
1067
+ Better Auth refuses to open (a user the admin plugin banned, or your own session hook) leaves the device as it was.
1068
+ - **Lock** ends the same sessions and keeps the device enrolled. A session on the device idle for `idleTimeout` is
1069
+ ended on its next request, as if locked (`kiosk-lock`, reason `idle`). Only a request that may change something
1070
+ counts as use: any method but GET, HEAD or OPTIONS, through Better Auth's routes or through the framework
1071
+ (`access.state` reads the host's method). A page left open that keeps reading, such as Better Auth's client polling
1072
+ `/get-session`, does not keep its session alive.
1073
+ - **Revoking** a device ends every session on it at once. Nothing is pushed to the till: it learns on its next
1074
+ request, which finds no session, and its next switch is refused `KIOSK_DEVICE_NOT_ENROLLED`.
1075
+
1076
+ What protects a PIN, a weak secret:
1077
+
1078
+ - **Per user, on one device**: 5 wrong PINs free, then one try per backoff that doubles from 1 minute to 1 hour
1079
+ (`429 KIOSK_PIN_LOCKED`, with `X-Retry-After`), the right PIN included. After 100 in a row (NIST SP 800-63B's
1080
+ ceiling) the PIN alone stops working on that device (`403 KIOSK_PIN_DISABLED`); the backoff makes that take days,
1081
+ every try logged. It is kept per device, so whoever stands at a device can slow or disable a colleague's PIN on that
1082
+ device only. On every other device it still works, and the user's next full sign-in, or a new PIN, clears it
1083
+ everywhere. An admin's impersonation is no full sign-in and clears nothing. The account itself is never locked.
1084
+ - **Per device**: 10 wrong PINs at once, for any users, then one more every 90 seconds (a token bucket, so no
1085
+ window's edge lets a second burst through). A right PIN gives back its own try and no more, so knowing one PIN does
1086
+ not reset the count between guesses at others. While the device is held, a user with no wrong PIN on it since their
1087
+ last right one still gets one try, so one person spending a till's tries does not lock everyone else out of it.
1088
+ - Both are counted in the database before the PIN is checked, so parallel guesses cannot all pass one reading, and they
1089
+ hold across restarts and instances. They are kept for any user id, whether it holds a PIN or names no user, and a
1090
+ user id with no PIN costs the same argon2id check as one with a PIN, so no answer tells who holds a PIN.
1091
+
1092
+ What binds a session to its device:
1093
+
1094
+ - **Every session opened on an enrolled device is bound to it**: a PIN switch's, and a password sign-in's on the
1095
+ device too. A bound session is held to the idle timeout, is issued no token (`GET /token` refuses it), and
1096
+ `/get-session` answers it without its token, so page code never holds one. With `mcp` or `audience`, that includes
1097
+ the OAuth provider's tokens: a bound session is refused at `/oauth2/authorize`, `/oauth2/consent` and
1098
+ `/oauth2/continue`, a sign-in on an enrolled device that resumes an authorization request (the login page's
1099
+ `oauth_query`) is refused before it runs, a social sign-in begun elsewhere with one, whose callback lands on the
1100
+ till, opens no session there, and a token issued in a bound session anyway verifies as nobody. A bound session is
1101
+ refused at the provider's routes that manage clients and consents too (`/oauth2/create-client`,
1102
+ `/oauth2/get-consents`, `/oauth2/delete-consent` and the like). An agent is authorized, and its clients managed,
1103
+ from a manager's own browser, off the till.
1104
+ - **The device's credential, on every request.** On every call of Better Auth's that carries a session, by its cookie
1105
+ or by an `Authorization: Bearer` header (its routes, its API with headers or a `request`, `access.state` and `GET /me`), a bound
1106
+ session is ended, and the call goes on without it, unless the request brings its device's current credential
1107
+ (`kiosk-lock`, reason `device-mismatch`). A session copied to another browser, or to another enrolled device, is no
1108
+ session there, and no longer one on the device it came from. A session bound to no device, brought with an enrolled
1109
+ device's credential (current, or one a switch replaced), is ended the same way: it was opened off the device, so it
1110
+ is no session on it.
1111
+ - **One credential at a time, and its whole family remembered.** The credential is replaced at every switch, and every
1112
+ credential a switch replaced is kept as a hash for 400 days, the device cookie's life. Any of them brought back revokes the device with every session on it
1113
+ (`kiosk-device`, reason `token-reuse`): a copy is in use somewhere, however many switches ago it was taken. The one
1114
+ exception is the credential the last switch replaced, within 30 seconds of it: that is a request that left before
1115
+ the device had its answer (a double tap, two calls at once), and it is refused (`409 KIOSK_DEVICE_BUSY`, logged as
1116
+ `kiosk-device`, reason `stale-in-grace`) with the device and its cookie left as they are; so is a sign-in that brings
1117
+ it, which opens no session. A device whose answer was lost in transit is revoked after those 30 seconds,
1118
+ and a manager enrolls it again.
1119
+ - **No way out of the device.** A PIN session reaches only `/get-session`, `/sign-out`, `/me`, `/jwks`,
1120
+ `/kiosk/switch-user`, `/kiosk/lock` and the routes that sign in with full credentials, whether the call comes
1121
+ through `access.handler` or your server's own `access.api` call with its cookie; any other is refused `403
1122
+ KIOSK_SESSION_NOT_ALLOWED`, so it mints no token, no other session (one-time tokens, impersonation), no credential
1123
+ (passkeys, API keys, linked accounts) and changes no account.
1124
+
1125
+ The kiosk's tables are `accessKioskDevice`, `accessKioskPin`, `accessKioskLockout` and `accessKioskRetiredSecret`,
1126
+ and Better Auth's `session` gains `kioskDeviceId`, `kioskSeenAt` and `kioskPin` (no user sets them). `npx auth@~1.7.12
1127
+ generate --yes` writes them as it writes Better Auth's own; off Prisma's adapter, name them in `resources.authTables`. Like
1128
+ Access's other tables they stay closed to every request, and a credential's or a PIN's hash is a secret column no grant
1129
+ opens.
1130
+
1131
+ | Code | Status | When |
1132
+ |---|---|---|
1133
+ | `KIOSK_DEVICE_NOT_ENROLLED` | 401 | The request brings no current device credential |
1134
+ | `INVALID_USER_OR_PIN` | 401 | A wrong PIN, a user with none, or no such user (also `400` for a PIN that is not 6 to 12 digits) |
1135
+ | `KIOSK_PIN_LOCKED` | 429 | The user's backoff on this device, or the device's limit; `X-Retry-After` says when |
1136
+ | `KIOSK_PIN_DISABLED` | 403 | 100 wrong PINs in a row for this user on this device; a full sign-in or a new PIN restores it |
1137
+ | `KIOSK_DEVICE_BUSY` | 409 | Two switches on one device at the same moment, or a request or sign-in with the credential the last switch replaced, within 30 seconds of it; try again |
1138
+ | `KIOSK_FULL_CREDENTIAL_REQUIRED`, `SESSION_NOT_FRESH` | 403 | Enrolling, listing, revoking or setting a PIN from a PIN session, or a session older than `freshAge` |
1139
+ | `KIOSK_ENROLL_REFUSED` | 403 | `canEnrollDevice` did not answer `true` |
1140
+ | `KIOSK_SESSION_NOT_ALLOWED` | 403 | A PIN session on a route it does not reach, a device-bound session asking for a token or managing OAuth clients or consents, or a sign-in on an enrolled device that would resume an authorization request |
1141
+ | `KIOSK_DEVICE_NOT_FOUND` | 404 | Revoking a device that is not enrolled |
1142
+ | `USER_NOT_FOUND` | 404 | `kioskSetUserPin` for no such user |
1143
+ | `PIN_TOO_WEAK` | 400 | A PIN that is not 6 to 12 digits, or one a guesser tries first |
1144
+
1145
+ A plugin in `betterAuth.plugins` with an endpoint under `/kiosk/` (or named `kioskSwitchUser` and the like) is refused
1146
+ with `ACCESS_PLUGIN_DUPLICATE` on this profile.
1147
+
1148
+ #### A till network without TLS
1149
+
1150
+ An on-prem shop may run its server on the tills' own network: a router of its own, WPA3, with nothing else on
1151
+ it, and no certificate a tablet would trust. `insecureLan` lists that network's ranges, and the kiosk then takes a
1152
+ plain http: base URL on an address inside one, such as `baseURL: "http://192.168.1.10:3000"` with
1153
+ `insecureLan: ["192.168.1.0/24"]`. Everything that crosses that network does so in the clear: the device's
1154
+ credential, the PIN sessions and the PINs themselves. Anyone who joins the network, or reads its radio, can take a
1155
+ till's session or learn a PIN. Use it only on a network that is the tills' alone.
1156
+
1157
+ - **A range** is an IPv4 CIDR written in full, four decimal numbers without leading zeros and a prefix length, with
1158
+ its host bits clear (`192.168.1.0/24`, or `192.168.1.10/32` for the server alone). It is a /16 or narrower and lies
1159
+ wholly inside 10.0.0.0/8, 172.16.0.0/12 or 192.168.0.0/16. A hostname (`localhost` and `till.lan` included), an IPv6
1160
+ range, a shared (100.64.0.0/10), link-local, loopback or public range, an empty list, a range listed twice and a
1161
+ range inside another listed one are refused with `ACCESS_OPTIONS_INVALID`; the type takes a non-empty list of `a.b.c.d/n` strings, so a hostname or a
1162
+ missing prefix length is a type error first.
1163
+ - **The base URL** is http: on an IPv4 address inside a listed range: never a hostname, which a resolver could point
1164
+ elsewhere, and never IPv6. It is written as the browser writes it: `http://192.168.001.010` (which a browser reads
1165
+ as 192.168.1.8) or `http://3232235786` is refused, naming the form to write, so the address you read is the one
1166
+ used. A range's network or broadcast address (`192.168.1.0` or `192.168.1.255` in a /24) is refused too: no server
1167
+ has it. It is a fixed `baseURL` (or `BETTER_AUTH_URL`), not a dynamic one. An https: base URL, or a loopback one,
1168
+ ignores `insecureLan`.
1169
+ - **Cookies** follow http: Better Auth's session cookie is not `Secure`, and the device's credential is
1170
+ `better-auth.kiosk_device`, HTTP-only, `SameSite=Lax`, at `/`, with no `Domain`, and not `Secure`. A browser drops a
1171
+ `Secure` cookie, or one named `__Secure-` or `__Host-`, on a plain http page off a loopback host, so
1172
+ `advanced.useSecureCookies: true`, a `secure: true` in `advanced.defaultCookieAttributes` or `advanced.cookies`, and
1173
+ such a prefix in `advanced.cookiePrefix` or a cookie's `name` are refused here. Cookies are not kept apart by port:
1174
+ run nothing else on the server's address. A service on another port of that address receives every till's
1175
+ credential and session, and can set cookies in any browser that visits it. With a till's credential planted in a
1176
+ browser, it can end that browser's session and bind its next sign-in to the till.
1177
+ - **Origins**: Better Auth's origin check takes the base URL's origin as it does on https, and refuses another, a
1178
+ second address on the same network included.
1179
+ - **At startup** Better Auth's logger warns, naming the base URL and the ranges. `logger: { disabled: true }` in
1180
+ `betterAuth` silences it with the rest of Better Auth's log.
1181
+
1182
+ Everything else is as on https: the device binding, the credential replaced at every switch, the idle lock, the PIN
1183
+ limits and the refusals of tokens.
1184
+
1185
+ #### A till that is an Expo app
1186
+
1187
+ A till can be a React Native app on Better Auth's Expo integration (`@better-auth/expo`) instead of a browser. Its
1188
+ client keeps the cookies in SecureStore and sends them in a `Cookie` header, so the device's credential and the session
1189
+ work as they do in a browser: enrolling, switching (each switch's new credential is the one it keeps), the idle lock,
1190
+ lock and revoking. On the server:
1191
+
1192
+ - Add Better Auth's `expo()` to `betterAuth.plugins`, and the app's scheme to `trustedOrigins` (`"tillapp://"`). The
1193
+ app names itself in an `expo-origin` header, which `expo()` makes the request's `Origin` when it has none, and Better
1194
+ Auth's origin check then takes it like a trusted site. A browser always sends its own `Origin`, which `expo()` leaves
1195
+ as it is. In Expo Go the origin is `exp://` and the dev server's address; `expo()` trusts `exp://` only when
1196
+ `NODE_ENV` is `development`.
1197
+ - Send the app's cookies to your Aventara API too (`Cookie: await authClient.getCookie()`): the device's credential is
1198
+ one of them, and without it a bound session is ended.
1199
+ - On the till, staff sign in with their password (`signIn.email`) or switch by PIN. Two of the Expo client's sign-ins
1200
+ open the session off the device and it is not bound, so the till ends it on its next request: a native ID-token
1201
+ sign-in (`signIn.social` with `idToken`), which the Expo client sends with no cookie, and a sign-in in the system
1202
+ browser handed back to the app (a magic link, social sign-in's redirect). For an ID-token sign-in on a till, pass the
1203
+ cookies and the origin yourself, `{ headers: { cookie: await authClient.getCookie(), "expo-origin": "tillapp://" } }`
1204
+ as `signIn.social`'s second argument, and the session is bound. A redirect sign-in cannot be bound: the system
1205
+ browser does not hold the device's credential, and it keeps that session itself, where the next person at the till
1206
+ could open it. On a till, use a password or a PIN; if a till must sign in that way, open the browser session as an
1207
+ ephemeral one (`preferEphemeralSession` in `expo-web-browser`'s `openAuthSessionAsync`, on iOS), so the system
1208
+ browser keeps nothing.
1209
+ - `expo()` hands a redirect sign-in's session cookie to the app in the redirect URL (`?cookie=`). On Android another
1210
+ app that registers the same scheme could receive it. That is Better Auth's Expo integration, not the kiosk, and on a
1211
+ till such a session is not bound, so the till ends it.
1212
+ - A native app has no HTTP-only cookie: the device's credential and the session token sit in SecureStore, where the
1213
+ app's own code can read them. `/get-session` still answers a bound session without its token.
1214
+ - Under `insecureLan`, React Native refuses plain http by default: allow the server's address in Android's network
1215
+ security configuration (`cleartextTrafficPermitted`) and in iOS's App Transport Security (an exception for the
1216
+ address, or `NSAllowsLocalNetworking`). The Expo client sends the cookies to the base URL whatever their attributes,
1217
+ so nothing on the till stops the credential from going over http: the network must be the tills' alone.
1218
+
1219
+ ## Agents over MCP
1220
+
1221
+ Better Auth authenticates; Access authorizes. An agent's client gets its token from Better Auth's own OAuth provider,
1222
+ `@better-auth/oauth-provider`, which you add to `betterAuth.plugins` with its options as Better Auth's docs give them.
1223
+ Access issues no token and serves no OAuth route. With `mcp: { resource }` it reads the provider's tokens for your MCP
1224
+ endpoint, holds the agent to its user's grants and its token's scopes, and gives the framework its `mcp.authorization`.
1225
+
1226
+ ```ts
1227
+ // src/access.mcp.ts
1228
+ import { createAccess } from "@aventara/access/auth";
1229
+ import { cimd } from "@better-auth/cimd";
1230
+ import { fetchClientMetadataResource } from "@better-auth/cimd/node";
1231
+ import { oauthProvider } from "@better-auth/oauth-provider";
1232
+ import { Pool } from "pg";
1233
+ import { policies } from "./access.policies.js";
1234
+
1235
+ /** The MCP endpoint, as its protected-resource metadata names it. */
1236
+ const resource = "https://app.example.com/mcp";
1237
+
1238
+ const claims = { role: "string", banned: "boolean" } as const;
1239
+
1240
+ export const access = createAccess({
1241
+ betterAuth: {
1242
+ database: new Pool({ connectionString: process.env.DATABASE_URL }),
1243
+ baseURL: "https://app.example.com",
1244
+ emailAndPassword: { enabled: true },
1245
+ user: {
1246
+ additionalFields: {
1247
+ role: { type: "string", required: false, input: false },
1248
+ banned: { type: "boolean", required: false, input: false },
1249
+ },
1250
+ },
1251
+ plugins: [
1252
+ // Better Auth's OAuth provider, as its docs give it: it issues every token.
1253
+ oauthProvider({
1254
+ loginPage: "/sign-in",
1255
+ consentPage: "/consent",
1256
+ scopes: ["aventara:read", "aventara:write", "offline_access"],
1257
+ resources: [resource],
1258
+ clientRegistrationDefaultResources: [resource],
1259
+ }),
1260
+ // Client ID Metadata Documents: an agent's client is the domain its document is served from.
1261
+ cimd({ fetchClientMetadataResource, metadataProfile: "mcp-2026-07-28" }),
1262
+ ],
1263
+ },
1264
+ policies: { rules: policies, claims },
1265
+ principal: ({ sub, claims }) => ({
1266
+ id: sub,
1267
+ claims: {
1268
+ ...(claims.role === "admin" ? { role: "admin" as const } : {}),
1269
+ ...(claims.banned === undefined ? {} : { banned: claims.banned }),
1270
+ },
1271
+ }),
1272
+ mcp: { resource },
1273
+ });
1274
+ ```
1275
+
1276
+ Then the framework's configuration takes it in one line: `mcp: { path: "/mcp", authorization: access.mcp }`, beside
1277
+ `access`. `access.mcp` is there exactly when `mcp` is given, and is `{ servers, verify }`:
1278
+
1279
+ - `servers` is Access's issuer, the origin of Better Auth's base URL, alone. The provider's metadata, the `iss` of each
1280
+ authorization response (RFC 9207) and every token's `iss` carry the same string. Mount `access.handler` at
1281
+ `/.well-known/oauth-authorization-server` too, at the root of that origin, where a client looks for the provider's
1282
+ metadata (RFC 8414). It is https, or on this machine `http://127.0.0.1` or `http://[::1]` with any port: set
1283
+ `baseURL` to that address, not `localhost` (a name is resolved, and may resolve elsewhere). An http issuer is for
1284
+ development: a client that holds to RFC 8414 strictly refuses one.
1285
+ - `verify(token)` takes the provider's JWT access token for the MCP endpoint and nothing else, and resolves who the
1286
+ agent acts for: `{ sub, claims, clientId, scopes }`, the claims read from the user now. It refuses a session token
1287
+ (`GET /token`), a token for another resource, an opaque token (a client that sent no `resource`), a token bound to a
1288
+ DPoP key, a client-credentials token (it acts for no user), and one whose client is disabled, whose user is gone or
1289
+ no longer consents to its scopes, or whose session (when it was issued in one) has ended.
1290
+ - A JWT access token is not revoked by `POST /oauth2/revoke`: the provider answers that a self-contained token cannot
1291
+ be revoked server-side, and the token keeps verifying until it expires. To stop an agent at once, revoke the user's
1292
+ consent to its client, disable the client or end the session it was issued in (each is read on every request);
1293
+ keep the provider's `accessTokenExpiresIn` short. A refresh token `/oauth2/revoke` does revoke.
1294
+ - With Better Auth's `secondaryStorage`, the provider requires `session.storeSessionInDatabase: true`, so the session a
1295
+ token names is a database row; with `preserveSessionInDatabase` too, Better Auth ends that row when the session ends,
1296
+ and the token stops.
1297
+ - Better Auth's OAuth provider (1.7.12 to 1.7.14) cannot register its resource on Better Auth's Prisma adapter: it writes
1298
+ `allowedScopes: null` into a `String[]` column, which Prisma will not leave unset. Run Better Auth on a `pg` `Pool`
1299
+ (its built-in Kysely) for the provider's tables, as the samples here do; the tables can still be in your Prisma
1300
+ schema (`auth generate` writes them, `prisma db push` makes them, `resources.authTables` names each).
1301
+
1302
+ Install the provider and `@better-auth/cimd` in the same range as Better Auth (`npm install
1303
+ @better-auth/oauth-provider@~1.7.12 @better-auth/cimd@~1.7.12`); npm takes the newest patch of each, with Better Auth's
1304
+ at the same patch. With it an agent's client registers itself by its `client_id`, the URL of its metadata document, and
1305
+ your consent page can name the domain it comes from. Open dynamic registration (`allowDynamicClientRegistration` with
1306
+ `allowUnauthenticatedClientRegistration: true`) registers any client as whoever it says it is, so Access refuses it
1307
+ unless you acknowledge it with `mcp: { resource, openRegistration: true }`. Registration by a signed-in user or with
1308
+ an initial access token needs no acknowledgement.
1309
+
1310
+ The request state reads the same token, so `ctx.$state.me` is the user's principal, as `principal` maps it. The
1311
+ policy holds it to the token before the rules:
1312
+
1313
+ - under the `agent` origin, `aventara:read` admits the read questions and `aventara:write` the writes (`create`,
1314
+ `update`, `delete`); a question the scopes do not admit is refused, and one they admit is the user's own, so an agent
1315
+ never does what its user may not. Your rules can narrow it further by origin;
1316
+ - under any other origin it is refused everything: the token opens the MCP endpoint alone, never the protocol routes,
1317
+ `_access/can`, a server-side call or Access's own `GET /me` route, which answers it `null`;
1318
+ - a refused question is an audit event like any other, with the reason `deny:mcp-scope` or `deny:mcp-audience`.
1319
+
1320
+ The claims are read from the user on every request, so a ban or a role taken away reaches the agent on its next call,
1321
+ not when its token expires.
1322
+
1323
+ `me()` is the token identity, `{ sub, claims }`, everywhere, the MCP surface included: the framework's MCP `me` tool
1324
+ calls `access.client.verbs.me.call` in-process with the agent's headers, and it answers who the agent acts for, marked
1325
+ so that `agentOf` (below) tells it is an agent's. Only the HTTP route `GET /me` answers an agent's token `null`.
1326
+
1327
+ To tell an agent's request apart in your own code, call `agentOf(me)`, exported from `@aventara/access/auth` and
1328
+ `@aventara/access/better-auth`. It answers `{ clientId, scopes }` for the principal an agent's token yields, and for the
1329
+ identity `principal` is given for one, so your mapping can see it too; for any other principal it answers `null`. The
1330
+ mark does not show in the principal's own fields, its spread or its JSON.
1331
+
1332
+ ```ts
1333
+ // src/agent.check.ts
1334
+ import { agentOf } from "@aventara/access/auth";
1335
+
1336
+ /** Whether a request's principal is an agent acting for its user, and which client. */
1337
+ export const actingAgent = (me: unknown): string | null => agentOf(me)?.clientId ?? null;
1338
+ ```
1339
+
1340
+ Access refuses to start (`ACCESS_OPTIONS_INVALID`) on a provider that would let an agent's authorization fall short:
1341
+ none in the plugins, or two; opaque access tokens (`disableJwtPlugin`); the endpoint missing from its `resources`; no
1342
+ `aventara:read` among its `scopes`; a refresh reuse window (`refreshTokenReuseInterval` other than 0, the provider's
1343
+ default: a spent refresh token is never answered again); a `validateRedirectUri` that accepts redirect URIs the client
1344
+ did not register; a `requestUriResolver` (an authorization request by reference); a `postLogin.consentReferenceId`
1345
+ (a JWT access token does not say which reference its consent was stored under); open registration without
1346
+ `openRegistration`; an endpoint that is the provider's own UserInfo URL; or the `service` profile, which hands no
1347
+ browser a session to consent in. A provider or a `cimd()` outside `~1.7.12` is refused
1348
+ with `ACCESS_BETTER_AUTH_VERSION`. Every authorization request must use PKCE with its SHA-256 method and carry its own
1349
+ parameters: Access refuses one without, or one by reference (`request_uri`), with `400 invalid_request`, from any
1350
+ client. `verify` also refuses a token without the session it was issued in (`sid`), one signed with an algorithm
1351
+ `jwt()` does not sign with, one for a resource the provider no longer has, and one whose user's consent does not name
1352
+ the endpoint.
1353
+
1354
+ With the plugin, pass the same `mcp` to `aventaraAccess()` and put `oauthProvider()` after it in `plugins`: Better Auth
1355
+ starts its plugins in order, and the provider reads the `jwt()` the Access plugin carries as it starts.
1356
+ `accessOf(auth).mcp` is then the framework's `mcp.authorization`.
1357
+
1358
+ Your consent page is your app's, as Better Auth's provider leaves it. Show the client's name, the domain of its
1359
+ metadata document and the host of the redirect URI it asked for, and say what each scope lets the agent do.
1360
+
1361
+ ### One audience per service
1362
+
1363
+ By default a service takes the one-org token `GET /token` issues, whose audience is the issuer. A service can opt in
1364
+ to tokens addressed to it alone (RFC 8707): `audience` names its URL, as a client names it when it asks the provider
1365
+ for a token (`resource`), and the provider lists it among its `resources`. Each client the service takes is linked to
1366
+ it, one by one (`auth.api.adminLinkClientResource`, the provider's own operator call), and asks for one of Access's
1367
+ scopes.
1368
+
1369
+ ```ts
1370
+ // src/access.audience.ts
1371
+ import { createAccess } from "@aventara/access/auth";
1372
+ import { oauthProvider } from "@better-auth/oauth-provider";
1373
+ import { Pool } from "pg";
1374
+ import { policies } from "./access.policies.js";
1375
+
1376
+ /** This service, as a client names it when it asks for a token. */
1377
+ const audience = "https://app.example.com/api";
1378
+
1379
+ const claims = { role: "string", banned: "boolean" } as const;
1380
+
1381
+ export const access = createAccess({
1382
+ betterAuth: {
1383
+ database: new Pool({ connectionString: process.env.DATABASE_URL }),
1384
+ baseURL: "https://app.example.com",
1385
+ emailAndPassword: { enabled: true },
1386
+ user: {
1387
+ additionalFields: {
1388
+ role: { type: "string", required: false, input: false },
1389
+ banned: { type: "boolean", required: false, input: false },
1390
+ },
1391
+ },
1392
+ plugins: [
1393
+ oauthProvider({
1394
+ loginPage: "/sign-in",
1395
+ consentPage: "/consent",
1396
+ scopes: ["aventara:read", "aventara:write", "offline_access"],
1397
+ resources: [audience],
1398
+ }),
1399
+ ],
1400
+ },
1401
+ policies: { rules: policies, claims },
1402
+ principal: ({ sub, claims }) => ({
1403
+ id: sub,
1404
+ claims: {
1405
+ ...(claims.role === "admin" ? { role: "admin" as const } : {}),
1406
+ ...(claims.banned === undefined ? {} : { banned: claims.banned }),
1407
+ },
1408
+ }),
1409
+ audience,
1410
+ });
1411
+ ```
1412
+
1413
+ With `audience`, the request state takes a bearer token only when it is the provider's access token addressed to the
1414
+ service alone, checked as `verify` checks an agent's (its client, user, consent and session), and carrying
1415
+ `aventara:read` or `aventara:write`; one with neither is nobody's. Its principal is the user's, held to those scopes
1416
+ under every origin, as an agent's is under `agent`: `aventara:read` reads, `aventara:write` writes, and a scope never
1417
+ grants what the user's own grants do not. The one-org token is refused; a session cookie is read as before. Without it
1418
+ nothing changes. `aventaraAccess()` takes the same `audience`. It cannot be the MCP endpoint, the issuer itself or the
1419
+ provider's UserInfo URL.
1420
+
1421
+ Access refuses (`ACCESS_OPTIONS_INVALID`) a provider whose `enforcePerClientResources: false` lets any client ask for a
1422
+ token for the service, and open registration, which `audience` alone never takes. With `mcp` beside it, the service's
1423
+ audience in `clientRegistrationDefaultResources` or `clientRegistrationAllowedResources` is refused too: an agent's
1424
+ client that registers itself would be linked to the service, and a token it refreshed for it would pass for the
1425
+ service's own.
1426
+
1427
+ ## The Better Auth plugin
1428
+
1429
+ `createAccess` builds Better Auth for you. When your app already calls `betterAuth({...})` itself, add Access to it as
1430
+ a plugin instead. `aventaraAccess()` takes the same `policies`, `principal`, `resources`, `audit`, `passwords` and
1431
+ `registry` as `createAccess`.
1432
+ `accessOf(auth)` gives the framework its `access` option from that Better Auth instance.
1433
+
1434
+ This app's rules are its organizations' (Better Auth's `organization()` plugin): the organization bridge, below, puts a
1435
+ member's role and organization into the token, and every rule on that role is held to that organization.
1436
+
1437
+ ```ts
1438
+ // src/org.policies.ts
1439
+ import { definePolicies } from "@aventara/access";
1440
+ import { model } from "./generated/aventara/discovery.artifact.js";
1441
+
1442
+ /** What every token carries beside `sub`: the ban, and the organization bridge's two claims. */
1443
+ export const claims = {
1444
+ banned: "boolean",
1445
+ orgRole: "string",
1446
+ org: "string",
1447
+ } as const;
1448
+
1449
+ /** The principal: the user, with the session's active organization copied up for the rules. */
1450
+ export type Principal = {
1451
+ id: string;
1452
+ org?: string;
1453
+ claims: { banned?: boolean; orgRole?: string; org?: string };
1454
+ };
1455
+
1456
+ export const orgPolicies = definePolicies(model)
1457
+ .principal<Principal>()
1458
+ .roles(() => ({
1459
+ // The bridge's role: an admin of the session's active organization.
1460
+ orgAdmin: { global: { claims: { orgRole: "admin" } } },
1461
+ }))
1462
+ .grants(({ grant, me }) => [
1463
+ // Every grant is held to the principal's organization: a role from the bridge is
1464
+ // that organization's, never a global one.
1465
+ grant("signedIn", "read", "Campaign", { where: { organizationId: me.org } }),
1466
+ grant("orgAdmin", ["create", "update", "delete"], "Campaign", {
1467
+ where: { organizationId: me.org },
1468
+ }),
1469
+ ])
1470
+ .denies(({ deny }) => [deny("*", "*", { if: { claims: { banned: true } } })]);
1471
+ ```
1472
+
1473
+ ```ts
1474
+ // src/auth.ts
1475
+ import { accessOf, aventaraAccess } from "@aventara/access/better-auth";
1476
+ import { PrismaPg } from "@prisma/adapter-pg";
1477
+ import { betterAuth } from "better-auth";
1478
+ import { prismaAdapter } from "better-auth/adapters/prisma";
1479
+ import { organization } from "better-auth/plugins/organization";
1480
+ import { PrismaClient } from "./generated/prisma/client.js";
1481
+ import { claims, orgPolicies } from "./org.policies.js";
1482
+
1483
+ const prisma = new PrismaClient({
1484
+ adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL ?? "" }),
1485
+ });
1486
+
1487
+ export const auth = betterAuth({
1488
+ database: prismaAdapter(prisma, { provider: "postgresql" }),
1489
+ emailAndPassword: { enabled: true },
1490
+ user: {
1491
+ additionalFields: {
1492
+ banned: { type: "boolean", required: false, input: false },
1493
+ },
1494
+ },
1495
+ plugins: [
1496
+ organization({
1497
+ // Invitations are accepted only by a verified email (GHSA-fmh4-wcc4-5jm3, below).
1498
+ requireEmailVerificationOnInvitation: true,
1499
+ // Who may create an organization, and so own it: here, nobody but the server.
1500
+ allowUserToCreateOrganization: false,
1501
+ }),
1502
+ aventaraAccess({
1503
+ policies: { rules: orgPolicies, claims },
1504
+ // A member's role in the session's active organization, and that organization.
1505
+ organization: {
1506
+ claim: "orgRole",
1507
+ organizationClaim: "org",
1508
+ roles: [
1509
+ ["owner", "admin"],
1510
+ ["admin", "admin"],
1511
+ ["member", "member"],
1512
+ ],
1513
+ },
1514
+ // The organization claim copied onto the principal, where the rules read it.
1515
+ principal: ({ sub, claims }) => ({
1516
+ id: sub,
1517
+ ...(claims.org === undefined ? {} : { org: claims.org }),
1518
+ claims,
1519
+ }),
1520
+ // Every sign-in and every decision: `true` for the `accessAudit` table.
1521
+ audit: { sink: (event) => console.log(event.kind, event.userId) },
1522
+ }),
1523
+ ],
1524
+ });
1525
+
1526
+ /** The framework's `access` option: the policy engine and each request's `me`. */
1527
+ export const access = accessOf(auth);
1528
+ ```
1529
+
1530
+ What the plugin does:
1531
+
1532
+ - It carries Better Auth's `jwt()` inside it, so `GET /api/auth/token` and `GET /api/auth/jwks` are there, and its
1533
+ tokens carry `sub` and the declared claims only, under your base URL's origin as issuer and audience, for
1534
+ `policies.tokenTtl`. Do not add `jwt()` beside it. It is carried under the Access plugin's id, so a Better Auth
1535
+ plugin that looks `jwt()` up by its own id (`"jwt"`) does not find it.
1536
+ - It runs `createAccess`'s own checks on your Better Auth options when Better Auth starts it, and `accessOf(auth)` runs
1537
+ them again when the framework is built: a base URL to fix the issuer, a session cookie that is `sameSite: "none"`
1538
+ only with `trustedOrigins`, a claim read from a field the user cannot set, which Resource is which of Better Auth's
1539
+ tables (`resources.authTables` off the Prisma adapter), no `jwt()` and no second Access plugin. A refusal is the
1540
+ same `AccessError` `createAccess` throws. Refused when Better Auth starts the plugin, Better Auth's own promise
1541
+ rejects, so your server fails at startup with the sentence.
1542
+ - `accessOf(auth)` reads requests as `createAccess`'s `state` does: a bearer token checked against the key set, or the
1543
+ session cookie from a site Better Auth trusts. Its engine closes Better Auth's tables and seals their secrets alike.
1544
+ `accessOf(auth).$Infer.Me` is the principal type, and `$Infer.Claims` the claims.
1545
+ - `audit` logs what `createAccess`'s does (see [The audit log](#the-audit-log)): sign-ins, refused sign-ins,
1546
+ sign-outs and tokens from Better Auth's hooks, and each decision of `accessOf(auth).policy`.
1547
+ - Passwords are hashed with argon2id as under `createAccess` ([Passwords](#passwords)): by default, at the profile
1548
+ `passwords` names, or not at all when your `betterAuth({...})` sets `emailAndPassword.password` (setting both is
1549
+ refused with `ACCESS_OPTIONS_INVALID`). The plugin puts its hook in Better Auth's context when Better Auth starts it.
1550
+ - What else `createAccess` adds around Better Auth is not here: profiles, `access.handler`, the standalone host, and
1551
+ the `$access` verbs (`access.client`, the `me` route). You mount `auth.handler` as you already do.
1552
+ - Call `aventaraAccess()` once for each `betterAuth({...})`. One plugin in two instances whose options differ (the
1553
+ database, secondary storage, base URL, session cookie or cookie cache) is refused with `ACCESS_PLUGIN_DUPLICATE`.
1554
+
1555
+ `aventaraAccess()` inside `createAccess({ betterAuth: { plugins } })` is a type error and is refused with
1556
+ `ACCESS_PLUGIN_DUPLICATE`: `createAccess` installs it itself.
1557
+
1558
+ ### The app as the hub
1559
+
1560
+ With `registry: { enabled: true }` the app that runs Better Auth is the registry other services push their files to
1561
+ and pull them from (see [The registry and avaccess](#the-registry-and-avaccess)), with no Access server beside it.
1562
+
1563
+ ```ts
1564
+ // src/hub.ts
1565
+ import { definePolicies } from "@aventara/access";
1566
+ import { accessOf, aventaraAccess } from "@aventara/access/better-auth";
1567
+ import { betterAuth } from "better-auth";
1568
+ import { Pool } from "pg";
1569
+ import { model } from "./generated/aventara/discovery.artifact.js";
1570
+
1571
+ /** The hub's own rules, in its own namespace: it pushes them like any other service. */
1572
+ const rules = definePolicies(model, { namespace: "campaign" }).grants(({ grant }) => [
1573
+ grant("signedIn", "read", "Campaign"),
1574
+ ]);
1575
+
1576
+ export const auth = betterAuth({
1577
+ database: new Pool({ connectionString: process.env.DATABASE_URL }),
1578
+ emailAndPassword: { enabled: true },
1579
+ plugins: [aventaraAccess({ policies: { rules, claims: {} }, registry: { enabled: true } })],
1580
+ });
1581
+
1582
+ /** The framework's `access` option, and the registry's operator API as `access.registry`. */
1583
+ export const access = accessOf(auth);
1584
+ ```
1585
+
1586
+ - The registry's tables are the plugin's schema, so `npx auth@~1.7.12 migrate` (or `generate` for an ORM's schema)
1587
+ creates them beside Better Auth's, through your adapter. They are the tables `createAccess` uses, so the data moves
1588
+ with you if you switch.
1589
+ - Its routes are the plugin's endpoints under Better Auth's base path, served wherever you mount `auth.handler`. A
1590
+ service reaches them only with its namespace's key in `x-access-key`. A session cookie or a token of the hub opens
1591
+ none of them, and a registry key is no session or token.
1592
+ - `access.registry` is the operator's API (`createNamespace`, `rotateKey`, `revokeKey`, `pushGuardrails`,
1593
+ `assignResource`, `releaseResource`), typed only when the registry is on. No route opens it: run it in the app's own
1594
+ process, or with `avaccess keys … --server src/hub.ts`, which loads the module and uses its `access` export.
1595
+ - A key is checked against the database on every request, so a revoked or rotated key is refused at once by every
1596
+ instance of the app.
1597
+ - On any mount, a push's key is checked before Better Auth reads its body: a push with no key, or one that is not the
1598
+ namespace's push key, is refused (`401`/`403`, logged as a push's refusal), and one whose declared length is over
1599
+ 2 MiB is answered `413`, its body unread. A push with the right key and no declared length is read as your mount
1600
+ reads bodies; the registry refuses a policies file over 1 MiB with `413` once it is read. `serve` also caps every
1601
+ body before reading it.
1602
+ - Better Auth's rate limit counts the registry's routes by client address, which it reads from `X-Forwarded-For`
1603
+ unless `advanced.ipAddress.trustedProxies` names your proxy: a caller that sends its own header gets a fresh bucket
1604
+ each time. Behind a proxy, set `trustedProxies`; on a server facing clients directly, have it set the client's
1605
+ address itself (as `serve` does).
1606
+ - The hub's own rules never come from the registry: they are the `policies` you pass in the app's process.
1607
+ - Another plugin is refused with `ACCESS_PLUGIN_DUPLICATE` when one of its endpoints is named like one of the
1608
+ registry's, sits at a path under `/access/v1` (a path is read as Better Auth's router reads it: `access/v1/key`
1609
+ is `/access/v1/key`) or has parameters that can match one of the registry's routes (`/:a/v1/key`), or when one of
1610
+ its `middlewares` has a path that can match one there (`/access/v1/**`, `/access/**`, `/**`, `/*/v1/key`). Other plugins' `onRequest` and `hooks` are not checked: every plugin you install
1611
+ runs in your app's process, inside its trust boundary, and sees every request, registry keys included. Install only
1612
+ plugins you trust with them.
1613
+ - The hub's tokens are Better Auth's `jwt()` tokens: one org token, with your base URL as issuer and audience. One org
1614
+ token is one trust domain: a token any service sees works at every service that checks the hub's key set, and at
1615
+ the hub itself, until it expires (`policies.tokenTtl`, 15 minutes by default). Access adds no token endpoint and
1616
+ leaves `/token` as Better Auth serves it. A token bound to one service is an opt-in that comes from Better Auth's
1617
+ OAuth provider with resource indicators ([One audience per service](#one-audience-per-service)); use it where
1618
+ services must not trust each other's callers.
1619
+
1620
+ ### The organization bridge
1621
+
1622
+ `organization: { claim, organizationClaim, roles }` puts a member's role in Better Auth's organization plugin into a
1623
+ claim, and the organization it holds in into another. Both must be declared in `policies.claims` as `"string"`. When a
1624
+ token is issued (and when `me` is read from the session cookie), Access reads the membership of the session's active
1625
+ organization from Better Auth's store and maps its role through `roles`, an ordered list of
1626
+ `[Better Auth role, value]`: a member with several roles gets the value of the first one listed. No active
1627
+ organization, no membership, or a role `roles` does not name gives neither claim. Removing a member, or changing their
1628
+ role, changes their next token. The bridge owns both claims: neither the user's field of the same name nor
1629
+ `policies.claimsOf` fills them.
1630
+
1631
+ An organization role is that organization's. The rules' roles are global, so a rule on the bridged role alone
1632
+ would let an admin of one organization act on every organization's rows. Hold every grant on it to the organization:
1633
+ copy the organization claim onto the principal (`principal` above), and give each grant
1634
+ `where: { organizationId: me.org }`. A principal without the claim matches no such grant.
1635
+
1636
+ The member's roles are read as Better Auth reads them: split on `,`, each taken as it is. A stored role with
1637
+ whitespace in it (`member, owner`) is one Better Auth does not grant, and the bridge gives nothing for it. A role in
1638
+ `roles` is one name, with no comma and no whitespace.
1639
+
1640
+ The bridge refuses an organization plugin that lets a member be made otherwise than the app decided:
1641
+
1642
+ - `requireEmailVerificationOnInvitation` must be `true` (GHSA-fmh4-wcc4-5jm3, below).
1643
+ - `allowUserToCreateOrganization` must be `false`, or a function that decides who may: by default any signed-in user
1644
+ creates an organization and is its owner.
1645
+ - With `dynamicAccessControl` on, an organization's admins name roles themselves, so `roles` may map only the plugin's
1646
+ static roles (`owner`, `admin`, `member`, or the keys of its own `roles`).
1647
+
1648
+ Each is refused with `ACCESS_OPTIONS_INVALID`. Without `organization()` in your plugins the bridge is refused with
1649
+ `ACCESS_RESOURCE_MISSING`. `resources: { members: true }`, with `createAccess` or the plugin, needs
1650
+ `requireEmailVerificationOnInvitation: true` too.
1651
+
1652
+ Better Auth's advisory [GHSA-fmh4-wcc4-5jm3](https://github.com/better-auth/better-auth/security/advisories/GHSA-fmh4-wcc4-5jm3)
1653
+ is why the flag is required. From `better-auth` 1.6.14 on, with Better Auth's own invitation ids, the organization
1654
+ plugin lets a signed-in user whose email is not verified accept an invitation sent to that email, unless
1655
+ `requireEmailVerificationOnInvitation` is `true`. Someone who signs up with the invited address before its owner, and
1656
+ learns the invitation's id (a forwarded email, an admin screen, an invitation list), joins at the invited role, and
1657
+ the bridge would hand them its claims. Better Auth has no fixed version: its advisory lists `>= 1.6.14` as affected
1658
+ with none. The GitHub advisory database's entry is out of date and still lists `< 1.6.11` only, so `npm audit` reports
1659
+ 1.7.14 as clean. Access refuses an organization plugin without the flag (`ACCESS_OPTIONS_INVALID`, naming the
1660
+ advisory). Only `true` passes.
1661
+
1662
+ ### The client plugin
1663
+
1664
+ `aventaraAccessClient({ api })` adds `authClient.access.can` to Better Auth's client. It asks the framework, not
1665
+ Better Auth: one `POST {api}/_access/can` for the whole page, answered by the same engine that decides the real
1666
+ requests, one boolean per question.
1667
+
1668
+ ```ts
1669
+ // src/auth-client.ts
1670
+ import { aventaraAccessClient } from "@aventara/access/better-auth";
1671
+ import { createAuthClient } from "better-auth/client";
1672
+
1673
+ export const authClient = createAuthClient({
1674
+ plugins: [aventaraAccessClient({ api: "/api" })],
1675
+ });
1676
+
1677
+ /** One request for the page: may the user edit this campaign, and add notes? */
1678
+ export const pageAnswers = async (campaignId: string) => {
1679
+ const { data, error } = await authClient.access.can([
1680
+ { resource: "Campaign", action: "update", where: { id: campaignId } },
1681
+ { resource: "Note", action: "create" },
1682
+ ]);
1683
+ if (error !== null) {
1684
+ throw new Error(`can: ${error.status} ${error.code ?? error.statusText}`);
1685
+ }
1686
+ const [editCampaign = false, createNote = false] = data;
1687
+ return { editCampaign, createNote };
1688
+ };
1689
+ ```
1690
+
1691
+ - `api` is the framework's API path or URL. A path is read against the auth client's `baseURL`, else the page's
1692
+ address.
1693
+ - The request goes through Better Auth's own fetch, so it carries the session cookie (or the bearer token you give
1694
+ Better Auth's client), and the answer is Better Auth's `{ data, error }`. Your client plugins see it as they see
1695
+ Better Auth's own requests: a fetch plugin of theirs (Better Auth's `redirectPlugin` among them) runs on it too.
1696
+ - A question with `where` asks about one row, by its unique where. A row the user cannot read answers `false`, exactly
1697
+ as a missing one does. Without `where`, it asks whether the user may run the action on the Resource at all.
1698
+ - The questions are asked under the ClientContract's hash: `contractHash` when you give it (your generated client's),
1699
+ else the hash `GET {api}/_contract` serves, read once and read again when the framework answers that it changed.
1700
+ - `can` only shapes the page. The real request is still checked.
1701
+ - `_access/can` is the framework's route for apps configured with `access` (`@aventara/core` `1.0.0-rc.10` or later).
1702
+
1703
+ ## The engine alone
1704
+
1705
+ You can run the engine without Better Auth: `policies.engine()` builds it from the rules, and `createAccessEngine(file)`
1706
+ from a compiled policies file. Hand it to the framework's `policy` option, with a function that reads the principal
1707
+ from the request's state. Your host's `state` option (or your MCP `verify`) puts the user there.
1708
+
1709
+ ```ts
1710
+ // src/aventara.config.ts
1711
+ import type { AvState } from "@aventara/core";
1712
+ import { type Principal, policies } from "./access.policies.js";
1713
+
1714
+ declare module "@aventara/core" {
1715
+ interface AvState {
1716
+ user?: Principal;
1717
+ }
1718
+ }
1719
+
1720
+ export const aventaraConfig = {
1721
+ api: "/api",
1722
+ exposed: true,
1723
+ policy: {
1724
+ engine: policies.engine(),
1725
+ principal: ({ $state }: { readonly $state: AvState }) =>
1726
+ $state.user ?? null,
1727
+ },
1728
+ } as const;
1729
+ ```
1730
+
1731
+ Any object with `policy` (an engine) and `state` (`({ headers }) => ({ me })`) also fits the framework's `access`
1732
+ option, so an app with its own auth gets the same wiring.
1733
+
1734
+ The engine answers:
1735
+
1736
+ | Method | Answers |
1737
+ |---|---|
1738
+ | `can(question)` | Whether the principal may do `action` on `resource`; on `row` when one is given |
1739
+ | `scopeOf(question)` | The rows the principal may read, update or delete: `true`, `false`, or an Aventara `where` |
1740
+ | `fieldsOf(question)` | The fields usable on every reachable row (`always`), and those usable on some rows only (`perRow`) |
1741
+ | `grantsOf(question)` | The update grants that cover a row before it is updated, as opaque keys, for `can`'s `beforeGrants` ([Updates](#updates)) |
1742
+ | `explain(question)` | In development, which rules took part in a `can` decision. It throws when `NODE_ENV` is `production`. |
1743
+ | `checkBeacons(reader, pins, environment)` | The startup check that the service reaches each foreign owner's database ([The ownership beacon](#the-ownership-beacon)). Await it before serving. |
1744
+ | `governed` | The fields governed for reading (`"read"`, `"both"` or the list form), by Resource: out of every default projection |
1745
+ | `afterChecks` | The Resources where the row an update leaves is checked, so the host asks `grantsOf` before the write |
1746
+ | `paths` | Every relation path and operator each rule filters on, by rule id |
1747
+ | `unheld` | The Resources the rules read as foreign: Aventara holds no written reference to them to the caller's read scope |
1748
+
1749
+ A deny on a field that may be NULL lets the NULL rows through: `can` reads `topic = 'off-the-record'` as false on a
1750
+ row whose `topic` is NULL, so the deny does not apply. `scopeOf` keeps the same rows in a database, which reads that
1751
+ comparison as unknown: on a nullable field it writes the deny's negation as
1752
+ `{ OR: [{ NOT: { topic: { equals: "off-the-record" } } }, { topic: { equals: null } }] }`. A field that is not
1753
+ nullable keeps the plain `NOT`. Which fields are nullable comes from the schema, through the policies file; a to-one
1754
+ relation is nullable when its reference field is, and a deny through it lets the rows with no related row through
1755
+ (`{ replyToId: { equals: null } }`). Access assumes the database enforces each reference: under Prisma's `relationMode = "prisma"`, which creates no foreign keys, a reference to a row that no longer exists makes `scopeOf` and `can` disagree on that row.
1756
+
1757
+ A `where` that matches no row is written `{ OR: [] }`, an empty `OR`: Aventara's `where` has no `false`, and every
1758
+ ORM translator reads the empty `OR` as no row (Prisma does). A `scopeOf` that allows nothing answers `false` itself;
1759
+ `{ OR: [] }` appears inside a `where`, and where a generated client's Prisma functions turn `false` into a Prisma
1760
+ `where`.
1761
+
1762
+ Each question states `principal`, `origin` and `resource`. `can` adds `action` (and, for a row, `row` and `exists`),
1763
+ `scopeOf` adds `action` (`read`, `update` or `delete`), and `fieldsOf` adds what the fields are for:
1764
+
1765
+ ```ts
1766
+ // scripts/fields.read.ts
1767
+ import { type Principal, policies } from "../src/access.policies.js";
1768
+
1769
+ const engine = policies.engine();
1770
+ const pat: Principal = { id: "pat", claims: {} };
1771
+
1772
+ // The fields pat may read on User.
1773
+ console.log(
1774
+ engine.fieldsOf({ principal: pat, origin: "client", resource: "User", access: "read" }),
1775
+ );
1776
+ // The fields pat may set when she updates a Note.
1777
+ console.log(
1778
+ engine.fieldsOf({
1779
+ principal: pat,
1780
+ origin: "client",
1781
+ resource: "Note",
1782
+ access: "write",
1783
+ action: "update",
1784
+ }),
1785
+ );
1786
+ ```
1787
+
1788
+ `access` is `"read"`, or `"write"` with an optional `action` (`"create"` or `"update"`; both when it is left out). A
1789
+ question without `access`, or with an `action` that does not fit it, is refused with `ACCESS_QUESTION_INVALID`.
1790
+
1791
+ `policies.engine({ guardrails, lock, requireLock })` and `createAccessEngine(file, { guardrails, lock, requireLock })`
1792
+ take the org's guardrails and the lock (below). Any other option is refused. The engine runs from its own frozen copy of
1793
+ the file: changing the object you handed it afterwards changes nothing.
1794
+
1795
+ ## The audit log
1796
+
1797
+ `audit: true` writes each event (sign-ins, refused sign-ins, sign-outs, tokens issued, decisions) to the `accessAudit`
1798
+ table. `npx auth@~1.7.12 generate --yes` writes it
1799
+ into an ORM's schema (Prisma: `model AccessAudit`), and `npx auth@~1.7.12 migrate` creates it, beside Better Auth's
1800
+ tables. Better Auth refuses to start while the table is missing.
1801
+
1802
+ Read the log through your app's ORM. With Prisma:
1803
+ `prisma.accessAudit.findMany({ orderBy: { at: "desc" } })`. It is not reachable through the framework's API: Access's
1804
+ own tables stay closed to every request, whatever `resources` says. A read of `AccessAudit` is refused `403 A4002`,
1805
+ and the audit log names the rule `deny:better-auth:AccessAudit`.
1806
+
1807
+ `audit: { sink }` hands each event to your function instead, and declares no table:
1808
+
1809
+ ```ts
1810
+ // src/audit.sink.ts
1811
+ import type { AccessAuditEvent } from "@aventara/access/auth";
1812
+
1813
+ /** Logs every refusal with the rule that refused it. */
1814
+ export const auditSink = (event: AccessAuditEvent): void => {
1815
+ if (event.kind === "decision" && !event.allowed) {
1816
+ console.warn(
1817
+ `${event.userId ?? "anonymous"} refused ${event.action} on ${event.resource}: ${event.reason}`,
1818
+ );
1819
+ }
1820
+ };
1821
+ ```
1822
+
1823
+ Then `audit: { sink: auditSink }`. Each event is one of:
1824
+
1825
+ ```text
1826
+ { at, kind: "sign-in", userId, origin, impersonatedBy? }
1827
+ { at, kind: "sign-in-failed", userId: null, route, reason, target?, deviceId?, origin, suppressed?, windowStart?, windowEnd? }
1828
+ { at, kind: "sign-out", userId, origin, impersonatedBy? }
1829
+ { at, kind: "token", userId, origin, impersonatedBy? }
1830
+ { at, kind: "kiosk-switch", userId, previousUserId, deviceId, origin }
1831
+ { at, kind: "kiosk-lock", userId, deviceId, reason, origin }
1832
+ { at, kind: "kiosk-device", userId, deviceId, reason, origin }
1833
+ { at, kind: "kiosk-pin", userId, origin }
1834
+ { at, kind: "decision", userId, resource, action, allowed, reason, origin, suppressed?, windowStart?, windowEnd? }
1835
+ ```
1836
+
1837
+ - A **sign-in** is a session Better Auth created: a sign-in, or a sign-up that signs the user in. Its `origin` is
1838
+ `client` through Access's routes and `application` when your server calls Better Auth's API. `impersonatedBy` is the
1839
+ admin's user id when the session is an impersonation (Better Auth's `admin()` plugin).
1840
+ - A **sign-in-failed** is a route that starts a session refusing the caller: `route` is Better Auth's endpoint
1841
+ (`/sign-in/email`) and `reason` the code it refused with (`INVALID_EMAIL_OR_PASSWORD`). `target` is an HMAC-SHA-256
1842
+ of the identifier the route reads, as it reads it, keyed by Better Auth's secret: the email on `/sign-in/email` and
1843
+ `/sign-in/email-otp` (lower-cased), the username on `/sign-in/username` (normalized by the `username()` plugin's
1844
+ `usernameNormalization`, lower-cased by default), the phone number on `/sign-in/phone-number` (as given). Every
1845
+ attempt on one identifier has the same target, whatever else the body carries, and without the secret it cannot be
1846
+ turned back into the identifier or checked against a guess. Other routes' refusals have no target. The identifier
1847
+ itself, and anything else the caller sent, is never kept. A server call of `signIn`, `signUp` or `resetPassword`
1848
+ that the rate limit refuses is one too, with reason `rate-limited`, the verb's route and its target.
1849
+ - A **sign-out** is a session ended through `/sign-out`.
1850
+ - The **kiosk** events are the kiosk profile's ([The kiosk profile](#the-kiosk-profile)). A failed PIN is a
1851
+ **sign-in-failed** on `/kiosk/switch-user`, with the device's `deviceId` and, as its `target`, the HMAC of the user
1852
+ id it named. With `audit: true` on the kiosk profile, the table has two more columns, `deviceId` and
1853
+ `previousUserId`.
1854
+ - A **token** is a JWT issued for a session by `GET /token`. The `set-auth-jwt` header of `/get-session` is not
1855
+ logged: a page asks for its session on every load.
1856
+ - A **decision** is a `can` answer. A request's admission is one event, allowed or refused, however many rows it reads
1857
+ or writes. A row question (the row a create inserts, or an update leaves) adds an event only when the rules refuse
1858
+ that row. `scopeOf`, `fieldsOf` and `explain` log nothing.
1859
+ - `reason` names the deciding rule: `grant:<id>`, `deny:<id>`, or `no grant`. A guardrail reads `deny:guardrail:<id>`.
1860
+ - `userId` is the `sub` of the token or session the principal came from, or `null` for an anonymous caller.
1861
+ - Anonymous refusals (a refused decision with no `userId`, and every refused sign-in) are logged once a minute for
1862
+ each origin and reason (a refused sign-in, for each route and `target` too). The first is logged; the repeats in the
1863
+ minute are counted, and when the minute closes the count is logged as a summary: the first event's fields with
1864
+ `suppressed` (the count), `windowStart` and `windowEnd`. The summary is written on the next event of any kind after
1865
+ the minute, or by a timer at its end. A sign-in writes its target's summary first, so a guessed password shows after
1866
+ its failures. A flood of anonymous requests writes two rows a minute, not one per request. At most 10,000 windows
1867
+ are open at once: past that, a refusal that would open another closes the oldest early (its count logged, with
1868
+ `windowEnd` then) and opens its own, so every target keeps its own rows.
1869
+ - An event holds no token, password, cookie, key or header.
1870
+ - The request waits for the write or the sink. A sink that throws, or a write that fails, does not fail the request:
1871
+ Better Auth's logger warns and the request goes on. Queue slow work in the sink.
1872
+
1873
+ ## Users and roles as Resources
1874
+
1875
+ Better Auth's tables are in your schema, so the framework sees them as Resources. Access runs built-in rules over every
1876
+ one of them. These rules only narrow. `resources` decides which tables your rules may open:
1877
+
1878
+ | Key | Opens | Writes through Aventara |
1879
+ |---|---|---|
1880
+ | `users: true` | `user`, `session` and `account` (session and account read only) | on `user`, an update of `role`, by a grant that names it |
1881
+ | `members: true` | the organization plugin's `member`, and its `organization` (read only) | on `member`, a create, an update of `role` by a grant that names it, and a delete |
1882
+
1883
+ - Every other Better Auth table stays closed: `verification`, `jwks`, the organization plugin's `invitation`,
1884
+ `twoFactor`, `deviceCode`, and Access's own tables. A key that is off leaves its tables closed too.
1885
+ - An opened table is read as your rules grant. Writes are limited because a write through Aventara skips Better Auth's
1886
+ hooks, its cookie cache and its secondary storage. Change a name or an email through Better Auth's API.
1887
+ - `role` is a claim, so Access governs `user`'s `role` as `"write"`: an update grant sets it only when it names it.
1888
+ To let an admin set roles, add `.govern("User", { role: "write" })` to your rules, which lets a grant name the
1889
+ field, and `fields: ["User.role"]` to that admin's update grant, ideally with `where: { NOT: { id: me.id } }` so an
1890
+ admin cannot change their own role. Never name it in a grant for `signedIn`, `anyone` or any role a user can reach
1891
+ on their own. Users edit their profile through Better Auth's `/update-user`.
1892
+ - With `members: true`, `member`'s `role` is governed the same way: a create or update grant sets it only when it
1893
+ names it. Name it only in an organization admin role's grant, held to `where: { organizationId: me.org }`.
1894
+ - `members: true` needs Better Auth's `organization()` plugin in `betterAuth.plugins`, with
1895
+ `requireEmailVerificationOnInvitation: true`: your rules trust its memberships, and without the flag a user whose
1896
+ email is not verified can accept an invitation sent to that address
1897
+ ([GHSA-fmh4-wcc4-5jm3](#the-organization-bridge)). Without it, `createAccess` refuses with `ACCESS_OPTIONS_INVALID`.
1898
+ - `explain` and the audit log name a built-in deny `better-auth:<Resource>` (a closed table) or
1899
+ `better-auth:<Resource>/<actions>` (a write an opened table never takes).
1900
+
1901
+ Secret columns are sealed. No grant opens them, on any table, so a request that selects, filters or sorts by one is
1902
+ refused:
1903
+
1904
+ | Table | Sealed columns |
1905
+ |---|---|
1906
+ | `account` | `password`, `accessToken`, `refreshToken`, `idToken` |
1907
+ | `session` | `token` |
1908
+ | `verification` | `identifier`, `value` |
1909
+ | `jwks` | `privateKey` |
1910
+ | `twoFactor` | `secret`, `backupCodes` |
1911
+ | `deviceCode` | `deviceCode`, `userCode` |
1912
+ | `accessKey` | `keyHash` |
1913
+
1914
+ Exempt these columns from your Contracts, or govern them, so a read that names no field leaves them out instead of
1915
+ being refused.
1916
+
1917
+ Access must know which Resource is which of Better Auth's tables, or it could not keep them closed and their secrets
1918
+ sealed:
1919
+
1920
+ - With Better Auth on its Prisma adapter (`prismaAdapter`), Access finds each table by the Prisma model the adapter
1921
+ reads it through: the model whose client delegate is the table's name (your `modelName`, or the table's own, plus
1922
+ `s` with `usePlural`). That is exact, whatever the model is called or mapped to (`model User`, `model user`,
1923
+ `model Users`), so there is nothing to configure. `resources.authTables` is refused there. A table whose delegate no
1924
+ Resource answers to, while the schema has one that differs only in case or a plural `s` (an adapter without
1925
+ `usePlural` on a plural schema), is refused: the schema and the adapter disagree.
1926
+ - With Better Auth on any other adapter (its built-in Kysely on a `pg` Pool or SQLite, Drizzle, MongoDB, your own),
1927
+ nothing ties a table to a Resource by name: a Prisma schema can give a table any model name with `@@map`, and its
1928
+ columns any field names with `@map`. So you name them: `resources.authTables` maps every table Better Auth is built
1929
+ with (its plugins' and Access's own included) to its Resource, or to `null` when the rules' schema does not have it.
1930
+ Without it, or with a table missing from it, `createAccess` refuses with `ACCESS_OPTIONS_INVALID` and lists the
1931
+ tables. A rules' schema with no Resource at all (a registry-only server) needs no map.
1932
+
1933
+ ```ts
1934
+ // src/auth-tables.ts
1935
+ /**
1936
+ * Better Auth on a pg Pool, its tables mapped to models of the app's own naming:
1937
+ * `resources: { users: true, authTables }`. Better Auth here has `user`, `session`,
1938
+ * `account`, `verification` and `jwks` (and `twoFactor` with that plugin, and so on).
1939
+ */
1940
+ export const authTables = {
1941
+ user: "AuthUser",
1942
+ session: "AuthSession",
1943
+ account: "AuthAccount",
1944
+ verification: null,
1945
+ jwks: null,
1946
+ };
1947
+ ```
1948
+
1949
+ `null` means "this table is not in the rules' schema", never "closed": a table that is in the schema is named, and
1950
+ then closed, or opened by its key, as any other. The map is held to the schema, and refused with
1951
+ `ACCESS_OPTIONS_INVALID` where they disagree:
1952
+
1953
+ - a table's Resource must exist, be no other table's, and have every column of the table under the name Better Auth's
1954
+ `fieldName` gives it (an `@map` that renames one is refused: Access could not seal what it cannot find);
1955
+ - a table mapped to `null` must not be in the schema: a Resource the map leaves unnamed that has every column of it,
1956
+ or one of its secret columns beside another of its columns (a model that keeps only some, `{ token, userId }`), is
1957
+ refused, naming that Resource. A table of your own with a lone `token` column is yours.
1958
+
1959
+ A table a key opens must have each of its secret columns under Better Auth's field name for it, on either adapter.
1960
+
1961
+ These checks run when `access.policy` is first read, which is when the framework is built. A key whose tables Better
1962
+ Auth lacks, or that the rules' schema lacks, is refused with `ACCESS_RESOURCE_MISSING`: add the plugin, or run
1963
+ `npx auth@~1.7.12 generate --yes` and regenerate the model. A grant on a closed table, or of a write the table never takes,
1964
+ is refused with `ACCESS_OPTIONS_INVALID`. The instance itself still builds, so `npx auth@~1.7.12 generate --yes` can load
1965
+ your configuration before the schema has the tables.
1966
+
1967
+ ## The registry and avaccess
1968
+
1969
+ When several services share one Access server, the registry holds each namespace's policies file and the org's
1970
+ guardrails. Each service pushes its own file and pulls it back with the guardrails, and builds never touch the network.
1971
+
1972
+ ### The server
1973
+
1974
+ Turn the registry on in the server's configuration. A server that only runs the registry needs no `policies`: its
1975
+ engine then denies every question, and its tokens carry `sub` alone.
1976
+
1977
+ ```ts
1978
+ // access.config.ts
1979
+ import { createAccess } from "@aventara/access/auth";
1980
+ import { Pool } from "pg";
1981
+
1982
+ export const access = createAccess({
1983
+ betterAuth: {
1984
+ database: new Pool({ connectionString: process.env.DATABASE_URL }),
1985
+ },
1986
+ registry: { enabled: true },
1987
+ });
1988
+
1989
+ export default access;
1990
+ ```
1991
+
1992
+ This server signs in no one: it has no `emailAndPassword`, so nobody can sign up on it. Add the sign-in methods its
1993
+ users need, and see [Sign up and sign in](#sign-up-and-sign-in) before you turn on open sign-up.
1994
+
1995
+ The registry's tables (`accessNamespace`, `accessKey`, `accessPolicyRevision`, `accessGuardrailRevision`, `accessHead`,
1996
+ `accessPushLog`, `accessResourceOwner`) are Better Auth plugin tables, created by Better Auth's migration or by
1997
+ `npx auth@~1.7.12 generate --yes` for an ORM's schema. `access.handler` serves the registry's routes under Better Auth's base path, so every mount carries them:
1998
+
1999
+ | Route | Does |
2000
+ |---|---|
2001
+ | `GET /access/v1/namespaces/:ns` | The namespace |
2002
+ | `GET …/:ns/revisions` | Its revisions |
2003
+ | `POST …/:ns/revisions` | Push a policies file |
2004
+ | `GET …/:ns/revisions/:hash` | One revision |
2005
+ | `GET …/:ns/head` | The current revision |
2006
+ | `GET …/:ns/guardrails` | The org's guardrails |
2007
+ | `GET …/:ns/foreign/:owner` | What `:ns` reads of `:owner` as foreign: `:owner`'s beacon hash, and its entry of each Resource `?resources=A,B` names |
2008
+ | `GET /access/v1/key` | The namespace and kind of the key sent |
2009
+
2010
+ A service sends its key in the `x-access-key` header. With the registry off, `/access/v1/*` answers `404`. One org per
2011
+ server. A revision's `:hash` is `sha256:` and 64 lower-case hex digits; anything else answers `404`.
2012
+
2013
+ The push log (`accessPushLog`) records every push made with a key the registry has, accepted or refused, with the key's
2014
+ id. A push with no key, or a key that is not this registry's, adds no row of its own: those are counted in one row per
2015
+ namespace per minute, its `count`.
2016
+
2017
+ ### Keys
2018
+
2019
+ Each namespace has a push key and a read-only pull key. The registry stores only their SHA-256 hashes. The operator
2020
+ issues them from the server's own module, in their own process, never over HTTP:
2021
+
2022
+ ```sh
2023
+ avaccess keys issue campaign --server access.config.ts
2024
+ avaccess keys rotate campaign push --server access.config.ts
2025
+ avaccess keys revoke campaign pull --server access.config.ts
2026
+ ```
2027
+
2028
+ `avaccess keys` loads that module in its own process and reads no `.env`. Give it the environment the server runs with:
2029
+ `DATABASE_URL` (or whatever your database client reads), `BETTER_AUTH_SECRET`, and `BETTER_AUTH_URL`, the server's
2030
+ base URL, which Better Auth cannot infer outside a request.
2031
+
2032
+ `issue` creates the namespace and prints both keys once. `rotate` prints the new key once. No other command prints a
2033
+ key. `--server` names the module that holds the `createAccess` instance (its default export, or `access`), or, for a
2034
+ Better Auth app that is the hub, `accessOf(auth)` ([The app as the hub](#the-app-as-the-hub)). The same
2035
+ calls are on `access.registry`: `createNamespace(name)`, `rotateKey(namespace, kind)`, `revokeKey(namespace, kind)`
2036
+ and `pushGuardrails(file)`. A namespace has one live key of each kind: two rotations at the same moment leave one.
2037
+
2038
+ A push key pushes and reads its own namespace. A pull key only reads it. A wrong, revoked or rotated-out key, a pull key
2039
+ that pushes, or another namespace's key is refused with `ACCESS_KEY_REFUSED`. Another namespace's key gets the same
2040
+ answer as a namespace the registry does not have (`401`), so a key tells its holder nothing about other namespaces.
2041
+
2042
+ ### Who owns a Resource
2043
+
2044
+ A namespace owns the Resources its file names as its own, and only the owner grants, denies or governs fields on them.
2045
+ The registry keeps one owner per Resource in `accessResourceOwner`, and checks and claims ownership in the push's own
2046
+ transaction, so two pushes at the same moment cannot both claim one.
2047
+
2048
+ `registry.ownership` says how a Resource gets its owner:
2049
+
2050
+ - `"first-push"` (the default): the first push that names it as its own, unless the operator assigned it first. A
2051
+ Resource claimed this way is free again once the namespace's head no longer names it.
2052
+ - `"assigned"`: the operator alone. A push that names an unassigned Resource as its own is refused.
2053
+
2054
+ The operator assigns and releases Resources on the server's instance:
2055
+
2056
+ ```ts
2057
+ // scripts/owners.assign.ts
2058
+ import access from "../access.config.js";
2059
+
2060
+ await access.registry.assignResource("Invoice", "billing");
2061
+ await access.registry.releaseResource("Draft");
2062
+ ```
2063
+
2064
+ `assignResource(resource, namespace)` gives the Resource to the namespace, whoever named it first: from then on
2065
+ another namespace's push that names it as its own is refused. `releaseResource(resource)` takes it from its owner. A
2066
+ refusal names the Resource and the rule, never the namespace that owns it.
2067
+
2068
+ ### A service's commands
2069
+
2070
+ The registry is `--registry <url>` or `AVACCESS_REGISTRY_URL`: the Access server's URL. With no path, Better Auth's
2071
+ `/api/auth` is added. The key is `AVACCESS_KEY`, never a flag. The URL is `https:`; `http:` is taken only on a loopback
2072
+ address (`localhost`, `127.0.0.0/8`, `[::1]`), since the key travels in a header. The CLI follows no redirect: a `3xx`
2073
+ answer stops it (exit `2`), and the key goes to no other address.
2074
+
2075
+ | Command | Does |
2076
+ |---|---|
2077
+ | `avaccess push <rules> [--beacon <module>]` | Pushes the namespace's policies file: a `.json` file, or the module that holds the rules (default export, or `rules`), TypeScript included. `--beacon` sends the beacons too ([The ownership beacon](#the-ownership-beacon)). |
2078
+ | `avaccess pull [--namespace <ns>] [--out <dir>] [--foreign <ns>:<Resource>,…] [--beacon <module>] [--env <env>]` | Writes the namespace's current file to `access/<ns>.policies.json`, and the org's guardrails to `access/guardrails.json` when the registry has guardrails, and pins their hashes in `avAccess.lock.json` |
2079
+ | `avaccess verify [--env <env>]` | Holds the pulled files to the lock, offline. A file edited by hand fails with `ACCESS_LOCK_MISMATCH`. It holds every `avAccess.beacons.<env>.json` in the project to the rules too, and with `--env` (or `AVACCESS_ENV`) fails when that environment's file is missing. |
2080
+ | `avaccess status` | Says whether the lock is behind the registry's current revisions, and exits `1` when it is. Run it in CI. |
2081
+ | `avaccess diff [<from> [<to>]] [--namespace <ns>]` | Names the rules that changed between two revisions: `lock`, `head`, a hash, or its first seven or more hex digits. The default, `lock` to `head`, is what a pull would bring. The namespace is `--namespace`, else the lock's. |
2082
+
2083
+ Exit codes: `0` done, `1` the check failed or the registry refused, `2` the command could not run.
2084
+
2085
+ - `push` takes the namespace from the file. The registry stores each file once, in its canonical form, under its hash,
2086
+ and never updates it. Pushing a file the registry already has moves the head back to it.
2087
+ - At push, a namespace may name as its own only Resources no other namespace owns, and its rules may grant, deny and
2088
+ govern only on those (`ACCESS_OWNERSHIP`, see [Who owns a Resource](#who-owns-a-resource)). A file the engine would
2089
+ refuse is refused too (`ACCESS_POLICIES_INVALID`), and so is a file over 1 MiB (`413`).
2090
+ - `pull` takes the namespace from `--namespace`, else the lock, else the key. It hashes each file again before it
2091
+ writes it, and refuses a file of another namespace than the one asked for. When the lock pins the org's guardrails
2092
+ and the registry serves none, the pull is refused and writes nothing: it would drop the org's denies. Commit the two
2093
+ files and the lock.
2094
+ - A pull trusts the registry it reaches: a file is held to its hash, not to who wrote it. Signed revisions are not
2095
+ built yet, so reach the registry over `https:` only.
2096
+ - `pull` and `generate` write only inside the project: a path through a symbolic link that leads out of it is refused
2097
+ (exit `2`). A lock's paths are relative, `/`-separated, with no `..`, drive letter, backslash or control character.
2098
+ - Text the registry sends (a refusal's code and message) is printed without control characters.
2099
+
2100
+ `avAccess.lock.json` pins content, not a registry:
2101
+
2102
+ ```json
2103
+ {
2104
+ "format": "aventara-access/lock@1",
2105
+ "namespace": "campaign",
2106
+ "policies": { "path": "access/campaign.policies.json", "hash": "sha256:…" },
2107
+ "guardrails": { "path": "access/guardrails.json", "hash": "sha256:…" }
2108
+ }
2109
+ ```
2110
+
2111
+ `guardrails` is `null` when the registry has none. Put `avaccess verify` first in `aventara:prepare`, so the build
2112
+ stops before anything is generated from a changed file:
2113
+
2114
+ ```json
2115
+ {
2116
+ "scripts": {
2117
+ "aventara:prepare": "avaccess verify && aventara-prisma7-generate … && aventara-build"
2118
+ }
2119
+ }
2120
+ ```
2121
+
2122
+ In a Next.js app, put it before `aventara-build` the same way.
2123
+
2124
+ ### The lock at startup
2125
+
2126
+ Read the lock and the pulled files as JSON and hand them to the instance:
2127
+
2128
+ ```ts
2129
+ // src/access.pulled.ts
2130
+ import { readFileSync } from "node:fs";
2131
+ import type { AccessGuardrailsJson, AccessLock } from "@aventara/access";
2132
+
2133
+ /** A file `avaccess pull` wrote, from the project's root. */
2134
+ const pulled = (path: string): unknown =>
2135
+ JSON.parse(readFileSync(path, "utf8"));
2136
+
2137
+ /** avAccess.lock.json: the namespace, and the hash of each pulled file. */
2138
+ export const lock = pulled("avAccess.lock.json") as AccessLock;
2139
+
2140
+ /** The org's guardrails, as the registry serves them to this namespace. */
2141
+ export const guardrails = pulled(
2142
+ "access/guardrails.json",
2143
+ ) as AccessGuardrailsJson;
2144
+ ```
2145
+
2146
+ Then `policies: { rules: policies, claims, guardrails, lock }`. The instance refuses to start
2147
+ (`ACCESS_LOCK_MISMATCH`) unless the guardrails hash to the lock's pin and are given exactly when it pins some, and the
2148
+ rules' namespace is the lock's. A policies file you hand over (not the typed rules, which are your own source) must hash
2149
+ to its own pin. The engine options take the same `lock`: `createAccessEngine(file, { guardrails, lock })`.
2150
+
2151
+ The lock is optional, so a project without a registry runs without one. In production, set
2152
+ `policies: { …, lock, requireLock: true }` (or `createAccessEngine(file, { lock, requireLock: true })`): the instance
2153
+ then refuses to start without a lock (`ACCESS_LOCK_MISMATCH`), so a deploy that lost `avAccess.lock.json` stops
2154
+ instead of running files nothing pins. With `requireLock`, typed rules are held to the lock's policies hash too: a
2155
+ lock that pins other rules is refused.
2156
+
2157
+ A pulled file imported as JSON fits these options as it is: TypeScript widens a JSON import's literals, so they take
2158
+ the file's outline (`AccessPoliciesJson`, `AccessGuardrailsJson`), and the engine checks the rest when it is built.
2159
+
2160
+ ### The ownership beacon
2161
+
2162
+ A namespace can read another namespace's Resources in conditions (`{ foreign: "<owner>" }`) only while both services
2163
+ run on one database: the facts must come from the owner's own tables, not from a copy. The beacon proves it. Each
2164
+ service writes one row in `_aventara_beacon` (`namespace`, `value`, `createdAt`) through its own data layer, with a
2165
+ random value (`avb_…`), once. Access names no ORM and writes no SQL: you hand it an `AccessBeaconStore` over your
2166
+ data layer, a module that exports `beacon` (a generated client's `access.prismaBeacon(prisma)` is one, over a Prisma
2167
+ model of the table). The table holds one row per namespace: in a deployment with a schema per service each service's
2168
+ schema holds its own row, and in one with a single schema one table holds them all.
2169
+
2170
+ - **At push.** `avaccess push <rules> --beacon <module>` sends the namespace's own beacon with the file, writing
2171
+ its row the first time, and for each namespace the file reads as foreign, that namespace's row as this service's
2172
+ connection reads it. The registry keeps each beacon's hash bound to its namespace (`sha256` of
2173
+ `<namespace>:<value>`), never the value: a value pushed under one namespace never matches or blocks another's.
2174
+ Only a namespace's own push registers or replaces its beacon; a reader only refers to the owner's. A file that
2175
+ reads another namespace is stored only when the push carries that namespace's beacon and it is the one that
2176
+ namespace pushed; otherwise the push is refused with `ACCESS_FOREIGN_CROSS_DATABASE`, which suggests carrying the
2177
+ fact in a JWT claim instead. A beacon a namespace replaced never comes back (`ACCESS_BEACON_MISMATCH`, `409`). A
2178
+ push never adopts a row it did not write: a row the registry holds no hash of, or another hash, is refused
2179
+ (`ACCESS_BEACON_MISMATCH`) until an operator verifies it or deletes it, and the next push writes a new one.
2180
+ - **At pull.** First push a file that marks the Resources it reads as the owner's (`{ foreign: "campaign" }` on your
2181
+ model's entries): the registry serves another namespace's facts only for what your current file reads as foreign.
2182
+ Then `avaccess pull --foreign campaign:Campaign,Member --beacon <module> --env <environment>` brings the owner's
2183
+ schema entries of those Resources into `access/foreign.json` (pinned in the lock, checked by `verify`), and
2184
+ `avaccess schema` adds them to your model, marked as the owner's. Every pull of rules that read another namespace
2185
+ reads that namespace's row through `--beacon` and holds it to the hash the namespace pushed; on another database it
2186
+ is refused and nothing is written. The hashes go to `avAccess.beacons.<environment>.json`, one file per environment
2187
+ (`--env`, or `AVACCESS_ENV`), never into the lock: a database's beacon is its own, and the lock holds against any
2188
+ environment's registry.
2189
+ - **At startup.** An engine whose rules read another namespace decides nothing until
2190
+ `await access.policy.checkBeacons(beacon, pins, environment)` (or `engine.checkBeacons`) has passed, with or without
2191
+ a lock: it holds the pins to `environment`, reads each owner's row through your connection and holds it to its pin.
2192
+ A service started against another database refuses with `ACCESS_BEACON_MISMATCH`, and a failed check latches: the
2193
+ engine stays shut until the process restarts. Await it before the service takes requests. A generated client does
2194
+ the same with `access.checkBeacons(reader, pins, environment)`, and throws an error with the same `code`.
2195
+
2196
+ In a single shared schema, every service with write access to `_aventara_beacon` can overwrite or delete another
2197
+ namespace's row. It cannot make a reader pass (the registry holds the owner's hash, bound to the owner), but the
2198
+ readers then refuse at startup until the owner's row is restored and pushed again. The check fails closed; where the
2199
+ database can, give each service's role write access to its own row only.
2200
+
2201
+ ```ts
2202
+ // src/access.beacons.ts
2203
+ import { readFileSync } from "node:fs";
2204
+ import type { AccessBeaconPins, AccessBeaconStore } from "@aventara/access";
2205
+
2206
+ /** Your data layer's view of _aventara_beacon, one row per namespace. */
2207
+ declare const beaconRows: {
2208
+ find(namespace: string): Promise<{ readonly value: string } | null>;
2209
+ insert(row: { readonly namespace: string; readonly value: string }): Promise<void>;
2210
+ };
2211
+
2212
+ /** What `avaccess push --beacon src/access.beacons.ts` loads, and what the startup check reads through. */
2213
+ export const beacon: AccessBeaconStore = {
2214
+ read: async (namespace) => (await beaconRows.find(namespace))?.value ?? null,
2215
+ create: (namespace, value) => beaconRows.insert({ namespace, value }),
2216
+ };
2217
+
2218
+ /** The environment this process runs in: required, never guessed. */
2219
+ export const environment = ((): string => {
2220
+ const named = process.env.AVACCESS_ENV;
2221
+ if (named === undefined || !/^[a-z][a-z0-9_-]{0,62}$/.test(named)) {
2222
+ throw new Error(
2223
+ "AVACCESS_ENV names the environment this service runs in (such as production); its beacon pins are avAccess.beacons.<environment>.json",
2224
+ );
2225
+ }
2226
+ return named;
2227
+ })();
2228
+
2229
+ /** This environment's pins, as `avaccess pull --env <environment>` wrote them. */
2230
+ export const beaconPins = JSON.parse(
2231
+ readFileSync(`avAccess.beacons.${environment}.json`, "utf8"),
2232
+ ) as AccessBeaconPins;
2233
+ ```
2234
+
2235
+ Then, before the service takes requests: `await access.policy.checkBeacons(beacon, beaconPins, environment)`.
2236
+
2237
+ With the registry on, an Access server whose rules name a namespace keeps its own beacon through Better Auth's
2238
+ adapter: its migration creates the `aventaraBeacon` table (Prisma model `AventaraBeacon`, as Better Auth's generator
2239
+ writes it), and `access.beacon` is the store. The auth-tables guard keeps every beacon table closed, the Access
2240
+ server's and any Resource named like one (`AventaraBeacon`, `_aventara_beacon`), with its `value` sealed.
2241
+
2242
+ ## Guardrails
2243
+
2244
+ The org's guardrails are deny-only rules that every namespace's engine adds on top of its own. They only narrow. The
2245
+ operator writes them with `defineGuardrails`, which has `.denies()` and no `.grants()`:
2246
+
2247
+ ```ts
2248
+ // src/org.guardrails.ts
2249
+ import { defineGuardrails } from "@aventara/access";
2250
+
2251
+ export const guardrails = defineGuardrails({ claims: { suspended: "boolean" } })
2252
+ .denies(({ deny }) => [
2253
+ // A suspended user reads, and writes nothing, in every service.
2254
+ deny(["create", "update", "delete"], "*", {
2255
+ name: "suspended-read-only",
2256
+ if: { claims: { suspended: true } },
2257
+ }),
2258
+ ])
2259
+ .contract();
2260
+ ```
2261
+
2262
+ - `deny(actions, resources, options?)` names whole Resources: `"*"`, or by namespace, `{ campaign: ["Note"] }` or
2263
+ `{ chat: "*" }`.
2264
+ - A guardrail's options are a `name` and `if: { claims, origin }`. A `grant`, or a row condition (`where`, `after`), is
2265
+ a type error.
2266
+ - `.contract()` compiles them to an `aventara-access/guardrails@1` file.
2267
+
2268
+ Push them through the server's own instance. No namespace key can push them:
2269
+
2270
+ ```ts
2271
+ // scripts/guardrails.push.ts
2272
+ import access from "../access.config.js";
2273
+ import { guardrails } from "../src/org.guardrails.js";
2274
+
2275
+ const pushed = await access.registry.pushGuardrails(guardrails);
2276
+ console.log(pushed);
2277
+ ```
2278
+
2279
+ Each service then gets them with `avaccess pull` and passes them as `policies.guardrails` (or the engine's
2280
+ `guardrails` option). Each guardrail becomes a deny on the Resources it reaches: every one for `"*"`, and the file's own
2281
+ namespace's list, where `{ chat: "*" }` is every Resource the file owns. A Resource the file does not list has no
2282
+ grant at all (a grant on `"*"` reaches only the file's own Resources), so nothing there is left for a guardrail to
2283
+ take away. Another namespace's list is left alone. A Resource in the file's own list that the file lacks, or
2284
+ reads as foreign, is refused, and so is a namespaced list when the file names no namespace. `createAccess` refuses
2285
+ guardrails that compare a claim `policies.claims` does not declare (`ACCESS_CLAIMS_UNDECLARED`), so declare
2286
+ `suspended` there too.
2287
+
2288
+ A guardrail's `if` fails closed on a claim the token lacks, as a deny's does: the guardrail applies, whatever value it
2289
+ compares with. Every service that declares `suspended` issues it in each token, `false` when unset, so
2290
+ `if: { claims: { suspended: true } }` leaves alone a user who is not suspended and applies to a token without
2291
+ `suspended`, such as one a service issued before it declared the claim; `if: { claims: { tier: "trial" } }` applies to
2292
+ a user with no `tier`. Write a guardrail so that a missing claim is the safe case.
2293
+
2294
+ Guardrails reach a service at build time, with a pull and a deploy. A change that must take effect sooner travels as
2295
+ a claim: a suspended user gets the claim with their next token, within `policies.tokenTtl`.
2296
+
2297
+ ## Services not on Aventara
2298
+
2299
+ A service that does not run on Aventara decides with a generated client: two TypeScript files that import nothing
2300
+ else. `src/generated/avAccess.ts` is the one the service imports. It exports `access` and the client's types
2301
+ (`ResourceName`, `ResourceRows`, `ResourceFields`, `QuestionOf`, `CanQuestionOf`, `GrantsQuestionOf`,
2302
+ `ScopeQuestionOf`, `FieldsQuestionOf`, and with `--orm prisma7` only, `PrismaClientOf`, `PrismaSelectOf`,
2303
+ `PrismaWhere` and `PrismaBeaconClient`), and holds
2304
+ the pulled policies file, the denies
2305
+ the org's guardrails add to it and the namespace's Resource types. `avAccess.internal.ts` beside it holds the copy of
2306
+ Access's decisions it imports; nothing in it is the client's API. `avAccess.ts` names it with the extension the
2307
+ project's `tsconfig.json` asks for, as Prisma 7's `prisma-client` does: `.js` under `nodenext`, none under
2308
+ `moduleResolution: "bundler"` or `module: "commonjs"`, `.ts` with `allowImportingTsExtensions` or
2309
+ `rewriteRelativeImportExtensions`. The tsconfig is found and read with `get-tsconfig`, as Prisma 7 and the
2310
+ framework's `avclient` read it: the nearest one above the generated file (`extends` followed; in a solution-style
2311
+ root such as Vite's, the referenced project that includes the file), or the one `--tsconfig <file>` names. A project with no tsconfig is refused, and nothing is written: the client is TypeScript, and JavaScript projects
2312
+ are not supported yet. The service needs no Access version at runtime.
2313
+
2314
+ `--orm` names the translator. For `generate` it is optional and defaults to `none`: a neutral client whose `access`
2315
+ has `can`, `grantsOf`, `scopeOf`, `fieldsOf` and `checkBeacons` only, answering in Aventara's `where`, with the types
2316
+ `ResourceName`, `ResourceRows`, `ResourceFields`, `QuestionOf`, `CanQuestionOf`, `GrantsQuestionOf`, `ScopeQuestionOf`
2317
+ and `FieldsQuestionOf`, and no ORM's name anywhere. `--orm prisma7` adds the Prisma 7 translator (`prismaWhere`,
2318
+ `readable`, `prismaExists`, `prismaBeacon` and the Prisma types). The generated file's banner always spells `--orm` out.
2319
+ `--out <file>` names the client's file, a `.ts` file inside the project (default `src/generated/avAccess.ts`); its
2320
+ internal file is written beside it, `<name>.internal.ts`. `schema` requires `--orm`: `prisma7` is the first.
2321
+
2322
+ ```sh
2323
+ avaccess schema --orm prisma7 # prisma/schema.prisma → src/generated/avAccess.schema.ts
2324
+ avaccess push src/access.rules.ts # with the namespace's push key
2325
+ avaccess pull # with its pull key
2326
+ avaccess generate --orm prisma7 # the pulled files → src/generated/avAccess.ts and avAccess.internal.ts
2327
+ ```
2328
+
2329
+ `avaccess schema` reads the service's Prisma schema (`--schema`, default `prisma/schema.prisma`; a folder of `.prisma`
2330
+ files reads as one) into a module that exports `model`. It leaves out, and lists, what it cannot model: an
2331
+ `Unsupported(...)` column, a composite `type`, a `view`, an `@ignore`d field or model. `--out <file>` writes it to
2332
+ another `.ts` file inside the project (default `src/generated/avAccess.schema.ts`). Author the rules against it:
2333
+
2334
+ ```ts
2335
+ // src/access.rules.ts
2336
+ import { definePolicies } from "@aventara/access";
2337
+ import { model } from "./generated/avAccess.schema.js";
2338
+
2339
+ export const rules = definePolicies(model, { namespace: "chat" })
2340
+ .principal<{ profileId: string }>()
2341
+ .govern("Message", ["body"])
2342
+ .grants(({ grant, me }) => [
2343
+ grant("anyone", "read", "Room", { where: { kind: "PUBLIC" } }),
2344
+ grant("signedIn", "read", "Message", {
2345
+ where: { authorId: me.profileId },
2346
+ fields: ["Message.body"],
2347
+ }),
2348
+ ]);
2349
+ ```
2350
+
2351
+ `avaccess generate` runs the same check as `avaccess verify` first: a pulled file edited by hand is refused and nothing
2352
+ is written. Regenerate after each pull, for example in the service's `postinstall`:
2353
+ `avaccess verify && avaccess generate --orm prisma7`. The same pulled files, translator and Access version give the
2354
+ same bytes. Never edit the generated files.
2355
+
2356
+ The client's `access` answers as the engine does, plus the Prisma 7 translator:
2357
+
2358
+ ```ts
2359
+ // src/messages.read.ts
2360
+ import { access } from "./generated/avAccess.js";
2361
+ import type { PrismaClient } from "./generated/prisma/client.js";
2362
+
2363
+ /** The service's principal: the profile its token maps to, and the token's claims. */
2364
+ export type Principal = { profileId: string; claims: { role?: string } };
2365
+
2366
+ /** A room's messages this principal may read, with the fields it sees on every row. */
2367
+ export async function readMessages(
2368
+ prisma: PrismaClient,
2369
+ principal: Principal | null,
2370
+ roomId: string,
2371
+ ) {
2372
+ const question = {
2373
+ principal,
2374
+ origin: "client",
2375
+ resource: "Message",
2376
+ } as const;
2377
+ const scope = access.scopeOf({ ...question, action: "read" });
2378
+ if (scope === false) {
2379
+ return null; // refused
2380
+ }
2381
+ return prisma.message.findMany({
2382
+ where: { AND: [{ roomId }, access.prismaWhere("Message", scope)] },
2383
+ select: access.readable(question),
2384
+ });
2385
+ }
2386
+
2387
+ /** Whether this principal may post `body` in a room, asking Prisma what the row does not carry. */
2388
+ export async function mayPost(
2389
+ prisma: PrismaClient,
2390
+ principal: Principal,
2391
+ roomId: string,
2392
+ body: string,
2393
+ ) {
2394
+ return access.can({
2395
+ principal,
2396
+ origin: "client",
2397
+ action: "create",
2398
+ resource: "Message",
2399
+ row: { roomId, authorId: principal.profileId, body },
2400
+ exists: (resource, where) => access.prismaExists(prisma, resource, where),
2401
+ });
2402
+ }
2403
+ ```
2404
+
2405
+ | Member | Answers |
2406
+ |---|---|
2407
+ | `can`, `grantsOf`, `scopeOf`, `fieldsOf` | As the engine does, in Aventara's `where` |
2408
+ | `checkBeacons(reader, pins, environment)` | The startup check of the beacons of the namespaces the rules read as foreign, as the engine's ([The ownership beacon](#the-ownership-beacon)). Until it passes, `access` answers nothing when the rules read another namespace. |
2409
+ | `prismaWhere(resource, scope)` | A scope as a Prisma `where`; `false` matches no row |
2410
+ | `readable(question)` | The fields a read shows on every row, as a Prisma `select`. A governed field opened on some rows only is left out, and no relation is selected. |
2411
+ | `prismaExists(prisma, resource, where)` | `can`'s `exists`, through your Prisma client |
2412
+ | `prismaBeacon(prisma)` | The ownership beacon's store over your Prisma client's `AventaraBeacon` model: what `checkBeacons` reads, and `avaccess push --beacon` and `pull --beacon` load |
2413
+
2414
+ The guarantees are weaker than inside Aventara. The scope covers only the queries you pass it to, and a Prisma
2415
+ `include` is not scoped. Verify the bearer token against the Access server's key set (`/api/auth/jwks`) before you
2416
+ build the principal.
2417
+
2418
+ For an update, run it on the rows `scopeOf({ action: "update" })` returns, and ask
2419
+ `can({ action: "update", row, before })` about each row it leaves, inside the same transaction, rolling back on
2420
+ `false`. `before` is the row as you read it in that transaction before the update. Hold the update to that row (its
2421
+ values in the update's `where`), so a concurrent change makes the update miss instead of passing on a stale `before`.
2422
+ Without `before`, `can` checks only the row the update leaves. Hand `can` the row's own fields; it never reads a relation on the row, and a client's relation data never
2423
+ belongs there.
2424
+
2425
+ ## Running `avaccess serve`
2426
+
2427
+ `avaccess serve [--config <file>] [--port <port>] [--hostname <host>] [--max-body-bytes <bytes>] [--migrate]` runs the
2428
+ standalone host from a configuration file. It finds `access.config.ts` in the working directory (or `.mts`, `.cts`, `.js`, `.mjs`,
2429
+ `.cjs`), loads it without a build step, and serves it until `SIGTERM` or `SIGINT`, on `127.0.0.1` unless `--hostname`
2430
+ says otherwise.
2431
+
2432
+ - Better Auth's migration runs on start only with `--migrate` or `AVACCESS_MIGRATE=true` (or `1`).
2433
+ - Without it, a database Better Auth migrates itself (a `pg` Pool, a MySQL pool, SQLite) is checked before the server
2434
+ listens. One that lacks a table, column or index the options need stops it with exit `1` and the list of what is
2435
+ missing.
2436
+ - A database behind an ORM's adapter (Prisma, Drizzle) is the ORM's to migrate: run `npx auth@~1.7.12 generate --yes`, then
2437
+ the ORM's migration. It is served as it is, and `--migrate` refuses it with exit `2`.
2438
+ - Better Auth's rate limit is on, whatever `NODE_ENV` says (Better Auth turns it on in production only), unless the
2439
+ configuration sets `betterAuth.rateLimit.enabled`.
2440
+ - `--max-body-bytes` sets `serve`'s body cap (default 1 MiB).
2441
+ - `access.config.ts` is the configuration, as in [The server](#the-server): its default export (or `access`) is the
2442
+ `createAccess` instance. It reads its secrets from the environment.
2443
+ - `GET /health` answers `200 {"status":"ok"}`, and `SIGTERM` drains the server and ends it with exit `0`.
2444
+
2445
+ Access ships no container image. To run the server in a container, build one from a project that installs the package,
2446
+ Better Auth and whatever the configuration imports (here `pg`), from its lockfile:
2447
+
2448
+ ```dockerfile
2449
+ FROM node:24-slim
2450
+ WORKDIR /srv/access
2451
+ COPY package.json package-lock.json ./
2452
+ RUN npm ci --omit=dev --ignore-scripts
2453
+ COPY access.config.ts ./
2454
+ USER node
2455
+ EXPOSE 3000
2456
+ CMD ["node_modules/.bin/avaccess", "serve", "--hostname", "0.0.0.0"]
2457
+ ```
2458
+
2459
+ `--hostname 0.0.0.0` makes it listen on the container's interfaces. The server speaks plain HTTP: put it behind a proxy
2460
+ that terminates TLS, and set Better Auth's `advanced.ipAddress.trustedProxies` to that proxy's address, so the client's
2461
+ address comes from its `X-Forwarded-For`.
2462
+
2463
+ ## File formats and hashes
2464
+
2465
+ Every file Access writes is passive JSON with a format string. A member the format does not define is refused, and so
2466
+ is a file of another format.
2467
+
2468
+ | Format | Members | Written by |
2469
+ |---|---|---|
2470
+ | `aventara-access/policies@1` | `format`, `namespace` (optional), `claims`, `schema`, `roles`, `govern`, `grants`, `denies` | `.contract()`, `avaccess pull` |
2471
+ | `aventara-access/guardrails@1` | `format`, `claims`, `denies` | `defineGuardrails(…).contract()`, `avaccess pull` |
2472
+ | `aventara-access/lock@1` | `format`, `namespace`, `policies`, `guardrails`, `foreign` (optional) | `avaccess pull` |
2473
+ | `aventara-access/foreign@1` | `format`, `namespace`, `schema` (the owners' entries, each marked `foreign`) | `avaccess pull --foreign` |
2474
+ | `aventara-access/beacons@1` | `format`, `namespace`, `environment`, `beacons` (owner to beacon hash) | `avaccess pull --env` |
2475
+
2476
+ A policies file's schema entry lists, beside its fields, the nullable ones (`nullable`) and the ones `can` compares by
2477
+ their type (`types`: `bigint`, `datetime` or `decimal`). A rule's literal is written in its field's canonical form: an
2478
+ instant as `toISOString()` writes it, a decimal without exponent or extra zeros. The engine checks every member of a
2479
+ file it is handed (each rule, condition, comparison and claim), and refuses a number JSON cannot carry: `NaN` and
2480
+ `Infinity` would be written as `null`. A Resource or field name is an identifier (never `__proto__`), each identifier,
2481
+ reference field and relation target names something the schema lists, a decimal is at most 1,000 characters, and a
2482
+ deny id never starts with `guardrail:` or `better-auth:`, the prefixes of the denies the engine adds itself.
2483
+
2484
+ `policiesHash(file)` and `guardrailsHash(file)` return a file's canonical hash, `"sha256:<hex>"`. It is the same for
2485
+ the same rules written in any order, and a formatter's re-indent does not change it.
2486
+
2487
+ ```ts
2488
+ // scripts/policies.write.ts
2489
+ import { readFileSync, writeFileSync } from "node:fs";
2490
+ import { createAccessEngine, policiesHash } from "@aventara/access";
2491
+ import { policies } from "../src/access.policies.js";
2492
+
2493
+ const file = policies.contract();
2494
+ writeFileSync("policies.json", `${JSON.stringify(file, null, "\t")}\n`);
2495
+ console.log(policiesHash(file)); // "sha256:…"
2496
+
2497
+ // Later, or in another process: the same engine, from the file alone.
2498
+ const engine = createAccessEngine(
2499
+ JSON.parse(readFileSync("policies.json", "utf8")),
2500
+ );
2501
+ console.log(Object.keys(engine.governed)); // ["User"]
2502
+ ```
2503
+
2504
+ Run it from the project's root with `npx tsx scripts/policies.write.ts`. It writes `policies.json` there.
2505
+
2506
+ The file's parts are exported as types (`AccessPolicies`, `AccessGrantRule`, `AccessDenyRule`, `AccessGuardrails`,
2507
+ `AccessLock` and the rest), so a project that emits declarations can export any part of a file.
2508
+
2509
+ ## Error codes
2510
+
2511
+ `createAccess`, its instance, `aventaraAccess()`, `accessOf(auth)`, the registry, and an engine checking its lock or a
2512
+ question throw `AccessError`,
2513
+ exported from `@aventara/access` (beside the engine, which throws it too). Its `code` says which
2514
+ refusal it is, and its message, which starts `Aventara Access:`, says what to change. The registry answers with
2515
+ Better Auth's `{ code, message }` body.
2516
+
2517
+ | Code | Meaning | Fix |
2518
+ |---|---|---|
2519
+ | `ACCESS_BETTER_AUTH_VERSION` | `better-auth` is missing, or outside `~1.7.12` (1.7.12 or a later 1.7 patch); under `mcp` or `audience`, an `@better-auth/oauth-provider` or `@better-auth/cimd` outside it | `npm i better-auth@~1.7.12`; under `mcp` or `audience`, `npm i @better-auth/oauth-provider@~1.7.12 @better-auth/cimd@~1.7.12` |
2520
+ | `ACCESS_CLAIMS_UNDECLARED` | The rules or the guardrails read a claim, or a token carries one, that `policies.claims` does not declare | Declare the claim in `policies.claims` |
2521
+ | `ACCESS_PLUGIN_DUPLICATE` | `betterAuth.plugins` holds `jwt()` or `aventaraAccess()`, which `createAccess` installs itself; Better Auth's plugins hold `jwt()` or a second Access plugin beside `aventaraAccess()`; one `aventaraAccess()` in two Better Auth instances whose options differ; or a plugin that defines `jwt()`'s endpoints, Access's own `/me` route (under `createAccess`), (kiosk profile) one under `/kiosk/`, or (registry on) a registry route | Remove it from the plugins |
2522
+ | `ACCESS_OPTIONS_INVALID` | An option `createAccess` does not take or cannot read; `passwords` beside your own `emailAndPassword.password`; Node.js 22 without `@node-rs/argon2`; a grant on a Better Auth table `resources` leaves closed; a claim read from a field the user can set; no base URL to fix the tokens' issuer; `sameSite: "none"` without `trustedOrigins`; Better Auth off its Prisma adapter without `resources.authTables`, or an opened table whose secret column is not found; a `principal` object shared by two users; an organization plugin without `requireEmailVerificationOnInvitation: true` under `resources.members` or the bridge (GHSA-fmh4-wcc4-5jm3); under the bridge, one that lets any user create an organization, or maps a role dynamic access control lets admins name; under `mcp` or `audience`, no OAuth provider or two, one listed before `aventaraAccess()`, `disableJwtPlugin`, a resource missing from its `resources`, no `aventara:read` among its `scopes`, a `refreshTokenReuseInterval` other than 0, a `validateRedirectUri`, a `requestUriResolver`, a `postLogin.consentReferenceId`, `allowUnauthenticatedClientRegistration` without `mcp.openRegistration` (never under `audience` alone), a resource that is the provider's UserInfo URL, the `service` profile, (`mcp`) an http issuer off `127.0.0.1` and `[::1]`, or (`audience`) `enforcePerClientResources: false` and, beside `mcp`, the audience in `clientRegistrationDefaultResources` or `clientRegistrationAllowedResources`; `accessOf` given an instance without `aventaraAccess()`; a `profile` of two kinds, or the kiosk's keys on another kind; the kiosk profile with Better Auth's cookie cache, with `secondaryStorage` and sessions kept out of the database, with `bearer()` or `multiSession()`, with `crossSubDomainCookies` or `SameSite=None`, or on a plain http: base URL off a loopback host and off `insecureLan`, or a dynamic `baseURL` serving a host off loopback over http:; under `insecureLan`, a range that is not a canonical private IPv4 CIDR of /16 or narrower, an empty or repeated list, a base URL that is a hostname, IPv6, outside every range or dynamic, or a cookie setting that makes a cookie `Secure` | Follow the message |
2523
+ | `ACCESS_RESOURCE_MISSING` | A table `resources` opens is missing: Better Auth lacks its plugin, or the rules' schema lacks its Resource; the organization bridge without `organization()` | Add the plugin, or run `npx auth@~1.7.12 generate --yes` and regenerate the model |
2524
+ | `ACCESS_LOCK_MISMATCH` | A pulled file no longer hashes to its pin in `avAccess.lock.json`, the engine was given files the lock does not pin, or `requireLock` is set and no lock was given | Run `avaccess pull` again; never edit a pulled file; pass the lock |
2525
+ | `ACCESS_KEY_REFUSED` | A registry key that is wrong, revoked, rotated out or another namespace's (`401`), or a pull key that pushes (`403`) | Use the namespace's current key of the right kind |
2526
+ | `ACCESS_OWNERSHIP` | A pushed file names a Resource another namespace owns, or one the operator has not assigned it (`ownership: "assigned"`), or the org's guardrails file grants (it holds `grants`, or a guardrail with `subjects`) (`403`) | Grant only on your own Resources; ask the owner or the operator, or add a guardrail |
2527
+ | `ACCESS_POLICIES_INVALID` | A pushed file is not a file the engine would run from (`400`), or is over 1 MiB (`413`) | Push the `.contract()` of your rules, or the pulled file unchanged |
2528
+ | `ACCESS_NAMESPACE` | A namespace name that is not one, one the registry does not have, or one it has already | Check the name; use `keys rotate` for an existing namespace |
2529
+ | `ACCESS_REVISION_CHANGED` | A stored revision no longer hashes to its key: it was changed outside the registry (`500`) | Restore the registry's tables; push the file again |
2530
+ | `ACCESS_FOREIGN_CROSS_DATABASE` | The rules read another namespace's Resources as foreign, and this service is on another database: the owner's beacon as its connection reads it is missing or not the one the owner pushed, or the owner pushed none (`403`) | Run the service on the owner's database, or carry the fact in a JWT claim (`policies.claims`) |
2531
+ | `ACCESS_BEACON_MISMATCH` | At startup, an owner's row in `_aventara_beacon` is missing or does not hash to this environment's pin, the pins are another environment's, a check failed before, or `checkBeacons` was not awaited; at push, a beacon the namespace replaced comes back (`409`), or the namespace's row is one the registry does not hold | Point the service at the owner's database; pull this environment's pins; await `checkBeacons` before serving; have an operator verify or delete a row the push did not write |
2532
+ | `ACCESS_QUESTION_INVALID` | A `fieldsOf` question without `access: "read"` or `access: "write"`, or with an `action` that does not fit it | Ask `{ access: "read" }`, or `{ access: "write", action? }` with `create` or `update` |
2533
+
2534
+ ## License
2535
+
2536
+ PolyForm Shield 1.0.0 with an additional permission. See [LICENSE](./LICENSE) and
2537
+ [LICENSE-ADDITIONAL-PERMISSION.md](./LICENSE-ADDITIONAL-PERMISSION.md).