@reventlessdev/reventless-spec 3.0.0-alpha.107 → 3.0.0-alpha.109

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/CHANGELOG.md CHANGED
@@ -3,6 +3,24 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ # 3.0.0-alpha.109 (2026-08-12)
7
+
8
+ ### Features
9
+
10
+ * **plugin:** publish the access a component's authorization rule implies ([e0d0f09](https://github.com/ReventlessDev/reventless-core/commit/e0d0f096b72fe44b185ed28dc1c133364fa841b8))
11
+ * **plugin:** publish which field ties a component to its owner ([ca71289](https://github.com/ReventlessDev/reventless-core/commit/ca7128931ce525af0d7d3a4487b2b5c54b19bec0))
12
+ * **spec:** let a record name the field that identifies its owner ([b69ee91](https://github.com/ReventlessDev/reventless-core/commit/b69ee9123e6beb38fcdd716519103ab9328213c6))
13
+ * **spec:** let the environment name the groups exempt from owner scoping ([2de5c51](https://github.com/ReventlessDev/reventless-core/commit/2de5c5118347a46998f9ab603308fca09addf00b))
14
+
15
+
16
+ # 3.0.0-alpha.108 (2026-08-11)
17
+
18
+ ### Features
19
+
20
+ * **api:** infer a queryable's key field and publish its provenance ([c835a42](https://github.com/ReventlessDev/reventless-core/commit/c835a42a0da07cdc4a3f010212e1f340a4a0ca27))
21
+ * **plugin:** publish singleQueryField on queryableDef ([a724ab5](https://github.com/ReventlessDev/reventless-core/commit/a724ab573614792c0615d68b6486b94da14f9f82))
22
+
23
+
6
24
  # 3.0.0-alpha.107 (2026-08-10)
7
25
 
8
26
  ### Bug Fixes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-spec",
3
- "version": "3.0.0-alpha.107",
3
+ "version": "3.0.0-alpha.109",
4
4
  "description": "Specifications for Reventless",
5
5
  "license": "Apache-2.0",
6
6
  "bin": {
@@ -21,7 +21,7 @@
21
21
  "sury": "11.0.0-alpha.4",
22
22
  "sury-ppx": "11.0.0-alpha.2",
23
23
  "yaml": "^2.8.3",
24
- "@reventlessdev/rescript-node": "2.0.0-alpha.3"
24
+ "@reventlessdev/rescript-node": "2.0.0-alpha.4"
25
25
  },
26
26
  "devDependencies": {
27
27
  "rescript": "12.3.0",
@@ -0,0 +1,118 @@
1
+ /**
2
+ Marks the field that ties a row — or a command — to the caller who owns it.
3
+
4
+ `@owner` is a *position*, not a type: it says "this field holds the id of the
5
+ principal this record belongs to". The field's nature (an entity id, a
6
+ reference) is declared separately and independently, exactly as a DCB tag and a
7
+ reference are two independent facts about one field.
8
+
9
+ Two things follow from the marker, both server-side and neither optional:
10
+
11
+ - the write path **overwrites** the field with the authenticated caller's id
12
+ before the command is published, so an absent field and a forged field produce
13
+ the same row; and
14
+ - reads of a view whose state carries the marker are narrowed to the caller's
15
+ own rows, unless the caller is elevated.
16
+
17
+ Because it drives enforcement, a reader that misses the marker fails *open* —
18
+ the field goes unstamped and the view goes unscoped, silently. That is why
19
+ `fieldNames` follows the same wrappers `Reference.getFieldTarget` follows, and
20
+ why it exists at all rather than leaving each consumer to look the marker up
21
+ itself.
22
+
23
+ There is no `@owner` ppx shorthand yet — `@s.matches(Owner.string)` is the
24
+ authoring form, not a workaround for one. The shorthand is sugar over exactly
25
+ this, the way `@ref` is sugar over `Reference.to_`, so it can be added without
26
+ changing what any reader here does; until it exists, prefer the explicit form
27
+ over inventing an attribute the ppx will reject.
28
+
29
+ @example
30
+ ```rescript
31
+ @schema type command =
32
+ PlaceOrder({
33
+ @partitionTag orderId: string,
34
+ customerId: @s.matches(Owner.string) string,
35
+ })
36
+ ```
37
+ */
38
+ let ownerId: S.Metadata.Id.t<bool> = S.Metadata.Id.make(~namespace="reventless", ~name="owner")
39
+
40
+ /** A string field declared as the record's owner. */
41
+ let string: S.t<string> = S.string->S.Metadata.set(~id=ownerId, true)
42
+
43
+ /** Whether this exact schema carries the marker. Does not look through wrappers. */
44
+ let isOwner = (schema: S.t<unknown>): bool =>
45
+ S.Metadata.get(schema, ~id=ownerId)->Option.getOr(false)
46
+
47
+ /**
48
+ Whether a *field* is the owner, wherever inside the field's type the marker sits.
49
+
50
+ An optional field keeps its marker inside the union wrapper, so a walk reading
51
+ only the outer schema answers `false` for `customerId?: string` — which for an
52
+ access-control predicate means an unscoped view rather than a reported mistake.
53
+ Object properties are deliberately not followed: a marker on a nested record's
54
+ field belongs to that field, and attributing it to the enclosing one would scope
55
+ the view on the wrong value.
56
+ */
57
+ let isFieldOwner = (schema: S.t<unknown>): bool =>
58
+ isOwner(schema) ||
59
+ switch schema->Semantic.unwrapOptional {
60
+ | Some(inner) => isOwner(inner)
61
+ | None => false
62
+ }
63
+
64
+ /**
65
+ The owner fields declared on an object schema, in declaration order.
66
+
67
+ Returns an array rather than an `option<string>` so the *caller* decides what a
68
+ second owner means. The structure walk rejects it; a plain reader emitting a
69
+ wire marker has no reason to.
70
+ */
71
+ let fieldNamesOfProperties = (properties: dict<S.t<unknown>>): array<string> =>
72
+ properties
73
+ ->Dict.toArray
74
+ ->Array.filterMap(((propName, propSchema)) => isFieldOwner(propSchema) ? Some(propName) : None)
75
+
76
+ let fieldNames = (schema: S.t<unknown>): array<string> =>
77
+ switch schema {
78
+ | Object({properties}) => fieldNamesOfProperties(properties)
79
+ | _ => []
80
+ }
81
+
82
+ /**
83
+ The owner fields of one constructor of a command or event union.
84
+
85
+ A command schema is a union of variants, and only the variant actually being
86
+ issued may be stamped — `PlaceOrder` and `ImportProducts` live in the same union
87
+ and have nothing to say about each other's fields. Resolving by TAG here, rather
88
+ than at the call site, is what stops a caller from stamping across variants.
89
+
90
+ Answers `[]` for an unknown tag and for a payload-less variant, both of which
91
+ mean the same thing to every caller: this command carries no owner.
92
+ */
93
+ let variantFieldNames = (schema: S.t<unknown>, ~variant: string): array<string> => {
94
+ let isVariant = (properties: dict<S.t<unknown>>) =>
95
+ switch properties->Dict.get("TAG") {
96
+ | Some(String({const: ?Some(name)})) => name == variant
97
+ | _ => false
98
+ }
99
+ switch schema {
100
+ | Union({anyOf}) =>
101
+ anyOf
102
+ ->Array.find(v =>
103
+ switch v {
104
+ | Object({properties}) => isVariant(properties)
105
+ | _ => false
106
+ }
107
+ )
108
+ ->Option.mapOr([], v =>
109
+ switch v {
110
+ | Object({properties}) => fieldNamesOfProperties(properties)
111
+ | _ => []
112
+ }
113
+ )
114
+ // A single-constructor command compiles to a bare object rather than a union.
115
+ | Object({properties}) => isVariant(properties) ? fieldNamesOfProperties(properties) : []
116
+ | _ => []
117
+ }
118
+ }
@@ -0,0 +1,96 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as S from "sury/src/S.res.mjs";
4
+ import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
5
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
6
+ import * as Semantic$Reventless from "../semantic/Semantic.res.mjs";
7
+
8
+ let ownerId = S.Metadata.Id.make("reventless", "owner");
9
+
10
+ let string = S.Metadata.set(S.string, ownerId, true);
11
+
12
+ function isOwner(schema) {
13
+ return Stdlib_Option.getOr(S.Metadata.get(schema, ownerId), false);
14
+ }
15
+
16
+ function isFieldOwner(schema) {
17
+ if (isOwner(schema)) {
18
+ return true;
19
+ }
20
+ let inner = Semantic$Reventless.unwrapOptional(schema);
21
+ if (inner !== undefined) {
22
+ return isOwner(inner);
23
+ } else {
24
+ return false;
25
+ }
26
+ }
27
+
28
+ function fieldNamesOfProperties(properties) {
29
+ return Stdlib_Array.filterMap(Object.entries(properties), param => {
30
+ if (isFieldOwner(param[1])) {
31
+ return param[0];
32
+ }
33
+ });
34
+ }
35
+
36
+ function fieldNames(schema) {
37
+ if (schema.type === "object") {
38
+ return fieldNamesOfProperties(schema.properties);
39
+ } else {
40
+ return [];
41
+ }
42
+ }
43
+
44
+ function variantFieldNames(schema, variant) {
45
+ let isVariant = properties => {
46
+ let match = properties["TAG"];
47
+ if (match === undefined) {
48
+ return false;
49
+ }
50
+ if (match.type !== "string") {
51
+ return false;
52
+ }
53
+ let name = match.const;
54
+ if (name !== undefined) {
55
+ return name === variant;
56
+ } else {
57
+ return false;
58
+ }
59
+ };
60
+ switch (schema.type) {
61
+ case "object" :
62
+ let properties = schema.properties;
63
+ if (isVariant(properties)) {
64
+ return fieldNamesOfProperties(properties);
65
+ } else {
66
+ return [];
67
+ }
68
+ case "union" :
69
+ return Stdlib_Option.mapOr(schema.anyOf.find(v => {
70
+ if (v.type === "object") {
71
+ return isVariant(v.properties);
72
+ } else {
73
+ return false;
74
+ }
75
+ }), [], v => {
76
+ if (v.type === "object") {
77
+ return fieldNamesOfProperties(v.properties);
78
+ } else {
79
+ return [];
80
+ }
81
+ });
82
+ default:
83
+ return [];
84
+ }
85
+ }
86
+
87
+ export {
88
+ ownerId,
89
+ string,
90
+ isOwner,
91
+ isFieldOwner,
92
+ fieldNamesOfProperties,
93
+ fieldNames,
94
+ variantFieldNames,
95
+ }
96
+ /* ownerId Not a pure module */
@@ -194,6 +194,34 @@ type commandDef = {
194
194
  (read as None) — those stores must be reset. See [[sury-optional-field-absent-vs-null]].
195
195
  */
196
196
  apiExposed: @s.matches(boolOptionSchema) option<bool>,
197
+ /**
198
+ Access keys a caller must hold — any one of them — to be *offered* this command,
199
+ derived from the authorization rule the server already enforces. `None` (or `[]`)
200
+ means the rule asks for nothing a client can check.
201
+
202
+ A hint, never a boundary: the rule in the resolver is what refuses a call, and a
203
+ caller who edits this list gains nothing. It exists so a client stops advertising
204
+ what the server would refuse — an offered command that always fails is a worse
205
+ answer than no command at all. Derived rather than authored, so it cannot drift
206
+ from the rule it describes. js_nullable, so defs written before this field
207
+ existed decode as None.
208
+ */
209
+ requiredAccess: @s.matches(stringArrayOptionSchema) option<array<string>>,
210
+ /**
211
+ Name of the command field the server stamps with the caller's own identity, when
212
+ the command declares one (`@owner`). A client should omit it from a generated
213
+ form for a caller who is not elevated: whatever it collects there is discarded
214
+ and replaced, so offering the field asks a question whose answer is ignored.
215
+
216
+ Derived from the annotation, never authored, so it cannot disagree with what the
217
+ write path actually does. js_nullable for the same JSON-safety reason as
218
+ `requiredAccess`.
219
+
220
+ ⚠️ A client cannot decide the *elevated* half from this alone — the manifest
221
+ states which field carries the owner, and the caller's own identity says whether
222
+ they are exempt. Both are needed, and neither is derivable from the other.
223
+ */
224
+ ownerField: @s.matches(stringOptionSchema) option<string>,
197
225
  }
198
226
 
199
227
  @schema
@@ -246,6 +274,17 @@ type queryableDef = {
246
274
  */
247
275
  statusField: @s.matches(stringOptionSchema) option<string>,
248
276
  /**
277
+ Name of the state field that ties a row to the principal owning it (`@owner`),
278
+ when the view declares one. Two consequences for a client: reads of this view
279
+ are narrowed server-side to a non-elevated caller's own rows, and the column is
280
+ constant for such a caller and so carries no information in a list.
281
+
282
+ Derived from the annotation. As on `commandDef.ownerField`, this states which
283
+ field carries the owner and not whether the current caller is exempt — that is
284
+ the caller's own identity to answer.
285
+ */
286
+ ownerField: @s.matches(stringOptionSchema) option<string>,
287
+ /**
249
288
  Component visibility hint (`@@reventless.visibility`). `Some("Internal")` marks a
250
289
  ReadModel / StateViewSlice that the deployed AutoUI hides from its menu, drill-down
251
290
  pages, web event graph and cross-plugin edges. `None` (absent) means Public. Internal
@@ -269,6 +308,62 @@ type queryableDef = {
269
308
  before this field existed must be reset/re-emitted. See [[deployed-chapter-grouping]].
270
309
  */
271
310
  chapter: @s.matches(stringOptionSchema) option<string>,
311
+ /**
312
+ The singular counterpart of `queryField`: the generated single-entity query
313
+ (`Plugin_Order(id: ID!)` beside the list field `Plugin_Orders`), and — because
314
+ `Api_Naming` returns the same string for both — the prefix of the queryable's
315
+ generated input types (`Plugin_OrderFilter`, `Plugin_OrderOrderBy`). One field
316
+ rather than two, so the two uses cannot drift apart.
317
+
318
+ Published because it is not derivable from `queryField` without re-implementing
319
+ `Api_Naming.singularize`: a consumer that strips a trailing `s` turns
320
+ `Plugin_Categories` into `Plugin_Categorie`, a name the schema does not serve,
321
+ and fails at query time against that one view. Sourced from the naming module
322
+ itself, never re-derived.
323
+
324
+ `None` means not stated — defs persisted before this field existed, and
325
+ hand-rolled defs that decline to say; a consumer falls back to its own
326
+ derivation there. js_nullable for the same JSON-safety reason as `statusField`.
327
+ */
328
+ singleQueryField: @s.matches(stringOptionSchema) option<string>,
329
+ /**
330
+ The state field that identifies a row — the queryable's own key, as opposed to
331
+ a reference to some other entity. `Products` carries `productId` and
332
+ `categoryId`; this says which of the two the row is about.
333
+
334
+ `None` means unresolved: a state with several `*Id` fields and no name match,
335
+ or with none at all. Such a component gets no key-derived filter or sort until
336
+ its spec declares `@id`. Also `None` on defs persisted before this field
337
+ existed. js_nullable for the same JSON-safety reason as `statusField`.
338
+ */
339
+ idField: @s.matches(stringOptionSchema) option<string>,
340
+ /**
341
+ Which rung produced `idField`, so a consumer can tell a declaration from a
342
+ guess — the same reason `labelFieldSource` exists:
343
+
344
+ - `"annotation"` — the state declares `@id`. The author said which field keys
345
+ the row; nothing inferred outranks it.
346
+ - `"convention"` — a field named `<singular component name>Id` exists
347
+ (`Products` → `productId`). A guess, and the one guess a client can make for
348
+ itself.
349
+ - `"sole"` — the state has exactly one `*Id` field, so there is nothing else
350
+ the key could be (`AvailableProducts` → `productId`). A guess, and one that
351
+ needs the state's full field list to make.
352
+
353
+ `None` whenever `idField` is `None`, and on defs that predate the field.
354
+ */
355
+ idFieldSource: @s.matches(stringOptionSchema) option<string>,
356
+ /**
357
+ Access keys a caller must hold — any one of them — to be *offered* this view,
358
+ derived from the component's module-level authorization rule. Same terms as
359
+ `commandDef.requiredAccess`: a hint that keeps a client from advertising a
360
+ surface the server would refuse, never the refusal itself.
361
+
362
+ Worth stating for reads in particular: a denied query does not error, it comes
363
+ back empty, so a client that offers a view it may not read renders a confident
364
+ blank table rather than a visible failure.
365
+ */
366
+ requiredAccess: @s.matches(stringArrayOptionSchema) option<array<string>>,
272
367
  }
273
368
 
274
369
  /**
@@ -104,7 +104,9 @@ let commandDefSchema = S.schema(s => ({
104
104
  references: s.m(S.array(fieldReferenceSchema)),
105
105
  allowedStates: s.m(stringArrayOptionSchema),
106
106
  targetState: s.m(stringOptionSchema),
107
- apiExposed: s.m(boolOptionSchema)
107
+ apiExposed: s.m(boolOptionSchema),
108
+ requiredAccess: s.m(stringArrayOptionSchema),
109
+ ownerField: s.m(stringOptionSchema)
108
110
  }));
109
111
 
110
112
  let queryableDefSchema = S.schema(s => ({
@@ -117,8 +119,13 @@ let queryableDefSchema = S.schema(s => ({
117
119
  searchableFields: s.m(S.array(S.string)),
118
120
  labelFieldSource: s.m(stringOptionSchema),
119
121
  statusField: s.m(stringOptionSchema),
122
+ ownerField: s.m(stringOptionSchema),
120
123
  visibility: s.m(stringOptionSchema),
121
- chapter: s.m(stringOptionSchema)
124
+ chapter: s.m(stringOptionSchema),
125
+ singleQueryField: s.m(stringOptionSchema),
126
+ idField: s.m(stringOptionSchema),
127
+ idFieldSource: s.m(stringOptionSchema),
128
+ requiredAccess: s.m(stringArrayOptionSchema)
122
129
  }));
123
130
 
124
131
  let eventDefSchema = S.schema(s => ({
@@ -0,0 +1,227 @@
1
+ /**
2
+ Classifies the caller behind a request for the purposes of `@owner` enforcement:
3
+ whose id gets stamped into an owner-marked command field, and whose rows an
4
+ owner-scoped read is narrowed to.
5
+
6
+ This is deliberately one function rather than a check written twice. The write
7
+ path and the read path must agree about who is exempt — an implementation that
8
+ scopes reads but not writes labels rows correctly and shows them to everyone,
9
+ and one that scopes writes but not reads does the reverse. Both call `resolve`.
10
+
11
+ ⚠️ **`Identity.t` is not trustworthy at this boundary, and this module is where
12
+ that stops mattering.** The AppSync resolver templates build the identity object
13
+ in generated JavaScript and hand it to a handler that types it as `Identity.t`
14
+ without decoding it. For a Cognito caller the shape matches. For an IAM-signed
15
+ caller — every AppSync API in a deployed estate carries `AWS_IAM` as an
16
+ additional provider for service-to-service traffic — the template emits
17
+ `{userArn, accountId, username, provider: 'IAM'}`: no `userId`, no `groups`, and
18
+ a `provider` string that is not one of the three the variant models. So the
19
+ fields this module reads are typed non-optional and are, at runtime, sometimes
20
+ absent. Every read here goes through a nullable cast for that reason; deleting
21
+ one restores a silent failure rather than a type error.
22
+ */
23
+
24
+ /** Whether a JS value is a primitive string — `Cognito` and `InMemory` compile to
25
+ bare strings while `Custom(_)` compiles to an object, so this is how a modelled
26
+ provider is told from an unmodelled one that arrived as raw JSON. */
27
+ let isJsString: 'a => bool = %raw(`v => typeof v === "string"`)
28
+
29
+ external asNullableString: string => Nullable.t<string> = "%identity"
30
+ external asNullableArray: array<string> => Nullable.t<array<string>> = "%identity"
31
+ external asString: Identity.provider => string = "%identity"
32
+ external asNullableIdentity: Identity.t => Nullable.t<Identity.t> = "%identity"
33
+
34
+ /**
35
+ The caller, classified.
36
+
37
+ `System` and `Elevated` behave identically today — neither is stamped, neither is
38
+ scoped — and are still separate cases because they are different claims. `System`
39
+ says the platform is calling itself; `Elevated` says a named human holds an
40
+ operator group. Collapsing them would make an audit unable to tell a service
41
+ write from an administrator's.
42
+
43
+ `Unidentified` carries a short reason because it is the fail-closed branch, and
44
+ the fail-closed branch is the one someone will be debugging.
45
+ */
46
+ type t =
47
+ | System
48
+ | Elevated({userId: string})
49
+ | Owned({userId: string})
50
+ | Unidentified(string)
51
+
52
+ /**
53
+ Providers that identify a *machine*, not a person. Members are exempt from
54
+ stamping and scoping.
55
+
56
+ An allowlist rather than "anything not modelled", because the fallback direction
57
+ is the whole safety property here: a provider nobody has classified must land in
58
+ `Unidentified` and be refused, not in `System` and be handed unscoped reads. Add
59
+ to this list deliberately.
60
+ */
61
+ let systemProviders = ["IAM"]
62
+
63
+ /**
64
+ Groups whose members read across every owner.
65
+
66
+ A single deployment-wide list, not a parameter of the annotation. Per-annotation
67
+ elevation would let two views disagree about who an operator is, so a caller
68
+ scoped on one view would be unscoped on the next — and the gap would appear one
69
+ view at a time, as views were added.
70
+
71
+ Resolved as: an explicit `setElevatedGroups` wins, else the environment, else
72
+ empty. The env fallback exists because a deployment is **two kinds of process**,
73
+ not one. On a cloud provider the read predicate for a table-backed view is baked
74
+ into resolver source by the deploy program, while stamping and the SQL-backed
75
+ reads run later inside separate function runtimes the deploy never enters. A
76
+ value set in the deploy program alone reaches the first and not the second — and
77
+ the failure that produces is a *wrong write*, not a narrow read: an operator
78
+ acting on someone's behalf gets the row stamped with their own id, because the
79
+ runtime believes nobody is elevated. An environment variable is the only carrier
80
+ both kinds of process share, which is the same reason the logger's level is one.
81
+
82
+ Empty remains the default in both directions: a deployment that configures
83
+ nothing shows operators too little rather than showing customers each other.
84
+ */
85
+ @val
86
+ external _elevatedGroupsEnv: option<string> = "process.env.REVENTLESS_ELEVATED_GROUPS"
87
+
88
+ let explicitElevatedGroups: ref<option<array<string>>> = ref(None)
89
+
90
+ /** Set the list for this process. Wins over the environment — a platform root
91
+ that states its operator groups in code means it, and should not be silently
92
+ overridden by a stray variable. */
93
+ let setElevatedGroups = (groups: array<string>) => explicitElevatedGroups := Some(groups)
94
+
95
+ /** Forget an explicit setting and fall back to the environment again. */
96
+ let clearElevatedGroups = () => explicitElevatedGroups := None
97
+
98
+ let parseElevatedGroups = (raw: string): array<string> =>
99
+ raw
100
+ ->String.split(",")
101
+ ->Array.map(String.trim)
102
+ ->Array.filter(part => part->String.length > 0)
103
+
104
+ /**
105
+ The groups exempt from owner scoping, right now.
106
+
107
+ A function rather than a `ref` anyone can read: the answer depends on the
108
+ environment as well as on what was set, and it is re-read per call so a value
109
+ appearing later in a process still takes effect. Reading a raw ref would have
110
+ frozen whichever half happened to be consulted first.
111
+ */
112
+ let elevatedGroups = (): array<string> =>
113
+ switch explicitElevatedGroups.contents {
114
+ | Some(groups) => groups
115
+ | None =>
116
+ switch _elevatedGroupsEnv {
117
+ | Some(raw) => parseElevatedGroups(raw)
118
+ | None => []
119
+ }
120
+ }
121
+
122
+ // Order matters: the provider is examined before `userId`, because the IAM
123
+ // caller fails the `userId` test for a reason that has nothing to do with being
124
+ // anonymous and must not be refused as though it did.
125
+ let classify = (identity: Identity.t, ~elevated: array<string>): t => {
126
+ let provider = identity.provider
127
+ let providerName = isJsString(provider) ? Some(provider->asString) : None
128
+
129
+ switch providerName {
130
+ | Some(name) if systemProviders->Array.includes(name) => System
131
+ // A modelled string provider, or `Custom(_)` (an object, so `providerName` is
132
+ // None) — either way a caller claiming to be a person. Unmodelled strings fall
133
+ // through to the refusal below.
134
+ | Some("Cognito") | Some("InMemory") | None =>
135
+ switch identity.userId->asNullableString->Nullable.toOption {
136
+ | None => Unidentified("identity carries no userId")
137
+ | Some("") => Unidentified("identity carries an empty userId")
138
+ | Some(userId) if userId == Identity.anonymous.userId =>
139
+ Unidentified("caller is anonymous")
140
+ | Some(userId) =>
141
+ let groups = identity.groups->asNullableArray->Nullable.toOption->Option.getOr([])
142
+ groups->Array.some(g => elevated->Array.includes(g))
143
+ ? Elevated({userId: userId})
144
+ : Owned({userId: userId})
145
+ }
146
+ | Some(name) => Unidentified(`unrecognised identity provider "${name}"`)
147
+ }
148
+ }
149
+
150
+ /**
151
+ Classify a caller. `~elevated` defaults to the configured list so call sites do
152
+ not each have to remember to read it.
153
+ */
154
+ let resolve = (identity: Identity.t, ~elevated: array<string>=elevatedGroups()): t =>
155
+ // The whole identity, not just its fields, can be missing: an internal caller
156
+ // that builds a payload without one reaches here with `undefined`. Reading
157
+ // through it would raise a TypeError, which surfaces as a crash rather than as
158
+ // the refusal this case actually is.
159
+ switch identity->asNullableIdentity->Nullable.toOption {
160
+ | None => Unidentified("request carries no identity")
161
+ | Some(identity) => classify(identity, ~elevated)
162
+ }
163
+
164
+ /**
165
+ The id an owner-marked field takes, and the value an owner-scoped read matches.
166
+
167
+ `None` for `System` and `Elevated` means "do not stamp, do not scope" — and it
168
+ means the same for `Unidentified`, which is why no caller may treat this as the
169
+ whole answer. A write must refuse an `Unidentified` caller outright rather than
170
+ publish an unstamped command; use `resolve` directly there.
171
+ */
172
+ let ownerId = (scope: t): option<string> =>
173
+ switch scope {
174
+ | Owned({userId}) => Some(userId)
175
+ | System | Elevated(_) | Unidentified(_) => None
176
+ }
177
+
178
+ /** Whether the caller reads and writes across every owner. */
179
+ let isExempt = (scope: t): bool =>
180
+ switch scope {
181
+ | System | Elevated(_) => true
182
+ | Owned(_) | Unidentified(_) => false
183
+ }
184
+
185
+ /**
186
+ What owner scoping does to one read of one view.
187
+
188
+ `RefuseOwned` is kept apart from "scope to a value nobody holds" because the two
189
+ produce the same empty page for different reasons, and only one of them is a
190
+ refusal — a door that wants to say so needs to be able to tell.
191
+ */
192
+ type decision =
193
+ | Unscoped
194
+ | ScopeTo(string, string)
195
+ | RefuseOwned
196
+
197
+ /**
198
+ Combine a view's declared owner field with the caller behind the request.
199
+
200
+ Lives here rather than at each read path because there are four of them —
201
+ the shared list spec, two SQL push-downs and the generated AppSync resolver —
202
+ and they must answer identically. Four copies of this `switch` would be four
203
+ chances to decide that an unidentified caller "just sees nothing".
204
+ */
205
+ let decide = (
206
+ identity: Identity.t,
207
+ ~ownerField: option<string>,
208
+ ~elevated: array<string>=elevatedGroups(),
209
+ ): decision =>
210
+ switch ownerField {
211
+ | None => Unscoped
212
+ | Some(field) =>
213
+ switch resolve(identity, ~elevated) {
214
+ | System | Elevated(_) => Unscoped
215
+ | Owned({userId}) => ScopeTo(field, userId)
216
+ // The view records an owner and the caller has none. Scoping to a value
217
+ // nobody holds would be indistinguishable from an empty view.
218
+ | Unidentified(_) => RefuseOwned
219
+ }
220
+ }
221
+
222
+ /** The `(field, value)` pair a list query narrows on, or `None` when it does not. */
223
+ let scopeOf = (decision: decision): option<(string, string)> =>
224
+ switch decision {
225
+ | ScopeTo(field, required) => Some((field, required))
226
+ | Unscoped | RefuseOwned => None
227
+ }
@@ -0,0 +1,173 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
4
+ import * as Primitive_option from "@rescript/runtime/lib/es6/Primitive_option.js";
5
+ import * as Identity$Reventless from "./Identity.res.mjs";
6
+
7
+ let isJsString = (v => typeof v === "string");
8
+
9
+ let systemProviders = ["IAM"];
10
+
11
+ let explicitElevatedGroups = {
12
+ contents: undefined
13
+ };
14
+
15
+ function setElevatedGroups(groups) {
16
+ explicitElevatedGroups.contents = groups;
17
+ }
18
+
19
+ function clearElevatedGroups() {
20
+ explicitElevatedGroups.contents = undefined;
21
+ }
22
+
23
+ function parseElevatedGroups(raw) {
24
+ return raw.split(",").map(prim => prim.trim()).filter(part => part.length > 0);
25
+ }
26
+
27
+ function elevatedGroups() {
28
+ let groups = explicitElevatedGroups.contents;
29
+ if (groups !== undefined) {
30
+ return groups;
31
+ }
32
+ let raw = process.env.REVENTLESS_ELEVATED_GROUPS;
33
+ if (raw !== undefined) {
34
+ return parseElevatedGroups(raw);
35
+ } else {
36
+ return [];
37
+ }
38
+ }
39
+
40
+ function classify(identity, elevated) {
41
+ let provider = identity.provider;
42
+ let providerName = isJsString(provider) ? provider : undefined;
43
+ if (providerName !== undefined) {
44
+ if (systemProviders.includes(providerName)) {
45
+ return "System";
46
+ }
47
+ switch (providerName) {
48
+ case "Cognito" :
49
+ case "InMemory" :
50
+ break;
51
+ default:
52
+ return {
53
+ TAG: "Unidentified",
54
+ _0: `unrecognised identity provider "` + providerName + `"`
55
+ };
56
+ }
57
+ }
58
+ let userId = identity.userId;
59
+ if (userId == null) {
60
+ return {
61
+ TAG: "Unidentified",
62
+ _0: "identity carries no userId"
63
+ };
64
+ }
65
+ if (userId === "") {
66
+ return {
67
+ TAG: "Unidentified",
68
+ _0: "identity carries an empty userId"
69
+ };
70
+ }
71
+ if (userId === Identity$Reventless.anonymous.userId) {
72
+ return {
73
+ TAG: "Unidentified",
74
+ _0: "caller is anonymous"
75
+ };
76
+ }
77
+ let groups = Stdlib_Option.getOr(Primitive_option.fromNullable(identity.groups), []);
78
+ if (groups.some(g => elevated.includes(g))) {
79
+ return {
80
+ TAG: "Elevated",
81
+ userId: userId
82
+ };
83
+ } else {
84
+ return {
85
+ TAG: "Owned",
86
+ userId: userId
87
+ };
88
+ }
89
+ }
90
+
91
+ function resolve(identity, elevatedOpt) {
92
+ let elevated = elevatedOpt !== undefined ? elevatedOpt : elevatedGroups();
93
+ if (identity == null) {
94
+ return {
95
+ TAG: "Unidentified",
96
+ _0: "request carries no identity"
97
+ };
98
+ } else {
99
+ return classify(identity, elevated);
100
+ }
101
+ }
102
+
103
+ function ownerId(scope) {
104
+ if (typeof scope !== "object" || scope.TAG !== "Owned") {
105
+ return;
106
+ } else {
107
+ return scope.userId;
108
+ }
109
+ }
110
+
111
+ function isExempt(scope) {
112
+ if (typeof scope !== "object") {
113
+ return true;
114
+ }
115
+ switch (scope.TAG) {
116
+ case "Elevated" :
117
+ return true;
118
+ case "Owned" :
119
+ case "Unidentified" :
120
+ return false;
121
+ }
122
+ }
123
+
124
+ function decide(identity, ownerField, elevatedOpt) {
125
+ let elevated = elevatedOpt !== undefined ? elevatedOpt : elevatedGroups();
126
+ if (ownerField === undefined) {
127
+ return "Unscoped";
128
+ }
129
+ let match = resolve(identity, elevated);
130
+ if (typeof match !== "object") {
131
+ return "Unscoped";
132
+ }
133
+ switch (match.TAG) {
134
+ case "Elevated" :
135
+ return "Unscoped";
136
+ case "Owned" :
137
+ return {
138
+ TAG: "ScopeTo",
139
+ _0: ownerField,
140
+ _1: match.userId
141
+ };
142
+ case "Unidentified" :
143
+ return "RefuseOwned";
144
+ }
145
+ }
146
+
147
+ function scopeOf(decision) {
148
+ if (typeof decision !== "object") {
149
+ return;
150
+ } else {
151
+ return [
152
+ decision._0,
153
+ decision._1
154
+ ];
155
+ }
156
+ }
157
+
158
+ export {
159
+ isJsString,
160
+ systemProviders,
161
+ explicitElevatedGroups,
162
+ setElevatedGroups,
163
+ clearElevatedGroups,
164
+ parseElevatedGroups,
165
+ elevatedGroups,
166
+ classify,
167
+ resolve,
168
+ ownerId,
169
+ isExempt,
170
+ decide,
171
+ scopeOf,
172
+ }
173
+ /* Identity-Reventless Not a pure module */