@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/CHANGELOG.md ADDED
@@ -0,0 +1,831 @@
1
+ # @aventara/access
2
+
3
+ ## Unreleased
4
+
5
+ - The engine warns, as a Node.js warning `ACCESS_REFERENCE_UNREADABLE`, when a role may create or update a reference
6
+ (a to-one relation's reference fields) to a Resource no read grant reaches for that role. Aventara holds a reference
7
+ a write sends to the rows the caller may read, so such a write is refused (`A4002`). It holds only references to the
8
+ Resources its Core Contract shows, and says which when it hosts the engine (`engine.hosted({ held })`, called by
9
+ `@aventara/core` `1.0.0-rc.10` or later), so the warning comes then, for those Resources only: one the app's root
10
+ exemptions hide is not warned about, and compiling the rules (`.contract()`, `avaccess push`) warns about nothing.
11
+ The warning changes nothing the engine decides. A reference the grant locks with `write: { except }` is not warned
12
+ about, nor is one to a foreign Resource (below).
13
+ - The engine lists the Resources its rules read as foreign in `unheld`. No caller reads a foreign Resource, so under
14
+ `@aventara/core` `1.0.0-rc.10` every write of a reference to one (a chat `Room`'s `campaignId`) was refused; with
15
+ `unheld`, Aventara holds no written reference to them, and the write grant decides which row may be linked: a
16
+ `where` on a create grant, an `after` (or the `where`) on an update grant, through the relation. Reads of a foreign
17
+ Resource are unchanged. When Aventara hosts the engine, a role whose create or update grant may write such a
18
+ reference with no condition through that relation is warned about (`ACCESS_FOREIGN_REFERENCE_UNCHECKED`, a Node.js
19
+ warning, once per process and namespace): any row of the foreign Resource could be linked by its identifier.
20
+ - The container image `aventara/access-server` is removed, with its `access-deps` script. Run `avaccess serve` in a
21
+ container you build: the README's "Running `avaccess serve`" shows a minimal Dockerfile. `avaccess serve` itself is
22
+ unchanged.
23
+ - Better Auth is taken at a patch range, `~1.7.12`: `better-auth`, `@better-auth/oauth-provider` and
24
+ `@better-auth/cimd` are optional peers at `~1.7.12`, and `createAccess`, `aventaraAccess()` and the `mcp`/`audience`
25
+ checks take 1.7.12 or any later 1.7 patch. An older version, 1.8 or a prerelease is still refused with
26
+ `ACCESS_BETTER_AUTH_VERSION`. A plain `npm install @aventara/access better-auth @better-auth/oauth-provider` now
27
+ resolves; `@better-auth/core` and `@better-auth/utils` no longer need pinning by hand. Tested on 1.7.14.
28
+ - `createAccess({ mcp: { resource } })` and `aventaraAccess({ mcp: { resource } })` make Access the authorization
29
+ layer of an MCP endpoint. Better Auth's own OAuth provider (`@better-auth/oauth-provider` `~1.7.12`, in Better Auth's
30
+ plugins with its options untouched) issues the tokens; Access issues none and serves no OAuth route.
31
+ `access.mcp` (and `accessOf(auth).mcp`) is the framework's `mcp.authorization`, `{ servers, verify }`, typed present
32
+ exactly when `mcp` is given. `verify` takes the provider's JWT access token for that endpoint alone (RFC 8707) and
33
+ resolves `{ sub, claims, clientId, scopes }`, the claims read from the user on every request; it refuses a session
34
+ token, a token for another resource, an opaque, DPoP-bound or client-credentials token, and one whose client is
35
+ disabled, whose user is gone or no longer consents to its scopes and its resource, whose session has ended or which
36
+ names none (`sid`), whose resource the provider no longer has, or which is signed with an algorithm `jwt()` does not
37
+ sign with. A consent stored under a reference never counts.
38
+ - The request state reads the same token. The principal it yields is held to it before the rules: refused every
39
+ question under any origin but `agent`; under `agent`, `aventara:read` admits reads and `aventara:write` writes, and
40
+ what they admit is the user's own grants. A refusal is an audit event with the reason `deny:mcp-audience` or
41
+ `deny:mcp-scope`. Access's `GET /me` route answers the token `null`; `access.client.verbs.me.call` (the MCP `me`
42
+ tool's) answers its identity `{ sub, claims }`, which `agentOf` tells is an agent's.
43
+ `agentOf(me)` (`/auth`, `/better-auth`) tells app code an agent's principal, and the identity `principal` maps for
44
+ one: `{ clientId, scopes }`, else `null`.
45
+ - `audience` (`createAccess` and `aventaraAccess()`) is a service's opt-in to its own token audience (RFC 8707): with
46
+ it, a bearer token is taken only when it is the provider's access token addressed to the service and carrying
47
+ `aventara:read` or `aventara:write`, held to those scopes under every origin; the one-org token `GET /token` issues
48
+ is refused. Off by default, with no change to the default path.
49
+ - Under `mcp` or `audience`, Access refuses to start on a provider that would let a token's authorization fall short:
50
+ none or two in the plugins, a version outside `~1.7.12` (`ACCESS_BETTER_AUTH_VERSION`), `disableJwtPlugin`, a
51
+ resource missing from its `resources`, no `aventara:read` among its `scopes`, a `refreshTokenReuseInterval`
52
+ other than 0, a `validateRedirectUri`, a `requestUriResolver`, a `postLogin.consentReferenceId`, open client
53
+ registration (`allowUnauthenticatedClientRegistration`) unless `mcp.openRegistration: true` acknowledges it (never
54
+ under `audience` alone), a resource that is the provider's UserInfo URL, or the `service` profile; with `audience`,
55
+ `enforcePerClientResources: false`, and beside `mcp`, the audience in `clientRegistrationDefaultResources` or
56
+ `clientRegistrationAllowedResources` (an agent's self-registered client would get tokens that pass for the
57
+ service's). A `cimd()` outside `~1.7.12` is refused with `ACCESS_BETTER_AUTH_VERSION`. Every authorization request
58
+ must use PKCE with its SHA-256 method and carry its own parameters (no `request_uri`). Under `mcp` the issuer must be https, or http on `127.0.0.1` or `[::1]` (`localhost` is refused
59
+ with the address to write).
60
+ - The plugin answers for `jwt()` in Better Auth's context (`ctx.getPlugin("jwt")`), so a plugin that looks `jwt()` up
61
+ by its id, as the OAuth provider does, finds the one `aventaraAccess()` carries. A provider listed before
62
+ `aventaraAccess()` is refused with a sentence naming the order.
63
+ - The session verifier refuses an OAuth access token (`typ: "at+jwt"`) whatever it carries.
64
+ - The session verifier and the access-token verifier take only the asymmetric algorithms `jwt()` signs with; a key
65
+ the set publishes without naming its algorithm no longer verifies a token signed with another.
66
+ - `@better-auth/oauth-provider` and `@better-auth/cimd` are optional peers at `~1.7.12`. New `/auth` and
67
+ `/better-auth` names: `AccessMcpOptions`, `AccessMcpAuthorization`, `AccessAgentIdentity`, `AccessAgent`, `agentOf`.
68
+ - `profile: { kind: "kiosk", canEnrollDevice, idleTimeout? }`: the web profile plus staff switching by PIN on enrolled
69
+ devices. A fresh full-credential session that `canEnrollDevice` allows enrolls a device (`POST
70
+ /kiosk/enroll-device`): the device gets a random credential in an HTTP-only cookie (a `__Host-` cookie on an https
71
+ base URL), stored as its SHA-256, and the enrolling session ends. `POST /kiosk/set-pin` sets a fresh
72
+ full-credential session's own PIN; your server sets anyone's with Better Auth's server-only endpoint
73
+ `access.api.kioskSetUserPin({ body: { userId, pin } })`. `POST /kiosk/switch-user` (`{ userId, pin }`) opens a
74
+ session for that user on an enrolled device and ends every session on it and every one the request carried; `POST
75
+ /kiosk/lock` ends the same; `POST /kiosk/revoke-device` revokes one and `GET /kiosk/list-devices` lists them, for a
76
+ manager. `access.kiosk` is the operator's API on the server: `listDevices()` and `revokeDevice(deviceId)`. On the
77
+ web and service profiles these routes answer `404`.
78
+ - A PIN is 6 to 12 digits and is refused when it is one a guesser tries first: iOS's 6-digit passcode blocklist (as
79
+ Markert et al. extracted it) widened to every length, and the most common 6-digit PINs of leaked passwords. It is
80
+ hashed with argon2id at the `passwords` profile, whatever hashes passwords. Wrong PINs are limited per user id on one
81
+ device (5, then a backoff doubling from 1 minute to 1 hour, and after 100 in a row the PIN stops working there,
82
+ `KIOSK_PIN_DISABLED`) and per device (10 at once for any users, then one more every 90 seconds), both counted in the
83
+ database before the PIN is checked. The per-user count is kept for any user id, one with no PIN or no user included,
84
+ so no answer tells who holds a PIN; it is per device, so it slows or disables a colleague's PIN on that device only,
85
+ and a full sign-in (not an admin's impersonation) or a new PIN clears it. While a device is held, a user with no
86
+ wrong PIN on it since their last right one still gets one try.
87
+ - Every session opened on an enrolled device (a PIN switch's, or a password sign-in's there) is bound to it: on every
88
+ call of Better Auth's that carries it, by cookie or by `Authorization: Bearer` (`access.state` and `GET /me`
89
+ included), it is ended unless the request brings the device's current credential, or when it has been idle for
90
+ `idleTimeout` (300 seconds by default, 60 to 3600) with no request that may change something (a read, such as a
91
+ page polling `/get-session`, does not count). A bound session is issued no token, and `/get-session` answers it
92
+ without its token; no answer on this profile carries `bearer()`'s `set-auth-token`, whatever the plugin's id. With
93
+ `mcp` or `audience`, a bound session is refused at the OAuth provider's grant and client- and consent-management
94
+ routes, and a sign-in on an enrolled device that would resume an authorization request (in its body, or in a social
95
+ sign-in's state begun elsewhere) opens no session. The credential is replaced at every switch, and every replaced one is kept as a hash: any of
96
+ them brought back revokes the device, except the one the last switch replaced within 30 seconds of it, which is
97
+ refused (`409 KIOSK_DEVICE_BUSY`) with the device and its cookie left as they are, a sign-in that brings it included. A PIN session reaches only
98
+ reading its session, signing out, `me`, the key set, the switch, the lock and the full-credential sign-ins (`403
99
+ KIOSK_SESSION_NOT_ALLOWED` otherwise), through `access.handler` and through the server's own `access.api` calls with
100
+ its cookie.
101
+ - A switch opens a new session with the switched-to user's own claims and ends the one before; nothing of it carries
102
+ over. The audit log gains `kiosk-switch` (with `previousUserId` and `deviceId`), `kiosk-lock` (`lock`, `idle`,
103
+ `device-mismatch`), `kiosk-device` (`enrolled`, `revoked`, `token-reuse`, `stale-in-grace`) and `kiosk-pin`; a wrong PIN is a
104
+ `sign-in-failed` on `/kiosk/switch-user` with its `deviceId`. With `audit: true` on this profile, the `accessAudit`
105
+ table gains `deviceId` and `previousUserId`.
106
+ - The kiosk adds the tables `accessKioskDevice`, `accessKioskPin`, `accessKioskLockout` and
107
+ `accessKioskRetiredSecret`, and `kioskDeviceId`, `kioskSeenAt` and `kioskPin` on Better Auth's `session`; the
108
+ credential and PIN hashes are secret columns no grant opens.
109
+ - On the kiosk profile `access.client.verbs` publishes the kiosk's verbs (`switchUser`, `lock`, `setPin`,
110
+ `enrollDevice`, `revokeDevice`) after Better Auth's, as the framework's `$access` takes them.
111
+ - The kiosk profile refuses a second kind (`kind: ["kiosk", "service"]`), its keys on another kind, Better Auth's
112
+ cookie cache, `secondaryStorage` without `session.storeSessionInDatabase`, `bearer()`, `multiSession()`,
113
+ `crossSubDomainCookies` and `SameSite=None`, a plain http: base URL off a loopback host, and a plugin with an
114
+ endpoint under `/kiosk/`
115
+ (`ACCESS_PLUGIN_DUPLICATE`). `/auth` exports `AccessProfileOptions`, `AccessKioskProfile`, `AccessKiosk` and
116
+ `AccessKioskDevice`.
117
+ - `profile.insecureLan` (kiosk only): for an on-prem till network without TLS, the private IPv4 ranges on whose
118
+ addresses the kiosk takes a plain http: base URL, such as `http://192.168.1.10:3000` under
119
+ `["192.168.1.0/24"]`. A range is an IPv4 CIDR written in full (no leading zeros, host bits clear), a /16 or
120
+ narrower, wholly inside 10.0.0.0/8, 172.16.0.0/12 or 192.168.0.0/16; a hostname, IPv6, shared, link-local,
121
+ loopback or public range, an empty list, a repeated range or one inside another is refused (`ACCESS_OPTIONS_INVALID`), and so is the
122
+ key on `web` or `service` (also a type error: the type is a non-empty list of `${number}.${number}.${number}.${number}/${number}`).
123
+ The base URL must be an IPv4 address inside a listed range, written as the URL parser writes it (no leading zeros,
124
+ octal, hex or single number) and not the range's network or broadcast address, never a hostname; no `advanced` cookie setting may make
125
+ a cookie `Secure` or name it `__Secure-`/`__Host-` there (a browser drops it over http); the device cookie is
126
+ then `better-auth.kiosk_device`, HTTP-only, `SameSite=Lax`, at `/`, without `Domain`. Better Auth's logger warns at
127
+ startup, naming the base URL and the ranges. An https: or loopback base URL ignores the option.
128
+ - The kiosk profile reads a dynamic `baseURL` whole: it needs `protocol: "https"` unless every allowed host is a
129
+ loopback host (its fallback alone was read before, so `protocol: "http"`, `"auto"` or unset served the allowed
130
+ hosts in the clear), and `insecureLan` takes a fixed `baseURL`.
131
+ - The kiosk ends a session bound to no device when the request brings an enrolled device's credential (current, in
132
+ its grace, or retired), as it ends one of another device (`kiosk-lock`, reason `device-mismatch`). Such a session
133
+ was opened off the device: from a React Native till on `@better-auth/expo`, a native ID-token sign-in (its client
134
+ sends no cookie with it) or a sign-in in the system browser handed to the app (a magic link, social sign-in's
135
+ redirect). Before, it went on unbound on the till: its token answered by `/get-session` and `GET /token`, and no idle
136
+ lock. The README's kiosk section documents a till that is an Expo app (`expo()`, the app's scheme in
137
+ `trustedOrigins`, the ID-token sign-in with its cookies passed, cleartext under `insecureLan`).
138
+ - `aventaraAccess({ registry: { enabled: true } })` makes the app that runs Better Auth the registry's hub, with no
139
+ Access server beside it. The block is `createAccess`'s (`enabled`, `ownership`). The registry's tables are the
140
+ plugin's schema (the same tables `createAccess` uses, created by Better Auth's `migrate`/`generate` through the app's
141
+ adapter) and its routes are the plugin's endpoints under `/access/v1`, served wherever the app mounts
142
+ `auth.handler`. Off by default: no tables, and `/access/v1/*` answers `404`.
143
+ - `accessOf(auth).registry` is the operator's API when the plugin serves the registry, typed present exactly then
144
+ (`AccessProvider`'s fourth parameter; the plugin's `$Infer.AccessRegistryEnabled`). No route opens it.
145
+ - `avaccess keys --server <module>` takes a module that exports `accessOf(auth)`. A module that exports only Better
146
+ Auth's own instance is refused with a sentence that names `accessOf(auth)`.
147
+ - With the registry on (`createAccess` or the plugin), another Better Auth plugin with an endpoint named like one of
148
+ the registry's (`accessPush`, `accessHead`, …) or at a path under `/access/v1`, or with a middleware whose path can
149
+ match one there (`/access/v1/**`, `/access/**`, `/**`, `/*/v1/key`), is refused with `ACCESS_PLUGIN_DUPLICATE`: it
150
+ would answer a service's push or pull and read the key it sends. Other plugins' `onRequest` and `hooks` are inside
151
+ the app's trust boundary and are not checked.
152
+ - An endpoint path is checked as Better Auth's router reads it, one leading slash and no trailing one, for the
153
+ registry's routes, `jwt()`'s `/jwks` and `/token`, and the `me` route: `token` or `access/v1/key` without the slash
154
+ was not refused before.
155
+ - The hub keeps Better Auth's one org token (issuer and audience the base URL): a token works at every service that
156
+ checks the hub's key set, and at the hub, until it expires. Access adds no token endpoint; a token bound to one
157
+ service is an opt-in from Better Auth's OAuth provider with resource indicators.
158
+ - `@aventara/access/better-auth` exports `AccessIdentity`, the default principal type: an app that leaves out
159
+ `principal` and emits declarations (`export const auth = betterAuth({...})`) failed with TS2742/TS2883 without it.
160
+ - A token Access issues carries every declared boolean claim: one the user's field leaves unset (absent, `null` or
161
+ `undefined`), or `policies.claimsOf` returns as `null` or `undefined`, is `false`. A token for a user with no ban
162
+ now carries `banned: false`.
163
+ - `policies.claimsOf` must return every declared boolean claim's key. An answer without one is refused with
164
+ `ACCESS_OPTIONS_INVALID`, naming the claim, and no token is issued. It is a type error too: `claimsOf` returns an
165
+ `AccessClaimsAnswer` (from `/auth`), where each declared boolean claim is required and the others stay optional, in
166
+ `createAccess` and in `aventaraAccess()`.
167
+ - `policies.claims` refuses a claim named `__proto__`. A claim name is matched against the declaration and a token's
168
+ payload by own key only, so `toString` or `constructor` is undeclared unless declared.
169
+ - Tokens issued before this version lack the boolean claims, so a deny such as `banned: true` refuses them until they
170
+ expire, within `policies.tokenTtl` (15 minutes by default). Users sign in again or fetch a new token.
171
+ - A deny's `if` (a guardrail's too) holds on a claim the principal lacks whatever value it compares with, `true`
172
+ included. `banned: true` still leaves alone a user whose ban is unset, since their token carries `banned: false`,
173
+ and now denies a token that lacks `banned` (one from another issuer, or issued before the claim was declared): it
174
+ fails closed. The anonymous caller is the exception: a comparison with `true` never holds for it, so a ban does
175
+ not close an `anyone` grant. A generated client copies the change; run `avaccess generate` again.
176
+ - A principal you build yourself (in tests, or from another issuer's token) must carry each boolean claim a deny
177
+ compares with `true`, as an issued token does, or that deny refuses it.
178
+ - An update is held to one grant. `can({ action: "update", row, before })`, with `before` the row as it was before
179
+ the update, allows it only when one update grant covers both rows: its `where` the row before, its `where` and
180
+ `after` the row after. A row that one update grant covers before the update and only another covers after it is
181
+ refused. Update denies are asked about the row after only, as before. Aventara hands `can` the row before every
182
+ update the engine lists in `afterChecks` from `@aventara/core` `1.0.0-rc.10` on; without `before` (an older core),
183
+ `can` answers as it did, on the row after alone.
184
+ - A generated client's `can` takes the same optional `before`, with the same answer; run `avaccess generate` again.
185
+ - The engine answers `grantsOf({ principal, origin, resource, action: "update", row, exists })`: the keys of the
186
+ update grants whose `where` and subject's row condition hold on `row` as it is now. `@aventara/core`
187
+ `1.0.0-rc.10` asks it before an update `afterChecks` lists writes, and hands the answer back to `can` as
188
+ `beforeGrants`. With `beforeGrants`, the grant that covers the row after must be one of them. With `before` too,
189
+ each listed grant's `where` is still checked on `before` in memory (a key list replayed from another row or
190
+ principal fails closed on a scalar condition), and a relation condition on it is not asked again: it is decided
191
+ before anything the same request writes. A row
192
+ handed to someone else and tagged "shared" in the same update is refused by a grant on shared rows. Without
193
+ `beforeGrants`, `can` answers as it did.
194
+ - The keys are opaque and stable for one policies file: a short hash of the file's grants, then a grant's place in
195
+ it and its subject's place in the grant, no rule id or condition; a list from another file covers nothing.
196
+ `grantsOf` answers `[]` on a row an update deny already refuses in memory. A `beforeGrants` that is not a list of strings fails closed, and a `grantsOf` question
197
+ about anything but an update is refused with `ACCESS_QUESTION_INVALID`. `AccessGrantsQuestion` is exported.
198
+ - A generated client has `grantsOf`, with `GrantsQuestionOf<R>`, and its `can` takes `beforeGrants` on an update
199
+ question only, with the same answers as the engine; a `grantsOf` about anything but an update throws an error
200
+ shaped as `AccessError` (`code: "ACCESS_QUESTION_INVALID"`). Run `avaccess generate` again.
201
+ - `access.client`: what the framework's `$access` reads from a `createAccess` instance. `url` is Better Auth's base URL
202
+ with its base path (the base path alone when `baseURL` is dynamic). `verbs` holds `signIn`, `signUp`, `signOut`,
203
+ `resetPassword` and `me`, each with its method, its path under `url`, the JSON Schema of its arguments and of its
204
+ result, and `call`. The arguments follow Better Auth's client; a result names the user's id and Better Auth's flags,
205
+ never the session token or a user field; `me` is the token identity, `{ sub, claims }`, with the declared claims.
206
+ - A verb the instance does not serve is left out: `signIn`, `signUp` and `resetPassword` without `emailAndPassword`,
207
+ `signUp` with `disableSignUp`, a verb whose path is in `disabledPaths`, and `signIn` and `signUp` on the `service`
208
+ profile.
209
+ - `call({ args, headers })` runs the verb in-process through Better Auth's API with the request's headers, so
210
+ `nextCookies()` sets the cookie from a Server Action. It is held to the route's checks: its arguments to the verb's
211
+ schema, Better Auth's origin check (which its router runs and a direct API call skips), the profile's refusals and the
212
+ audit log. Its answer is cut to the verb's result schema.
213
+ - `call` of `signIn`, `signUp` and `resetPassword` is rate-limited, since a call to Better Auth's API skips the limit
214
+ its router applies. The limit is Better Auth's own `rateLimit` options (on when they turn it on, the route's rule,
215
+ their storage), counted by client address (read as Better Auth reads it, `trustedProxies` included, under the
216
+ router's key) and by target (the email or reset token named, as an HMAC). The target bucket counts failures, 10
217
+ each within 5 minutes of the one before, and a success clears it, so others' failures cannot keep the owner out.
218
+ Over either, the call throws Better Auth's `429` with `X-Retry-After`, and the audit log records a
219
+ `sign-in-failed` with reason `rate-limited`.
220
+ - `call` runs every plugin's `onRequest`, as Better Auth's router does before a route, so `captcha()` and its kind
221
+ hold for server calls too: a refusal is thrown as Better Auth's `APIError` with the plugin's status and body.
222
+ - `GET /api/auth/me` answers the request's identity, `{ sub, claims }`, or `null`, uncached. A plugin in
223
+ `betterAuth.plugins` with an endpoint at `/me` or named `accessMe` is refused with `ACCESS_PLUGIN_DUPLICATE`.
224
+ - `@aventara/access/better-auth`: `aventaraAccess({ policies, principal?, resources?, organization?, audit? })` is Access as a
225
+ plugin of the Better Auth an app builds itself (`betterAuth({ plugins: [aventaraAccess(...)] })`). It carries Better
226
+ Auth's `jwt()`, so its tokens carry `sub` and the declared claims only, under the base URL's origin as issuer and
227
+ audience. It runs `createAccess`'s own checks on Better Auth's options when Better Auth starts it, and refuses what
228
+ `createAccess` refuses, with the same codes.
229
+ - `accessOf(auth)` is the framework's `access` option from that instance: the engine, with Better Auth's tables closed
230
+ and their secrets sealed as under `createAccess`, and each request's `me` from a bearer token or the session
231
+ cookie. `accessOf(auth).$Infer` types the principal and the claims.
232
+ - The organization bridge, `organization: { claim, organizationClaim, roles }`, puts a member's role in the session's
233
+ active organization into one claim and that organization into another, both declared strings; `roles` is an ordered
234
+ list of `[Better Auth role, value]`. It is read from Better Auth's store when a token is issued, so removing a member
235
+ removes both claims from the next token. A rule on the bridged role is held to the organization
236
+ (`where: { organizationId: me.org }`). The bridge refuses an organization plugin that lets any user create an
237
+ organization (`allowUserToCreateOrganization` must be `false` or a function), and, with dynamic access control, a
238
+ role that is not static. A member's roles are read as Better Auth reads them: `member, owner` gives nothing.
239
+ - `audit` (`true` or `{ sink }`) logs in plugin mode as under `createAccess`: sign-ins, refused sign-ins, sign-outs
240
+ and tokens, and the decisions of `accessOf(auth).policy`.
241
+ - One `aventaraAccess()` in two Better Auth instances whose options differ is refused (`ACCESS_PLUGIN_DUPLICATE`).
242
+ - `aventaraAccessClient({ api })` adds `authClient.access.can([...])` to Better Auth's client: one
243
+ `POST {api}/_access/can` to the framework for the whole page, one boolean per question, answered as Better Auth's
244
+ `{ data, error }`.
245
+ - Better Auth's advisory GHSA-fmh4-wcc4-5jm3 is unfixed as of `better-auth` 1.7.14 (the GitHub advisory database's entry
246
+ still says it was fixed in 1.6.11): its organization plugin lets a user whose email is not verified accept an
247
+ invitation sent to that email. `createAccess` with `resources: { members: true }`, and the organization bridge,
248
+ now refuse an `organization()` plugin without `requireEmailVerificationOnInvitation: true`
249
+ (`ACCESS_OPTIONS_INVALID`).
250
+ - `aventaraAccess()` inside `createAccess`'s `betterAuth.plugins` is refused by name (`ACCESS_PLUGIN_DUPLICATE`).
251
+ - `createAccess` hashes passwords with argon2id. Better Auth hashes with scrypt; Access now offers three ways:
252
+ - by default, when neither `passwords` nor `betterAuth.emailAndPassword.password` is set, argon2id at the
253
+ `"owasp-minimum"` profile (m=19456 KiB, t=2, p=1, the OWASP Password Storage Cheat Sheet's minimum);
254
+ - a named profile, `passwords: { argon2id: "owasp-minimum" | "rfc9106-low-memory" }` (the second is RFC 9106's
255
+ second recommended option, m=65536 KiB, t=3, p=4). A misspelled profile is a type error, and
256
+ `ACCESS_OPTIONS_INVALID` at runtime;
257
+ - the app's own `betterAuth.emailAndPassword.password` hook, which passes through untouched. Setting it and
258
+ `passwords` together is refused with `ACCESS_OPTIONS_INVALID`.
259
+ - argon2id runs on Node.js's `crypto.argon2` (Node.js 24.7.0 and later), with no native dependency. On Node.js 22 it
260
+ runs on `@node-rs/argon2` `2.2.2`, a new exact optional peer, loaded only when `crypto.argon2` is missing; without
261
+ it, `createAccess` refuses with `ACCESS_OPTIONS_INVALID`. Both write the same PHC strings.
262
+ - Hashes are PHC strings (`$argon2id$v=19$m=…,t=…,p=…$salt$hash`), checked at the parameters they name with a
263
+ constant-time compare, so a later profile change keeps every older hash verifying. A stored hash that costs more
264
+ than the largest profile is refused without being computed. A malformed or tampered hash, or a failure inside the
265
+ computation, fails the check instead of throwing, and is logged through Better Auth's logger. A password over Better
266
+ Auth's `maxPasswordLength` is never hashed (`PASSWORD_TOO_LONG`).
267
+ - Better Auth's scrypt hashes fail the check unless `passwords.legacyScrypt` is `true`. With it, they verify through
268
+ Better Auth's own `verifyPassword`, and a sign-in that matches one stores the password again with argon2id. Every
269
+ check costs the same whatever an account holds: one argon2id computation, and with `legacyScrypt` one scrypt too.
270
+ - A sign-in whose password matches a hash stored at other parameters than the `passwords` profile's (an older profile,
271
+ cheaper or costlier) stores the password again at the profile, as a scrypt hash moves under `legacyScrypt`: the
272
+ password is checked against the stored hash again, and the write is a compare-and-set on that hash, so a password
273
+ changed meanwhile is never overwritten. A hash at the profile costs one read per password sign-in and no write.
274
+ Kiosk PINs move the same way at the next switch.
275
+ - `aventaraAccess()` takes the same `passwords` and hashes the same way, putting its hook in Better Auth's context when
276
+ Better Auth starts the plugin; an app's own `emailAndPassword.password` passes through, and setting both is refused.
277
+ - `@aventara/access/auth` exports `AccessPasswordsOptions` and `AccessArgon2idProfile`.
278
+ - The ownership beacon. A namespace reads another namespace's Resources in conditions (`{ foreign: "<owner>" }`)
279
+ only while both services run on one database, and Access now checks it. Each service writes one row in
280
+ `_aventara_beacon` (`namespace`, `value`, `createdAt`), a random `avb_…` value written once, through its own data
281
+ layer: Access takes an `AccessBeaconStore` (`read`, `create`) and writes no SQL.
282
+ - `avaccess push <rules> --beacon <module>` sends the namespace's own beacon with its file, and the beacon of each
283
+ namespace the file reads, as this service's connection reads it. The registry keeps only hashes bound to their
284
+ namespace (`sha256` of `<namespace>:<value>`, a new `accessBeacon` table that keeps each namespace's past ones), and
285
+ stores a file that reads another namespace only when the push carries that namespace's beacon as pushed: otherwise
286
+ `ACCESS_FOREIGN_CROSS_DATABASE` (`403`), which suggests a JWT claim instead. Only a namespace's own push registers
287
+ or replaces its beacon; one it replaced never comes back (`ACCESS_BEACON_MISMATCH`, `409`). A push never adopts a
288
+ row in `_aventara_beacon` it did not write and the registry does not hold: it is refused until an operator
289
+ verifies or deletes it. The push body may be the file, or `{ file, beacons }`; a namespace's info
290
+ (`GET /access/v1/namespaces/:ns`) carries its beacon's hash.
291
+ - `avaccess pull --foreign <owner>:<Resource>,… --beacon <module> --env <environment>` writes the owner's schema
292
+ entries of those Resources to `access/foreign.json` (format `aventara-access/foreign@1`, pinned in the lock as
293
+ `foreign`, checked by `verify`), and `avaccess schema` marks them as the owner's in the model it writes. A pull of
294
+ rules that read another namespace checks that namespace's beacon through `--beacon` and writes nothing on another
295
+ database. The new registry route `GET /access/v1/namespaces/:ns/foreign/:owner?resources=…` answers only for what
296
+ the namespace's current file reads as that owner's, refuses everything else with one and the same answer, and logs
297
+ every read.
298
+ - Beacon pins are per environment: `avaccess pull --env <environment>` (or `AVACCESS_ENV`) writes
299
+ `avAccess.beacons.<environment>.json` (format `aventara-access/beacons@1`). `avAccess.lock.json` holds no beacon, so
300
+ one lock still holds against any registry. `avaccess verify [--env]` checks every environment's pins too.
301
+ - An engine whose rules read another namespace, with a lock or without, decides nothing until
302
+ `await engine.checkBeacons(reader, pins, environment)` (`access.policy.checkBeacons` under `createAccess`) has
303
+ passed: the pins must be `environment`'s, and each owner's row, read through the service's own connection, must hash
304
+ to its pin, or every decision throws `ACCESS_BEACON_MISMATCH`. A failed check latches, and checks run one after
305
+ another. The generated client does the same with `access.checkBeacons(reader, pins, environment)` and throws an
306
+ error with that `code`; with `--orm prisma7` it adds `access.prismaBeacon(prisma)` (a store over the
307
+ `AventaraBeacon` Prisma model, `PrismaBeaconClient`). `avaccess schema` leaves that model out of the Resources.
308
+ - With the registry on, an Access server whose rules name a namespace declares the `aventaraBeacon` table through
309
+ Better Auth's plugin schema and exposes `access.beacon`, its store over Better Auth's adapter. The auth-tables guard
310
+ keeps every beacon table closed and its `value` sealed: the Access server's, and any Resource named like one.
311
+ - New exports: `AccessBeaconReader`, `AccessBeaconStore`, `AccessBeaconPins`; new codes `ACCESS_FOREIGN_CROSS_DATABASE`
312
+ and `ACCESS_BEACON_MISMATCH`. `aventara-access/lock@1` takes an optional `foreign` pin.
313
+
314
+ ## 0.1.0-rc.0
315
+
316
+ The first release candidate: the policy engine, Better Auth with Access beside it (`@aventara/access/auth`), the
317
+ policy registry and the org's guardrails, the audit log, the `avaccess` bin with its generator, and the standalone
318
+ host (`@aventara/access/standalone`) with its container image.
319
+
320
+ - `definePolicies(model)` writes role-first rules, typed from your app's schema (an Aventara discovery artifact's
321
+ `model`). Global roles come from the principal's claims; roles held on a row (`on`, `where`) come from your own
322
+ tables. `.govern()` keeps fields out of every result unless a grant names them. `.grants()` and `.denies()` take the
323
+ rules: nothing is allowed until a grant allows it, any deny wins, and order never matters.
324
+ - Every name a rule uses (a Resource, field, relation, enum value, role, claim or governed field) is checked against
325
+ the schema: a misspelled one is a type error at its key.
326
+ - `definePolicies(model, { namespace })` names the namespace that owns the rules' Resources. A Resource marked
327
+ `{ foreign: "<namespace>" }` in the schema can be read in conditions, and a grant on it is refused.
328
+ - `.contract()` compiles the rules to a policies file in the `aventara-access/policies@1` format: plain JSON with no
329
+ functions in it. Unknown members are refused, and so are files in the earlier unstable format.
330
+ - The file's parts are exported as types (`AccessGrantRule`, `AccessDenyRule`, `AccessRoleDefinition`,
331
+ `AccessPredicate` and the rest), so a project that emits declarations can export any part of a file or of an
332
+ explanation.
333
+ - `policiesHash(file)` returns the file's canonical hash (`"sha256:…"`), the same for the same rules in any order.
334
+ - `.engine()` and `createAccessEngine(file)` return the engine. It fits `@aventara/core`'s `AvPolicyEngine` by shape,
335
+ so it plugs into the framework's `policy` option or into an `access` provider. It answers `can`, `scopeOf` and
336
+ `fieldsOf`, and `explain` shows, in development, which rules took part in a decision.
337
+ - The engine (`@aventara/access`) imports nothing at runtime, and the package has no dependency on the
338
+ framework.
339
+ - A deny on a nullable field lets the NULL rows through in `scopeOf` too, as it does in `can`. A `policies@1` schema
340
+ entry lists its nullable scalar fields in an optional member, `nullable`, which `.contract()` writes from the schema
341
+ and the hash reads as a set; a file without it has no nullable field, and an empty list is refused. `scopeOf` writes
342
+ a negated comparison on one of them as `NOT (f = v) OR f IS NULL`; a field that is not nullable keeps the plain
343
+ `NOT`. The same holds for `ne`, `in` and `notIn`, for a deny on several fields, under `every`, for guardrail denies,
344
+ and in the generated client. A to-one relation is nullable when one of its reference fields is, and a negated step
345
+ on it adds `OR <reference> IS NULL`. The startup `paths` name the operators this adds, so an app that took one away
346
+ fails at startup; a create-only deny, which no scope negates, names none of them.
347
+ - The package ships `LICENSE-ADDITIONAL-PERMISSION.md` beside `LICENSE`: PolyForm Shield 1.0.0 stays the license, and the
348
+ additional permission limits its Noncompete section to products that compete with Aventara Access itself.
349
+ - `@aventara/access/auth`: `createAccess({ betterAuth, policies, principal?, profile?, registry?, audit?, resources? })`
350
+ builds Better Auth with Access beside it. Its options, its instance and its blocks' types are exported from
351
+ `/auth` too; `@aventara/access` holds the engine alone, so nothing it declares names `better-auth`, and an
352
+ engine-only project type-checks without it with `skipLibCheck: false`.
353
+ `betterAuth` takes Better Auth's own options, exactly as `betterAuth({...})` takes them, and passes them through
354
+ unchanged; Access installs Better Auth's `jwt()` plugin and its own. The instance mounts nothing: mount
355
+ `access.handler` where you would mount Better Auth's handler. `access.api` is Better Auth's server API and
356
+ `access.options` the options Better Auth runs with (what `npx auth generate` reads).
357
+ - `policies.claims` declares the token's claims by type (`{ role: "string" }`). A token carries `sub` and the declared
358
+ claims only, read from the user's fields of the same name or from `policies.claimsOf`. A claim the rules read and
359
+ the declaration lacks is refused at construction, and is a type error when the rules are typed.
360
+ - The instance fits `@aventara/core`'s `AvAccessProvider` by shape: `createFramework({ ...config, access })` takes it
361
+ with no `policy` and no `state`. Its `state` verifies a bearer token against Better Auth's key set (read again every
362
+ 5 minutes), or reads the session cookie from Better Auth's store, and gives `me`: the identity `{ sub, claims }`, or
363
+ what `principal` maps it to. A token with an undeclared claim is refused.
364
+ - `profile: { kind: "service" }` hands no caller a new session, and reads `me` from bearer tokens only.
365
+ `{ kind: "web" }` is the default.
366
+ - `better-auth` is an optional peer, pinned to one exact version, and loaded only when `createAccess` runs: the engine
367
+ works without it. Another version is refused at construction (`ACCESS_BETTER_AUTH_VERSION`).
368
+ - Refusals are `AccessError`s with a `code`: `ACCESS_BETTER_AUTH_VERSION`, `ACCESS_CLAIMS_UNDECLARED`,
369
+ `ACCESS_PLUGIN_DUPLICATE` (a plugin `createAccess` installs itself, also a type error at that plugin),
370
+ `ACCESS_OPTIONS_INVALID`, and the registry's (below).
371
+ - Depends on `jose` for token verification.
372
+ - Access's routes mount the way Better Auth's do, with Better Auth's own helpers: in Next.js, the route file
373
+ `app/api/auth/[...all]/route.ts` holds `export const { GET, POST } = toNextJsHandler(access)`
374
+ (`better-auth/next-js`); in Node.js, Express or NestJS, `toNodeHandler(access)` (`better-auth/node`) is mounted
375
+ before the body parser. In NestJS that is `app.use()` between `NestFactory.create()` and `listen()`, with a path
376
+ test, `request.url?.startsWith("/api/auth/") ? handler(request, response) : next()`, which works on Express and
377
+ Fastify alike (on Fastify, `app.use("/api/auth", …)` hands Better Auth the URL without its base path). The
378
+ framework's hosts mount no Access route.
379
+ - `@aventara/access/standalone`: `serve(access, { port, hostname, maxBodyBytes })` runs Access as its own HTTP server
380
+ around the same handler, with `GET /health` answering `200 {"status":"ok"}`. `port` defaults to `3000`, and `0`
381
+ picks a free one; `hostname` defaults to `127.0.0.1`. It resolves to `{ port, url, close() }` once the server
382
+ listens.
383
+ - `createAccess` loads `better-auth` through the package's own `#better-auth.require` import: Node.js's `require` under
384
+ the `node` condition, which a bundler building for Node.js also reads, so a Next.js server bundle (Turbopack or
385
+ webpack) and its standalone output carry Better Auth. Under any other condition (a browser, an edge runtime) it names
386
+ no Node.js API, so the engine bundles there too.
387
+ - Requires Node.js `^22.23.4 || >=24.22.1`, the Node.js security releases of 2026-10-20 (CVE-2026-105481,
388
+ CVE-2026-105482, CVE-2026-105483, CVE-2026-106069, CVE-2026-106072, CVE-2026-106073, CVE-2026-106074,
389
+ CVE-2026-111497).
390
+ - The policy registry: `registry: { enabled: true }`. Its tables (`accessNamespace`, `accessKey`,
391
+ `accessPolicyRevision`, `accessGuardrailRevision`, `accessHead`, `accessPushLog`, `accessResourceOwner`) are Better
392
+ Auth plugin tables:
393
+ Better Auth's own migration, or `npx auth generate` for an ORM's schema, creates them beside Better Auth's, and they
394
+ are stored through whatever database adapter `betterAuth.database` names. `access.handler` serves its routes under
395
+ Better Auth's base path, so every mount carries them: `GET /access/v1/namespaces/:ns`, `…/revisions`,
396
+ `…/revisions/:hash`, `…/head`, `…/guardrails`, and `POST …/revisions` to push. One org per server.
397
+ - `access.registry` is the operator's API: `createNamespace(name)` issues the namespace's push key and its read-only
398
+ pull key (shown in that answer only; the registry keeps their SHA-256 hashes), `rotateKey(namespace, kind)`,
399
+ `revokeKey(namespace, kind)` and `pushGuardrails(file)`; a name that is not a namespace name, or one the registry
400
+ has already, is refused (`ACCESS_NAMESPACE`). A service sends its key in the `x-access-key` header. A
401
+ wrong, revoked or rotated-out key, a pull key that pushes, or another namespace's key is refused
402
+ (`ACCESS_KEY_REFUSED`).
403
+ - A pushed file is stored once, in its canonical form, under its `policiesHash`, and is never updated: pushing a file
404
+ the registry has moves the namespace's head back to it. A stored revision that no longer hashes to its key is
405
+ never served (`ACCESS_REVISION_CHANGED`).
406
+ - Ownership at push (`ACCESS_OWNERSHIP`): a namespace's file names as its own only Resources no other namespace owns,
407
+ and its grants, denies and governed fields name only those. The org's guardrails file holds no grant. A file the
408
+ engine would refuse is refused too (`ACCESS_POLICIES_INVALID`).
409
+ - `registry: { enabled: false }` stays the default: no registry table, and `/access/v1/*` answers `404`.
410
+ - The org's guardrails: `defineGuardrails({ claims })` writes deny-only rules that central Access adds on top of
411
+ every namespace's own. The builder has `.denies()` and no `.grants()`, its callback hands out `deny` alone, and a
412
+ guardrail's options are a `name` and `if: { claims, origin }`: a `grant` in it, or a row condition (`where`,
413
+ `after`), is a type error. `deny(actions, resources, options?)` names whole Resources: `"*"`, or by namespace,
414
+ `{ campaign: ["Note"] }` or `{ chat: "*" }`. `.contract()` compiles them to an `aventara-access/guardrails@1` file
415
+ (`format`, the `claims` they compare, `denies`), passive JSON hashed like a policies file: `guardrailsHash(file)`
416
+ is the same for the same guardrails in any order. A member the format does not define is refused with a sentence
417
+ (`grants` and a guardrail's `where` by name), and so is a file of another format.
418
+ - An engine applies pulled guardrails and only narrows: `rules.engine({ guardrails })`,
419
+ `createAccessEngine(file, { guardrails })`, and in `createAccess`, `policies.guardrails`. Each guardrail becomes a
420
+ deny on the Resources it reaches: every one for `"*"`, and the file's own namespace's list; another namespace's
421
+ list is left alone. A Resource in the file's own list that the file lacks, or reads as another namespace's, is
422
+ refused, and so is a namespaced list when the file names no namespace. The governed fields, the paths and the
423
+ engine's type are the file's own. `createAccess` refuses guardrails that compare a claim `policies.claims` does not
424
+ declare (`ACCESS_CLAIMS_UNDECLARED`), and `policies` refuses a member it does not take.
425
+ - `policies.tokenTtl`: how long a token lives, in whole seconds from 300 to 900 (5 to 15 minutes); 900 by default.
426
+ A claim that must take effect soon (`banned`, `suspended`) reaches every service with the user's next token.
427
+ - The registry's `pushGuardrails(file)` takes the `guardrails@1` file. A `policies@1` file is refused by its format
428
+ (`ACCESS_POLICIES_INVALID`), a `grants` member or a guardrail with `subjects` by ownership (`ACCESS_OWNERSHIP`),
429
+ and a guardrail naming a namespace the registry does not have by `ACCESS_NAMESPACE`. `…/guardrails` serves the
430
+ file with its `guardrailsHash`. Guardrails are pushed through `access.registry` only: no namespace key can push
431
+ them.
432
+ - A deny or a governed field on a `foreign` Resource is refused, by the types, the compiler and the registry: only
433
+ the owner narrows its Resources; another namespace's are narrowed by the org's guardrails.
434
+ - The `avaccess` bin, the registry from a project. It keeps nothing of its own: every answer is the registry's, over
435
+ its HTTP routes. The registry is `--registry <url>` or `AVACCESS_REGISTRY_URL` (the Access server's URL; with no
436
+ path, Better Auth's `/api/auth` is added, as Better Auth's client does), and the key is `AVACCESS_KEY`, never a
437
+ flag. Exit codes: `0` done, `1` the check failed or the registry refused, `2` the command could not run.
438
+ - `avaccess push <rules>` pushes the namespace's policies file: a `.json` file, or the module that holds the rules
439
+ (`definePolicies(model, { namespace })…` or its `.contract()`, as the default export or `rules`), loaded with
440
+ jiti as written, TypeScript included. The namespace is the file's own.
441
+ - `avaccess pull [--namespace <ns>] [--out <dir>]` writes the namespace's current file and the org's guardrails
442
+ into the project (`access/<ns>.policies.json`, `access/guardrails.json`) and pins both hashes in
443
+ `avAccess.lock.json`: `{ format: "aventara-access/lock@1", namespace, policies: { path, hash }, guardrails:
444
+ { path, hash } | null }`. Commit all three. The lock pins content, not a registry. A file the registry serves is
445
+ hashed again before it is written.
446
+ - `avaccess verify` holds the pulled files to the lock, offline: no registry, no network. A file edited by hand
447
+ fails it with exit 1 and `ACCESS_LOCK_MISMATCH`, naming the file and both hashes. Put it first in the project's
448
+ `aventara:prepare` script, so the build stops before anything is generated from a changed file:
449
+ `"aventara:prepare": "avaccess verify && aventara-prisma7-generate … && aventara-build"` (in a Next.js app, before
450
+ `aventara-build` the same way).
451
+ - `avaccess status` says, for the namespace's file and the org's guardrails, whether the lock holds the registry's
452
+ current revision, and exits 1 when either is behind: run it in CI.
453
+ - `avaccess diff [<from> [<to>]]` names the rules that changed between two revisions (each grant and deny by its id,
454
+ roles, governed fields, schema entries and claims). A revision is `lock`, `head`, a hash or its first seven or
455
+ more hex digits; the default, `lock` to `head`, is what a pull would bring.
456
+ - `avaccess keys issue <ns>`, `keys rotate <ns> <push|pull>` and `keys revoke <ns> <push|pull>`, with
457
+ `--server <module>`: the operator's API, run in the operator's own process from the Access server's module (its
458
+ `createAccess({ …, registry: { enabled: true } })` instance, as the default export or `access`), never over HTTP.
459
+ A key is printed once, by the command that issues it, and by no other command.
460
+ - `avaccess generate [--orm <orm>] [--out <file>] [--tsconfig <file>]` writes the generated client of a service that does not run on
461
+ Aventara: two TypeScript files (default `src/generated/avAccess.ts`, and `avAccess.internal.ts` beside it; a
462
+ custom `--out <name>.ts` writes `<name>.internal.ts`) from the pulled files the lock pins,
463
+ after the same check `avaccess verify` runs (a file edited by hand is refused, exit 1, and nothing is written).
464
+ It holds the pulled policies file as a constant and the denies the org's guardrails add to it, a copy of Access's
465
+ decisions, the namespace's Resource types (`ResourceName`, `ResourceRows`, `ResourceFields`), and `access`:
466
+ `can`, `scopeOf` and `fieldsOf`, which answer as the engine does, in Aventara's `where`. `--orm` is optional and
467
+ defaults to `none` (that neutral client, naming no ORM; its banner spells `--orm none` out); `--orm prisma7`
468
+ adds the Prisma 7 translator's `prismaWhere(resource, scope)` (a scope as a Prisma `where`; `false` matches no row),
469
+ `readable(question)` (the fields a read shows on every row, as a Prisma `select`; a governed field opened on some
470
+ rows only is left out, and no relation is selected) and `prismaExists(prisma, resource, where)` (`can`'s `exists`
471
+ through the service's Prisma client). `avAccess.ts` exports `access` and the client's types only (`ResourceName`,
472
+ `ResourceRows`, `ResourceFields`, `QuestionOf`, `CanQuestionOf`, `ScopeQuestionOf`, `FieldsQuestionOf`,
473
+ `PrismaClientOf`, `PrismaSelectOf`, `PrismaWhere`, the last three with `--orm prisma7` only); the copied
474
+ decisions stay in `avAccess.internal.ts`, which it imports with the extension the project's `tsconfig.json`
475
+ asks for, by Prisma 7's `prisma-client` rules (`.js`, none, or `.ts`). The tsconfig is the nearest one above the
476
+ generated file (a solution-style root's referenced project that includes it), or the one `--tsconfig` names; a
477
+ project with none, or one that cannot be read, is refused (exit 2) and nothing is written, as the framework's
478
+ `avclient` refuses a JavaScript project. Neither imports
479
+ anything else: no `@aventara` package and no Prisma module, so the service needs no Access version at runtime. The same pulled files, translator and Access version give the
480
+ same bytes. Regenerate after each pull, for example in the project's `postinstall`:
481
+ `"avaccess verify && avaccess generate --orm prisma7"`. The scope covers only the queries it is passed to; a
482
+ Prisma `include` is not scoped.
483
+ - `avaccess schema --orm prisma7 [--schema <path>] [--out <file>]` reads the service's Prisma schema (default
484
+ `prisma/schema.prisma`; a folder of `.prisma` files reads as one) into the `AccessSchema` its rules are authored
485
+ against, written as a module (default `src/generated/avAccess.schema.ts`) exporting `model`:
486
+ `definePolicies(model, { namespace })`, as an Aventara app does with its discovered model. It reads the same
487
+ facts the framework's Prisma 7 discovery reads (fields, enums, relations with their reference pairs and inverse,
488
+ identifiers in order) and leaves out, and lists, what it cannot model: an `Unsupported(...)` column, a composite
489
+ `type`, a `view`, an `@ignore`d field or model.
490
+ - `--orm` names the translator, `<orm><major>`: required by `schema` (`prisma7` is the first), optional for
491
+ `generate` (default `none`). Drizzle follows once Drizzle ORM v1 is stable.
492
+ - `avaccess serve [--config <file>] [--port <port>] [--hostname <host>] [--max-body-bytes <bytes>] [--migrate]` runs
493
+ the standalone host, on `127.0.0.1` unless `--hostname` says otherwise, from
494
+ a configuration file: `access.config.ts` in the working directory (or `.mts`, `.cts`, `.js`, `.mjs`, `.cjs`), or
495
+ the file `--config` names, loaded with jiti the way `prisma.config.ts` is, so it needs no build step. Its default
496
+ export (or its `access` export) is the `createAccess({...})` instance; `serve()` serves it, with `GET /health`,
497
+ until SIGTERM or SIGINT, when it closes the server and exits 0. The port is `--port`, else `PORT`, else `3000`.
498
+ Better Auth's migration (the one `npx auth migrate` runs) runs on start only with `--migrate` or
499
+ `AVACCESS_MIGRATE=true` (`1` also; `false`, `0` or unset do not). Without it, a database Better Auth migrates
500
+ itself (a `pg` Pool, a MySQL pool, SQLite) is checked before the server listens: one that lacks a table, column
501
+ or index the options need is refused with exit 1 and the list of what it lacks. A database behind an ORM's
502
+ adapter (Prisma, Drizzle) is the ORM's to migrate (`npx auth generate`, then its migration): it is served as it
503
+ is, and `--migrate` refuses it with exit 2.
504
+ - The container image of the standalone host, `aventara/access-server:<version>`, built from the package's tarball
505
+ (`docker/access-server/Dockerfile`; the build context holds the Dockerfile, its two scripts, its `package.json` and
506
+ `package-lock.json`, and the tarball, nothing else). It holds `@aventara/access` and the Better Auth version the
507
+ package pins, under `/srv/node_modules`, every package installed by `npm ci` from the lockfile with no install
508
+ script run, on Node.js 24, and runs `avaccess serve --hostname 0.0.0.0` as user `node` in `/srv/access`; arguments
509
+ after the image name go to `avaccess serve`. The operator mounts `access.config.ts` into `/srv/access`, read-only:
510
+ its default export is the `createAccess({...})` instance, and it reads its secrets from the environment
511
+ (`process.env.DATABASE_URL` and the like), passed with `docker run -e`. What the configuration imports beyond Access
512
+ and Better Auth (the database driver, a Better Auth plugin in its own package) is installed at build time, in an
513
+ image built `FROM` this one that copies the operator's `package.json` and `package-lock.json` into `/srv/access` and
514
+ runs `/srv/bin/access-deps` (`npm ci`, no install script, no peers: a plugin's `better-auth` peer is the image's).
515
+ A file that names `@aventara/access` or `better-auth` is refused, since the image provides them, and so is one
516
+ without its lockfile. Nothing is installed at start.
517
+
518
+ ```bash
519
+ docker run -p 3000:3000 \
520
+ -v ./access.config.ts:/srv/access/access.config.ts:ro \
521
+ -e DATABASE_URL=postgresql://... -e BETTER_AUTH_SECRET=... \
522
+ aventara/access-server:<version> --migrate
523
+ ```
524
+
525
+ `PORT` (default `3000`) sets the port, and the image's health check calls `GET /health` on it. `docker stop` drains
526
+ and ends it cleanly. Pass `--migrate` (or `-e AVACCESS_MIGRATE=true`) on the start that should create or update the
527
+ tables; without it, an empty database stops the container with exit 1 and the list of what is missing. Nothing
528
+ pushes the image to a registry yet.
529
+ - `avAccess.lock.json` at start: `createAccessEngine(file, { guardrails, lock })`, `rules.engine({ guardrails, lock })`
530
+ and `createAccess({ policies: { …, lock } })` refuse to start (`ACCESS_LOCK_MISMATCH`) unless the guardrails hash to
531
+ the lock's pin and are given exactly when it pins some, and a policies file handed over (not the typed rules, which
532
+ are the project's own source) hashes to its own; the rules' namespace must be the lock's. Read the lock and the
533
+ pulled files as JSON; `AccessLock` (and `AccessLockPin`) types the lock with `format` as a string, so an imported
534
+ JSON file fits it.
535
+ - Depends on `jiti`, which the bin loads only to read a project's modules, and on `get-tsconfig` (pinned exactly,
536
+ the version Prisma 7 and the framework's `avclient` read tsconfigs with), which `avaccess generate` loads only to
537
+ read the project's tsconfig.
538
+ - The audit log: `audit: true` or `audit: { sink }` (off by default). Each event is an `AccessAuditEvent`:
539
+ `{ at, kind: "sign-in", userId, origin, impersonatedBy? }`,
540
+ `{ at, kind: "sign-in-failed", userId: null, route, reason, target?, origin, suppressed?, windowStart?, windowEnd? }`,
541
+ `{ at, kind: "sign-out", userId, origin, impersonatedBy? }`, `{ at, kind: "token", userId, origin, impersonatedBy? }`
542
+ or `{ at, kind: "decision", userId, resource, action, allowed, reason, origin, suppressed?, windowStart?,
543
+ windowEnd? }`.
544
+ - `audit: true` writes each event to the `accessAudit` table, a Better Auth plugin table: `npx auth generate` writes
545
+ it into an ORM's schema (Prisma: `model AccessAudit`) and `npx auth migrate` creates it, beside Better Auth's
546
+ tables, through the app's own Better Auth adapter. Better Auth refuses to start while the table is missing.
547
+ - `audit: { sink }` hands each event to the app's function instead, and the table is not declared at all.
548
+ - A sign-in is a session Better Auth created: a sign-in, or a sign-up that signs the user in. Its `origin` is
549
+ `client` through Access's routes and `application` when the server calls Better Auth's API itself.
550
+ - A decision is a `can` answer: a request's admission (may this caller ever do this action on this Resource?) is
551
+ one event, allowed or refused, however many rows the request reads or writes; a row question (the row a create
552
+ inserts, or an update leaves) adds an event only when the rules refuse that row. `scopeOf`, `fieldsOf` and
553
+ `explain` log nothing. `reason` names the deciding rule: `grant:<id>`, `deny:<id>`, or `no grant`. `userId` is
554
+ the `sub` of the token or session the principal came from, `null` for an anonymous caller.
555
+ - An event holds no token, password, cookie, key or header.
556
+ - The request waits for the write or the sink. A sink that throws, or a write that fails, does not fail the
557
+ request: Better Auth's logger warns and the request goes on.
558
+ - Better Auth's tables as Resources of the app's rules: `resources: { users?: boolean, members?: boolean }`
559
+ (type `AccessResourcesOptions`). The engine runs built-in rules over every Better Auth table in the rules' schema
560
+ (its plugins' and Access's own included, matched by the model name Better Auth's generator writes, the app's
561
+ `modelName` and `fieldName` honoured). They only narrow:
562
+ - `users: true` opens `user`, `session` and `account`, `members: true` the organization plugin's `member` and its
563
+ `organization` (read only). An opened table is read as the rules grant. Every other table of Better Auth's
564
+ (`verification`, `jwks`, the organization plugin's `invitation`, `twoFactor`, `deviceCode`, Access's own) stays
565
+ closed: every action is denied, and a key that is off leaves its tables closed too.
566
+ - A write through Aventara skips Better Auth's hooks, its cookie cache and its secondary storage, so it is limited
567
+ to what those do not guard: on `user`, an update of `role`; on `member`, a create (the membership's fields), an
568
+ update of `role`, and a delete. Nothing else is written.
569
+ - Secret columns are never in an answer, whatever a grant says, so a request that selects, filters or sorts by one
570
+ is refused: `account.password`, `accessToken`, `refreshToken`, `idToken`; `session.token`; `verification.identifier`
571
+ and `value`; `jwks.privateKey`; `twoFactor.secret` and `backupCodes`; `deviceCode.deviceCode` and `userCode`;
572
+ `accessKey.keyHash`. Exempt them from your Contracts too, or govern them, so a read that names no field leaves
573
+ them out instead of being refused.
574
+ - Checked at startup, the first read of `access.policy` (the framework's construction): a key whose tables Better
575
+ Auth lacks (`members` without `organization()`), or that the rules' schema lacks (run `npx auth generate`, then
576
+ regenerate the model), is refused (`ACCESS_RESOURCE_MISSING`); so is a grant on a closed table, or a grant of a
577
+ write the table never takes (`ACCESS_OPTIONS_INVALID`). The instance itself still builds, so `npx auth generate`
578
+ loads the configuration before the schema has the tables.
579
+ - `explain` and the audit log name a built-in deny `better-auth:<Resource>` (a closed table) or
580
+ `better-auth:<Resource>/<actions>` (the writes an opened one never takes).
581
+ - `access.policy` is built on its first read and, like `access.api`, is not enumerable.
582
+ - Type the rules with the principal `createAccess` hands them: `definePolicies(model).principal<AccessIdentity<typeof
583
+ claims>>()`, where `claims` is the object you pass as `policies.claims` (declared `as const`). Without
584
+ `.principal<…>()` the rules have no principal type, so `me.sub` in a `where` and `claims` in a role are type errors.
585
+ With it, `createAccess` checks that every claim the rules read is declared in `policies.claims`, and `principal` is
586
+ optional, since `me` is then the verified identity `{ sub, claims }` itself. Rules typed with your own profile type
587
+ instead (`.principal<Profile>()`) make `principal` required: it maps the identity to that profile.
588
+
589
+ ```ts
590
+ // src/access/rules.ts
591
+ import { type AccessIdentity, definePolicies } from "@aventara/access";
592
+ import { model } from "../aventara/generated/discovery.artifact";
593
+
594
+ export const claims = { role: "string", suspended: "boolean" } as const;
595
+
596
+ export const rules = definePolicies(model, { namespace: "blog" })
597
+ .principal<AccessIdentity<typeof claims>>()
598
+ .grants(({ grant, me }) => [
599
+ grant("signedIn", "create", "Post", { where: { authorId: me.sub } }),
600
+ ]);
601
+ ```
602
+ - Install Better Auth at the exact version Access is built against, so a later `npm install` cannot move it:
603
+ `npm i better-auth@1.7.12 --save-exact`. Another version is refused at construction (`ACCESS_BETTER_AUTH_VERSION`).
604
+ - Hand the instance to the framework where your host takes its configuration:
605
+ - Next.js (`@aventara/next`): add `access` to the configuration in `src/aventara/config.ts`, which `server.ts`
606
+ spreads into `Aventara({ ...aventaraConfig, adapter, prebuilt })`. In `config.ts` it is what `aventara-build`
607
+ compiles the prebuilt Contracts with; given only in `server.ts`, the prebuilt Contracts no longer match, and the
608
+ framework warns `AVENTARA_PREBUILT_STALE` and compiles them at startup. `aventara-build` runs `config.ts` at build
609
+ time, so the module that calls `createAccess` must load there: with Prisma, the build's environment needs the
610
+ `DATABASE_URL` your database client reads (`aventara-build` does not read `.env`), or build the client lazily.
611
+
612
+ ```ts
613
+ // src/aventara/config.ts
614
+ import { access } from "../access/access";
615
+
616
+ export const aventaraConfig = {
617
+ api: "/api",
618
+ access,
619
+ // exposed, resources, diagnostics … as the scaffold wrote them
620
+ } as const;
621
+ ```
622
+ - NestJS (`@aventara/nest`): add `access` to the `config` your `AventaraModule.forRootAsync` factory returns, and
623
+ mount `access.handler` in `main.ts` with `toNodeHandler(access)`, as above.
624
+
625
+ ```ts
626
+ AventaraModule.forRootAsync({
627
+ imports: [PrismaModule],
628
+ inject: [PrismaService],
629
+ useFactory: async (prisma: PrismaService) => ({
630
+ config: {
631
+ ...aventaraConfig,
632
+ access,
633
+ adapter: await createPrismaAdapter({ ...adapter, client: prisma }),
634
+ },
635
+ }),
636
+ });
637
+ ```
638
+ - Anywhere else: `createFramework({ ...config, access })`.
639
+ - The audit log's decision `userId` is set under Next.js too. Next evaluates the app's `access.ts` once per bundle, so
640
+ one process holds several Access instances, and `@aventara/next` shares one framework (with one bundle's
641
+ `access.policy`) across them, while a route's `state` comes from its own bundle's instance. The `sub` now travels on
642
+ the principal `state` returns (under a non-enumerable `Symbol.for` key: a spread, `JSON.stringify` or `Object.keys`
643
+ of `me` does not show it), so any instance's engine reads it. Each instance also keeps its own copy of Better Auth's
644
+ key set, read once from your database on its first token; that is expected under Next.js and costs one read per
645
+ bundle.
646
+ - A pulled file imported as JSON fits as it is: `import guardrails from "../access/guardrails.json"` and a pulled
647
+ policies file go straight into `policies.guardrails` and `policies.rules` (and `createAccessEngine(file, {
648
+ guardrails })`), with no `as unknown as`. TypeScript widens a JSON import's literals, so these options take the
649
+ file's outline (`AccessGuardrailsJson`, `AccessPoliciesJson`) and the engine checks the rest when it is built, as
650
+ it checks a typed file. A policies file passed as guardrails is still a type error. `defineGuardrails(…).contract()`
651
+ and `.contract()` still return the strict `AccessGuardrails` and `AccessPolicies`.
652
+ - A registry-only server leaves `policies` out: `createAccess({ betterAuth, registry: { enabled: true } })`. It has
653
+ no rules of its own, so its engine denies every question and its tokens carry `sub` alone. Every other instance
654
+ needs `policies`; leaving it out is a type error and is refused with a sentence that says what to pass.
655
+ - `grant(…, { fields: ["Note.body"] })` on a Resource with no governed field is a type error that says what to do:
656
+ `no field of "Note" is governed; govern it first, .govern("Note", [<field>]), and a grant can then open it`.
657
+ - `ACCESS_PLUGIN_DUPLICATE` and its type error name the Access plugin by what it is (plugin id `aventara-access`), not
658
+ by a factory this release does not export.
659
+ - `createAccess` without `betterAuth` says the option is missing.
660
+ - `avaccess pull` with no lock and no `--namespace` pulls the namespace the key belongs to. The registry names it at
661
+ `GET /access/v1/key` (with the key in `x-access-key`: `{ namespace, kind }`).
662
+ - `fieldsOf` refuses a question it cannot answer as asked, with `ACCESS_QUESTION_INVALID`: one without
663
+ `access: "read"` or `access: "write"`, a read with an `action`, or a write whose `action` is not `create` or
664
+ `update`. Such a question was answered as a read, or with no field.
665
+ - The README starts from `npx @aventara/cli@1.0.0-rc.9 new <app> --orm prisma7 --db postgresql`, and pins Better
666
+ Auth's CLI as `npx auth@1.7.12`. It shows the scaffold's `Int` user id kept with Better Auth's own
667
+ `advanced.database.generateId: "serial"` (and the principal's `id: Number(sub)`), sign-up and sign-in with `curl`
668
+ and the `Origin` header Better Auth requires, reading the audit log through the app's ORM, the `fieldsOf` question's
669
+ shape, that `avaccess pull` writes `access/guardrails.json` only when the registry has guardrails, and the
670
+ environment `avaccess keys` needs (`BETTER_AUTH_URL` with the rest).
671
+ - Identity, tokens and sessions, hardened after the identity audit:
672
+ - Better Auth's tables are recognized under `usePlural` names (`Users`, `Accounts`, `Jwkss`) and hand-written ones,
673
+ where they were left open to every grant (see below for how).
674
+ - A declared claim read by the default mapping from a user field without `input: false` (one the user sets at
675
+ sign-up or with `/update-user`, a plugin's field included) is refused at construction (`ACCESS_OPTIONS_INVALID`).
676
+ A user could make themselves `role: "admin"`.
677
+ - The service profile matches the routes that start a session by Better Auth's endpoint path, whatever the URL's
678
+ spelling, `basePath` or `baseURL` (a `baseURL` with a path, `basePath: "/"`, a trailing slash let sign-up
679
+ through), covers the plugins' sign-in routes (`/phone-number/verify` signed a caller in), and answers `404` to any
680
+ request that creates a session anyway, revoking it before a cookie or token leaves.
681
+ - `state` reads the session cookie from Better Auth's store, never its cookie cache, so a revoked session is gone at
682
+ once; with `session.cookieCache` on, a token is issued only for a session the store still has.
683
+ - The key set is read again every 5 minutes (the last good set kept when a read fails), so a key removed from it
684
+ stops verifying within 5 minutes, not only when a token with an unknown key arrives.
685
+ - Tokens carry, and are verified against, one fixed issuer and audience: the origin of the base URL (`baseURL`,
686
+ `BETTER_AUTH_URL`, or a dynamic `baseURL`'s `fallback`). With none, `createAccess` refuses; an unset base URL left
687
+ tokens unchecked for issuer and audience. An audience list that merely includes it is refused.
688
+ - A token must carry `iat`, be no older than `tokenTtl` and not issued in the future (5 seconds of skew allowed).
689
+ - The audit log records refused sign-ins (route and reason, nothing the caller sent), sign-outs, issued tokens and
690
+ the admin behind an impersonated session (`impersonatedBy`). Anonymous refusals are logged once a minute per origin
691
+ and reason, with the repeats counted in `suppressed`. The `accessAudit` table gains `route`, `impersonatedBy` and
692
+ `suppressed`: run `npx auth@1.7.12 generate` (or `migrate`) again.
693
+ - A claim refused for its type names the type it got (`an object`), never its value.
694
+ - `ACCESS_PLUGIN_DUPLICATE` also refuses `jwt()` or the Access plugin under another case or spacing of its id, and a
695
+ plugin that defines one of `jwt()`'s endpoints (`getJwks`, `getToken`, `signJWT`, `verifyJWT`, `/jwks`, `/token`).
696
+ - Any `Authorization` header means `state` reads no session cookie: a header that is not a valid bearer token gives
697
+ `me: null` instead of the cookie's user.
698
+ - A `principal` that returns one object for two users is refused (`ACCESS_OPTIONS_INVALID`), so the audit cannot name
699
+ the wrong user.
700
+ - A session cookie set to `sameSite: "none"` is refused unless `trustedOrigins` is set; with it, `state` reads the
701
+ cookie only from a trusted `Origin` (or a request the browser does not mark cross-site).
702
+ - The README states what is not revoked: a bearer token outlives a sign-out or a deleted user for at most
703
+ `tokenTtl`. It also states the cookie's `SameSite=Lax` stance.
704
+ - Hardened again after the re-audit of the identity fixes:
705
+ - Which Resource is which of Better Auth's tables is known, not guessed. On Better Auth's Prisma adapter, it is the
706
+ model the adapter reads (exact: a lowercase or plural model is found too). On any other adapter, the app names each
707
+ table in `resources.authTables` (a Resource, or `null`), required when the rules have a schema; without it,
708
+ `createAccess` refuses (`ACCESS_OPTIONS_INVALID`). Better Auth's tables under `@@map`'d names, and secret columns
709
+ under `@map`'d names, were left open to every grant. The map is held to the schema: a table's Resource must have
710
+ every column of it under Better Auth's `fieldName`, and a table mapped to `null` (not in this schema) must not
711
+ match an unnamed Resource column for column, nor have its secret column there beside another of its columns. On Prisma's adapter, a schema that differs from the adapter only in
712
+ case or plural is refused. An opened table whose secret column is not found under Better Auth's field name is
713
+ refused. The guesses by name (singular and plural, case, look-alike columns) are gone.
714
+ - The audit log's repeat window hides nothing: the count of an anonymous refusal's repeats is logged as a summary
715
+ (`suppressed`, `windowStart`, `windowEnd`) when the minute closes, on the next event of any kind or by a timer,
716
+ not on the next repeat. A refused sign-in carries `target`, an HMAC of the identifier keyed by Better Auth's
717
+ secret, of the identifier the route reads (the email, username or phone number its sign-in route looks up,
718
+ normalized as it is), and is counted per target; a sign-in logs its target's count first, so a guessed password
719
+ follows its failures. At most 10,000 windows stay open; past that, the oldest closes early, so every target keeps
720
+ its own rows.
721
+ The `accessAudit` table gains `target`, `windowStart` and `windowEnd`.
722
+ - `token` events are logged for `GET /token` only, not for `/get-session`'s `set-auth-jwt` header.
723
+ - The service profile refuses the session row itself (Better Auth's `session.create` before-hook) on a request
724
+ through the handler, so no session exists even while a route runs; answers `404` to a response that sets the
725
+ session cookie of an existing session; and `access.handler` strips any session cookie (a `multiSession()` one
726
+ too) from what Better Auth answers, after every plugin's `onResponse`, a route's own `Response` included.
727
+ - On a request that may change something, a session cookie counts only from a trusted `Origin`, or a
728
+ `Sec-Fetch-Site` of `same-origin` or `none`, whatever `sameSite` is (the method the client sent, Express's
729
+ `originalMethod` before a method override). A request with neither header is still read.
730
+ - The README warns that `claimsOf` is not checked for fields a user can set, and that apps sharing an origin and a
731
+ database accept each other's tokens.
732
+
733
+ ### Engine security fixes (audit of 2026-10-23)
734
+
735
+ - An update is checked on the row it leaves by default. That row must still meet the `where` and the `after` of one
736
+ update grant, and no update deny may match it, whether or not a grant has an `after`. A grant
737
+ `where: { authorId: me.id }` no longer lets the author hand the row to someone else. `afterChecks` now lists every
738
+ Resource whose update grants read the row (a `where`, an `after`, a role held on a row) or whose update denies have
739
+ a `where`, so Aventara asks `can` about each changed row there, and refuses those updates where it has no
740
+ interactive transaction. `can({ action: "update", row })` (a generated client's too) evaluates the update grants
741
+ and denies on the row; it no longer answers `true` on a Resource without an `after` grant.
742
+ - A deny that reads a `me` value the principal lacks matches, whatever `not` or `NOT` surrounds it. A deny's `if` that
743
+ compares a claim the principal lacks holds, except a comparison with `true`: `banned: true` leaves alone a token with
744
+ no `banned`, and `verified: false` denies a token with no `verified`. Grants are unchanged: a missing value or claim
745
+ matches no grant.
746
+ - `can` compares by the field's type: a decimal by value (a decimal object, a string and a number alike), a datetime
747
+ by instant, a bigint exactly. A row value of another type is unknown: no grant matches it, and a deny matches. A
748
+ `policies@1` schema entry gains an optional member, `types`, which `.contract()` writes for its `bigint`,
749
+ `datetime` and `decimal` fields; a rule's literal is written in its field's canonical form (`toISOString()`, a
750
+ decimal without exponent or extra zeros, a bigint past a safe integer as its digits).
751
+ - Only a plain object is an operator object: a `Date` literal compiles to its instant (it compiled to `true`), and
752
+ another object, or `{}`, is refused.
753
+ - The engine checks every member of a policies file it is handed, as strictly as the guardrails checker: the members
754
+ of each rule and condition, the comparison (`eq`, `ne`, `in`, `notIn`), a list for `in` and `notIn` only (a string
755
+ is refused), finite literals in their field's form, a claim path of one declared claim, and a non-empty `and`.
756
+ `NaN` and `Infinity` are refused in policies and guardrails files, and the canonical writer throws on them, so no
757
+ two files share a hash.
758
+ - A global role with no claim (`{ global: { claims: {} } }`), which every principal held, is refused.
759
+ - `write.except` naming a relation locks its reference fields, as naming a reference field already locked its
760
+ relation. `.govern()` naming a relation without its reference fields, or the reverse, is refused.
761
+ - A grant on `"*"` reaches only the file's own Resources: never one its schema does not list.
762
+ - The engine runs from its own frozen copy of the file, taken before the lock check.
763
+ - `requireLock: true` (`policies.requireLock`, or the engine's option) refuses to build without a lock
764
+ (`ACCESS_LOCK_MISMATCH`). The lock stays optional; set this in production.
765
+ - The principal is any object, an instance of a class included; an array, a function or a primitive is anonymous.
766
+ An empty-string `me` value counts as missing.
767
+ - `can` never reads a relation carried on the row (`author: { … }`, `tags: [ … ]`): a relation condition is always
768
+ asked through the row's identifier or reference fields and `exists`. A forged relation object no longer satisfies a
769
+ create, an update or a read.
770
+ - A datetime literal must read back as written: `2020-02-30`, hour 24 and digits past the millisecond are refused,
771
+ and such a row value is unknown.
772
+ - The checker refuses a Resource or field name that is no identifier (or is `__proto__`), an identifier, reference
773
+ field or relation target the schema does not list, a decimal over 1,000 characters, and a deny id that starts with
774
+ `guardrail:` or `better-auth:`. A translated `where` writes each key as an own member.
775
+ - On an updated row, a to-one step whose reference fields the row carries is asked on the row those values name,
776
+ as on a created row: a generated client asking before the write no longer reads the database's old reference
777
+ (moving a row into another tenant was allowed).
778
+ - A generated client compiles when a relation leads to a Resource the file does not list: that relation is typed
779
+ `unknown`.
780
+ - With `requireLock`, typed rules are held to the lock's policies hash. The engine's `afterChecks` and `paths` are
781
+ frozen.
782
+ - Through Aventara, a rule comparing a `datetime`, `decimal` or `bigint` field still answers `A3000` (the framework
783
+ validates a policy's `where` against typed values); it fails closed until the framework's seam changes.
784
+ - Every lookup of a name from a file, a row or a principal reads own members only: `constructor` names no Resource,
785
+ and an inherited claim is no claim.
786
+ - A generated client's `fieldsOf` refuses a malformed question, as the engine does.
787
+ - The README states that string comparisons in `can` are exact, so a case- or accent-insensitive collation can make
788
+ `scopeOf` and `can` disagree, and that a `where` matching no row is `{ OR: [] }`.
789
+
790
+ ### Registry, CLI and host security fixes (audit of 2026-10-23)
791
+
792
+ - The CLI follows no redirect (`redirect: "manual"`): a `3xx` from the registry stops it with exit 2, and the key and a
793
+ pushed file are sent to no other address.
794
+ - The registry's URL is `https:`; `http:` is refused except on a loopback address (`localhost`, `127.0.0.0/8`,
795
+ `[::1]`), and so is a URL with a user name or password.
796
+ - `avaccess pull` refuses, writing nothing, a pull that would drop the guardrails the lock pins (the registry serves
797
+ none), and a file of another namespace than the one asked for. A hash the registry names is checked to be one. A
798
+ file is held to its hash, not to who wrote it: signed revisions are a follow-up.
799
+ - Ownership is checked and claimed in the push's own transaction, against a new table, `accessResourceOwner`, whose
800
+ unique Resource name refuses a second claim made at the same moment: two namespaces pushing the same new Resource
801
+ at once were both accepted.
802
+ - `registry.ownership`: `"first-push"` (the default) or `"assigned"`. The operator's `access.registry.assignResource(
803
+ resource, namespace)` gives a Resource to a namespace, whoever named it first, and `releaseResource(resource)` takes
804
+ it back. With `"assigned"`, a push that names an unassigned Resource as its own is refused.
805
+ - `serve()` caps a request body at `maxBodyBytes` (1 MiB by default; `avaccess serve --max-body-bytes`) before any of
806
+ it is handed on, with `413`, and checks a registry write's key before it reads the body. The registry refuses a
807
+ policies file over 1 MiB (`ACCESS_POLICIES_INVALID`, `413`) on every mount, and a body it cannot measure (`400`).
808
+ - `serve()` reads the client's address from the socket. A forwarded-for header is kept only from a proxy Better
809
+ Auth's `advanced.ipAddress.trustedProxies` names, and replaced with the socket's address otherwise, so Better Auth's
810
+ rate limit counts each client on its own: a spoofed `X-Forwarded-For` bought a fresh bucket, and every client
811
+ shared one without it. `avaccess serve` turns Better Auth's rate limit on whatever `NODE_ENV` says, unless the
812
+ configuration sets `betterAuth.rateLimit.enabled`.
813
+ - The push log writes a row only for a push made with a key the registry has; pushes with no key, or one that is not
814
+ the registry's, add up in one row per namespace per minute (`accessPushLog.count`).
815
+ - A namespace has one live key of each kind, held by a unique slot (`accessKey.slot`): two rotations at the same
816
+ moment left two live keys.
817
+ - Another namespace's key is refused as a namespace the registry does not have, with the same `401` and message, and
818
+ an ownership refusal never names the namespace that owns the Resource.
819
+ - The CLI prints no control character a registry sends.
820
+ - A revision's `:hash` that is not `sha256:` and 64 hex digits answers `404` (a NUL byte answered `500` on
821
+ PostgreSQL).
822
+ - `close()` and `docker stop` stop accepting, let requests in flight finish for up to 10 seconds, then end the rest.
823
+ - `serve()` and `avaccess serve` listen on `127.0.0.1` by default; the image passes `--hostname 0.0.0.0`. The README
824
+ says to terminate TLS in front of it.
825
+ - A lock's paths refuse a drive letter, a backslash and a control character. `pull` and `generate` refuse a path whose
826
+ directory leads out of the project through a symbolic link, and create their temporary files new (`wx`). Paths and
827
+ the command line written into the generated client's comments cannot end a comment.
828
+ - The image installs from a lockfile with `npm ci --ignore-scripts`; `access-deps` runs at build time, from the
829
+ operator's lockfile, and a container started with a `package.json` its image did not install refuses to start.
830
+ - A registry list longer than 10,000 rows is refused, not cut short.
831
+ - The README's registry server no longer turns on open sign-up, and says what `emailAndPassword` opens.