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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (248) hide show
  1. package/CHANGELOG.md +877 -0
  2. package/LICENSE +91 -0
  3. package/LICENSE-ADDITIONAL-PERMISSION.md +9 -0
  4. package/README.md +2536 -2
  5. package/dist/access/access-options.d.ts +74 -0
  6. package/dist/access/access-options.js +5 -0
  7. package/dist/access/access.error.d.ts +35 -0
  8. package/dist/access/access.error.js +11 -0
  9. package/dist/access/access.factory.d.ts +275 -0
  10. package/dist/access/access.factory.js +440 -0
  11. package/dist/access/better-auth.hardening.d.ts +120 -0
  12. package/dist/access/better-auth.hardening.js +420 -0
  13. package/dist/access/better-auth.loader.d.ts +67 -0
  14. package/dist/access/better-auth.loader.js +65 -0
  15. package/dist/access/better-auth.range.d.ts +9 -0
  16. package/dist/access/better-auth.range.js +28 -0
  17. package/dist/access/better-auth.require.d.ts +90 -0
  18. package/dist/access/better-auth.require.js +6 -0
  19. package/dist/access/better-auth.require.node.d.ts +7 -0
  20. package/dist/access/better-auth.require.node.js +52 -0
  21. package/dist/access/claims.mapper.d.ts +79 -0
  22. package/dist/access/claims.mapper.js +152 -0
  23. package/dist/access/key-set.cache.d.ts +10 -0
  24. package/dist/access/key-set.cache.js +63 -0
  25. package/dist/access/policies-block.checker.d.ts +64 -0
  26. package/dist/access/policies-block.checker.js +138 -0
  27. package/dist/access/principal.subject.d.ts +22 -0
  28. package/dist/access/principal.subject.js +89 -0
  29. package/dist/access/profile.policy.d.ts +74 -0
  30. package/dist/access/profile.policy.js +110 -0
  31. package/dist/access/request.state.d.ts +89 -0
  32. package/dist/access/request.state.js +266 -0
  33. package/dist/access/session.hooks.d.ts +114 -0
  34. package/dist/access/session.hooks.js +258 -0
  35. package/dist/access/token.verifier.d.ts +35 -0
  36. package/dist/access/token.verifier.js +69 -0
  37. package/dist/access-schema.d.ts +48 -0
  38. package/dist/access-schema.js +5 -0
  39. package/dist/access-warning.emitter.d.ts +4 -0
  40. package/dist/access-warning.emitter.js +24 -0
  41. package/dist/access.builder.d.ts +340 -0
  42. package/dist/access.builder.js +75 -0
  43. package/dist/access.engine.d.ts +155 -0
  44. package/dist/access.engine.js +477 -0
  45. package/dist/audit/audit-event.d.ts +135 -0
  46. package/dist/audit/audit-event.js +8 -0
  47. package/dist/audit/audit.log.d.ts +122 -0
  48. package/dist/audit/audit.log.js +345 -0
  49. package/dist/audit/audit.schema.d.ts +143 -0
  50. package/dist/audit/audit.schema.js +50 -0
  51. package/dist/auth/index.d.ts +12 -0
  52. package/dist/auth/index.js +2 -0
  53. package/dist/aventara-where.translator.d.ts +19 -0
  54. package/dist/aventara-where.translator.js +87 -0
  55. package/dist/beacon/beacon.check.d.ts +59 -0
  56. package/dist/beacon/beacon.check.js +128 -0
  57. package/dist/beacon/beacon.schema.d.ts +41 -0
  58. package/dist/beacon/beacon.schema.js +48 -0
  59. package/dist/beacon/beacon.store.d.ts +29 -0
  60. package/dist/beacon/beacon.store.js +78 -0
  61. package/dist/better-auth/access.client-plugin.d.ts +75 -0
  62. package/dist/better-auth/access.client-plugin.js +133 -0
  63. package/dist/better-auth/access.plugin.d.ts +164 -0
  64. package/dist/better-auth/access.plugin.js +348 -0
  65. package/dist/better-auth/access.provider.d.ts +80 -0
  66. package/dist/better-auth/access.provider.js +100 -0
  67. package/dist/better-auth/index.d.ts +7 -0
  68. package/dist/better-auth/index.js +4 -0
  69. package/dist/better-auth/organization.bridge.d.ts +66 -0
  70. package/dist/better-auth/organization.bridge.js +144 -0
  71. package/dist/canonical-json.writer.d.ts +12 -0
  72. package/dist/canonical-json.writer.js +30 -0
  73. package/dist/cli/avaccess.bin.d.ts +2 -0
  74. package/dist/cli/avaccess.bin.js +26 -0
  75. package/dist/cli/avaccess.program.d.ts +15 -0
  76. package/dist/cli/avaccess.program.js +166 -0
  77. package/dist/cli/beacon-pins.file.d.ts +14 -0
  78. package/dist/cli/beacon-pins.file.js +62 -0
  79. package/dist/cli/beacon.loader.d.ts +20 -0
  80. package/dist/cli/beacon.loader.js +71 -0
  81. package/dist/cli/cli.context.d.ts +49 -0
  82. package/dist/cli/cli.context.js +66 -0
  83. package/dist/cli/diff.command.d.ts +2 -0
  84. package/dist/cli/diff.command.js +66 -0
  85. package/dist/cli/generate.command.d.ts +2 -0
  86. package/dist/cli/generate.command.js +82 -0
  87. package/dist/cli/keys.command.d.ts +2 -0
  88. package/dist/cli/keys.command.js +96 -0
  89. package/dist/cli/lock.file.d.ts +31 -0
  90. package/dist/cli/lock.file.js +181 -0
  91. package/dist/cli/module.loader.d.ts +13 -0
  92. package/dist/cli/module.loader.js +63 -0
  93. package/dist/cli/policies.diff.d.ts +16 -0
  94. package/dist/cli/policies.diff.js +101 -0
  95. package/dist/cli/pull.command.d.ts +2 -0
  96. package/dist/cli/pull.command.js +234 -0
  97. package/dist/cli/push.command.d.ts +2 -0
  98. package/dist/cli/push.command.js +59 -0
  99. package/dist/cli/registry.client.d.ts +63 -0
  100. package/dist/cli/registry.client.js +287 -0
  101. package/dist/cli/schema.command.d.ts +2 -0
  102. package/dist/cli/schema.command.js +60 -0
  103. package/dist/cli/serve.command.d.ts +6 -0
  104. package/dist/cli/serve.command.js +200 -0
  105. package/dist/cli/status.command.d.ts +2 -0
  106. package/dist/cli/status.command.js +31 -0
  107. package/dist/cli/translator.flag.d.ts +6 -0
  108. package/dist/cli/translator.flag.js +24 -0
  109. package/dist/cli/verify.command.d.ts +2 -0
  110. package/dist/cli/verify.command.js +40 -0
  111. package/dist/client/access-client.factory.d.ts +92 -0
  112. package/dist/client/access-client.factory.js +269 -0
  113. package/dist/client/access-verbs.schema.d.ts +66 -0
  114. package/dist/client/access-verbs.schema.js +159 -0
  115. package/dist/client/verb-rate.limiter.d.ts +116 -0
  116. package/dist/client/verb-rate.limiter.js +358 -0
  117. package/dist/foreign/foreign-facts.d.ts +30 -0
  118. package/dist/foreign/foreign-facts.js +75 -0
  119. package/dist/foreign/foreign.merger.d.ts +10 -0
  120. package/dist/foreign/foreign.merger.js +52 -0
  121. package/dist/foreign-references.check.d.ts +6 -0
  122. package/dist/foreign-references.check.js +131 -0
  123. package/dist/generator/client.emitter.d.ts +28 -0
  124. package/dist/generator/client.emitter.js +411 -0
  125. package/dist/generator/copied-modules.reader.d.ts +14 -0
  126. package/dist/generator/copied-modules.reader.js +80 -0
  127. package/dist/generator/namespace.facts.d.ts +28 -0
  128. package/dist/generator/namespace.facts.js +32 -0
  129. package/dist/generator/orm.translator.d.ts +48 -0
  130. package/dist/generator/orm.translator.js +29 -0
  131. package/dist/generator/prisma7/prisma-schema.reader.d.ts +9 -0
  132. package/dist/generator/prisma7/prisma-schema.reader.js +394 -0
  133. package/dist/generator/prisma7/prisma-where.translator.d.ts +73 -0
  134. package/dist/generator/prisma7/prisma-where.translator.js +138 -0
  135. package/dist/generator/prisma7/prisma7.translator.d.ts +2 -0
  136. package/dist/generator/prisma7/prisma7.translator.js +87 -0
  137. package/dist/generator/schema.emitter.d.ts +14 -0
  138. package/dist/generator/schema.emitter.js +23 -0
  139. package/dist/generator/tsconfig.reader.d.ts +44 -0
  140. package/dist/generator/tsconfig.reader.js +110 -0
  141. package/dist/generator/typescript-literal.writer.d.ts +5 -0
  142. package/dist/generator/typescript-literal.writer.js +42 -0
  143. package/dist/guardrails/guardrails-contract.d.ts +45 -0
  144. package/dist/guardrails/guardrails-contract.js +12 -0
  145. package/dist/guardrails/guardrails.builder.d.ts +48 -0
  146. package/dist/guardrails/guardrails.builder.js +32 -0
  147. package/dist/guardrails/guardrails.compiler.d.ts +10 -0
  148. package/dist/guardrails/guardrails.compiler.js +254 -0
  149. package/dist/guardrails/guardrails.composer.d.ts +7 -0
  150. package/dist/guardrails/guardrails.composer.js +95 -0
  151. package/dist/guardrails/guardrails.hash.d.ts +10 -0
  152. package/dist/guardrails/guardrails.hash.js +38 -0
  153. package/dist/index.d.ts +16 -0
  154. package/dist/index.js +6 -0
  155. package/dist/kiosk/kiosk-verbs.schema.d.ts +19 -0
  156. package/dist/kiosk/kiosk-verbs.schema.js +61 -0
  157. package/dist/kiosk/kiosk.plugin.d.ts +217 -0
  158. package/dist/kiosk/kiosk.plugin.js +877 -0
  159. package/dist/kiosk/kiosk.schema.d.ts +176 -0
  160. package/dist/kiosk/kiosk.schema.js +101 -0
  161. package/dist/kiosk/kiosk.store.d.ts +171 -0
  162. package/dist/kiosk/kiosk.store.js +478 -0
  163. package/dist/kiosk/lan-range.policy.d.ts +31 -0
  164. package/dist/kiosk/lan-range.policy.js +113 -0
  165. package/dist/kiosk/pin.policy.d.ts +9 -0
  166. package/dist/kiosk/pin.policy.js +143 -0
  167. package/dist/kiosk/session-use.marker.d.ts +4 -0
  168. package/dist/kiosk/session-use.marker.js +16 -0
  169. package/dist/lock/lock-contract.d.ts +38 -0
  170. package/dist/lock/lock-contract.js +67 -0
  171. package/dist/lock/lock.verifier.d.ts +27 -0
  172. package/dist/lock/lock.verifier.js +62 -0
  173. package/dist/mcp/agent-scope.gate.d.ts +13 -0
  174. package/dist/mcp/agent-scope.gate.js +75 -0
  175. package/dist/mcp/agent.grant.d.ts +40 -0
  176. package/dist/mcp/agent.grant.js +92 -0
  177. package/dist/mcp/mcp-options.d.ts +60 -0
  178. package/dist/mcp/mcp-options.js +72 -0
  179. package/dist/oauth/access-token.verifier.d.ts +66 -0
  180. package/dist/oauth/access-token.verifier.js +201 -0
  181. package/dist/oauth/oauth-provider.hardening.d.ts +62 -0
  182. package/dist/oauth/oauth-provider.hardening.js +261 -0
  183. package/dist/passwords/argon2id.hasher.d.ts +97 -0
  184. package/dist/passwords/argon2id.hasher.js +256 -0
  185. package/dist/passwords/node-rs-argon2.adapter.d.ts +25 -0
  186. package/dist/passwords/node-rs-argon2.adapter.js +42 -0
  187. package/dist/passwords/passwords.options.d.ts +79 -0
  188. package/dist/passwords/passwords.options.js +212 -0
  189. package/dist/policies-contract.d.ts +219 -0
  190. package/dist/policies-contract.js +8 -0
  191. package/dist/policies.compiler.d.ts +38 -0
  192. package/dist/policies.compiler.js +1433 -0
  193. package/dist/policies.decider.d.ts +103 -0
  194. package/dist/policies.decider.js +390 -0
  195. package/dist/policies.hash.d.ts +10 -0
  196. package/dist/policies.hash.js +54 -0
  197. package/dist/predicate.evaluator.d.ts +62 -0
  198. package/dist/predicate.evaluator.js +378 -0
  199. package/dist/random-secret.generator.d.ts +2 -0
  200. package/dist/random-secret.generator.js +15 -0
  201. package/dist/read-governed-unique.check.d.ts +3 -0
  202. package/dist/read-governed-unique.check.js +21 -0
  203. package/dist/reference-reads.check.d.ts +16 -0
  204. package/dist/reference-reads.check.js +158 -0
  205. package/dist/registry/access.registry.d.ts +173 -0
  206. package/dist/registry/access.registry.js +865 -0
  207. package/dist/registry/beacon.ledger.d.ts +30 -0
  208. package/dist/registry/beacon.ledger.js +93 -0
  209. package/dist/registry/ownership.check.d.ts +54 -0
  210. package/dist/registry/ownership.check.js +221 -0
  211. package/dist/registry/registry.endpoints.d.ts +50 -0
  212. package/dist/registry/registry.endpoints.js +193 -0
  213. package/dist/registry/registry.key.d.ts +7 -0
  214. package/dist/registry/registry.key.js +13 -0
  215. package/dist/registry/registry.options.d.ts +13 -0
  216. package/dist/registry/registry.options.js +28 -0
  217. package/dist/registry/registry.refusal.d.ts +6 -0
  218. package/dist/registry/registry.refusal.js +11 -0
  219. package/dist/registry/registry.schema.d.ts +238 -0
  220. package/dist/registry/registry.schema.js +127 -0
  221. package/dist/resources/auth-tables.catalog.d.ts +96 -0
  222. package/dist/resources/auth-tables.catalog.js +110 -0
  223. package/dist/resources/auth-tables.guard.d.ts +106 -0
  224. package/dist/resources/auth-tables.guard.js +462 -0
  225. package/dist/scope.resolver.d.ts +144 -0
  226. package/dist/scope.resolver.js +529 -0
  227. package/dist/sha256.digest.d.ts +2 -0
  228. package/dist/sha256.digest.js +81 -0
  229. package/dist/standalone/access.server.d.ts +41 -0
  230. package/dist/standalone/access.server.js +301 -0
  231. package/dist/standalone/index.d.ts +1 -0
  232. package/dist/standalone/index.js +1 -0
  233. package/package.json +102 -3
  234. package/skill/SKILL.md +170 -0
  235. package/skill/reference/callers.md +99 -0
  236. package/skill/reference/errors.md +89 -0
  237. package/skill/reference/recipes.md +281 -0
  238. package/skill/reference/rules.md +153 -0
  239. package/skill/reference/setup.md +334 -0
  240. package/src/aventara-where.translator.ts +116 -0
  241. package/src/beacon/beacon.check.ts +204 -0
  242. package/src/canonical-json.writer.ts +51 -0
  243. package/src/generator/prisma7/prisma-where.translator.ts +243 -0
  244. package/src/policies-contract.ts +215 -0
  245. package/src/policies.decider.ts +662 -0
  246. package/src/predicate.evaluator.ts +574 -0
  247. package/src/scope.resolver.ts +819 -0
  248. package/src/sha256.digest.ts +85 -0
@@ -0,0 +1,153 @@
1
+ # The rule language
2
+
3
+ `definePolicies(model, options?)` takes the schema and returns an immutable builder; each call returns a new builder.
4
+ In an Aventara app the schema is the discovery artifact's `model` (`./generated/aventara/discovery.artifact.js`; in
5
+ Next.js `@/aventara/generated/discovery.artifact`). Every name in a rule is checked against it.
6
+
7
+ | Call | What it does |
8
+ |---|---|
9
+ | `.principal<P>()` | Types `me` in every callback. Call it first. Without it, `me.id` and `claims` are type errors. |
10
+ | `.roles(({ me }) => ({ … }))` | Names roles: `global` (from claims) or held `on` a Resource's row (`where`) |
11
+ | `.govern(Resource, [fields])` | Governs fields for reading and writing: only a grant that names one opens it |
12
+ | `.govern(Resource, { field: mode })` | Governs each field for `"write"`, `"read"` or `"both"` |
13
+ | `.grants(({ grant, me }) => [ … ])` | `grant(subjects, actions, resource, options?)`. Calls add up. |
14
+ | `.denies(({ deny, me }) => [ … ])` | `deny(actions, resource, options?)`. Calls add up. |
15
+ | `.contract()` | Compiles to the `aventara-access/policies@1` file (`AccessPolicies`) |
16
+ | `.engine(options?)` | The engine (`AccessEngine`), from the compiled file |
17
+
18
+ Declare roles and governed fields before the grants that name them: the grant's types read them.
19
+
20
+ ## Subjects, actions, resources
21
+
22
+ - Subjects: `"signedIn"` (any non-null principal), `"anyone"` (the anonymous caller too), or a role name. One or a
23
+ list: `["dm", "player"]`.
24
+ - Actions: `"read"`, `"create"`, `"update"`, `"delete"`, one or a list. A deny also takes `"*"`.
25
+ - Resource: one of your own Resources, or `"*"` (every Resource the file owns). A foreign Resource (`{ foreign }` in the
26
+ schema) is read in conditions and never granted, denied or governed on.
27
+
28
+ ## The principal
29
+
30
+ The principal is whatever your app derives for a request: with `createAccess`, what `principal` returns (or the token's
31
+ identity `{ sub, claims }`, typed `AccessIdentity<typeof claims>`). It is an object; `null`, an array, a function or a
32
+ primitive is anonymous. Its values are read from its own properties only. `me.<path>` in a rule is a reference read at
33
+ question time, never a value baked into the file.
34
+
35
+ `claims` on the principal is what roles and `if` compare: `{ claims: { role?: "admin"; banned?: boolean } }`.
36
+
37
+ ## Roles
38
+
39
+ ```text
40
+ .roles(({ me }) => ({
41
+ // global: held when every listed claim equals its value
42
+ admin: { global: { claims: { role: "admin" } } },
43
+ // on a row: held on each Campaign row the where matches
44
+ dm: { on: "Campaign", where: { members: { some: { userId: me.id, role: "DM" } } } },
45
+ }))
46
+ ```
47
+
48
+ A grant to a row role covers the rows where the caller holds it. On another Resource, `in: "<to-one relation>"` says
49
+ the role is held on that relation's target: `grant("dm", "read", "Note", { in: "campaign" })` covers the notes of the
50
+ campaigns where the caller is DM. In a `where`, `$role: "dm"` means the caller holds that role on this row
51
+ (`campaign: { $role: "player" }` on the related row).
52
+
53
+ ## Grant options
54
+
55
+ | Option | Meaning |
56
+ |---|---|
57
+ | `name` | The rule's id in `explain` and the audit log (default `Resource/actions/subjects#n`) |
58
+ | `where` | The rows the grant covers. On an update grant it is checked on the row the update leaves too. |
59
+ | `in` | The to-one relation whose target holds the subject's row role |
60
+ | `fields` | Governed fields the grant opens, as `"Resource.field"` |
61
+ | `write: { except: [...] }` | Fields a create or update by this grant may not set (create and update grants only) |
62
+ | `after` | What the row an update leaves must also meet (update grants only) |
63
+
64
+ ## Deny options
65
+
66
+ | Option | Meaning |
67
+ |---|---|
68
+ | `name` | The rule's id |
69
+ | `where` | The rows the deny covers |
70
+ | `if: { claims, origin }` | A condition on the caller alone: claim values, and the origin (`"client"`, `"agent"`, `"application"`) |
71
+
72
+ A deny with neither `where` nor `if` refuses its actions on its Resource to everyone.
73
+
74
+ ## `where`
75
+
76
+ Aventara's `where` vocabulary, typed per Resource:
77
+
78
+ - a field and a value (`visibility: "PARTY"`, `authorId: me.id`), or an operator object: `equals`, `not`, `in`,
79
+ `notIn` (`{ visibility: { in: ["PARTY", "PRIVATE"] } }`);
80
+ - `AND: [ … ]`, `OR: [ … ]`, `NOT: { … }`;
81
+ - a to-one relation by its own `where` (`campaign: { … }`); a to-many by `some`, `every` or `none`;
82
+ - `$role: "<row role on this Resource>"`.
83
+
84
+ Compare values of the field's type. Through Aventara, a scope that compares a `datetime`, `decimal` or `bigint` field
85
+ cannot be used yet (`A3000`, the request is refused): keep those comparisons out of `where` on reads, updates and
86
+ deletes.
87
+
88
+ ## How the rules combine
89
+
90
+ - Nothing is allowed until a grant allows it. Any grant that matches allows. Any deny that matches wins over every
91
+ grant, an admin's included. Order never matters.
92
+ - A grant that reads a `me` value the principal lacks never matches. A deny that reads one in its `where` matches,
93
+ whatever `not` or `NOT` surrounds it. A missing or `null` property, an empty string, or a number JSON cannot carry
94
+ counts as missing.
95
+ - A claim the principal lacks fails closed the same way: a global role comparing it is not held; a deny's `if`
96
+ comparing it holds. The anonymous caller has no claims: a comparison with `true` never holds for it (a ban does not
97
+ close an `anyone` grant), and any other comparison holds for a deny.
98
+ - A row value of another type than its field's is unknown: no grant matches it, a deny that compares it matches.
99
+
100
+ ## Governed fields
101
+
102
+ A governed field is opened only by a grant that names it in `fields`. Each field has a mode:
103
+
104
+ | Form | Reads | Writes |
105
+ |---|---|---|
106
+ | `.govern("User", ["email"])` (the list: `"both"`) | Out of every default read; only a read grant naming `User.email` opens it, when the request selects it | Only a create or update grant naming it sets it |
107
+ | `.govern("Member", { role: "write" })` | As any other field | Only a create or update grant naming `Member.role` sets it |
108
+ | `.govern("Citizen", { nationalId: "read" })` | As `"both"` | As any other field |
109
+
110
+ Govern every field your roles, claims or principal read, usually as `"write"`: a role held on `Member` reads
111
+ `Member.role`, so an update grant on `Member` that does not name it must not change it. Selecting a field governed for
112
+ reading without a grant that opens it is refused (`403 A4002`), never dropped quietly. A relation and its reference
113
+ fields are governed together, with one mode (`"author"` with `"authorId"`). A field governed twice with two modes is a
114
+ type error, and refused when the rules compile. `fields` on a Resource with nothing governed is a type error.
115
+
116
+ Access governs Better Auth's `User.role` and the organization `member` table's `role` as `"write"` itself once
117
+ `resources` opens them. To let an admin set one, govern it too (`.govern("User", { role: "write" })`), which lets a
118
+ grant name it, and name it in that admin's grant.
119
+
120
+ ## Updates
121
+
122
+ An update is checked twice: its scope (the rows an update grant's `where` covers and no update deny matches), then
123
+ each row it leaves, which must be covered by the same grant (its `where` and `after` together) and matched by no update
124
+ deny. So `where: { authorId: me.id }` on an update grant also stops the author from handing the row to someone else.
125
+ Aventara runs the second check inside the update's transaction and rolls back on failure; on a database without
126
+ interactive transactions it refuses such updates.
127
+
128
+ ## References
129
+
130
+ A create or update that sends a reference (`Note.authorId`) to a Resource the app serves is held to the rows the caller
131
+ may read there: no read grant on the target, no write (`A4002`), whatever the write grant allows. When the framework
132
+ starts, Access warns `ACCESS_REFERENCE_UNREADABLE` for each role that may write a reference it cannot read. Fix it with
133
+ a read grant on the target for that role, narrowed as far as the feature allows, or lock the reference with
134
+ `write: { except }`.
135
+
136
+ ## Guardrails
137
+
138
+ An org's guardrails (`defineGuardrails({ claims }).denies(…).contract()`) are deny-only rules every service adds on
139
+ top of its own; they never grant. A service gets them with `avaccess pull` and passes them as `policies.guardrails`.
140
+ See the README's Guardrails section.
141
+
142
+ ## Asking the engine
143
+
144
+ | Method | Answers |
145
+ |---|---|
146
+ | `can({ principal, origin, resource, action, row?, before?, beforeGrants?, exists? })` | Whether the principal may do it, on `row` when given. On an update, `before` is the row as stored, and `beforeGrants` what `grantsOf` answered for it before the write |
147
+ | `grantsOf({ principal, origin, resource, action: "update", row, exists? })` | The update grants that cover `row` as it is now. Ask it before the write |
148
+ | `scopeOf({ principal, origin, resource, action })` | `true`, `false`, or an Aventara `where` (read, update, delete) |
149
+ | `fieldsOf({ principal, origin, resource, access: "read" })` | The fields usable on every reachable row (`always`) and on some only (`perRow`); `access: "write"` takes an optional `action` |
150
+ | `explain(question)` | In development, which rules took part in a `can` decision. It throws when `NODE_ENV` is `production`. |
151
+
152
+ For an update, always pass `before` (the row as stored). Without it only the new row is checked, and a caller can take
153
+ over another user's row. Prefer routing writes through the framework, which does both checks.
@@ -0,0 +1,334 @@
1
+ # Setup and wiring
2
+
3
+ Three ways in. Pick one:
4
+
5
+ | The app | Use | Section |
6
+ |---|---|---|
7
+ | A new or existing Aventara app, Better Auth not set up yet | `createAccess()` from `@aventara/access/auth` | [createAccess](#createaccess) |
8
+ | An app that already calls `betterAuth({...})` itself | `aventaraAccess()` and `accessOf(auth)` from `@aventara/access/better-auth` | [The Better Auth plugin](#the-better-auth-plugin) |
9
+ | Its own authentication, rules only | `definePolicies(…).engine()` on the framework's `policy` option | [The engine alone](#the-engine-alone) |
10
+
11
+ ## Install
12
+
13
+ ```sh
14
+ npm install --save-exact @aventara/access@rc better-auth@~1.7.12
15
+ # Node.js 22 only (Node.js 24.7 and later hash with crypto.argon2):
16
+ npm install --save-exact @node-rs/argon2@2.2.2
17
+ ```
18
+
19
+ Node.js `^22.23.4 || >=24.22.1`, ESM. Another Better Auth version is refused when `createAccess` runs
20
+ (`ACCESS_BETTER_AUTH_VERSION`). Agents over MCP also need `@better-auth/oauth-provider@~1.7.12` (and
21
+ `@better-auth/cimd@~1.7.12` for clients that register by a metadata URL).
22
+
23
+ `.env` needs `DATABASE_URL`, `BETTER_AUTH_SECRET` (a long random string) and `BETTER_AUTH_URL` (the app's origin,
24
+ `http://localhost:3000` in development). Access fixes its tokens' issuer and audience to that URL's origin and refuses
25
+ to start without one (`ACCESS_OPTIONS_INVALID`).
26
+
27
+ ## createAccess
28
+
29
+ ```ts
30
+ // src/access.ts
31
+ import { createAccess } from "@aventara/access/auth";
32
+ import { PrismaPg } from "@prisma/adapter-pg";
33
+ import { prismaAdapter } from "better-auth/adapters/prisma";
34
+ import { PrismaClient } from "./generated/prisma/client.js";
35
+ import { teamPolicies } from "./team.policies.js";
36
+
37
+ /** Better Auth's own Prisma client: it reads and writes its tables through it. */
38
+ const prisma = new PrismaClient({
39
+ adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL ?? "" }),
40
+ });
41
+
42
+ /** What every token carries beside `sub`, by type. */
43
+ const claims = { role: "string", banned: "boolean" } as const;
44
+
45
+ export const access = createAccess({
46
+ // Better Auth's options, exactly as betterAuth({...}) takes them.
47
+ betterAuth: {
48
+ database: prismaAdapter(prisma, { provider: "postgresql" }),
49
+ emailAndPassword: { enabled: true },
50
+ user: {
51
+ additionalFields: {
52
+ // input: false: the user cannot set these at sign-up or with /update-user.
53
+ role: { type: "string", required: false, input: false },
54
+ banned: { type: "boolean", required: false, input: false },
55
+ },
56
+ },
57
+ },
58
+ policies: { rules: teamPolicies, claims },
59
+ // Map the verified token to the rules' principal. Keep the claims as they are.
60
+ principal: ({ sub, claims }) => ({
61
+ id: sub,
62
+ claims: {
63
+ ...(claims.role === "admin" ? { role: "admin" as const } : {}),
64
+ ...(claims.banned === undefined ? {} : { banned: claims.banned }),
65
+ },
66
+ }),
67
+ // The rules read User, one of Better Auth's tables: open it explicitly.
68
+ resources: { users: true },
69
+ });
70
+
71
+ export default access;
72
+ ```
73
+
74
+ - `betterAuth` is Better Auth's own options, passed through untouched. Access's concerns sit beside it: `policies`,
75
+ `principal`, `resources`, `audit`, `passwords`, `profile`, `registry`, `mcp`, `audience`. Anything else is refused
76
+ (`ACCESS_OPTIONS_INVALID`).
77
+ - `policies.claims` declares every claim the rules (and guardrails) read: `"string"`, `"number"` or `"boolean"`. Each
78
+ is read from the user's field of the same name, which must have `input: false`. A token carries `sub` and the
79
+ declared claims only; every declared boolean claim is in it, `false` when the user's field is unset.
80
+ - `principal` maps `{ sub, claims }` to the rules' principal. Return a new object per user, keep `claims` as given (a
81
+ boolean claim you drop reads as missing, and a `banned: true` deny then refuses the caller), and return `null` to
82
+ leave the caller anonymous. Leave `principal` out when the rules are typed with `AccessIdentity<typeof claims>`
83
+ (`{ sub, claims }`, from `@aventara/access/auth`).
84
+ - `policies.tokenTtl` (seconds, 300 to 900, default 900) is how long a ban or a role change can take to reach a
85
+ token. Keep it short when that matters.
86
+ - Passwords are hashed with argon2id (`"owasp-minimum"` by default). `passwords: { argon2id, legacyScrypt: true }` lets
87
+ users whose hashes Better Auth stored with scrypt sign in and moves them to argon2id. Do not set both `passwords` and
88
+ `betterAuth.emailAndPassword.password`.
89
+ - With `emailAndPassword` on, anyone who reaches the server can sign up. When users are created another way (an admin,
90
+ an invitation, a social provider), set `emailAndPassword: { enabled: true, disableSignUp: true }`.
91
+ - `audit: true` writes every sign-in, token and decision to an `accessAudit` table; `audit: { sink }` hands each
92
+ `AccessAuditEvent` to your function instead.
93
+
94
+ ### The scaffold's numeric ids
95
+
96
+ `aventara new` writes `model User { id Int @id @default(autoincrement()) … }`; Better Auth's ids are strings. Before
97
+ generating Better Auth's tables, put `advanced: { database: { generateId: "serial" } }` in the `betterAuth` block, type
98
+ the principal's `id` as `number` and map it:
99
+
100
+ ```text
101
+ principal: ({ sub, claims }) => ({ id: Number(sub), claims: { … } }),
102
+ ```
103
+
104
+ A rule comparing `User.id` with a string is then a type error, which is what you want.
105
+
106
+ ### Better Auth's tables
107
+
108
+ ```sh
109
+ npx auth@~1.7.12 generate --config src/access.ts --yes # Better Auth's models into prisma/schema.prisma
110
+ npx prisma validate
111
+ npx prisma db push # or your migration
112
+ npm run aventara:prepare # the rules see the new model
113
+ ```
114
+
115
+ `generate` reads the module's default export. Access closes every Better Auth table to every request until a
116
+ `resources` key opens it: `users: true` opens `user` (writes: an update of `role` only), `session` and `account` (read
117
+ only); `members: true` opens the organization plugin's `member` and `organization` (and needs
118
+ `requireEmailVerificationOnInvitation: true` on `organization()`). `verification`, `jwks`, `invitation`, `twoFactor`,
119
+ `deviceCode` and Access's own tables never open. Secret columns are sealed on every table: exempt them from your
120
+ Contracts, or govern them, so a read that names no field leaves them out instead of being refused. Off Prisma's
121
+ adapter, `resources.authTables` must map every Better Auth table to its Resource (or `null`).
122
+
123
+ `users: true` lets a write through Aventara set a user's `role`, and `role` is a claim, so Access governs it as
124
+ `"write"`: only an update grant that names `User.role` sets it. To let an admin set roles, add
125
+ `.govern("User", { role: "write" })` to the rules (so a grant may name it) and `fields: ["User.role"]` to that admin's
126
+ update grant, ideally with `where: { NOT: { id: me.id } }` so an admin cannot change their own role. Never name it in a
127
+ grant for `signedIn`, `anyone` or any role a user can reach on their own. Users edit their profile through Better
128
+ Auth's `/update-user`. With `members: true`, the `member` table's `role` is governed the same way: name it only in an
129
+ organization admin role's grant, held to `where: { organizationId: me.org }`.
130
+
131
+ ## Mount the routes
132
+
133
+ Access's routes are Better Auth's plus its own, under `/api/auth`. The framework mounts none of them. In NestJS (Express
134
+ or Fastify), mount the Node.js handler before the body parser:
135
+
136
+ ```ts
137
+ // src/main.ts
138
+ import type { IncomingMessage, ServerResponse } from "node:http";
139
+ import { NestFactory } from "@nestjs/core";
140
+ import { toNodeHandler } from "better-auth/node";
141
+ import { access } from "./access.js";
142
+ import { AppModule } from "./app.module.js";
143
+
144
+ async function bootstrap() {
145
+ const app = await NestFactory.create(AppModule);
146
+ // Access's routes, under /api/auth, before Nest's body parser (listen() adds it).
147
+ const auth = toNodeHandler(access);
148
+ app.use(
149
+ (request: IncomingMessage, response: ServerResponse, next: () => void) =>
150
+ request.url?.startsWith("/api/auth/") ? auth(request, response) : next(),
151
+ );
152
+ await app.listen(process.env.PORT ?? 3000);
153
+ }
154
+ await bootstrap();
155
+ ```
156
+
157
+ Keep the CORS and rate-limit lines the scaffold wrote, before this mount. A request to Better Auth that carries a cookie
158
+ needs an `Origin` header matching `BETTER_AUTH_URL` or one of `trustedOrigins` (else `403 MISSING_OR_NULL_ORIGIN` or
159
+ `INVALID_ORIGIN`).
160
+
161
+ ## Wire it into the framework
162
+
163
+ The instance fits the framework's `access` option (`AvAccessProvider`) by shape: `access.policy` is the engine asked
164
+ about every client and agent request, and `access.state({ headers, request })` seeds each request's `ctx.$state` with
165
+ `{ me }`. With `access`, the configuration has no `policy` key and the host has no `state` option.
166
+
167
+ ```ts
168
+ // src/app.module.ts
169
+ import { Module } from '@nestjs/common';
170
+ import { AppController } from './app.controller.js';
171
+ import { AppService } from './app.service.js';
172
+ import { AventaraModule } from '@aventara/nest';
173
+ import { createPrismaAdapter } from '@aventara/prisma7-adapter';
174
+ import adapter from './aventara.adapter.js';
175
+ import { aventaraConfig } from './aventara.config.js';
176
+ import { PrismaModule, PrismaService } from './prisma.service.js';
177
+ import { access } from './access.js';
178
+
179
+ @Module({
180
+ imports: [
181
+ PrismaModule,
182
+ AventaraModule.forRootAsync({
183
+ imports: [PrismaModule],
184
+ inject: [PrismaService],
185
+ useFactory: async (prisma: PrismaService) => ({
186
+ config: {
187
+ ...aventaraConfig,
188
+ adapter: await createPrismaAdapter({
189
+ ...adapter,
190
+ client: prisma,
191
+ }),
192
+ access,
193
+ },
194
+ }),
195
+ }),
196
+ ],
197
+ controllers: [AppController],
198
+ providers: [AppService],
199
+ })
200
+ export class AppModule {}
201
+ ```
202
+
203
+ Anywhere else: `createFramework({ ...config, access })`. Agents over MCP: add
204
+ `mcp: { path: "/mcp", authorization: access.mcp }` beside `access` ([callers](callers.md#agents-over-mcp)).
205
+
206
+ ### Next.js
207
+
208
+ ```text
209
+ // src/app/api/auth/[...all]/route.ts
210
+ import { toNextJsHandler } from "better-auth/next-js";
211
+ import { access } from "@/access";
212
+
213
+ export const { GET, POST } = toNextJsHandler(access);
214
+
215
+ // src/aventara/config.ts: put access here, not in server.ts
216
+ import { access } from "../access";
217
+
218
+ export const aventaraConfig = {
219
+ api: "/api",
220
+ // the rest of what `aventara new` wrote stays as it is
221
+ access,
222
+ } as const;
223
+ ```
224
+
225
+ The rules import the model from `@/aventara/generated/discovery.artifact`. With `access` only in `server.ts`, the
226
+ framework warns `AVENTARA_PREBUILT_STALE` and governed fields miss the generated client.
227
+
228
+ ## The request's principal
229
+
230
+ `access.state` reads each request once:
231
+
232
+ - An `Authorization: Bearer <token>` header is read first. A token Access accepts is signed by the instance's key set
233
+ (`GET /api/auth/jwks`), has the instance's origin as `iss` and `aud`, is unexpired and no older than `tokenTtl`, and
234
+ carries no undeclared claim. Anything else in that header (another scheme, a tampered or expired token) gives
235
+ `me: null`, and the session cookie is not tried.
236
+ - Without the header, the session cookie (profiles `web` and `kiosk`) is read from Better Auth's store, so a sign-out
237
+ or a revoked session is `null` at once. On a request that may change something, the cookie counts only when the
238
+ request comes from a site Better Auth trusts (`Origin`, or `Sec-Fetch-Site`).
239
+ - `me` is a copy of what `principal` returns, or `null`. The framework puts it at `ctx.$state.me`. Its plain data
240
+ (plain objects and arrays) is frozen and the state holds it read-only: no stage can replace it or change it, so the
241
+ policy decides every part of the request for the same principal. A class instance, `Map`, `Set`, `Date`, typed array
242
+ or getter is kept as returned and not frozen, so return plain data for the fields the rules read. Your own objects
243
+ are never frozen. Under an OAuth access token, return a plain object: a `Map` or `Set` principal is not supported
244
+ there, and a class instance loses its `#private` fields. Return a new object for each user (one cached per user is fine): one `principal` already returned
245
+ for another user is refused (`ACCESS_OPTIONS_INVALID`). Only the returned object is checked, not what it holds.
246
+
247
+ A client gets a token by trading its session: `GET /api/auth/token`. A token is not revoked by a sign-out; it expires.
248
+
249
+ To read `me` typed in your own stages, declare it on the framework's state:
250
+
251
+ ```ts
252
+ // src/aventara.state.ts
253
+ import type { AvState } from "@aventara/core";
254
+ import type { TeamPrincipal } from "./team.policies.js";
255
+
256
+ declare module "@aventara/core" {
257
+ interface AvState {
258
+ /** Seeded by Access on every request: a frozen copy of the principal, or null when anonymous. */
259
+ readonly me?: TeamPrincipal | null;
260
+ }
261
+ }
262
+
263
+ /** The caller's user id, or null for an anonymous caller. */
264
+ export const callerIdOf = (state: AvState) => state.me?.id ?? null;
265
+ ```
266
+
267
+ Never parse the `Authorization` header or the cookie yourself in a stage: `ctx.$state.me` is the verified answer.
268
+
269
+ ## Server code runs unchecked unless it names a principal
270
+
271
+ The policy checks every client and agent request. A server-side `framework.application.*` call is server code, and the
272
+ policy does not check it unless the call names a principal with the `as` option. When server code acts for a caller,
273
+ name them:
274
+
275
+ ```text
276
+ // checked exactly as the caller's own request would be; null is the anonymous caller
277
+ await framework.application.Note.find.many({}, { as: ctx.$state.me ?? null });
278
+ ```
279
+
280
+ A `framework.client.*` call is checked either way, and `as` replaces the principal its state would give. Leave `as`
281
+ out only for work no caller asked for (a job, a migration, a seed).
282
+
283
+ ## The Better Auth plugin
284
+
285
+ For an app that builds `betterAuth({...})` itself: add `aventaraAccess({ policies, principal, resources, audit,
286
+ passwords, registry, mcp, audience, organization })` to its `plugins`, and hand the framework `accessOf(auth)` as
287
+ `access`. `accessOf(auth).mcp` is the framework's `mcp.authorization`.
288
+
289
+ ```text
290
+ // src/auth.ts
291
+ export const auth = betterAuth({
292
+ database: …,
293
+ emailAndPassword: { enabled: true },
294
+ user: { additionalFields: { banned: { type: "boolean", required: false, input: false } } },
295
+ plugins: [aventaraAccess({ policies: { rules, claims } })],
296
+ });
297
+
298
+ export const access = accessOf(auth);
299
+ ```
300
+
301
+ - The plugin carries Better Auth's `jwt()` inside it: do not add `jwt()` beside it, and call `aventaraAccess()` once
302
+ per Better Auth instance. With `mcp` or `audience`, list `oauthProvider()` after it.
303
+ - You mount `auth.handler` as you already do. `createAccess`'s profiles, `access.handler` and the `$access` verbs are
304
+ not part of the plugin.
305
+ - `organization: { claim, organizationClaim, roles }` puts a member's role in the session's active organization into a
306
+ claim, and the organization into another. An organization role is that organization's: hold every grant on it to
307
+ the organization, `where: { organizationId: me.org }`, with the organization claim copied onto the principal. It
308
+ needs `organization({ requireEmailVerificationOnInvitation: true, allowUserToCreateOrganization: false })` (or a
309
+ function that decides who may create one).
310
+ - `aventaraAccessClient({ api: "/api" })` in Better Auth's client adds `authClient.access.can([...])`: one
311
+ `POST {api}/_access/can` for the page, answered by the engine that decides the real requests.
312
+
313
+ ## The engine alone
314
+
315
+ Without Better Auth, hand the engine to the framework's `policy` option, with a function that reads the principal from
316
+ the request's state, which your host's `state` option fills:
317
+
318
+ ```text
319
+ // src/aventara.config.ts
320
+ export const aventaraConfig = {
321
+ api: "/api",
322
+ policy: {
323
+ engine: policies.engine(),
324
+ principal: ({ $state }: { readonly $state: AvState }) => $state.user ?? null,
325
+ },
326
+ } as const;
327
+ ```
328
+
329
+ `createAccessEngine(file)` builds the same engine from a compiled policies file (`policies.contract()`). Any object
330
+ with `policy` (an engine) and `state` (`({ headers }) => ({ me })`) also fits the `access` option.
331
+
332
+ Your `state` function is the whole of authentication here. Set `user` only from a credential your server verified (a
333
+ signature-checked token, a session looked up server-side), never from a header, a cookie value, a query or the body
334
+ as sent. Unverified is `null`.
@@ -0,0 +1,116 @@
1
+ // Folded condition → Aventara's `where` grammar, with explicit operators
2
+ // (`{ equals: … }`), as core validates it against the Core Contract.
3
+
4
+ import type { AccessComparison, AccessLiteral } from "./policies-contract.js";
5
+ import type { Resolved } from "./scope.resolver.js";
6
+
7
+ /** A fragment of Aventara's `where` (core's `AvPolicyWhere`, by shape). */
8
+ export type AccessWhere = { readonly [key: string]: unknown };
9
+
10
+ const OPERATOR: Record<AccessComparison, string> = {
11
+ eq: "equals",
12
+ ne: "not",
13
+ in: "in",
14
+ notIn: "notIn",
15
+ };
16
+
17
+ /** The Aventara filter operator a comparison is written with. */
18
+ export const operatorOf = (cmp: AccessComparison): string => OPERATOR[cmp];
19
+
20
+ const isField = (
21
+ node: Resolved,
22
+ ): node is Extract<Resolved, { field: string } | { relation: string }> =>
23
+ node.k === "cmp" ||
24
+ node.k === "rel" ||
25
+ node.k === "some" ||
26
+ node.k === "every" ||
27
+ node.k === "none";
28
+
29
+ const keyOf = (
30
+ node: Extract<Resolved, { field: string } | { relation: string }>,
31
+ ): string => (node.k === "cmp" ? node.field : node.relation);
32
+
33
+ /**
34
+ * Sets `key` on `target` as its own data member. An assignment would read
35
+ * `__proto__` as the prototype setter and drop the condition.
36
+ */
37
+ export const defineMember = (
38
+ target: Record<string, unknown>,
39
+ key: string,
40
+ value: unknown,
41
+ ): void => {
42
+ Object.defineProperty(target, key, {
43
+ value,
44
+ enumerable: true,
45
+ writable: true,
46
+ configurable: true,
47
+ });
48
+ };
49
+
50
+ /** An object with one own member. */
51
+ const one = (key: string, value: unknown): AccessWhere => {
52
+ const where: Record<string, unknown> = {};
53
+ defineMember(where, key, value);
54
+ return where;
55
+ };
56
+
57
+ /** Merges fragments into one object when no key repeats; otherwise `undefined`. */
58
+ export const mergeWhere = (
59
+ fragments: readonly AccessWhere[],
60
+ ): AccessWhere | undefined => {
61
+ const merged: Record<string, unknown> = {};
62
+ for (const fragment of fragments) {
63
+ for (const [key, value] of Object.entries(fragment)) {
64
+ if (Object.hasOwn(merged, key)) {
65
+ return undefined;
66
+ }
67
+ defineMember(merged, key, value);
68
+ }
69
+ }
70
+ return merged;
71
+ };
72
+
73
+ /** Translates a row condition that is neither `true` nor `false` at its root. */
74
+ export const translateWhere = (node: Resolved): AccessWhere => {
75
+ switch (node.k) {
76
+ case "true":
77
+ return {};
78
+ case "false":
79
+ // Aventara's `where` has no `false`: the empty `OR` is its one way to match no
80
+ // row, and an ORM reads it so.
81
+ return { OR: [] };
82
+ case "cmp":
83
+ return one(node.field, {
84
+ [operatorOf(node.cmp)]: Array.isArray(node.value)
85
+ ? [...(node.value as readonly AccessLiteral[])]
86
+ : node.value,
87
+ });
88
+ case "rel":
89
+ return one(node.relation, translateWhere(node.where));
90
+ case "some":
91
+ case "every":
92
+ case "none":
93
+ return one(node.relation, { [node.k]: translateWhere(node.where) });
94
+ case "not":
95
+ return { NOT: translateWhere(node.arg) };
96
+ case "or":
97
+ return { OR: node.args.map(translateWhere) };
98
+ case "all":
99
+ return { AND: node.args.map(translateWhere) };
100
+ case "and": {
101
+ // Written by the author as one object: written back as one when every part is
102
+ // a field condition and no field repeats.
103
+ if (
104
+ node.args.every(isField) &&
105
+ new Set(node.args.map(keyOf)).size === node.args.length
106
+ ) {
107
+ return mergeWhere(node.args.map(translateWhere)) as AccessWhere;
108
+ }
109
+ return { AND: node.args.map(translateWhere) };
110
+ }
111
+ }
112
+ };
113
+
114
+ /** A seam answer: `true` every row, `false` none, or a `where`. */
115
+ export const translateAnswer = (node: Resolved): boolean | AccessWhere =>
116
+ node.k === "true" ? true : node.k === "false" ? false : translateWhere(node);