@reventlessdev/reventless-spec 3.0.0-alpha.108 → 3.0.0-alpha.110

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,23 @@
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.110 (2026-08-12)
7
+
8
+ ### Features
9
+
10
+ * **ppx:** let a field say [@owner](https://github.com/owner) instead of spelling out its schema ([3bb0a4b](https://github.com/ReventlessDev/reventless-core/commit/3bb0a4bf3e5823fa929815fbe6f47203ba7958d7))
11
+
12
+
13
+ # 3.0.0-alpha.109 (2026-08-12)
14
+
15
+ ### Features
16
+
17
+ * **plugin:** publish the access a component's authorization rule implies ([e0d0f09](https://github.com/ReventlessDev/reventless-core/commit/e0d0f096b72fe44b185ed28dc1c133364fa841b8))
18
+ * **plugin:** publish which field ties a component to its owner ([ca71289](https://github.com/ReventlessDev/reventless-core/commit/ca7128931ce525af0d7d3a4487b2b5c54b19bec0))
19
+ * **spec:** let a record name the field that identifies its owner ([b69ee91](https://github.com/ReventlessDev/reventless-core/commit/b69ee9123e6beb38fcdd716519103ab9328213c6))
20
+ * **spec:** let the environment name the groups exempt from owner scoping ([2de5c51](https://github.com/ReventlessDev/reventless-core/commit/2de5c5118347a46998f9ab603308fca09addf00b))
21
+
22
+
6
23
  # 3.0.0-alpha.108 (2026-08-11)
7
24
 
8
25
  ### Features
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-spec",
3
- "version": "3.0.0-alpha.108",
3
+ "version": "3.0.0-alpha.110",
4
4
  "description": "Specifications for Reventless",
5
5
  "license": "Apache-2.0",
6
6
  "bin": {
@@ -0,0 +1,140 @@
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
+ `@owner` is the authoring form and is sugar over the constructors below, the way
24
+ `@ref` is sugar over `Reference.to_`. Write `@s.matches(Owner.string)` by hand
25
+ only where the ppx shorthand cannot reach — a file with no `@@reventless.spec`
26
+ annotation, where the attribute would survive into the compiler as an unknown
27
+ one.
28
+
29
+ @example
30
+ ```rescript
31
+ @schema type command =
32
+ PlaceOrder({
33
+ @partitionTag orderId: string,
34
+ @owner customerId: string,
35
+ })
36
+ ```
37
+ */
38
+ let ownerId: S.Metadata.Id.t<bool> = S.Metadata.Id.make(~namespace="reventless", ~name="owner")
39
+
40
+ /**
41
+ Layers the owner marker onto a schema that already says something else.
42
+
43
+ Owner-ness is independent of everything else a field declares: the same field
44
+ may be a DCB tag, a partition key, or a reference, and none of those implies or
45
+ is implied by owning. But a field carries at most one `@s.matches`, so the
46
+ shorthand composes by *wrapping* whatever schema the field already resolved to
47
+ rather than replacing it — replacing would silently drop the field's DCB tag,
48
+ and a dropped tag is a decision read that quietly misses events.
49
+ */
50
+ let mark = (schema: S.t<'a>): S.t<'a> => schema->S.Metadata.set(~id=ownerId, true)
51
+
52
+ /** A string field declared as the record's owner. */
53
+ let string: S.t<string> = S.string->mark
54
+
55
+ /**
56
+ An `option<string>` field declared as the record's owner.
57
+
58
+ Needed because `@s.matches` on an explicitly-`option`-typed field must supply
59
+ the whole field schema, wrapper included. The `f?: string` form needs nothing
60
+ extra: sury wraps the annotated inner schema itself, and `isFieldOwner` looks
61
+ through that wrapper either way.
62
+ */
63
+ let optionString: S.t<option<string>> = S.option(string)
64
+
65
+ /** Whether this exact schema carries the marker. Does not look through wrappers. */
66
+ let isOwner = (schema: S.t<unknown>): bool =>
67
+ S.Metadata.get(schema, ~id=ownerId)->Option.getOr(false)
68
+
69
+ /**
70
+ Whether a *field* is the owner, wherever inside the field's type the marker sits.
71
+
72
+ An optional field keeps its marker inside the union wrapper, so a walk reading
73
+ only the outer schema answers `false` for `customerId?: string` — which for an
74
+ access-control predicate means an unscoped view rather than a reported mistake.
75
+ Object properties are deliberately not followed: a marker on a nested record's
76
+ field belongs to that field, and attributing it to the enclosing one would scope
77
+ the view on the wrong value.
78
+ */
79
+ let isFieldOwner = (schema: S.t<unknown>): bool =>
80
+ isOwner(schema) ||
81
+ switch schema->Semantic.unwrapOptional {
82
+ | Some(inner) => isOwner(inner)
83
+ | None => false
84
+ }
85
+
86
+ /**
87
+ The owner fields declared on an object schema, in declaration order.
88
+
89
+ Returns an array rather than an `option<string>` so the *caller* decides what a
90
+ second owner means. The structure walk rejects it; a plain reader emitting a
91
+ wire marker has no reason to.
92
+ */
93
+ let fieldNamesOfProperties = (properties: dict<S.t<unknown>>): array<string> =>
94
+ properties
95
+ ->Dict.toArray
96
+ ->Array.filterMap(((propName, propSchema)) => isFieldOwner(propSchema) ? Some(propName) : None)
97
+
98
+ let fieldNames = (schema: S.t<unknown>): array<string> =>
99
+ switch schema {
100
+ | Object({properties}) => fieldNamesOfProperties(properties)
101
+ | _ => []
102
+ }
103
+
104
+ /**
105
+ The owner fields of one constructor of a command or event union.
106
+
107
+ A command schema is a union of variants, and only the variant actually being
108
+ issued may be stamped — `PlaceOrder` and `ImportProducts` live in the same union
109
+ and have nothing to say about each other's fields. Resolving by TAG here, rather
110
+ than at the call site, is what stops a caller from stamping across variants.
111
+
112
+ Answers `[]` for an unknown tag and for a payload-less variant, both of which
113
+ mean the same thing to every caller: this command carries no owner.
114
+ */
115
+ let variantFieldNames = (schema: S.t<unknown>, ~variant: string): array<string> => {
116
+ let isVariant = (properties: dict<S.t<unknown>>) =>
117
+ switch properties->Dict.get("TAG") {
118
+ | Some(String({const: ?Some(name)})) => name == variant
119
+ | _ => false
120
+ }
121
+ switch schema {
122
+ | Union({anyOf}) =>
123
+ anyOf
124
+ ->Array.find(v =>
125
+ switch v {
126
+ | Object({properties}) => isVariant(properties)
127
+ | _ => false
128
+ }
129
+ )
130
+ ->Option.mapOr([], v =>
131
+ switch v {
132
+ | Object({properties}) => fieldNamesOfProperties(properties)
133
+ | _ => []
134
+ }
135
+ )
136
+ // A single-constructor command compiles to a bare object rather than a union.
137
+ | Object({properties}) => isVariant(properties) ? fieldNamesOfProperties(properties) : []
138
+ | _ => []
139
+ }
140
+ }
@@ -0,0 +1,104 @@
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
+ function mark(schema) {
11
+ return S.Metadata.set(schema, ownerId, true);
12
+ }
13
+
14
+ let string = mark(S.string);
15
+
16
+ let optionString = S.option(string);
17
+
18
+ function isOwner(schema) {
19
+ return Stdlib_Option.getOr(S.Metadata.get(schema, ownerId), false);
20
+ }
21
+
22
+ function isFieldOwner(schema) {
23
+ if (isOwner(schema)) {
24
+ return true;
25
+ }
26
+ let inner = Semantic$Reventless.unwrapOptional(schema);
27
+ if (inner !== undefined) {
28
+ return isOwner(inner);
29
+ } else {
30
+ return false;
31
+ }
32
+ }
33
+
34
+ function fieldNamesOfProperties(properties) {
35
+ return Stdlib_Array.filterMap(Object.entries(properties), param => {
36
+ if (isFieldOwner(param[1])) {
37
+ return param[0];
38
+ }
39
+ });
40
+ }
41
+
42
+ function fieldNames(schema) {
43
+ if (schema.type === "object") {
44
+ return fieldNamesOfProperties(schema.properties);
45
+ } else {
46
+ return [];
47
+ }
48
+ }
49
+
50
+ function variantFieldNames(schema, variant) {
51
+ let isVariant = properties => {
52
+ let match = properties["TAG"];
53
+ if (match === undefined) {
54
+ return false;
55
+ }
56
+ if (match.type !== "string") {
57
+ return false;
58
+ }
59
+ let name = match.const;
60
+ if (name !== undefined) {
61
+ return name === variant;
62
+ } else {
63
+ return false;
64
+ }
65
+ };
66
+ switch (schema.type) {
67
+ case "object" :
68
+ let properties = schema.properties;
69
+ if (isVariant(properties)) {
70
+ return fieldNamesOfProperties(properties);
71
+ } else {
72
+ return [];
73
+ }
74
+ case "union" :
75
+ return Stdlib_Option.mapOr(schema.anyOf.find(v => {
76
+ if (v.type === "object") {
77
+ return isVariant(v.properties);
78
+ } else {
79
+ return false;
80
+ }
81
+ }), [], v => {
82
+ if (v.type === "object") {
83
+ return fieldNamesOfProperties(v.properties);
84
+ } else {
85
+ return [];
86
+ }
87
+ });
88
+ default:
89
+ return [];
90
+ }
91
+ }
92
+
93
+ export {
94
+ ownerId,
95
+ mark,
96
+ string,
97
+ optionString,
98
+ isOwner,
99
+ isFieldOwner,
100
+ fieldNamesOfProperties,
101
+ fieldNames,
102
+ variantFieldNames,
103
+ }
104
+ /* 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
@@ -314,6 +353,17 @@ type queryableDef = {
314
353
  `None` whenever `idField` is `None`, and on defs that predate the field.
315
354
  */
316
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>>,
317
367
  }
318
368
 
319
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,11 +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
124
  chapter: s.m(stringOptionSchema),
122
125
  singleQueryField: s.m(stringOptionSchema),
123
126
  idField: s.m(stringOptionSchema),
124
- idFieldSource: s.m(stringOptionSchema)
127
+ idFieldSource: s.m(stringOptionSchema),
128
+ requiredAccess: s.m(stringArrayOptionSchema)
125
129
  }));
126
130
 
127
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 */