@aventara/access 0.0.0-stage → 0.1.0-rc.0

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