@aventara/access 0.1.0-rc.0 → 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 (50) hide show
  1. package/CHANGELOG.md +53 -7
  2. package/README.md +146 -49
  3. package/dist/access/access-options.d.ts +10 -2
  4. package/dist/access/access.factory.d.ts +3 -1
  5. package/dist/access/principal.subject.d.ts +11 -1
  6. package/dist/access/principal.subject.js +32 -3
  7. package/dist/access/request.state.js +75 -5
  8. package/dist/access-warning.emitter.d.ts +2 -0
  9. package/dist/access-warning.emitter.js +4 -0
  10. package/dist/access.builder.d.ts +48 -7
  11. package/dist/access.builder.js +10 -1
  12. package/dist/access.engine.d.ts +16 -3
  13. package/dist/access.engine.js +16 -9
  14. package/dist/better-auth/access.provider.d.ts +3 -1
  15. package/dist/cli/policies.diff.js +19 -1
  16. package/dist/foreign-references.check.d.ts +0 -6
  17. package/dist/foreign-references.check.js +2 -5
  18. package/dist/generator/prisma7/prisma-where.translator.d.ts +5 -4
  19. package/dist/generator/prisma7/prisma-where.translator.js +7 -5
  20. package/dist/mcp/agent-scope.gate.d.ts +3 -1
  21. package/dist/mcp/agent-scope.gate.js +1 -1
  22. package/dist/mcp/agent.grant.d.ts +5 -1
  23. package/dist/mcp/agent.grant.js +1 -1
  24. package/dist/policies-contract.d.ts +31 -4
  25. package/dist/policies.compiler.d.ts +3 -1
  26. package/dist/policies.compiler.js +89 -14
  27. package/dist/policies.decider.js +11 -3
  28. package/dist/policies.hash.js +2 -1
  29. package/dist/read-governed-unique.check.d.ts +3 -0
  30. package/dist/read-governed-unique.check.js +21 -0
  31. package/dist/reference-reads.check.d.ts +10 -6
  32. package/dist/reference-reads.check.js +20 -10
  33. package/dist/registry/ownership.check.js +8 -2
  34. package/dist/resources/auth-tables.catalog.d.ts +5 -0
  35. package/dist/resources/auth-tables.catalog.js +11 -3
  36. package/dist/resources/auth-tables.guard.d.ts +11 -0
  37. package/dist/resources/auth-tables.guard.js +58 -1
  38. package/dist/scope.resolver.d.ts +6 -0
  39. package/dist/scope.resolver.js +17 -0
  40. package/package.json +3 -2
  41. package/skill/SKILL.md +170 -0
  42. package/skill/reference/callers.md +99 -0
  43. package/skill/reference/errors.md +89 -0
  44. package/skill/reference/recipes.md +281 -0
  45. package/skill/reference/rules.md +153 -0
  46. package/skill/reference/setup.md +334 -0
  47. package/src/generator/prisma7/prisma-where.translator.ts +7 -5
  48. package/src/policies-contract.ts +35 -4
  49. package/src/policies.decider.ts +14 -2
  50. package/src/scope.resolver.ts +28 -0
package/CHANGELOG.md CHANGED
@@ -1,6 +1,58 @@
1
1
  # @aventara/access
2
2
 
3
- ## Unreleased
3
+ ## 1.0.0-rc.1
4
+
5
+ The first release candidate of 1.0, released beside `@aventara/core` `1.0.0-rc.11`: Access moves to 1.0 with the
6
+ framework. It is tested with `@aventara/core` `1.0.0-rc.11`, and still works with `1.0.0-rc.10` or
7
+ later.
8
+
9
+ - An engine whose beacons were not checked refuses with a message that names the call an app makes,
10
+ `await access.policy.checkBeacons(beacon, beaconPins, environment)` (`engine.checkBeacons` on an engine of one's own),
11
+ where it said `engine.checkBeacons(reader, pins, environment)`. The `code` is unchanged.
12
+ - `ACCESS_REFERENCE_UNREADABLE` names `resources: { users: true }` when the target is Better Auth's `user` table and
13
+ `createAccess` (or the plugin) keeps it closed, since no read grant on it is taken until the key opens it. The same
14
+ holds for the other tables a `resources` key opens.
15
+ - `ACCESS_REFERENCE_UNREADABLE` also warns when the only read grant that would reach the target is a `*` grant and the
16
+ target is one of Better Auth's tables that `createAccess` (or the plugin) keeps closed: the built-in deny refuses
17
+ every action on such a table, a `*` read included, so every write of a reference to it is refused. The warning
18
+ names the `resources` key that opens the table. It was silent before. Nothing the engine decides changes.
19
+ - `govern` takes a mode per field beside the list form: `.govern("Member", { role: "write" })` keeps the field readable
20
+ and lets only a grant that names it write it; `{ nationalId: "read" }` keeps it writable and lets only a grant that
21
+ names it read it; `"both"`, and the list form as before, govern each. A field governed twice with two modes is a type
22
+ error and refused when the rules compile. The policies file gains the optional member `governModes` (the `"read"`
23
+ and `"write"` fields; a file without it reads as before, and the list form compiles to the same file and hash). The
24
+ engine's `governed` lists the fields governed for reading only, typed as a list (`readonly "email"[]`, no longer
25
+ the tuple the rules wrote), and `avaccess diff` names a field whose mode changed. When `resources` opens Better Auth's tables, Access governs `User.role` and the organization `member`
26
+ table's `role` as `"write"`: a create or update grant sets one only when it names it (govern it in the rules too, so
27
+ a grant can name it). An update grant on `User` that named no field set the role before; it sets nothing now.
28
+ - The request state's `me` is a frozen copy of the plain data `principal` returns (plain objects and arrays), held
29
+ as a read-only member of the state: no code that runs during a request can replace the principal
30
+ or change it, an access token's scopes included. The app's own objects are never frozen or marked; one object
31
+ `principal` returns for two users is still refused (`ACCESS_OPTIONS_INVALID`), now over an access token too. Only
32
+ the returned object is checked. A class instance, `Map`, `Set`, `Date`, typed array or getter inside the principal is
33
+ not frozen, and a top-level one is `me` as returned. Under an OAuth access token a `Map` or `Set` principal is not
34
+ supported, and a class instance loses its `#private` fields.
35
+ - A field governed as `"read"` that is one of its Resource's identifiers warns `ACCESS_READ_GOVERNED_UNIQUE` when the
36
+ rules compile and when the engine is built: a write that sends a value another row holds fails on the duplicate,
37
+ which tells the writer the value exists. An empty `governModes` is refused.
38
+ - The README links the framework's documentation at `https://aventara.razor34.dev/framework`.
39
+ - The package ships an agent skill in `skill/` (`SKILL.md` and `reference/*.md`): setup and wiring, the rule
40
+ language, recipes and deny patterns, signed-out callers and cached reads, and the codes an agent meets. Copy it into
41
+ your agent's skills folder or point the agent at it; the README's "AI agents" section says how. Its TypeScript
42
+ examples compile in the packed-install gate. No new export and no new command.
43
+ - Proven against the framework's Operation Sets (`POST {api}/_all`) and cached reads, with nothing in Access changed
44
+ for either. Each entry of a set is decided as its single route would be, under one principal derived once for the
45
+ whole request; a refused entry changes nothing for the others. A cached read `for: "anonymous"` is `public` only
46
+ when Access finds no one in the request (its principal is `null`): a caller with a valid session cookie or bearer
47
+ token gets a `private` answer, and a token that verifies to no one gets the signed-out answer.
48
+
49
+ ## 0.1.0-rc.0
50
+
51
+ The first release candidate: the policy engine, Better Auth with Access beside it (`@aventara/access/auth`), the
52
+ policy registry and the org's guardrails, the audit log, the `avaccess` bin with its generator, and the standalone
53
+ host (`@aventara/access/standalone`). The published package listed the first group of entries below under
54
+ "Unreleased": they were made after this candidate was first written up and before it was published, and they ship
55
+ in it (the container image the later entries describe was removed then).
4
56
 
5
57
  - The engine warns, as a Node.js warning `ACCESS_REFERENCE_UNREADABLE`, when a role may create or update a reference
6
58
  (a to-one relation's reference fields) to a Resource no read grant reaches for that role. Aventara holds a reference
@@ -311,12 +363,6 @@
311
363
  - New exports: `AccessBeaconReader`, `AccessBeaconStore`, `AccessBeaconPins`; new codes `ACCESS_FOREIGN_CROSS_DATABASE`
312
364
  and `ACCESS_BEACON_MISMATCH`. `aventara-access/lock@1` takes an optional `foreign` pin.
313
365
 
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
366
  - `definePolicies(model)` writes role-first rules, typed from your app's schema (an Aventara discovery artifact's
321
367
  `model`). Global roles come from the principal's claims; roles held on a row (`on`, `where`) come from your own
322
368
  tables. `.govern()` keeps fields out of every result unless a grant names them. `.grants()` and `.denies()` take the
package/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # @aventara/access
2
2
 
3
3
  Access extends [Better Auth](https://www.better-auth.com) with policies for
4
- [Aventara](https://aventara-framework.vercel.app) apps and the services beside them. Better Auth signs users in and
4
+ [Aventara](https://aventara.razor34.dev/framework) apps and the services beside them. Better Auth signs users in and
5
5
  issues their tokens. Access decides what each user may do with each row and each field.
6
6
 
7
7
  You write role-first rules, typed from your app's schema. Access compiles them to a policies file: plain JSON in the
@@ -20,7 +20,7 @@ This is a release candidate. It holds:
20
20
  | `avaccess` | The command line: push, pull, verify, status, diff, keys, generate, schema, serve. |
21
21
  | The generated client | Two TypeScript files (`avAccess.ts` and `avAccess.internal.ts`) that import nothing else and decide for a service that does not run on Aventara. |
22
22
 
23
- Contents: [Install](#install) · [Write the rules](#write-the-rules) · [createAccess](#createaccess) ·
23
+ Contents: [Install](#install) · [AI agents](#ai-agents) · [Write the rules](#write-the-rules) · [createAccess](#createaccess) ·
24
24
  [Mount the routes](#mount-the-routes) · [Wire it into Aventara](#wire-it-into-aventara) ·
25
25
  [The Better Auth plugin](#the-better-auth-plugin) · [The engine alone](#the-engine-alone) · [The audit log](#the-audit-log) ·
26
26
  [Users and roles as Resources](#users-and-roles-as-resources) · [The registry and avaccess](#the-registry-and-avaccess)
@@ -34,7 +34,7 @@ Access goes into an Aventara app. To start one, create it with the Aventara CLI
34
34
  PostgreSQL), then install Access in it:
35
35
 
36
36
  ```sh
37
- npx @aventara/cli@1.0.0-rc.10 new <app> --orm prisma7 --db postgresql
37
+ npx @aventara/cli@1.0.0-rc.11 new <app> --orm prisma7 --db postgresql
38
38
  cd <app>
39
39
  npm install --save-exact @aventara/access@rc better-auth@~1.7.12
40
40
  # or, with pnpm:
@@ -42,7 +42,26 @@ pnpm add --save-exact @aventara/access@rc better-auth@~1.7.12
42
42
  ```
43
43
 
44
44
  On a terminal, `aventara new` asks for the package manager; anywhere else, add `--package-manager npm` (or `pnpm`)
45
- or `--yes`. Then set `DATABASE_URL` in `.env`, as the CLI says.
45
+ or `--yes`. Then set `DATABASE_URL` in `.env`, as the CLI says, and add Better Auth's secret and the app's origin.
46
+ `npx auth@~1.7.12 secret` prints a new secret. Without `BETTER_AUTH_URL`, `createAccess` refuses to start
47
+ (`ACCESS_OPTIONS_INVALID`).
48
+
49
+ ```sh
50
+ # .env
51
+ BETTER_AUTH_SECRET="<the value npx auth@~1.7.12 secret prints>"
52
+ BETTER_AUTH_URL="http://localhost:3000"
53
+ ```
54
+
55
+ Then, in this order:
56
+
57
+ 1. Add your models to `prisma/schema.prisma` and run `npm run aventara:prepare`
58
+ ([Write the rules](#write-the-rules)).
59
+ 2. Write the rules in `src/access.policies.ts`.
60
+ 3. Write `src/access.ts` with `createAccess`, keeping the scaffold's numeric ids
61
+ ([The scaffold's numeric ids](#the-scaffolds-numeric-ids)).
62
+ 4. Generate Better Auth's tables, push them and run `npm run aventara:prepare` again
63
+ ([Better Auth's tables](#better-auths-tables)).
64
+ 5. [Mount the routes](#mount-the-routes) and [wire it into Aventara](#wire-it-into-aventara).
46
65
 
47
66
  Access runs on Better Auth `~1.7.12`: 1.7.12 or any later 1.7 patch. npm installs the newest patch, and
48
67
  `--save-exact` records it. Another version (an older one, 1.8, a prerelease) is refused when `createAccess` runs
@@ -56,12 +75,32 @@ Access works with `@aventara/core` `1.0.0-rc.10` or later. It has no dependency
56
75
  instance fit the framework's types by shape. Node.js `^22.23.4 || >=24.22.1`. On Node.js 22, `createAccess` needs
57
76
  `@node-rs/argon2` to hash passwords ([Passwords](#passwords)).
58
77
 
78
+ ## AI agents
79
+
80
+ The package ships an agent skill at `node_modules/@aventara/access/skill/`: a short `SKILL.md` and reference pages an
81
+ agent loads when a task needs them (setup and wiring, the rule language, recipes, signed-out callers and cached reads,
82
+ error codes). Its TypeScript examples compile against this release. Copy the folder into your agent's skills folder:
83
+
84
+ ```sh
85
+ mkdir -p .claude/skills
86
+ cp -R node_modules/@aventara/access/skill .claude/skills/aventara-access
87
+ ```
88
+
89
+ Or point your agent at `node_modules/@aventara/access/skill/SKILL.md`. Copy it again after you upgrade Access.
90
+
59
91
  ## Write the rules
60
92
 
61
93
  `definePolicies` takes your schema. In an Aventara app that is the discovery artifact's `model`, which
62
94
  `aventara:prepare` writes. Every name in a rule is checked against it. A Resource, field, relation or enum value that
63
95
  does not exist is a type error at the key that names it.
64
96
 
97
+ The rules below use `Campaign`, `Member` and `Note`, which the scaffold does not have. Add your own models to
98
+ `prisma/schema.prisma` first, then run `npm run aventara:prepare` so the model has them.
99
+
100
+ The examples in this README type the principal's `id` as `string`, for a project whose `User.id` is a `String`. On
101
+ the scaffold's `Int` ids, type it as `number` and map it with `Number(sub)`
102
+ ([The scaffold's numeric ids](#the-scaffolds-numeric-ids)).
103
+
65
104
  ```ts
66
105
  // src/access.policies.ts
67
106
  import { definePolicies } from "@aventara/access";
@@ -90,9 +129,13 @@ export const policies = definePolicies(model)
90
129
  },
91
130
  }))
92
131
 
93
- // Governed fields: out of every default projection; only a grant that names one
94
- // opens it, and only when selected.
132
+ // Governed fields: only a grant that names one opens it. The list governs reads
133
+ // and writes: out of every default projection, written by no grant that does not
134
+ // name it.
95
135
  .govern("User", ["email"])
136
+ // "write": read as any other field, written only by a grant that names it. The
137
+ // row roles read a member's role, so no grant hands one out without naming it.
138
+ .govern("Member", { role: "write" })
96
139
 
97
140
  .grants(({ grant, me }) => [
98
141
  grant("signedIn", "read", "User"),
@@ -110,6 +153,12 @@ export const policies = definePolicies(model)
110
153
  grant("dm", ["read", "create", "update", "delete"], "Note", {
111
154
  in: "campaign",
112
155
  }),
156
+ // The DM changes a seat's role, never who sits in it: the grant names the role.
157
+ grant("dm", "update", "Member", {
158
+ in: "campaign",
159
+ write: { except: ["userId", "campaignId"] },
160
+ fields: ["Member.role"],
161
+ }),
113
162
  ])
114
163
 
115
164
  .denies(({ deny }) => [
@@ -160,7 +209,9 @@ How the rules combine:
160
209
  write (`A4002`) otherwise. When Aventara hosts the engine (`@aventara/core` `1.0.0-rc.10` or later) it tells the
161
210
  engine which Resources it holds references to (`engine.hosted({ held })`), and the engine warns
162
211
  (`ACCESS_REFERENCE_UNREADABLE`, a Node.js warning) about a role that may write a reference to one of them no read
163
- grant reaches for it. Compiling the rules warns about nothing, and the engine decides as before. A reference to a
212
+ grant reaches for it. When that Resource is a Better Auth table `createAccess` keeps closed (`User` without
213
+ `resources: { users: true }`), the warning names the key that opens it. Compiling the rules warns about nothing, and
214
+ the engine decides as before. A reference to a
164
215
  foreign Resource is the exception: no caller reads one, so the engine lists the rules' foreign Resources in
165
216
  `unheld` and Aventara does not hold a reference to them ([Namespaces and foreign
166
217
  Resources](#namespaces-and-foreign-resources)).
@@ -227,12 +278,34 @@ are locked together: `except: ["author"]` locks `authorId` too, and `except: ["a
227
278
 
228
279
  ### Governed fields
229
280
 
230
- `.govern("User", ["email"])` takes a field out of every default projection. Only a grant that names it
231
- (`fields: ["User.email"]`) opens it, and only when the request selects it. Selecting a governed field without such a
232
- grant is refused with `403 A4002`. The field is not quietly dropped from the result.
281
+ A governed field is opened only by a grant that names it (`fields: ["User.email"]`). Each governed field has a mode:
282
+
283
+ | Form | Reads | Writes |
284
+ |---|---|---|
285
+ | `.govern("User", ["email"])`, or `{ email: "both" }` | Out of every default projection; a read grant that names it opens it, when the request selects it | Only a create or update grant that names it sets it |
286
+ | `.govern("Member", { role: "write" })` | As any other field | Only a create or update grant that names it sets it |
287
+ | `.govern("Citizen", { nationalId: "read" })` | As `"both"` | As any other field |
288
+
289
+ Govern every field your roles, claims or principal read, usually as `"write"`. A role held on a `Member` row reads
290
+ `Member.role`; governed as `"write"`, an update grant on `Member` that does not name it cannot change it, while every
291
+ reader still sees it. `"read"` fits a field people write and almost nobody reads back: a government id, a stored
292
+ secret.
293
+
294
+ Selecting a field governed for reading without a grant that opens it is refused with `403 A4002`. The field is not
295
+ quietly dropped from the result. Setting a field governed for writing without a grant that names it is refused the
296
+ same way. A field governed twice with two modes is a type error, and refused when the rules compile.
297
+
298
+ A relation and its reference fields tell each other (`author.id` is `authorId`), so they are governed together, with
299
+ one mode: `.govern("Post", ["author"])` without `"authorId"`, or the reverse, is refused when the rules compile.
233
300
 
234
- A relation and its reference fields tell each other (`author.id` is `authorId`), so they are governed together:
235
- `.govern("Post", ["author"])` without `"authorId"`, or the reverse, is refused when the rules compile.
301
+ A field governed as `"read"` that is unique (one of its Resource's identifiers) still tells writers which values
302
+ exist: a create or update that sends a value another row holds fails on the duplicate. Compiling the rules or building
303
+ the engine warns `ACCESS_READ_GOVERNED_UNIQUE` (a Node.js warning) for each such field. Govern it as `"both"` so only a
304
+ grant that names it writes it, or accept the leak.
305
+
306
+ In the policies file, `govern` lists every governed field, and `governModes` gives the fields governed for one access
307
+ only: `"governModes": { "Member": { "role": "write" } }`. A field without an entry is governed for both, so the list
308
+ form compiles to the same file, with the same hash, as `"both"`.
236
309
 
237
310
  `fields` on a Resource with no governed field is a type error that says what to do:
238
311
  `no field of "Note" is governed; govern it first, .govern("Note", [<field>]), and a grant can then open it`.
@@ -392,39 +465,54 @@ claim it reads from a field the user can set (an `email`, a
392
465
  `username`, an additional field without `input: false`) is again the user's own choice, so read claims there only from
393
466
  fields your server alone writes.
394
467
 
395
- Better Auth's tables go into your schema the way Better Auth's docs say. With Prisma,
396
- `npx auth@~1.7.12 generate --config src/access.ts` writes Better Auth's models (and Access's own tables, when `audit`
397
- or `registry` is on) into `prisma/schema.prisma`. It reads the module's default export. Then migrate with Prisma and run
398
- `aventara:prepare` again, so the framework's model has the new tables. Access closes them to every request until you
399
- open them (see [Users and roles as Resources](#users-and-roles-as-resources)).
400
-
401
- On its first run, `npx auth generate` may print a Babel note that an exported identifier such as `AvComputedFields`
402
- "is not declared in Babel's scope tracker". It comes from a type the generated discovery artifact re-exports, and
403
- changes nothing: the schema is written all the same.
404
-
405
468
  ### The scaffold's numeric ids
406
469
 
407
470
  The project `aventara new` creates has `model User { id Int @id @default(autoincrement()) … }`. Better Auth's ids are
408
- strings by default, so `npx auth@~1.7.12 generate` writes `String` foreign keys (`Session.userId`, `Account.userId`)
409
- against that `Int`, and `prisma validate` fails. Better Auth has its own option for a database that assigns numeric
410
- ids. Put it in the `betterAuth` block of `src/access.ts`, beside `database`:
411
- `advanced: { database: { generateId: "serial" } }`.
412
-
413
- Every Better Auth table, and Access's own, then gets `Int @id @default(autoincrement())`, and its references are `Int`.
414
- Add the option before you run `npx auth@~1.7.12 generate --config src/access.ts`, then run `npx prisma validate`. The
415
- command only adds what the schema lacks: on a schema that already has Better Auth's models with `String` ids, it
416
- answers "Your schema is already up to date" and changes nothing. Delete the models it wrote (and Access's), then run it
417
- again.
418
-
419
- - Better Auth hands an id out as a string (`"1"`), so a token's `sub` is `"1"`. Type your principal's `id` as `number`
420
- and map it, `principal: ({ sub, claims }) => ({ id: Number(sub), claims: … })`. A rule that compares `User.id` with a
421
- string, such as `where: { id: me.id }` with `id: string` or `me.sub` from `AccessIdentity`, is a type error.
471
+ strings by default, so without the option below `npx auth@~1.7.12 generate --yes` writes `String` foreign keys
472
+ (`Session.userId`, `Account.userId`) against that `Int`, and `prisma validate` fails. Before you generate Better Auth's
473
+ tables, put Better Auth's option for numeric ids in the `betterAuth` block of `src/access.ts`, beside `database`, and
474
+ map the principal's `id` to a number:
475
+
476
+ ```text
477
+ betterAuth: { …, advanced: { database: { generateId: "serial" } } },
478
+ principal: ({ sub, claims }) => ({ id: Number(sub), claims: { … } }),
479
+ ```
480
+
481
+ with `id: number` in the `Principal` type of `src/access.policies.ts`. Every Better Auth table, and Access's own, then
482
+ gets `Int @id @default(autoincrement())`, and its references are `Int`. `generate` only adds what the schema lacks: on
483
+ a schema that already has Better Auth's models with `String` ids, it answers "Your schema is already up to date" and
484
+ changes nothing. Delete the models it wrote (and Access's), then run it again.
485
+
486
+ - Better Auth hands an id out as a string (`"1"`), so a token's `sub` is `"1"`, and `Number(sub)` is the user's id. A
487
+ rule that compares `User.id` with a string, such as `where: { id: me.id }` with `id: string` or `me.sub` from
488
+ `AccessIdentity`, is a type error.
422
489
  - Better Auth warns that its single-use checks (SAML assertion ids, DPoP proofs, `private_key_jwt` assertions) need
423
490
  string ids and are not enforced with `"serial"`. If you use those, change `User.id` to `String` instead and leave
424
491
  `generateId` out.
425
492
  - The scaffold's `User` is now Better Auth's `user` table, so a rule on `User` needs `resources: { users: true }`
426
493
  ([Users and roles as Resources](#users-and-roles-as-resources)).
427
494
 
495
+ ### Better Auth's tables
496
+
497
+ Better Auth's tables go into your schema the way Better Auth's docs say. With Prisma:
498
+
499
+ ```sh
500
+ npx auth@~1.7.12 generate --config src/access.ts --yes
501
+ npx prisma validate
502
+ npx prisma db push
503
+ npm run aventara:prepare
504
+ ```
505
+
506
+ `generate` writes Better Auth's models (and Access's own tables, when `audit` or `registry` is on) into
507
+ `prisma/schema.prisma`. It reads the module's default export. Without `--yes` it asks before it writes the file, and
508
+ where it cannot ask (a script, CI) it writes nothing and still exits 0. Run `aventara:prepare` again so the
509
+ framework's model has the new tables. Access closes them to every request until you open them (see
510
+ [Users and roles as Resources](#users-and-roles-as-resources)).
511
+
512
+ On its first run, `generate` may print a Babel note that an exported identifier such as `AvComputedFields`
513
+ "is not declared in Babel's scope tracker". It comes from a type the generated discovery artifact re-exports, and
514
+ changes nothing: the schema is written all the same.
515
+
428
516
  ### The options
429
517
 
430
518
  | Option | Takes | Default |
@@ -450,7 +538,9 @@ again.
450
538
  claims (`identity.claims`) as they are: a boolean claim it drops is unknown to the rules, so a deny such as
451
539
  `banned: true` refuses it. Return a new object for each user (one
452
540
  cached per user is fine): the audit log names the user each principal stands for, so an object `principal` already
453
- returned for another user is refused with `ACCESS_OPTIONS_INVALID`.
541
+ returned for another user is refused with `ACCESS_OPTIONS_INVALID`. Only the object `principal` returns is checked;
542
+ a `Map`, `Date` or other object inside it is not. `me` is a frozen copy of its plain data, so your object itself is
543
+ never frozen.
454
544
  - A claim the rules read that `policies.claims` does not declare is a type error, and is refused at construction
455
545
  (`ACCESS_CLAIMS_UNDECLARED`). A token carries `sub` and the declared claims only. Every declared boolean claim is
456
546
  in it: one the user's field leaves unset (absent, `null` or `undefined`), or `claimsOf` returns as `null` or
@@ -594,10 +684,10 @@ refuses with `ACCESS_OPTIONS_INVALID`, unless you hash passwords yourself (`bett
594
684
  |---|---|
595
685
  | `access.handler` | Better Auth's request handler, `(Request) => Promise<Response>`, with Access's routes |
596
686
  | `access.api` | Better Auth's server API, as `auth.api` |
597
- | `access.options` | The options Better Auth runs with: your block plus the installed plugins (what `npx auth@~1.7.12 generate` reads) |
687
+ | `access.options` | The options Better Auth runs with: your block plus the installed plugins (what `npx auth@~1.7.12 generate --yes` reads) |
598
688
  | `access.$Infer` | Types only: `Session`, `Me` (the principal) and `Claims` |
599
689
  | `access.policy` | The policy engine. It is built on first read and is not enumerable. |
600
- | `access.state({ headers })` | The request's state, `{ me }`: from a bearer token checked against Better Auth's key set, or (profiles `web` and `kiosk`) the session cookie; `null` when neither |
690
+ | `access.state({ headers })` | The request's state, `{ readonly me }`: from a bearer token checked against Better Auth's key set, or (profiles `web` and `kiosk`) the session cookie; `null` when neither. `me` is a copy of the plain data `principal` returned (its plain objects and arrays, all the way down), frozen, and the state holds it read-only, so no code that runs during the request can replace or change the principal the policy decides for. A class instance, `Map`, `Set`, `Date`, typed array or getter is kept as you returned it and is not frozen: return plain data for the fields the rules read. Your own objects are never frozen. Under an OAuth access token, return a plain object: a `Map` or `Set` principal is not supported there, and a class instance loses its `#private` fields |
601
691
  | `access.registry` | The registry's operator API, present only with `registry: { enabled: true }` |
602
692
  | `access.client` | Where Access is mounted and the `$access` verbs it serves, for the framework's clients ([below](#the-access-verbs)); on the kiosk profile, the kiosk's verbs too |
603
693
  | `access.kiosk` | The kiosk profile's operator API: `listDevices()` and `revokeDevice(deviceId)` ([The kiosk profile](#the-kiosk-profile)) |
@@ -1034,7 +1124,7 @@ What binds a session to its device:
1034
1124
 
1035
1125
  The kiosk's tables are `accessKioskDevice`, `accessKioskPin`, `accessKioskLockout` and `accessKioskRetiredSecret`,
1036
1126
  and Better Auth's `session` gains `kioskDeviceId`, `kioskSeenAt` and `kioskPin` (no user sets them). `npx auth@~1.7.12
1037
- generate` writes them as it writes Better Auth's own; off Prisma's adapter, name them in `resources.authTables`. Like
1127
+ generate --yes` writes them as it writes Better Auth's own; off Prisma's adapter, name them in `resources.authTables`. Like
1038
1128
  Access's other tables they stay closed to every request, and a credential's or a PIN's hash is a secret column no grant
1039
1129
  opens.
1040
1130
 
@@ -1651,7 +1741,7 @@ The engine answers:
1651
1741
  | `grantsOf(question)` | The update grants that cover a row before it is updated, as opaque keys, for `can`'s `beforeGrants` ([Updates](#updates)) |
1652
1742
  | `explain(question)` | In development, which rules took part in a `can` decision. It throws when `NODE_ENV` is `production`. |
1653
1743
  | `checkBeacons(reader, pins, environment)` | The startup check that the service reaches each foreign owner's database ([The ownership beacon](#the-ownership-beacon)). Await it before serving. |
1654
- | `governed` | The governed fields, by Resource |
1744
+ | `governed` | The fields governed for reading (`"read"`, `"both"` or the list form), by Resource: out of every default projection |
1655
1745
  | `afterChecks` | The Resources where the row an update leaves is checked, so the host asks `grantsOf` before the write |
1656
1746
  | `paths` | Every relation path and operator each rule filters on, by rule id |
1657
1747
  | `unheld` | The Resources the rules read as foreign: Aventara holds no written reference to them to the caller's read scope |
@@ -1705,7 +1795,7 @@ the file: changing the object you handed it afterwards changes nothing.
1705
1795
  ## The audit log
1706
1796
 
1707
1797
  `audit: true` writes each event (sign-ins, refused sign-ins, sign-outs, tokens issued, decisions) to the `accessAudit`
1708
- table. `npx auth@~1.7.12 generate` writes it
1798
+ table. `npx auth@~1.7.12 generate --yes` writes it
1709
1799
  into an ORM's schema (Prisma: `model AccessAudit`), and `npx auth@~1.7.12 migrate` creates it, beside Better Auth's
1710
1800
  tables. Better Auth refuses to start while the table is missing.
1711
1801
 
@@ -1787,13 +1877,20 @@ one of them. These rules only narrow. `resources` decides which tables your rule
1787
1877
 
1788
1878
  | Key | Opens | Writes through Aventara |
1789
1879
  |---|---|---|
1790
- | `users: true` | `user`, `session` and `account` (session and account read only) | on `user`, an update of `role` |
1791
- | `members: true` | the organization plugin's `member`, and its `organization` (read only) | on `member`, a create, an update of `role`, and a delete |
1880
+ | `users: true` | `user`, `session` and `account` (session and account read only) | on `user`, an update of `role`, by a grant that names it |
1881
+ | `members: true` | the organization plugin's `member`, and its `organization` (read only) | on `member`, a create, an update of `role` by a grant that names it, and a delete |
1792
1882
 
1793
1883
  - Every other Better Auth table stays closed: `verification`, `jwks`, the organization plugin's `invitation`,
1794
1884
  `twoFactor`, `deviceCode`, and Access's own tables. A key that is off leaves its tables closed too.
1795
1885
  - An opened table is read as your rules grant. Writes are limited because a write through Aventara skips Better Auth's
1796
1886
  hooks, its cookie cache and its secondary storage. Change a name or an email through Better Auth's API.
1887
+ - `role` is a claim, so Access governs `user`'s `role` as `"write"`: an update grant sets it only when it names it.
1888
+ To let an admin set roles, add `.govern("User", { role: "write" })` to your rules, which lets a grant name the
1889
+ field, and `fields: ["User.role"]` to that admin's update grant, ideally with `where: { NOT: { id: me.id } }` so an
1890
+ admin cannot change their own role. Never name it in a grant for `signedIn`, `anyone` or any role a user can reach
1891
+ on their own. Users edit their profile through Better Auth's `/update-user`.
1892
+ - With `members: true`, `member`'s `role` is governed the same way: a create or update grant sets it only when it
1893
+ names it. Name it only in an organization admin role's grant, held to `where: { organizationId: me.org }`.
1797
1894
  - `members: true` needs Better Auth's `organization()` plugin in `betterAuth.plugins`, with
1798
1895
  `requireEmailVerificationOnInvitation: true`: your rules trust its memberships, and without the flag a user whose
1799
1896
  email is not verified can accept an invitation sent to that address
@@ -1863,8 +1960,8 @@ A table a key opens must have each of its secret columns under Better Auth's fie
1863
1960
 
1864
1961
  These checks run when `access.policy` is first read, which is when the framework is built. A key whose tables Better
1865
1962
  Auth lacks, or that the rules' schema lacks, is refused with `ACCESS_RESOURCE_MISSING`: add the plugin, or run
1866
- `npx auth@~1.7.12 generate` and regenerate the model. A grant on a closed table, or of a write the table never takes,
1867
- is refused with `ACCESS_OPTIONS_INVALID`. The instance itself still builds, so `npx auth@~1.7.12 generate` can load
1963
+ `npx auth@~1.7.12 generate --yes` and regenerate the model. A grant on a closed table, or of a write the table never takes,
1964
+ is refused with `ACCESS_OPTIONS_INVALID`. The instance itself still builds, so `npx auth@~1.7.12 generate --yes` can load
1868
1965
  your configuration before the schema has the tables.
1869
1966
 
1870
1967
  ## The registry and avaccess
@@ -1897,7 +1994,7 @@ users need, and see [Sign up and sign in](#sign-up-and-sign-in) before you turn
1897
1994
 
1898
1995
  The registry's tables (`accessNamespace`, `accessKey`, `accessPolicyRevision`, `accessGuardrailRevision`, `accessHead`,
1899
1996
  `accessPushLog`, `accessResourceOwner`) are Better Auth plugin tables, created by Better Auth's migration or by
1900
- `npx auth@~1.7.12 generate` for an ORM's schema. `access.handler` serves the registry's routes under Better Auth's base path, so every mount carries them:
1997
+ `npx auth@~1.7.12 generate --yes` for an ORM's schema. `access.handler` serves the registry's routes under Better Auth's base path, so every mount carries them:
1901
1998
 
1902
1999
  | Route | Does |
1903
2000
  |---|---|
@@ -2336,7 +2433,7 @@ says otherwise.
2336
2433
  - Without it, a database Better Auth migrates itself (a `pg` Pool, a MySQL pool, SQLite) is checked before the server
2337
2434
  listens. One that lacks a table, column or index the options need stops it with exit `1` and the list of what is
2338
2435
  missing.
2339
- - A database behind an ORM's adapter (Prisma, Drizzle) is the ORM's to migrate: run `npx auth@~1.7.12 generate`, then
2436
+ - A database behind an ORM's adapter (Prisma, Drizzle) is the ORM's to migrate: run `npx auth@~1.7.12 generate --yes`, then
2340
2437
  the ORM's migration. It is served as it is, and `--migrate` refuses it with exit `2`.
2341
2438
  - Better Auth's rate limit is on, whatever `NODE_ENV` says (Better Auth turns it on in production only), unless the
2342
2439
  configuration sets `betterAuth.rateLimit.enabled`.
@@ -2423,7 +2520,7 @@ Better Auth's `{ code, message }` body.
2423
2520
  | `ACCESS_CLAIMS_UNDECLARED` | The rules or the guardrails read a claim, or a token carries one, that `policies.claims` does not declare | Declare the claim in `policies.claims` |
2424
2521
  | `ACCESS_PLUGIN_DUPLICATE` | `betterAuth.plugins` holds `jwt()` or `aventaraAccess()`, which `createAccess` installs itself; Better Auth's plugins hold `jwt()` or a second Access plugin beside `aventaraAccess()`; one `aventaraAccess()` in two Better Auth instances whose options differ; or a plugin that defines `jwt()`'s endpoints, Access's own `/me` route (under `createAccess`), (kiosk profile) one under `/kiosk/`, or (registry on) a registry route | Remove it from the plugins |
2425
2522
  | `ACCESS_OPTIONS_INVALID` | An option `createAccess` does not take or cannot read; `passwords` beside your own `emailAndPassword.password`; Node.js 22 without `@node-rs/argon2`; a grant on a Better Auth table `resources` leaves closed; a claim read from a field the user can set; no base URL to fix the tokens' issuer; `sameSite: "none"` without `trustedOrigins`; Better Auth off its Prisma adapter without `resources.authTables`, or an opened table whose secret column is not found; a `principal` object shared by two users; an organization plugin without `requireEmailVerificationOnInvitation: true` under `resources.members` or the bridge (GHSA-fmh4-wcc4-5jm3); under the bridge, one that lets any user create an organization, or maps a role dynamic access control lets admins name; under `mcp` or `audience`, no OAuth provider or two, one listed before `aventaraAccess()`, `disableJwtPlugin`, a resource missing from its `resources`, no `aventara:read` among its `scopes`, a `refreshTokenReuseInterval` other than 0, a `validateRedirectUri`, a `requestUriResolver`, a `postLogin.consentReferenceId`, `allowUnauthenticatedClientRegistration` without `mcp.openRegistration` (never under `audience` alone), a resource that is the provider's UserInfo URL, the `service` profile, (`mcp`) an http issuer off `127.0.0.1` and `[::1]`, or (`audience`) `enforcePerClientResources: false` and, beside `mcp`, the audience in `clientRegistrationDefaultResources` or `clientRegistrationAllowedResources`; `accessOf` given an instance without `aventaraAccess()`; a `profile` of two kinds, or the kiosk's keys on another kind; the kiosk profile with Better Auth's cookie cache, with `secondaryStorage` and sessions kept out of the database, with `bearer()` or `multiSession()`, with `crossSubDomainCookies` or `SameSite=None`, or on a plain http: base URL off a loopback host and off `insecureLan`, or a dynamic `baseURL` serving a host off loopback over http:; under `insecureLan`, a range that is not a canonical private IPv4 CIDR of /16 or narrower, an empty or repeated list, a base URL that is a hostname, IPv6, outside every range or dynamic, or a cookie setting that makes a cookie `Secure` | Follow the message |
2426
- | `ACCESS_RESOURCE_MISSING` | A table `resources` opens is missing: Better Auth lacks its plugin, or the rules' schema lacks its Resource; the organization bridge without `organization()` | Add the plugin, or run `npx auth@~1.7.12 generate` and regenerate the model |
2523
+ | `ACCESS_RESOURCE_MISSING` | A table `resources` opens is missing: Better Auth lacks its plugin, or the rules' schema lacks its Resource; the organization bridge without `organization()` | Add the plugin, or run `npx auth@~1.7.12 generate --yes` and regenerate the model |
2427
2524
  | `ACCESS_LOCK_MISMATCH` | A pulled file no longer hashes to its pin in `avAccess.lock.json`, the engine was given files the lock does not pin, or `requireLock` is set and no lock was given | Run `avaccess pull` again; never edit a pulled file; pass the lock |
2428
2525
  | `ACCESS_KEY_REFUSED` | A registry key that is wrong, revoked, rotated out or another namespace's (`401`), or a pull key that pushes (`403`) | Use the namespace's current key of the right kind |
2429
2526
  | `ACCESS_OWNERSHIP` | A pushed file names a Resource another namespace owns, or one the operator has not assigned it (`ownership: "assigned"`), or the org's guardrails file grants (it holds `grants`, or a guardrail with `subjects`) (`403`) | Grant only on your own Resources; ask the owner or the operator, or add a guardrail |
@@ -5,7 +5,15 @@ import type { AccessPolicies, AccessPoliciesJson } from "../policies-contract.js
5
5
  import type { AccessClaimsAnswer, AccessClaimsDeclaration, AccessClaimsSource } from "./claims.mapper.js";
6
6
  /** The fields each Resource governs, by Resource: what the rules' engine is typed with. */
7
7
  type GovernedShape = {
8
- readonly [resource: string]: readonly string[];
8
+ readonly [resource: string]: readonly string[] | {
9
+ readonly [field: string]: "read" | "write" | "both";
10
+ };
11
+ };
12
+ /** The builder's `govern` block: a mode per governed field. */
13
+ type ModesShape = {
14
+ readonly [resource: string]: {
15
+ readonly [field: string]: "read" | "write" | "both";
16
+ };
9
17
  };
10
18
  /** The claims a rules principal type `R` reads, by name. */
11
19
  type ClaimNamesOf<R> = R extends {
@@ -24,7 +32,7 @@ type DeclaredFor<R, C extends AccessClaimsDeclaration> = [
24
32
  * The rules an instance evaluates: `definePolicies(model)…` as written, or its
25
33
  * compiled file (typed, or imported as JSON as `avaccess pull` wrote it).
26
34
  */
27
- export type AccessRules<R, Gov extends GovernedShape> = AccessBuilder<any, R, any, Gov> | AccessPolicies<Gov> | AccessPoliciesJson<Gov>;
35
+ export type AccessRules<R, Gov extends GovernedShape> = AccessBuilder<any, R, any, Gov & ModesShape> | AccessPolicies<Gov> | AccessPoliciesJson<Gov>;
28
36
  /** The `policies` block. */
29
37
  export type AccessPoliciesOptions<C extends AccessClaimsDeclaration, R, Gov extends GovernedShape> = {
30
38
  /** The rules this instance evaluates: the builder (its principal type is checked here) or its compiled file. */
@@ -14,7 +14,9 @@ import { type AccessClaimsDeclaration, type AccessClaimsOf, type AccessIdentity
14
14
  import { type AccessProfileOptions } from "./profile.policy.js";
15
15
  export type { AccessPoliciesOptions, AccessRules, } from "./access-options.js";
16
16
  type GovernedShape = {
17
- readonly [resource: string]: readonly string[];
17
+ readonly [resource: string]: readonly string[] | {
18
+ readonly [field: string]: "read" | "write" | "both";
19
+ };
18
20
  };
19
21
  /** The headers of a request, as the framework hands them to `state`. */
20
22
  type RequestHeaders = {
@@ -1,12 +1,22 @@
1
+ /**
2
+ * Records that `principal` returned `value` for `sub`, writing nothing to it, and
3
+ * refuses an object it already returned for another `sub`: a cache keyed wrongly
4
+ * would serve one user as another. Only the value `principal` returns is checked,
5
+ * not what it holds.
6
+ */
7
+ export declare const claimReturned: (value: unknown, sub: string) => void;
1
8
  /**
2
9
  * Marks `me` as derived from `sub`: a non-enumerable property, so a spread,
3
10
  * `JSON.stringify` or `Object.keys` of the principal does not show it.
4
11
  *
12
+ * With `write` false (an object the app owns), the `sub` is kept in the process's
13
+ * map instead, and the object is not touched.
14
+ *
5
15
  * Refuses an object that already stands for another `sub`: one object handed out
6
16
  * for two users (a principal function that returns a shared object) would carry
7
17
  * whichever user came last, and the audit would name the wrong user for the other
8
18
  * one's decisions.
9
19
  */
10
- export declare const carrySubject: (me: unknown, sub: string) => void;
20
+ export declare const carrySubject: (me: unknown, sub: string, write?: boolean) => void;
11
21
  /** The `sub` of a principal Access derived; `null` for any other principal. */
12
22
  export declare const subjectOf: (principal: unknown) => string | null;
@@ -15,24 +15,53 @@ const frozenSubjects = () => {
15
15
  holder[FROZEN] ??= new WeakMap();
16
16
  return holder[FROZEN];
17
17
  };
18
+ /** The key of the one map, per process, of each object `principal` returned and the `sub` it returned it for. */
19
+ const RETURNED = Symbol.for("@aventara/access:returned-principals");
20
+ const returnedPrincipals = () => {
21
+ const holder = globalThis;
22
+ holder[RETURNED] ??= new WeakMap();
23
+ return holder[RETURNED];
24
+ };
25
+ /** The refusal of one object `principal` handed out for two users. */
26
+ const twoUsers = () => new AccessError("ACCESS_OPTIONS_INVALID", "`principal` returned an object it already returned for another user: the audit names the user each principal stands for, and one object cannot stand for two. Return a new object for each user (one cached per user is fine).");
27
+ /**
28
+ * Records that `principal` returned `value` for `sub`, writing nothing to it, and
29
+ * refuses an object it already returned for another `sub`: a cache keyed wrongly
30
+ * would serve one user as another. Only the value `principal` returns is checked,
31
+ * not what it holds.
32
+ */
33
+ export const claimReturned = (value, sub) => {
34
+ if (typeof value !== "object" || value === null) {
35
+ return;
36
+ }
37
+ const returned = returnedPrincipals();
38
+ const prior = returned.get(value);
39
+ if (prior !== undefined && prior !== sub) {
40
+ throw twoUsers();
41
+ }
42
+ returned.set(value, sub);
43
+ };
18
44
  /**
19
45
  * Marks `me` as derived from `sub`: a non-enumerable property, so a spread,
20
46
  * `JSON.stringify` or `Object.keys` of the principal does not show it.
21
47
  *
48
+ * With `write` false (an object the app owns), the `sub` is kept in the process's
49
+ * map instead, and the object is not touched.
50
+ *
22
51
  * Refuses an object that already stands for another `sub`: one object handed out
23
52
  * for two users (a principal function that returns a shared object) would carry
24
53
  * whichever user came last, and the audit would name the wrong user for the other
25
54
  * one's decisions.
26
55
  */
27
- export const carrySubject = (me, sub) => {
56
+ export const carrySubject = (me, sub, write = true) => {
28
57
  if (typeof me !== "object" || me === null) {
29
58
  return;
30
59
  }
31
60
  const carried = subjectOf(me);
32
61
  if (carried !== null && carried !== sub) {
33
- throw new AccessError("ACCESS_OPTIONS_INVALID", "`principal` returned an object it already returned for another user: the audit names the user each principal stands for, and one object cannot stand for two. Return a new object for each user (one cached per user is fine).");
62
+ throw twoUsers();
34
63
  }
35
- if (Object.isExtensible(me)) {
64
+ if (write && Object.isExtensible(me)) {
36
65
  try {
37
66
  Object.defineProperty(me, SUBJECT, {
38
67
  value: sub,