@loradb/lora-graphql 0.18.0 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -101,47 +101,55 @@ and only by the operators listed; sortable only with `@sortable`;
101
101
 
102
102
  ## Directives
103
103
 
104
+ A directive applies where the tables below say, and nowhere else: one in
105
+ a position the model would not apply (an `@authorization` on an interface
106
+ field, a `@selectable` on a field of an object type without `@node`, a
107
+ `@limit` on a scalar field) is a model error naming the directive and the
108
+ position, never silently ignored. `DIRECTIVE_POSITIONS` in
109
+ `src/model/positions.ts` is the full table.
110
+
104
111
  Model:
105
112
 
106
- | Directive | On | Meaning |
107
- | ---------------------------------------------------------------------------------------------------------- | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
108
- | `@node(labels:, plural:)` | type | A node label set; default: the type name |
109
- | `@key(generate:)` | field | Required, unique and immutable. The sort tie-breaker, cursor anchor and mutation address. `generate: true` fills a UUID on create |
110
- | `@unique` | field | Uniqueness constraint |
111
- | `@index(kind: RANGE \| TEXT \| POINT)` | field | An explicit index, usually inferred |
112
- | `@storedAs(type:)` | custom scalar | How a custom scalar is stored (`STRING`, `INT`, `FLOAT`, `BOOLEAN`, `DATETIME`, `DATE`); its SDL description is what clients see, whatever implementation `scalars` passes |
113
- | `@relationship(type:, direction:, properties:, queryDirection:, onDelete:, nestedOperations:, aggregate:)` | field | An edge to a `@node` type, interface or union. `queryDirection: UNDIRECTED` reads both ways; `onDelete: DETACH \| CASCADE \| RESTRICT`; `nestedOperations` lists the nested writes inputs offer; `aggregate: false` drops its aggregates |
114
- | `@declareRelationship` | interface field | Every implementation declares this relationship (type and direction may differ); select it on the interface |
115
- | `@relationshipProperties` | type | Properties on a relationship type |
116
- | `@alias(property:)` | field | API name differs from the stored property |
117
- | `@private` | field | Stored, never exposed |
118
- | `@readonly` | field | Exposed, never client-settable; on a relationship, absent from create and update inputs |
119
- | `@settable(onCreate:, onUpdate:)` | field | Which mutations may set it, e.g. set once on create; on a relationship, whether the inputs offer it (an upsert of an existing node keeps it) |
120
- | `@selectable(onRead:, onAggregate:)` | field | `onRead: false` makes a field write-only |
121
- | `@default(value:)` | field | Stored on create when the input omits it; on a relationship property, when the relationship is created |
122
- | `@timestamp(operations: [CREATE, UPDATE])` | field | Set to the current time; never client-settable. On a relationship property: CREATE when the relationship is created, UPDATE on edge updates and re-connects that set properties |
123
- | `@populatedBy(callback:, operations:)` | field | Computed by a named callback on write |
124
- | `@cardinality(max:)` | list relationship | Declared fan-out, for cost estimates |
125
- | `@cypher(statement:, columnName:)` | field | A field backed by a Cypher statement. Returns scalars, `@node` types, interfaces or unions over them, or object types without `@node` (read from a map) |
126
- | `@fulltext(indexes: [{ name, fields, analyzer, queryName }])` | type | FULLTEXT indexes, each with a search root field |
127
- | `@vector(dimensions:, similarity:, queryName:)` | `[Float!]` field | A VECTOR index and a similarity root field |
128
- | `@plural(value:)` | interface, union | The root field's name |
113
+ | Directive | On | Meaning |
114
+ | ---------------------------------------------------------------------------------------------------------- | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
115
+ | `@node(labels:, plural:)` | type | A node label set; default: the type name |
116
+ | `@key(generate:)` | field | Required, unique and immutable. The sort tie-breaker, cursor anchor and mutation address. `generate: true` fills a UUID on create |
117
+ | `@unique` | field | Uniqueness constraint |
118
+ | `@index(kind: RANGE \| TEXT \| POINT)` | field | An explicit index, usually inferred |
119
+ | `@storedAs(type:)` | custom scalar | How a custom scalar is stored (`STRING`, `INT`, `FLOAT`, `BOOLEAN`, `DATETIME`, `DATE`); its SDL description is what clients see, whatever implementation `scalars` passes |
120
+ | `@relationship(type:, direction:, properties:, queryDirection:, onDelete:, nestedOperations:, aggregate:)` | field | An edge to a `@node` type, interface or union. `queryDirection: UNDIRECTED` reads both ways; `onDelete: DETACH \| CASCADE \| RESTRICT`; `nestedOperations` lists the nested writes inputs offer; `aggregate: false` drops its aggregates |
121
+ | `@declareRelationship` | interface field | Every implementation declares this relationship (type and direction may differ); select it on the interface |
122
+ | `@relationshipProperties` | type | Properties on a relationship type |
123
+ | `@alias(property:)` | field | API name differs from the stored property |
124
+ | `@private` | field | Stored, never exposed |
125
+ | `@readonly` | field | Exposed, never client-settable; on a relationship, absent from create and update inputs |
126
+ | `@settable(onCreate:, onUpdate:)` | field | Which mutations may set it, e.g. set once on create; on a relationship, whether the inputs offer it (an upsert of an existing node keeps it); on a relationship property, `onUpdate: false` also refuses a re-connect that would change it |
127
+ | `@selectable(onRead:, onAggregate:)` | field | `onRead: false` makes a field write-only; on a relationship property, it leaves the edge type too (`onAggregate: false`, the edge aggregates) |
128
+ | `@default(value:)` | field | Stored on create when the input omits it; on a relationship property, when the relationship is created |
129
+ | `@timestamp(operations: [CREATE, UPDATE])` | field | Set to the current time; never client-settable. On a relationship property: CREATE when the relationship is created, UPDATE on edge updates and re-connects that set properties |
130
+ | `@populatedBy(callback:, operations:)` | field | Computed by a named callback on write |
131
+ | `@cardinality(max:)` | list relationship | Declared fan-out, for cost estimates |
132
+ | `@cypher(statement:, columnName:)` | field | A field backed by a Cypher statement. Returns scalars, `@node` types, interfaces or unions over them, or object types without `@node` (read from a map) |
133
+ | `@fulltext(indexes: [{ name, fields, analyzer, queryName }])` | type | FULLTEXT indexes, each with a search root field |
134
+ | `@vector(dimensions:, similarity:, queryName:)` | `[Float!]` field | A VECTOR index and a similarity root field |
135
+ | `@plural(value:)` | interface, union | The root field's name |
129
136
 
130
137
  API:
131
138
 
132
- | Directive | On | Meaning |
133
- | ----------------------------------------------------- | -------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
134
- | `@query(read:, aggregate:)` | type, interface, union | Generated reads |
135
- | `@mutation(operations: [CREATE, UPDATE, DELETE])` | type | Generated mutations; none without it |
136
- | `@subscription(operations: [CREATE, UPDATE, DELETE])` | type | Generated subscriptions; none without it |
137
- | `@filterable(byValue: [...])` | field | Filter operators. Bare: `EQ` and `IN` (lists: `INCLUDES`). On a relationship: enables relationship filters |
138
- | `@sortable` | field | Sort and paginate by this field (on a relationship property: `sort: [{ edge: { ... } }]`) |
139
- | `@groupBy` | field | A grouping key of `<plural>Grouped(by:)` (needs `@query(aggregate: true)`) |
140
- | `@limit(default:, max:)` | type, interface, union, list relationship | Page size bounds |
141
- | `@relayId` | `@key` field | Adds a global `id` and the `Node` interface |
142
- | `@authentication(operations:, jwt:)` | type, field | Needs an authenticated request, whose claims satisfy `jwt` |
143
- | `@authorization(filter:, validate:)` | type (filter and validate), field (validate) | Row-level rules, compiled into statements |
144
- | `@jwt`, `@jwtClaim(path:)` | type, field | The claims shape; rules may only use declared claims |
139
+ | Directive | On | Meaning |
140
+ | ----------------------------------------------------- | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
141
+ | `@query(read:, aggregate:)` | type, interface, union | Generated reads |
142
+ | `@mutation(operations: [CREATE, UPDATE, DELETE])` | type | Generated mutations; none without it |
143
+ | `@subscription(operations: [CREATE, UPDATE, DELETE])` | type | Generated subscriptions; none without it |
144
+ | `@filterable(byValue: [...])` | field | Filter operators. Bare: `EQ` and `IN` (lists: `INCLUDES`). On a relationship: enables relationship filters |
145
+ | `@sortable` | field | Sort and paginate by this field (on a relationship property: `sort: [{ edge: { ... } }]`) |
146
+ | `@groupBy` | field | A grouping key of `<plural>Grouped(by:)` (needs `@query(aggregate: true)`) |
147
+ | `@limit(default:, max:)` | type, interface, union, list relationship | Page size bounds |
148
+ | `@relayId` | `@key` field | Adds a global `id` and the `Node` interface |
149
+ | `@authentication(operations:, jwt:)` | type, field | Needs an authenticated request, whose claims satisfy `jwt` |
150
+ | `@authorization(filter:, validate:)` | type (filter and validate), field (validate) | Row-level rules, compiled into statements |
151
+ | `@jwt`, `@jwtClaim(path:)` | type, field | The claims shape; rules may only use declared claims |
152
+ | `@viewer(type:, field:)` | `@jwt` claim | The claim naming the caller's node (by a `@key` or `@unique` field): enables `isViewer` and `viewer` in rules |
145
153
 
146
154
  `directiveTypeDefs` (or `lora-graphql directives`) prints these as SDL for
147
155
  editors and codegen.
@@ -204,8 +212,11 @@ string), `Date`, `Time`, `LocalTime`, `DateTime`, `LocalDateTime`,
204
212
  properties together.
205
213
  - **Absent and `null` filters are left out of the statement**, so a filter
206
214
  bound to an unset variable costs nothing. `eq: null` does not mean
207
- `IS NULL`: use `isNull: true`. A relationship quantifier whose filter is
208
- empty is left out too: ask `count: { gt: 0 }` for "has any".
215
+ `IS NULL`: use `isNull: true`. An `OR` branch left empty that way is left
216
+ out of the `OR`, and an `OR` or `NOT` with nothing left is left out
217
+ entirely, so an unset variable never widens a filter to every row; a
218
+ literal `OR: []` matches nothing. A relationship quantifier whose filter
219
+ is empty is left out too: ask `count: { gt: 0 }` for "has any".
209
220
  - `count` and `single` count related nodes once each, however many
210
221
  relationships lead to them. `all` holds on an empty set, and a missing
211
222
  property fails it.
@@ -413,7 +424,15 @@ commits:
413
424
  (`CONSTRAINT_VIOLATION`);
414
425
  - `@authorization` validate rules, type and field level (`FORBIDDEN`).
415
426
 
416
- Any failure rolls the whole mutation back. Engine constraint errors come
427
+ Any failure rolls the whole mutation back. Atomicity is per root field:
428
+ in an operation with several root fields, each runs in its own
429
+ transaction, so a later failure leaves the earlier ones committed. Pass
430
+ `mutationTransaction: "operation"` to run every root field of a mutation
431
+ in one transaction through `execute()` (persisted operations included):
432
+ it commits only when the operation reports no error, and otherwise rolls
433
+ back and returns `data: null`. With another server, put a `lora.begin()`
434
+ transaction in the context (see [Transactions](#transactions)). Engine
435
+ constraint errors come
417
436
  back as `CONSTRAINT_VIOLATION` naming the type and field. A mutation creates
418
437
  or deletes at most `maxBatch` nodes (default 1000). `@key` is not updatable.
419
438
  `info` reports `nodesCreated`, `nodesUpdated`, `nodesDeleted`,
@@ -467,7 +486,11 @@ type Event @node @fulltext(indexes: [{ fields: ["title", "summary"] }]) {
467
486
  ```
468
487
 
469
488
  Full-text queries AND their terms, fold case and accents, and treat a
470
- trailing `*` as a prefix. Vector search takes a query `vector` or the key of
489
+ trailing `*` as a prefix. A `[String!]` field in `@fulltext` indexes each
490
+ of its strings. Search connections (`searchEventsConnection`) page with
491
+ cursors and take `totalCount`: every match after `where` and the read
492
+ rules (a vector search counts within its candidate window); a selection of
493
+ only `totalCount` reads no page. Vector search takes a query `vector` or the key of
471
494
  a node whose embedding to start from (`to`, which is left out of the
472
495
  results). The index returns its top candidates before `where` applies, so
473
496
  the library asks it for four times the page when a filter is present.
@@ -595,17 +618,29 @@ type Post
595
618
  }
596
619
  ```
597
620
 
598
- - A rule is `{ node, jwt, AND, OR, NOT }`. `node` is a filter over the type,
599
- where `"$jwt.path"` strings become the caller's claims and
621
+ - A rule is `{ node, jwt, AND, OR, NOT }`. `node` is a filter over the type
622
+ (relationship properties included, through `<field>Connection: { some:
623
+ { node, edge } }`), where `"$jwt.path"` strings become the caller's claims and
600
624
  `"$context.path"` strings values from the GraphQL context. `jwt` tests
601
625
  claims (`eq`, `in`, `includes`, `contains`, `startsWith`, `endsWith`,
602
626
  `lt`, `lte`, `gt`, `gte`, `exists`).
627
+ - **The caller's own node.** Mark the claim that identifies the caller
628
+ with `@viewer(type: "Person", field: "subject")` (the field is `@key` or
629
+ `@unique`), so the key can stay a slug while the subject is an identity
630
+ provider's opaque id. Rules then say `{ node: { isViewer: true } }` on
631
+ the viewer type, or `{ node: { author: { isViewer: true } } }` through a
632
+ relationship; this expands to `{ subject: { eq: "$jwt.sub" } }` at
633
+ startup and compiles to exactly the same statement. `viewer: { verified:
634
+ { eq: true } }` tests the caller's own node: one seek by the claim. Both
635
+ are unknown without the claim, so `NOT { isViewer: true }` never grants a
636
+ signed-out caller. `isViewer` takes `true` only; use `NOT` for the
637
+ opposite.
603
638
  - Inside a longer string, write `${jwt.path}` or `${context.path}`:
604
639
  `key: { startsWith: "${jwt.sub}:" }` confines a user to keys that begin
605
640
  with their `sub` and `:`. The claim must be a string, number or boolean;
606
641
  otherwise the rule denies. Pick a separator no `sub` contains: with `-`,
607
642
  user `a` could take `a-b-…`, the key space of user `a-b`. See
608
- [docs/design/graphql-claim-interpolation.md](../../docs/design/graphql-claim-interpolation.md).
643
+ [docs/design/graphql-threat-model.md](../../docs/design/graphql-threat-model.md).
609
644
  - **Claim tests run in JavaScript at compile time**, so an admin's
610
645
  statement carries no filter at all. Statements stay specialised and
611
646
  index-friendly.
@@ -641,10 +676,118 @@ type Post
641
676
  them as a signed-in caller.
642
677
  - `@authentication(operations:, jwt:)` covers `READ`, `CREATE`, `UPDATE`,
643
678
  `DELETE`, `CREATE_RELATIONSHIP`, `DELETE_RELATIONSHIP` and `SUBSCRIBE`,
644
- and may require claims.
679
+ and may require claims. On a relationship field, `CREATE` / `UPDATE`
680
+ cover setting it in a create or update input, `CREATE_RELATIONSHIP` a
681
+ `connect` or nested `create` through it, and `DELETE_RELATIONSHIP` a
682
+ `disconnect` or nested `delete`.
645
683
  - Rules are checked against the model at startup: an unknown field,
646
684
  operator or (with `@jwt`) claim, or a test that is empty or null, is an
647
685
  error, not an open door.
686
+ - **Owner-scoped keys.** `key: String! @key(scope: VIEWER, separator: ":")`
687
+ keeps created keys in the caller's key space: a create (nested creates
688
+ and upsert-creates included) must use a key that starts with the
689
+ `@viewer` claim and the separator, and is longer than that prefix. It
690
+ is checked before any statement runs, so `FORBIDDEN` for `bob:x` reads
691
+ the same whether `bob:x` exists or not. A claim containing the
692
+ separator is refused, so user `a` cannot write into the key space of
693
+ user `a:b`. It needs `@viewer`; the schema's bypass skips it. Keys shared
694
+ by two owners (`f1:lou`) stay hand-written rules.
695
+ - **Masks.** A field-level READ rule fails the row; a mask substitutes a
696
+ value instead:
697
+
698
+ ```graphql
699
+ status: ConnectionRequestStatus!
700
+ @authorization(
701
+ mask: [
702
+ {
703
+ unless: {
704
+ OR: [
705
+ { node: { status: { in: [PENDING, ACCEPTED] } } }
706
+ { node: { to: { isViewer: true } } }
707
+ ]
708
+ }
709
+ value: PENDING
710
+ }
711
+ ]
712
+ )
713
+ lastSeenAt: DateTime
714
+ @authorization(mask: [{ unless: { node: { isViewer: true } } }])
715
+ ```
716
+
717
+ A row failing `unless` reads the field as `value` (`null` when left
718
+ out, which a non-null field refuses; `value` is type-checked). Filters
719
+ compare the value the reader sees, so a mask never leaks through a
720
+ filter, and such a filter cannot use the field's index. Rules see the
721
+ stored value. Sorting, grouping and aggregating by a masked field are
722
+ refused unless the claims settle the mask (the brief proposed sorting by
723
+ the masked value; refusing keeps order from hinting at hidden values).
724
+ Masks sit on scalar fields of `@node` types other than the `@key`.
725
+
726
+ - **Named rules.** Define a rule once and use it as `{ rule: "name" }`
727
+ wherever a rule part may stand:
728
+
729
+ ```graphql
730
+ extend schema
731
+ @authorizationRules(
732
+ rules: [{ name: "admin", where: { jwt: { roles: { includes: "admin" } } } }]
733
+ )
734
+
735
+ type Trip
736
+ @node
737
+ @authorizationRule(
738
+ name: "member"
739
+ where: {
740
+ OR: [
741
+ { node: { members: { some: { isViewer: true } } } }
742
+ { node: { owner: { isViewer: true } } }
743
+ ]
744
+ }
745
+ )
746
+ @authorization(
747
+ filter: [{ where: { OR: [{ rule: "member" }, { rule: "admin" }] } }]
748
+ ) { ... }
749
+
750
+ type PackingItem
751
+ @node
752
+ @authorization(
753
+ filter: [
754
+ {
755
+ where: {
756
+ OR: [{ node: { trip: { rule: "member" } } }, { rule: "admin" }]
757
+ }
758
+ }
759
+ ]
760
+ ) { ... }
761
+ ```
762
+
763
+ Schema rules (`@authorizationRules`) test claims only. A type's rules
764
+ (`@authorizationRule`, repeatable) are found first, then the schema's;
765
+ inside a node filter (`trip: { rule: "member" }`) the name is a rule of
766
+ that node's type, and that rule must test `node` only. Rules are inlined
767
+ at startup, so they compile to exactly the hand-written statement. An
768
+ unknown name, a type rule shadowing a schema rule, and a cycle (named
769
+ with its chain) are model errors. `bypass` and `mutations` in
770
+ `@authorizationDefaults` may name rules too.
771
+
772
+ - **Schema-wide defaults.**
773
+
774
+ ```graphql
775
+ extend schema
776
+ @authorizationDefaults(
777
+ bypass: { jwt: { roles: { includes: "admin" } } }
778
+ mutations: { jwt: { roles: { includes: "editor" } } }
779
+ )
780
+ ```
781
+
782
+ A request passing `bypass` skips every filter and validate rule, field
783
+ and relationship rules included; `@authentication` still applies.
784
+ `bypass` tests claims only, so it is decided before the statement is
785
+ built: an admin's statement carries no rule predicate. A type keeps its
786
+ rules for everyone with `@authorization(bypass: false)`. `mutations` is
787
+ the write rule (`CREATE`, `UPDATE`, `DELETE`) of every `@mutation` type
788
+ that declares no rule for those operations; a type's own rules replace
789
+ it, never merge with it. `check()` fails on a `@mutation` type whose
790
+ writes nothing guards, unless it says `@authorization(public: [...])`.
648
791
 
649
792
  Relationship and `@cypher` fields take field-level `@authorization`
650
793
  with READ validate rules: a row failing the rule reads the field as
@@ -659,6 +802,66 @@ relationship and `UPDATE` for one that already exists; `update: { edge }`
659
802
  checks `UPDATE`. A request the READ rules refuse reads the property as
660
803
  `FORBIDDEN` and cannot filter, sort or aggregate by it.
661
804
 
805
+ ### Rules on relationships
806
+
807
+ A relationship field also takes validate rules for the relationship's
808
+ own operations, where both ends are known:
809
+
810
+ ```graphql
811
+ type Trip @node @mutation {
812
+ key: String! @key
813
+ owner: Person! @relationship(type: "OWNS", direction: IN)
814
+ members: [Person!]!
815
+ @relationship(type: "MEMBER", direction: IN, properties: "TripInvite")
816
+ @authorization(
817
+ validate: [
818
+ # the owner invites
819
+ {
820
+ operations: [CONNECT]
821
+ where: { source: { owner: { isViewer: true } } }
822
+ }
823
+ # the owner removes anyone; a member removes only themselves
824
+ {
825
+ operations: [DISCONNECT]
826
+ where: {
827
+ OR: [
828
+ { source: { owner: { isViewer: true } } }
829
+ { target: { isViewer: true } }
830
+ ]
831
+ }
832
+ }
833
+ # only the member answers their own invitation, and reads its marker
834
+ {
835
+ operations: [UPDATE_EDGE, READ_EDGE]
836
+ where: { target: { isViewer: true } }
837
+ }
838
+ ]
839
+ )
840
+ }
841
+ ```
842
+
843
+ - `source` is the node declaring the field, `target` the related node and
844
+ `edge` the relationship's properties; `jwt`, `viewer`, `AND`, `OR` and
845
+ `NOT` work as in any rule. A `node` part is a model error here, and so
846
+ is a relationship operation on a type or scalar field, or in the same
847
+ rule as `READ`.
848
+ - `CONNECT` covers a new relationship (connect, nested create);
849
+ `DISCONNECT` a disconnect, including replacing a single relationship;
850
+ `UPDATE_EDGE` `update: [{ edge }]` and a re-connect that sets
851
+ properties; `READ_EDGE` reading the properties. `CONNECT` and
852
+ `UPDATE_EDGE` are checked on the relationship after the write and
853
+ `DISCONNECT` before it, in the mutation's transaction: a failure is
854
+ `FORBIDDEN` and rolls the mutation back.
855
+ - The rules hold whichever side the write comes from: a connect through
856
+ `Person.trips` (the same relationship type, the other direction)
857
+ answers to `Trip.members`' rules, with source and target as declared on
858
+ `Trip.members`. If both fields carry rules, both apply.
859
+ - A relationship failing `READ_EDGE` reads its properties as `FORBIDDEN`.
860
+ Unless the claims alone settle the rule, nothing may filter, sort or
861
+ aggregate by that relationship's properties.
862
+ - Deleting a node removes its relationships without `DISCONNECT` rules:
863
+ who may delete the node is the type's `DELETE` rule.
864
+
662
865
  ```graphql
663
866
  type Membership @relationshipProperties {
664
867
  role: String
@@ -859,9 +1062,25 @@ files, and exits non-zero on any finding. It is the CI gate. Options:
859
1062
  - `--database dir [--name app]`: check an existing database as it is, and
860
1063
  report indexes it has that the API does not use.
861
1064
 
862
- It also prints lint notes that do not fail the run: mutations without
863
- rules, `CASE_INSENSITIVE` and `IS_NULL` filters (no index applies), and
864
- list relationships without `@cardinality` or statistics.
1065
+ `access` prints who may do what: for every type and guarded field, each
1066
+ operation as each kind of caller (anonymous, authenticated, and each role
1067
+ the rules test, such as `roles:admin`), with the verdict (`allowed`,
1068
+ `filtered`, `validated`, `masked`, `denied`, `unauthenticated`) and the
1069
+ rules that decide it. `lora.accessMatrix()` returns the same list; its
1070
+ order is stable, so a snapshot in CI turns access changes into diffs.
1071
+
1072
+ `check` lints authorization too: a filter rule every signed-in caller
1073
+ passes, a rule whose default `requireAuthentication` refuses anonymous
1074
+ callers a branch that needs no claims, and field rules the schema's
1075
+ bypass skips.
1076
+
1077
+ A `@mutation` type with a generated write no rule guards (no
1078
+ `@authentication` or `@authorization` rule for it, and no
1079
+ `@authorizationDefaults(mutations:)` default) fails the run: declare it
1080
+ with `@authorization(public: [CREATE, ...])` when every caller may make
1081
+ it. It also prints lint notes that do not fail the run: `CASE_INSENSITIVE`
1082
+ and `IS_NULL` filters (no index applies), and list relationships without
1083
+ `@cardinality` or statistics.
865
1084
 
866
1085
  `compile` validates persisted operations (`.graphql` files, one entry per
867
1086
  operation, or a JSON map of id to source) into `manifest.json`, which
@@ -912,12 +1131,37 @@ asserted, runs the seed, and records every statement in `t.statements`.
912
1131
  the query uses the index access it was compiled for (`rowBudget` too).
913
1132
  Both need `@loradb/lora-node` and work with any test runner.
914
1133
 
1134
+ `expectAccess` checks who may do what, against the database:
1135
+
1136
+ ```ts
1137
+ await expectAccess(t, {
1138
+ as: { sub: "lou", roles: [] },
1139
+ allowed: [
1140
+ "read Trip lou:tomorrowland",
1141
+ 'update Person lou {"name": "Lou"}',
1142
+ "connect Trip.members lou:tomorrowland → f1",
1143
+ ],
1144
+ denied: ['update-edge Trip.members lou:tomorrowland → f1 {"rsvp": "GOING"}'],
1145
+ });
1146
+ ```
1147
+
1148
+ Entries are `read`, `create`, `update` or `delete` a `Type key`, and
1149
+ `connect`, `disconnect` or `update-edge` a `Type.field key → key`, each
1150
+ with optional input as JSON. Every entry runs as the caller in a
1151
+ transaction that is rolled back, so probes leave no trace. Denied means
1152
+ `FORBIDDEN`, `UNAUTHENTICATED`, `NOT_FOUND` or (for `read`) not visible;
1153
+ any other error is a mismatch either way. All mismatches are reported at
1154
+ once.
1155
+
915
1156
  ## Drivers, limits and errors
916
1157
 
917
1158
  `loraDriver(db)` adapts a `Database` from `@loradb/lora-node` or
918
- `@loradb/lora-wasm`. Single-statement reads stream; multi-statement reads
919
- run in one read-only transaction; mutations need interactive transactions,
920
- which only the Node binding has, so the WASM binding serves reads.
1159
+ `@loradb/lora-wasm`. A read of one statement runs with `execute()` (with
1160
+ lora-node, on a libuv worker, not the JavaScript thread), except a lookup
1161
+ by `@key` that selects only stored fields, which streams its single row
1162
+ synchronously; a read of several statements runs in one read-only
1163
+ transaction. Mutations need interactive transactions, which only the Node
1164
+ binding has, so the WASM binding serves reads.
921
1165
  `explain()`, and so plan checks, need the Node binding too.
922
1166
 
923
1167
  | Option | Default | Meaning |
@@ -944,8 +1188,10 @@ which only the Node binding has, so the WASM binding serves reads.
944
1188
  Errors carry `extensions.code`: `BAD_USER_INPUT`, `INVALID_CURSOR`,
945
1189
  `LIMIT_EXCEEDED`, `COST_EXCEEDED`, `UNAUTHENTICATED`, `FORBIDDEN`,
946
1190
  `NOT_FOUND`, `CONSTRAINT_VIOLATION` (with `type` and `field`),
947
- `DATABASE_ERROR` (with an `id`, also given to `onError`) and
948
- `PERSISTED_QUERY_ONLY` (`execute()` or `subscribe()` got a document under `persistedOnly`). An invalid SDL
1191
+ `DATABASE_ERROR` (with an `id`, also given to `onError`),
1192
+ `PERSISTED_QUERY_ONLY` (`execute()` or `subscribe()` got a document under
1193
+ `persistedOnly`) and `WRONG_OPERATION_TYPE` (`execute()` got a
1194
+ subscription, or `subscribe()` a query or mutation). An invalid SDL
949
1195
  throws one `ModelError` listing every problem, each located by type and
950
1196
  field.
951
1197
 
@@ -956,13 +1202,14 @@ the document before that. `execute()`, `subscribe()` and `persist()` apply them,
956
1202
  `lora.validationRules()` / `lora.envelopPlugin()` bring them to any other
957
1203
  server (GraphQL Yoga takes the plugin as is):
958
1204
 
959
- | Guard | Default | Limit |
960
- | --------------- | ---------- | ---------------------------------------- |
961
- | `maxDepth` | 12 | Field nesting, through fragments |
962
- | `maxAliases` | 30 | Aliased fields per document |
963
- | `maxRootFields` | 20 | Root fields per operation |
964
- | `maxTokens` | 5000 | Lexer tokens per document, while parsing |
965
- | `introspection` | production | Off when `NODE_ENV` is `production` |
1205
+ | Guard | Default | Limit |
1206
+ | ----------------------- | ---------- | ---------------------------------------- |
1207
+ | `maxDepth` | 12 | Field nesting, through fragments |
1208
+ | `maxIntrospectionDepth` | 20 | Nesting under `__schema` / `__type` |
1209
+ | `maxAliases` | 30 | Aliased fields per document |
1210
+ | `maxRootFields` | 20 | Root fields per operation |
1211
+ | `maxTokens` | 5000 | Lexer tokens per document, while parsing |
1212
+ | `introspection` | production | Off when `NODE_ENV` is `production` |
966
1213
 
967
1214
  With `NODE_ENV=production`, database errors are masked and introspection
968
1215
  is off unless configured otherwise. See
@@ -1017,7 +1264,7 @@ Measured on LoraDB 0.15 over 20 000 festivals and 100 000 relationships
1017
1264
 
1018
1265
  | Rule | Why |
1019
1266
  | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------ |
1020
- | Relationship filters as `size([… \| 1])`; aggregates of related values with `reduce` | No `OPTIONAL MATCH`; `EXISTS { }` does not parse; aggregates nest safely |
1267
+ | Relationship filters as `size([… \| 1])`; aggregates of related values with `reduce` | No `OPTIONAL MATCH`; aggregates nest safely |
1021
1268
  | `CALL { }` for nested lists, nested connections, `@cypher` and interface members | The only way to sort, limit and aggregate per parent |
1022
1269
  | Ordered by an always-present string: `WHERE s >= ""` | The planner walks the index in order and stops at the limit: 0.03 ms instead of 7 ms |
1023
1270
  | Mutation statements seek the key in their own `MATCH`, then expand | The plan no longer depends on the optimizer finding the seek: 0.06 ms per connect |
@@ -1029,22 +1276,22 @@ Measured on LoraDB 0.15 over 20 000 festivals and 100 000 relationships
1029
1276
 
1030
1277
  ## Coming from @neo4j/graphql
1031
1278
 
1032
- | @neo4j/graphql | lora-graphql |
1033
- | ----------------------------------------------------------- | ---------------------------------------------------------------- |
1034
- | Every type gets every operation | Reads by default; mutations and subscriptions opt in |
1035
- | Every field filterable by every operator | `@filterable(byValue:)`, checked against the type |
1036
- | Offset cursors (`arrayconnection:N`) | Keyset cursors tagged with their sort (signed with a secret) |
1037
- | `update`/`delete` with an optional `where` | By `@key`, or bulk with a required, bounded `where` |
1038
- | `connect: { where }`, silently a no-op when nothing matches | Connect by key; a missing target is `NOT_FOUND` |
1039
- | Single relationships not enforced; required ones refused | Enforced from both sides; required ones supported |
1040
- | `{ viewers: { add: 1 } }` | `adjust: { viewers: { add: 1 } }` |
1041
- | `@id`, `@populatedBy`, `@timestamp` | `@key(generate: true)`, `@populatedBy`, `@timestamp` |
1042
- | `@authorization` rules evaluated in Cypher | Claim checks folded in JavaScript; node rules compiled |
1043
- | You pick indexes | Inferred from the API, and verified with `explain()` |
1044
- | Unbounded lists; complexity left to you | Every list bounded; a cost limit per operation |
1045
- | Subscriptions over CDC | `@subscription`, from library-made writes |
1046
- | Interfaces and unions with `UNION` | Per-member subqueries merged by sort, each using its own indexes |
1047
- | Federation | Not supported |
1279
+ | @neo4j/graphql | lora-graphql |
1280
+ | ----------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
1281
+ | Every type gets every operation | Reads by default; mutations and subscriptions opt in |
1282
+ | Every field filterable by every operator | `@filterable(byValue:)`, checked against the type |
1283
+ | Offset cursors (`arrayconnection:N`) | Keyset cursors tagged with their sort (signed with a secret) |
1284
+ | `update`/`delete` with an optional `where` | By `@key`, or bulk with a required, bounded `where` |
1285
+ | `connect: { where }`, silently a no-op when nothing matches | Connect by key; a missing target is `NOT_FOUND` |
1286
+ | Single relationships not enforced; required ones refused | Enforced from both sides; required ones supported |
1287
+ | `{ viewers: { add: 1 } }` | `adjust: { viewers: { add: 1 } }` |
1288
+ | `@id`, `@populatedBy`, `@timestamp` | `@key(generate: true)`, `@populatedBy`, `@timestamp` |
1289
+ | `@authorization` rules evaluated in Cypher | Claim checks folded in JavaScript; node rules compiled |
1290
+ | You pick indexes | Inferred from the API, and verified with `explain()` |
1291
+ | Unbounded lists; complexity left to you | Every list bounded; a cost limit per operation |
1292
+ | Subscriptions over CDC | `@subscription`, from library-made writes or, with `changeFeed: true`, every committed write |
1293
+ | Interfaces and unions with `UNION` | Per-member subqueries merged by sort, each using its own indexes |
1294
+ | Federation | Not supported |
1048
1295
 
1049
1296
  ## LoraDB behaviours this works around
1050
1297
 
@@ -1062,7 +1309,7 @@ before a following `SET`, list comparison (`[a, b] > $list`) and
1062
1309
  | An aggregate nested in a call (`head(collect(x))`, `collect(x)[0..2]`) is not aggregated | `WITH collect(x) AS c RETURN head(c)`; `reduce` for per-parent aggregates |
1063
1310
  | `max`, `sum` and `avg` over durations are wrong | Duration aggregates are folded with `reduce` |
1064
1311
  | Integer division returns a float; negative list slices (`l[..-1]`) return `[]` | `toInteger(a / b)` for Int fields; `l[..size(l) - n]` |
1065
- | `COUNT { … RETURN DISTINCT x }`, `EXISTS { }` and `UNION` inside `CALL` do not parse | `reduce` for distinct counts; comprehensions; per-member subqueries |
1312
+ | `COUNT { … RETURN DISTINCT x }` and `UNION` inside `CALL` do not parse | `reduce` for distinct counts; per-member subqueries |
1066
1313
 
1067
1314
  ## Development
1068
1315
 
@@ -0,0 +1,32 @@
1
+ import { GraphQLSchema } from 'graphql';
2
+ import { AuthOperation, GraphModel, ModelWarning } from '../model/types.js';
3
+ export type AccessVerdict =
4
+ /** Every row, for this caller. */
5
+ "allowed"
6
+ /** Only rows a filter rule admits; others are invisible. */
7
+ | "filtered"
8
+ /** Checked per row; a failing row is FORBIDDEN. */
9
+ | "validated"
10
+ /** Rows failing a mask read a substitute value. */
11
+ | "masked"
12
+ /** Never, for this caller. */
13
+ | "denied"
14
+ /** Needs a token this caller does not have. */
15
+ | "unauthenticated";
16
+ export interface AccessEntry {
17
+ type: string;
18
+ /** The field, for field-level and relationship rules. */
19
+ field?: string;
20
+ operation: AuthOperation;
21
+ principal: string;
22
+ verdict: AccessVerdict;
23
+ /** What decides it: `filter[0]`, `validate[1]`, `@authentication`, `bypass`, … */
24
+ by: string[];
25
+ }
26
+ /** The access matrix of `model`, in a stable order. */
27
+ export declare function accessMatrix(model: GraphModel, schema: GraphQLSchema): AccessEntry[];
28
+ /**
29
+ * Authorization lints for `check`: valid rules that are probably not what
30
+ * was meant.
31
+ */
32
+ export declare function accessLints(model: GraphModel, schema: GraphQLSchema): ModelWarning[];
@@ -4,3 +4,12 @@ export interface LintOptions {
4
4
  statistics?: boolean;
5
5
  }
6
6
  export declare function lintModel(model: GraphModel, options?: LintOptions): ModelWarning[];
7
+ /**
8
+ * `@mutation` types with a generated write no rule guards: any caller
9
+ * that reaches the API can make it. An error in `check()`, unless the type
10
+ * declares the operation open with `@authorization(public: [...])`. A
11
+ * write is guarded by `@authentication` for it, a filter or validate rule
12
+ * for it (a `@authorizationDefaults(mutations:)` default included), or a
13
+ * schema bypass alone does not count: it only lets some callers skip rules.
14
+ */
15
+ export declare function unguardedMutations(model: GraphModel): ModelWarning[];