@loradb/lora-graphql 0.19.0 → 0.20.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.
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); with every property hidden, the edge has no `properties` |
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,11 +618,34 @@ 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. It works through relationships and union members
638
+ (`author: { Person: { isViewer: true } }`); in a filter over an
639
+ interface it is a model error, since there is no single type to expand
640
+ against. In rule strings, `"${viewer.key}"` (any scalar field of the
641
+ viewer type) is the caller's own value: the claim itself for the field
642
+ `@viewer` maps to, otherwise read with one seek by the claim. Use it
643
+ for keys built from the caller's key while the claim is an opaque
644
+ subject: `key: { endsWith: ":${viewer.key}" }`, `key: { eq:
645
+ "${viewer.key}" }`. It stands for one value, not inside a list.
646
+ - A whole string starting with `$` must be a placeholder (`$jwt.<claim>`,
647
+ `$context.<path>`): a misspelt one (`"$jtw.sub"`) is a model error, not
648
+ a literal. Write a literal `$…` as `"\\$…"`.
603
649
  - Inside a longer string, write `${jwt.path}` or `${context.path}`:
604
650
  `key: { startsWith: "${jwt.sub}:" }` confines a user to keys that begin
605
651
  with their `sub` and `:`. The claim must be a string, number or boolean;
@@ -608,7 +654,11 @@ type Post
608
654
  [docs/design/graphql-threat-model.md](../../docs/design/graphql-threat-model.md).
609
655
  - **Claim tests run in JavaScript at compile time**, so an admin's
610
656
  statement carries no filter at all. Statements stay specialised and
611
- index-friendly.
657
+ index-friendly. A write whose rules the claims alone refuse is refused
658
+ before any statement runs.
659
+ - A create under CREATE rules answers the same whether a `@unique` value
660
+ (or the key) is taken by a node the caller may not create: what a free
661
+ value gets. A create that would succeed answers `CONSTRAINT_VIOLATION`.
612
662
  - **A test that needs a claim or context value the request lacks is
613
663
  unknown**: false where it stands, and a `NOT` over it is false too, so
614
664
  negation can never turn a missing claim into a grant. Node conditions
@@ -641,10 +691,121 @@ type Post
641
691
  them as a signed-in caller.
642
692
  - `@authentication(operations:, jwt:)` covers `READ`, `CREATE`, `UPDATE`,
643
693
  `DELETE`, `CREATE_RELATIONSHIP`, `DELETE_RELATIONSHIP` and `SUBSCRIBE`,
644
- and may require claims.
694
+ and may require claims. On a relationship field, `CREATE` / `UPDATE`
695
+ cover setting it in a create or update input, `CREATE_RELATIONSHIP` a
696
+ `connect` or nested `create` through it, and `DELETE_RELATIONSHIP` a
697
+ `disconnect` or nested `delete`.
645
698
  - Rules are checked against the model at startup: an unknown field,
646
699
  operator or (with `@jwt`) claim, or a test that is empty or null, is an
647
700
  error, not an open door.
701
+ - **Owner-scoped keys.** `key: String! @key(scope: VIEWER, separator: ":")`
702
+ keeps created keys in the caller's key space: a create (nested creates
703
+ and upsert-creates included) must use a key that starts with the
704
+ `@viewer` claim and the separator, and is longer than that prefix. It
705
+ is checked before any statement runs, so `FORBIDDEN` for `bob:x` reads
706
+ the same whether `bob:x` exists or not. A claim containing the
707
+ separator is refused, so user `a` cannot write into the key space of
708
+ user `a:b`. It needs `@viewer`; the schema's bypass skips it. When
709
+ `@viewer` maps to a non-key field (an opaque subject), the key space is
710
+ the caller's node's `@key`, looked up once by the claim before anything
711
+ is written; a token naming no node creates nothing. Keys shared by two
712
+ owners (`f1:lou`) stay hand-written rules, with `${viewer.key}`.
713
+ - **Masks.** A field-level READ rule fails the row; a mask substitutes a
714
+ value instead:
715
+
716
+ ```graphql
717
+ status: ConnectionRequestStatus!
718
+ @authorization(
719
+ mask: [
720
+ {
721
+ unless: {
722
+ OR: [
723
+ { node: { status: { in: [PENDING, ACCEPTED] } } }
724
+ { node: { to: { isViewer: true } } }
725
+ ]
726
+ }
727
+ value: PENDING
728
+ }
729
+ ]
730
+ )
731
+ lastSeenAt: DateTime
732
+ @authorization(mask: [{ unless: { node: { isViewer: true } } }])
733
+ ```
734
+
735
+ A row failing `unless` reads the field as `value` (`null` when left
736
+ out, which a non-null field refuses; `value` is type-checked). Filters
737
+ compare the value the reader sees, so a mask never leaks through a
738
+ filter, and such a filter cannot use the field's index. Rules see the
739
+ stored value. Sorting, grouping and aggregating by a masked field are
740
+ refused unless the claims settle the mask (the brief proposed sorting by
741
+ the masked value; refusing keeps order from hinting at hidden values).
742
+ Masks sit on scalar fields of `@node` types other than the `@key`.
743
+
744
+ - **Named rules.** Define a rule once and use it as `{ rule: "name" }`
745
+ wherever a rule part may stand:
746
+
747
+ ```graphql
748
+ extend schema
749
+ @authorizationRules(
750
+ rules: [{ name: "admin", where: { jwt: { roles: { includes: "admin" } } } }]
751
+ )
752
+
753
+ type Trip
754
+ @node
755
+ @authorizationRule(
756
+ name: "member"
757
+ where: {
758
+ OR: [
759
+ { node: { members: { some: { isViewer: true } } } }
760
+ { node: { owner: { isViewer: true } } }
761
+ ]
762
+ }
763
+ )
764
+ @authorization(
765
+ filter: [{ where: { OR: [{ rule: "member" }, { rule: "admin" }] } }]
766
+ ) { ... }
767
+
768
+ type PackingItem
769
+ @node
770
+ @authorization(
771
+ filter: [
772
+ {
773
+ where: {
774
+ OR: [{ node: { trip: { rule: "member" } } }, { rule: "admin" }]
775
+ }
776
+ }
777
+ ]
778
+ ) { ... }
779
+ ```
780
+
781
+ Schema rules (`@authorizationRules`) test claims only. A type's rules
782
+ (`@authorizationRule`, repeatable) are found first, then the schema's;
783
+ inside a node filter (`trip: { rule: "member" }`) the name is a rule of
784
+ that node's type, and that rule must test `node` only. Rules are inlined
785
+ at startup, so they compile to exactly the hand-written statement. An
786
+ unknown name, a type rule shadowing a schema rule, and a cycle (named
787
+ with its chain) are model errors. `bypass` and `mutations` in
788
+ `@authorizationDefaults` may name rules too.
789
+
790
+ - **Schema-wide defaults.**
791
+
792
+ ```graphql
793
+ extend schema
794
+ @authorizationDefaults(
795
+ bypass: { jwt: { roles: { includes: "admin" } } }
796
+ mutations: { jwt: { roles: { includes: "editor" } } }
797
+ )
798
+ ```
799
+
800
+ A request passing `bypass` skips every filter and validate rule, field
801
+ and relationship rules included; `@authentication` still applies.
802
+ `bypass` tests claims only, so it is decided before the statement is
803
+ built: an admin's statement carries no rule predicate. A type keeps its
804
+ rules for everyone with `@authorization(bypass: false)`. `mutations` is
805
+ the write rule (`CREATE`, `UPDATE`, `DELETE`) of every `@mutation` type
806
+ that declares no rule for those operations; a type's own rules replace
807
+ it, never merge with it. `check()` fails on a `@mutation` type whose
808
+ writes nothing guards, unless it says `@authorization(public: [...])`.
648
809
 
649
810
  Relationship and `@cypher` fields take field-level `@authorization`
650
811
  with READ validate rules: a row failing the rule reads the field as
@@ -659,6 +820,66 @@ relationship and `UPDATE` for one that already exists; `update: { edge }`
659
820
  checks `UPDATE`. A request the READ rules refuse reads the property as
660
821
  `FORBIDDEN` and cannot filter, sort or aggregate by it.
661
822
 
823
+ ### Rules on relationships
824
+
825
+ A relationship field also takes validate rules for the relationship's
826
+ own operations, where both ends are known:
827
+
828
+ ```graphql
829
+ type Trip @node @mutation {
830
+ key: String! @key
831
+ owner: Person! @relationship(type: "OWNS", direction: IN)
832
+ members: [Person!]!
833
+ @relationship(type: "MEMBER", direction: IN, properties: "TripInvite")
834
+ @authorization(
835
+ validate: [
836
+ # the owner invites
837
+ {
838
+ operations: [CONNECT]
839
+ where: { source: { owner: { isViewer: true } } }
840
+ }
841
+ # the owner removes anyone; a member removes only themselves
842
+ {
843
+ operations: [DISCONNECT]
844
+ where: {
845
+ OR: [
846
+ { source: { owner: { isViewer: true } } }
847
+ { target: { isViewer: true } }
848
+ ]
849
+ }
850
+ }
851
+ # only the member answers their own invitation, and reads its marker
852
+ {
853
+ operations: [UPDATE_EDGE, READ_EDGE]
854
+ where: { target: { isViewer: true } }
855
+ }
856
+ ]
857
+ )
858
+ }
859
+ ```
860
+
861
+ - `source` is the node declaring the field, `target` the related node and
862
+ `edge` the relationship's properties; `jwt`, `viewer`, `AND`, `OR` and
863
+ `NOT` work as in any rule. A `node` part is a model error here, and so
864
+ is a relationship operation on a type or scalar field, or in the same
865
+ rule as `READ`.
866
+ - `CONNECT` covers a new relationship (connect, nested create);
867
+ `DISCONNECT` a disconnect, including replacing a single relationship;
868
+ `UPDATE_EDGE` `update: [{ edge }]` and a re-connect that sets
869
+ properties; `READ_EDGE` reading the properties. `CONNECT` and
870
+ `UPDATE_EDGE` are checked on the relationship after the write and
871
+ `DISCONNECT` before it, in the mutation's transaction: a failure is
872
+ `FORBIDDEN` and rolls the mutation back.
873
+ - The rules hold whichever side the write comes from: a connect through
874
+ `Person.trips` (the same relationship type, the other direction)
875
+ answers to `Trip.members`' rules, with source and target as declared on
876
+ `Trip.members`. If both fields carry rules, both apply.
877
+ - A relationship failing `READ_EDGE` reads its properties as `FORBIDDEN`.
878
+ Unless the claims alone settle the rule, nothing may filter, sort or
879
+ aggregate by that relationship's properties.
880
+ - Deleting a node removes its relationships without `DISCONNECT` rules:
881
+ who may delete the node is the type's `DELETE` rule.
882
+
662
883
  ```graphql
663
884
  type Membership @relationshipProperties {
664
885
  role: String
@@ -689,8 +910,12 @@ index the API needs, with the reason for each:
689
910
  | `WITHIN_BBOX`, `DISTANCE` | POINT index |
690
911
  | `@fulltext`, `@vector` | FULLTEXT and VECTOR indexes, by name |
691
912
 
692
- `assertSchema()` reports what the database lacks;
693
- `assertSchema({ create: true })` creates it, idempotently.
913
+ `assertSchema()` reports what the database lacks (`missing`), and a
914
+ full-text or vector index present under its name but defined differently
915
+ (labels, fields, analyzer: `mismatched`), since search would keep using the
916
+ old definition. `assertSchema({ create: true })` creates what is missing
917
+ and drops and re-creates what is mismatched (`created`, `recreated`),
918
+ idempotently. `check()` fails on either.
694
919
 
695
920
  **S2: plans are checked.** Every compiled statement records the access path
696
921
  it was written for. `lora.explain(query, variables)` plans each statement and
@@ -859,9 +1084,25 @@ files, and exits non-zero on any finding. It is the CI gate. Options:
859
1084
  - `--database dir [--name app]`: check an existing database as it is, and
860
1085
  report indexes it has that the API does not use.
861
1086
 
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.
1087
+ `access` prints who may do what: for every type and guarded field, each
1088
+ operation as each kind of caller (anonymous, authenticated, and each role
1089
+ the rules test, such as `roles:admin`), with the verdict (`allowed`,
1090
+ `filtered`, `validated`, `masked`, `denied`, `unauthenticated`) and the
1091
+ rules that decide it. `lora.accessMatrix()` returns the same list; its
1092
+ order is stable, so a snapshot in CI turns access changes into diffs.
1093
+
1094
+ `check` lints authorization too: a filter rule every signed-in caller
1095
+ passes, a rule whose default `requireAuthentication` refuses anonymous
1096
+ callers a branch that needs no claims, and field rules the schema's
1097
+ bypass skips.
1098
+
1099
+ A `@mutation` type with a generated write no rule guards (no
1100
+ `@authentication` or `@authorization` rule for it, and no
1101
+ `@authorizationDefaults(mutations:)` default) fails the run: declare it
1102
+ with `@authorization(public: [CREATE, ...])` when every caller may make
1103
+ it. It also prints lint notes that do not fail the run: `CASE_INSENSITIVE`
1104
+ and `IS_NULL` filters (no index applies), and list relationships without
1105
+ `@cardinality` or statistics.
865
1106
 
866
1107
  `compile` validates persisted operations (`.graphql` files, one entry per
867
1108
  operation, or a JSON map of id to source) into `manifest.json`, which
@@ -912,6 +1153,28 @@ asserted, runs the seed, and records every statement in `t.statements`.
912
1153
  the query uses the index access it was compiled for (`rowBudget` too).
913
1154
  Both need `@loradb/lora-node` and work with any test runner.
914
1155
 
1156
+ `expectAccess` checks who may do what, against the database:
1157
+
1158
+ ```ts
1159
+ await expectAccess(t, {
1160
+ as: { sub: "lou", roles: [] },
1161
+ allowed: [
1162
+ "read Trip lou:tomorrowland",
1163
+ 'update Person lou {"name": "Lou"}',
1164
+ "connect Trip.members lou:tomorrowland → f1",
1165
+ ],
1166
+ denied: ['update-edge Trip.members lou:tomorrowland → f1 {"rsvp": "GOING"}'],
1167
+ });
1168
+ ```
1169
+
1170
+ Entries are `read`, `create`, `update` or `delete` a `Type key`, and
1171
+ `connect`, `disconnect` or `update-edge` a `Type.field key → key`, each
1172
+ with optional input as JSON. Every entry runs as the caller in a
1173
+ transaction that is rolled back, so probes leave no trace. Denied means
1174
+ `FORBIDDEN`, `UNAUTHENTICATED`, `NOT_FOUND` or (for `read`) not visible;
1175
+ any other error is a mismatch either way. All mismatches are reported at
1176
+ once.
1177
+
915
1178
  ## Drivers, limits and errors
916
1179
 
917
1180
  `loraDriver(db)` adapts a `Database` from `@loradb/lora-node` or
@@ -961,14 +1224,14 @@ the document before that. `execute()`, `subscribe()` and `persist()` apply them,
961
1224
  `lora.validationRules()` / `lora.envelopPlugin()` bring them to any other
962
1225
  server (GraphQL Yoga takes the plugin as is):
963
1226
 
964
- | Guard | Default | Limit |
965
- | --------------- | ---------- | ---------------------------------------- |
966
- | `maxDepth` | 12 | Field nesting, through fragments |
967
- | `maxIntrospectionDepth` | 20 | Nesting under `__schema` / `__type` |
968
- | `maxAliases` | 30 | Aliased fields per document |
969
- | `maxRootFields` | 20 | Root fields per operation |
970
- | `maxTokens` | 5000 | Lexer tokens per document, while parsing |
971
- | `introspection` | production | Off when `NODE_ENV` is `production` |
1227
+ | Guard | Default | Limit |
1228
+ | ----------------------- | ---------- | ---------------------------------------- |
1229
+ | `maxDepth` | 12 | Field nesting, through fragments |
1230
+ | `maxIntrospectionDepth` | 20 | Nesting under `__schema` / `__type` |
1231
+ | `maxAliases` | 30 | Aliased fields per document |
1232
+ | `maxRootFields` | 20 | Root fields per operation |
1233
+ | `maxTokens` | 5000 | Lexer tokens per document, while parsing |
1234
+ | `introspection` | production | Off when `NODE_ENV` is `production` |
972
1235
 
973
1236
  With `NODE_ENV=production`, database errors are masked and introspection
974
1237
  is off unless configured otherwise. See
@@ -1035,22 +1298,22 @@ Measured on LoraDB 0.15 over 20 000 festivals and 100 000 relationships
1035
1298
 
1036
1299
  ## Coming from @neo4j/graphql
1037
1300
 
1038
- | @neo4j/graphql | lora-graphql |
1039
- | ----------------------------------------------------------- | ---------------------------------------------------------------- |
1040
- | Every type gets every operation | Reads by default; mutations and subscriptions opt in |
1041
- | Every field filterable by every operator | `@filterable(byValue:)`, checked against the type |
1042
- | Offset cursors (`arrayconnection:N`) | Keyset cursors tagged with their sort (signed with a secret) |
1043
- | `update`/`delete` with an optional `where` | By `@key`, or bulk with a required, bounded `where` |
1044
- | `connect: { where }`, silently a no-op when nothing matches | Connect by key; a missing target is `NOT_FOUND` |
1045
- | Single relationships not enforced; required ones refused | Enforced from both sides; required ones supported |
1046
- | `{ viewers: { add: 1 } }` | `adjust: { viewers: { add: 1 } }` |
1047
- | `@id`, `@populatedBy`, `@timestamp` | `@key(generate: true)`, `@populatedBy`, `@timestamp` |
1048
- | `@authorization` rules evaluated in Cypher | Claim checks folded in JavaScript; node rules compiled |
1049
- | You pick indexes | Inferred from the API, and verified with `explain()` |
1050
- | Unbounded lists; complexity left to you | Every list bounded; a cost limit per operation |
1301
+ | @neo4j/graphql | lora-graphql |
1302
+ | ----------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
1303
+ | Every type gets every operation | Reads by default; mutations and subscriptions opt in |
1304
+ | Every field filterable by every operator | `@filterable(byValue:)`, checked against the type |
1305
+ | Offset cursors (`arrayconnection:N`) | Keyset cursors tagged with their sort (signed with a secret) |
1306
+ | `update`/`delete` with an optional `where` | By `@key`, or bulk with a required, bounded `where` |
1307
+ | `connect: { where }`, silently a no-op when nothing matches | Connect by key; a missing target is `NOT_FOUND` |
1308
+ | Single relationships not enforced; required ones refused | Enforced from both sides; required ones supported |
1309
+ | `{ viewers: { add: 1 } }` | `adjust: { viewers: { add: 1 } }` |
1310
+ | `@id`, `@populatedBy`, `@timestamp` | `@key(generate: true)`, `@populatedBy`, `@timestamp` |
1311
+ | `@authorization` rules evaluated in Cypher | Claim checks folded in JavaScript; node rules compiled |
1312
+ | You pick indexes | Inferred from the API, and verified with `explain()` |
1313
+ | Unbounded lists; complexity left to you | Every list bounded; a cost limit per operation |
1051
1314
  | Subscriptions over CDC | `@subscription`, from library-made writes or, with `changeFeed: true`, every committed write |
1052
- | Interfaces and unions with `UNION` | Per-member subqueries merged by sort, each using its own indexes |
1053
- | Federation | Not supported |
1315
+ | Interfaces and unions with `UNION` | Per-member subqueries merged by sort, each using its own indexes |
1316
+ | Federation | Not supported |
1054
1317
 
1055
1318
  ## LoraDB behaviours this works around
1056
1319
 
@@ -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[];