@reventlessdev/reventless-local 3.0.0-alpha.297 → 3.0.0-alpha.299

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/package.json +10 -10
  3. package/src/EmitCapabilities.res +3 -3
  4. package/src/LocalPlatformRegistry.res +1 -1
  5. package/src/LocalSeedTarget.res +1 -1
  6. package/src/Platform.res +57 -3
  7. package/src/Platform.res.mjs +61 -6
  8. package/src/ShellConfig.res +11 -1
  9. package/src/ShellConfig.res.mjs +10 -4
  10. package/src/adapter/Auth/Auth_GraphqlContext.res +18 -2
  11. package/src/adapter/Auth/Auth_GraphqlContext.res.mjs +8 -1
  12. package/src/adapter/Auth/UserStore.res +102 -3
  13. package/src/adapter/Auth/UserStore.res.mjs +128 -2
  14. package/src/adapter/CommandGenerator/CommandGeneratorResolvers_GraphQL.res +33 -0
  15. package/src/adapter/CommandGenerator/CommandGeneratorResolvers_GraphQL.res.mjs +26 -0
  16. package/src/adapter/CommandGenerator/InboundTranslationResolvers_GraphQL.res +11 -3
  17. package/src/adapter/CommandGenerator/InboundTranslationResolvers_GraphQL.res.mjs +3 -0
  18. package/src/adapter/DomainGraphQL_Server.res +1 -1
  19. package/src/adapter/GraphQL_CallerError.res +4 -0
  20. package/src/adapter/GraphQL_CallerError.res.mjs +9 -0
  21. package/src/adapter/LocalAnonymousLimit.res +35 -0
  22. package/src/adapter/LocalAnonymousLimit.res.mjs +52 -0
  23. package/src/adapter/LocalCapabilities.res +14 -2
  24. package/src/adapter/LocalCapabilities.res.mjs +21 -5
  25. package/src/adapter/QueryDb/QueryDbResolvers_GraphQL.res +22 -0
  26. package/src/adapter/QueryDb/QueryDbResolvers_GraphQL.res.mjs +7 -0
  27. package/src/capability/Capability_IdentityProvider_UserStore.res +6 -2
  28. package/src/capability/Capability_IdentityProvider_UserStore.res.mjs +6 -2
  29. package/src/components/InboundTranslationSlice_Builder.res +1 -0
  30. package/src/components/InboundTranslationSlice_Builder.res.mjs +4 -1
  31. package/tests/EmitCapabilitiesTest.res +4 -6
  32. package/tests/EmitCapabilitiesTest.res.mjs +1 -2
  33. package/tests/HostShellDistTest.res.mjs +1 -1
  34. package/tests/LocalPlatformRegistryTest.res +3 -5
  35. package/tests/LocalPlatformRegistryTest.res.mjs +2 -4
  36. package/tests/LocalPlatformStartTest.res +2 -2
  37. package/tests/LocalPlatformStartTest.res.mjs +1 -2
  38. package/tests/ShellConfigTest.res.mjs +27 -27
  39. package/tests/adapter/InboundTranslationMutationTest.res +13 -2
  40. package/tests/adapter/InboundTranslationMutationTest.res.mjs +7 -4
  41. package/tests/adapter/LocalAnonymousLimitTest.res +70 -0
  42. package/tests/adapter/LocalAnonymousLimitTest.res.mjs +85 -0
  43. package/tests/adapter/LocalAuthLoginTest.res +2 -2
  44. package/tests/adapter/LocalAuthUserStoreTest.res +88 -0
  45. package/tests/adapter/LocalAuthUserStoreTest.res.mjs +83 -0
  46. package/tests/capability/Capability_IdentityProvider_UserStoreTest.res +6 -0
  47. package/tests/capability/Capability_IdentityProvider_UserStoreTest.res.mjs +4 -0
  48. package/tests/capability/LocalCapabilitiesTest.res +2 -1
  49. package/tests/capability/LocalCapabilitiesTest.res.mjs +3 -2
  50. package/tests/components/inboundtranslationslice/InboundTranslationCredentialTest.res +136 -0
  51. package/tests/components/inboundtranslationslice/InboundTranslationCredentialTest.res.mjs +183 -0
  52. package/tests/components/inboundtranslationslice/InboundTranslationSliceCallbackTest.res +143 -11
  53. package/tests/components/inboundtranslationslice/InboundTranslationSliceCallbackTest.res.mjs +129 -22
  54. package/tests/components/inboundtranslationslice/InboundTranslationSliceFixtures.res +18 -5
  55. package/tests/components/inboundtranslationslice/InboundTranslationSliceFixtures.res.mjs +12 -3
  56. package/tests/components/inboundtranslationslice/InboundTranslationWebhookTest.res +26 -6
  57. package/tests/components/inboundtranslationslice/InboundTranslationWebhookTest.res.mjs +48 -13
  58. package/tests/plugin/Plugin_Fixtures.res +0 -4
  59. package/tests/plugin/Plugin_Fixtures.res.mjs +13 -33
@@ -1,12 +1,21 @@
1
1
  // Generated by ReScript, PLEASE EDIT WITH CARE
2
2
 
3
3
  import * as Nodefs from "node:fs";
4
+ import * as Nodepath from "node:path";
4
5
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
5
6
  import * as Stdlib_Result from "@rescript/runtime/lib/es6/Stdlib_Result.js";
6
7
  import * as Stdlib_JsError from "@rescript/runtime/lib/es6/Stdlib_JsError.js";
8
+ import * as Role$Reventless from "@reventlessdev/reventless-spec/src/types/Role.res.mjs";
9
+ import * as Primitive_object from "@rescript/runtime/lib/es6/Primitive_object.js";
10
+ import * as Primitive_option from "@rescript/runtime/lib/es6/Primitive_option.js";
11
+ import * as RoleCoverage$Reventless from "@reventlessdev/reventless-spec/src/types/RoleCoverage.res.mjs";
7
12
  import * as LocalAuth$ReventlessLocal from "./LocalAuth.res.mjs";
8
13
  import * as AccountsManifest$Reventless from "@reventlessdev/reventless-spec/src/types/AccountsManifest.res.mjs";
9
14
 
15
+ let fromFile = {
16
+ contents: []
17
+ };
18
+
10
19
  function _registerEntries(entries) {
11
20
  entries.forEach(param => {
12
21
  let username = param.username;
@@ -33,6 +42,7 @@ let pluginChecked = {
33
42
  function resetResolution() {
34
43
  resolved.contents = false;
35
44
  pluginChecked.contents = false;
45
+ fromFile.contents = [];
36
46
  }
37
47
 
38
48
  function providedGroups() {
@@ -54,6 +64,7 @@ function load(users, usersFile, param) {
54
64
  if (usersFile !== undefined) {
55
65
  return Stdlib_Result.map(AccountsManifest$Reventless.parseFile(usersFile), entries => {
56
66
  _registerEntries(entries);
67
+ fromFile.contents = entries.map(e => e.username);
57
68
  return {
58
69
  TAG: "UsersFile",
59
70
  _0: usersFile
@@ -64,6 +75,7 @@ function load(users, usersFile, param) {
64
75
  if (Nodefs.existsSync(defaultPath)) {
65
76
  return Stdlib_Result.map(AccountsManifest$Reventless.parseFile(defaultPath), entries => {
66
77
  _registerEntries(entries);
78
+ fromFile.contents = entries.map(e => e.username);
67
79
  return {
68
80
  TAG: "DefaultFile",
69
81
  _0: defaultPath
@@ -78,11 +90,118 @@ function load(users, usersFile, param) {
78
90
  }
79
91
  }
80
92
 
93
+ function reload(path) {
94
+ let e = AccountsManifest$Reventless.parseFile(path);
95
+ if (e.TAG !== "Ok") {
96
+ return {
97
+ TAG: "Error",
98
+ _0: e._0
99
+ };
100
+ }
101
+ let entries = e._0;
102
+ let names = entries.map(e => e.username);
103
+ let dropped = fromFile.contents.filter(n => !names.includes(n));
104
+ dropped.forEach(username => LocalAuth$ReventlessLocal.Login.removeCredentials(username));
105
+ _registerEntries(entries);
106
+ fromFile.contents = names;
107
+ return {
108
+ TAG: "Ok",
109
+ _0: names.length.toString() + ` accounts` + (
110
+ Primitive_object.equal(dropped, []) ? "" : `, removed: ` + dropped.join(", ")
111
+ )
112
+ };
113
+ }
114
+
115
+ function unmetAfterReload() {
116
+ let provided = Role$Reventless.providedGroups();
117
+ if (provided !== undefined) {
118
+ return RoleCoverage$Reventless.unmet(RoleCoverage$Reventless.checked.contents, provided);
119
+ } else {
120
+ return [];
121
+ }
122
+ }
123
+
124
+ let _watcher = {
125
+ contents: undefined
126
+ };
127
+
128
+ let _pending = {
129
+ contents: undefined
130
+ };
131
+
132
+ function unwatch() {
133
+ Stdlib_Option.forEach(_watcher.contents, prim => {
134
+ prim.close();
135
+ });
136
+ _watcher.contents = undefined;
137
+ Stdlib_Option.forEach(_pending.contents, prim => {
138
+ clearTimeout(prim);
139
+ });
140
+ _pending.contents = undefined;
141
+ }
142
+
143
+ function watch(path) {
144
+ unwatch();
145
+ let dir = Nodepath.dirname(path);
146
+ let base = Nodepath.basename(path);
147
+ let last = {
148
+ contents: Nodefs.existsSync(path) ? Nodefs.readFileSync(path, "utf8") : ""
149
+ };
150
+ let w;
151
+ try {
152
+ w = Nodefs.watch(dir, (param, file) => {
153
+ if (Stdlib_Option.mapOr((file == null) ? undefined : Primitive_option.some(file), true, f => f === base)) {
154
+ Stdlib_Option.forEach(_pending.contents, prim => {
155
+ clearTimeout(prim);
156
+ });
157
+ _pending.contents = Primitive_option.some(setTimeout(() => {
158
+ _pending.contents = undefined;
159
+ let text;
160
+ try {
161
+ text = Nodefs.readFileSync(path, "utf8");
162
+ } catch (exn) {
163
+ return;
164
+ }
165
+ if (text === last.contents) {
166
+ return;
167
+ }
168
+ last.contents = text;
169
+ let said = reload(path);
170
+ if (said.TAG === "Ok") {
171
+ console.log(`[LocalAuth] ` + base + ` changed — reloaded ` + said._0 + `.`);
172
+ let missing = unmetAfterReload();
173
+ if (missing.length !== 0) {
174
+ console.warn(`[LocalAuth] No account is in the group of: ` + missing.map(RoleCoverage$Reventless.describe).join("; ") + ". Every such caller is refused until one is.");
175
+ return;
176
+ } else {
177
+ return;
178
+ }
179
+ }
180
+ console.warn(`[LocalAuth] ` + base + ` changed but was not read: ` + said._0);
181
+ }, 100));
182
+ return;
183
+ }
184
+ });
185
+ } catch (exn) {
186
+ return;
187
+ }
188
+ _watcher.contents = Primitive_option.some(w.unref());
189
+ }
190
+
81
191
  function autoLoadOnce() {
82
- if (!resolved.contents) {
83
- load(undefined, undefined, undefined);
192
+ if (resolved.contents) {
193
+ return;
194
+ }
195
+ let match = load(undefined, undefined, undefined);
196
+ if (match.TAG !== "Ok") {
84
197
  return;
85
198
  }
199
+ let path = match._0;
200
+ if (typeof path !== "object") {
201
+ return;
202
+ } else {
203
+ return watch(path._0);
204
+ }
86
205
  }
87
206
 
88
207
  let parseString = AccountsManifest$Reventless.parseString;
@@ -94,6 +213,7 @@ let _defaultPath = AccountsManifest$Reventless.defaultPath;
94
213
  export {
95
214
  parseString,
96
215
  parseFile,
216
+ fromFile,
97
217
  _registerEntries,
98
218
  _defaultPath,
99
219
  resolved,
@@ -101,6 +221,12 @@ export {
101
221
  resetResolution,
102
222
  providedGroups,
103
223
  load,
224
+ reload,
225
+ unmetAfterReload,
226
+ _watcher,
227
+ _pending,
228
+ unwatch,
229
+ watch,
104
230
  autoLoadOnce,
105
231
  }
106
232
  /* node:fs Not a pure module */
@@ -45,6 +45,35 @@ let rejectForbidden = (~field: string): JSON.t =>
45
45
  }),
46
46
  )
47
47
 
48
+ // -- Limit on anonymous doors ---------------------------------------------------
49
+ // A door whose rule admits callers who are not signed in is counted per source
50
+ // address, whoever the caller turns out to be. See `LocalAnonymousLimit`.
51
+
52
+ @get @return(nullable) external sourceAddressOf: JSON.t => option<string> = "sourceAddress"
53
+
54
+ let rejectThrottled = (~field: string): JSON.t =>
55
+ CommandTopic.commandOutcomeToJson(
56
+ Rejected({
57
+ msgId: ReventlessCore.Message.uuid(),
58
+ errorCode: Reventless.CommandOutcome.code(Throttled),
59
+ errorDetail: Some(`Mutation.${field}: too many requests from this source; retry later`),
60
+ cause: InfrastructureFailure,
61
+ }),
62
+ )
63
+
64
+ /** `Some(rejection)` when `rule` admits anonymous callers and this request's
65
+ source has spent its allowance for the door. */
66
+ let throttled = (~rule: Reventless.Authorization.permission, ~field, ~ctx): option<JSON.t> =>
67
+ switch rule {
68
+ | AllowAnonymous
69
+ if !LocalAnonymousLimit.admit(
70
+ ~door=field,
71
+ ~source=ctx->sourceAddressOf->Option.getOr("unknown"),
72
+ ) =>
73
+ Some(rejectThrottled(~field))
74
+ | _ => None
75
+ }
76
+
48
77
  // -- Plugin status gate -------------------------------------------------------
49
78
  // Set by the platform after Admin.construct to wire a per-mutation lookup against
50
79
  // the Plugin read model. Takes a mutation field name (e.g. "Catalog_Product_Add"),
@@ -213,6 +242,8 @@ let register = (
213
242
  let rule = authorizationOf(commandName)
214
243
  if !Reventless.Authorization.isAllowed(rule, identity) {
215
244
  rejectForbidden(~field)
245
+ } else if throttled(~rule, ~field, ~ctx)->Option.isSome {
246
+ rejectThrottled(~field)
216
247
  } else {
217
248
  let payload: CommandGenerator.payload = {
218
249
  command: commandName,
@@ -283,6 +314,8 @@ let registerDcb = (
283
314
  let rule = authorizationOf(tag)
284
315
  if !Reventless.Authorization.isAllowed(rule, identity) {
285
316
  rejectForbidden(~field=fieldName)
317
+ } else if throttled(~rule, ~field=fieldName, ~ctx)->Option.isSome {
318
+ rejectThrottled(~field=fieldName)
286
319
  } else {
287
320
  let payload: CommandGenerator.payload = {
288
321
  command: tag,
@@ -5,6 +5,7 @@ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
5
5
  import * as Effect from "effect/Effect";
6
6
  import * as Pulumi from "@pulumi/pulumi";
7
7
  import * as Stdlib_JsError from "@rescript/runtime/lib/es6/Stdlib_JsError.js";
8
+ import * as Primitive_option from "@rescript/runtime/lib/es6/Primitive_option.js";
8
9
  import * as DcbTag$Reventless from "@reventlessdev/reventless-spec/src/components/DcbTag.res.mjs";
9
10
  import * as Identity$Reventless from "@reventlessdev/reventless-spec/src/types/Identity.res.mjs";
10
11
  import * as Primitive_exceptions from "@rescript/runtime/lib/es6/Primitive_exceptions.js";
@@ -13,6 +14,7 @@ import * as Authorization$Reventless from "@reventlessdev/reventless-spec/src/ty
13
14
  import * as CommandOutcome$Reventless from "@reventlessdev/reventless-spec/src/components/CommandOutcome.res.mjs";
14
15
  import * as CommandTopic$ReventlessCore from "@reventlessdev/reventless-core/src/components/CommandTopic/CommandTopic.res.mjs";
15
16
  import * as GraphQL_CallerError$ReventlessLocal from "../GraphQL_CallerError.res.mjs";
17
+ import * as LocalAnonymousLimit$ReventlessLocal from "../LocalAnonymousLimit.res.mjs";
16
18
  import * as Plugin_ResolverError$ReventlessCore from "@reventlessdev/reventless-core/src/plugin/component/Plugin_ResolverError.res.mjs";
17
19
  import * as GraphQL_FragmentGenerator$ReventlessCore from "@reventlessdev/reventless-core/src/components/Api/GraphQL_FragmentGenerator.res.mjs";
18
20
 
@@ -39,6 +41,22 @@ function rejectForbidden(field) {
39
41
  });
40
42
  }
41
43
 
44
+ function rejectThrottled(field) {
45
+ return CommandTopic$ReventlessCore.commandOutcomeToJson({
46
+ TAG: "Rejected",
47
+ msgId: Message$ReventlessCore.uuid(),
48
+ errorCode: CommandOutcome$Reventless.code("Throttled"),
49
+ errorDetail: `Mutation.` + field + `: too many requests from this source; retry later`,
50
+ cause: "InfrastructureFailure"
51
+ });
52
+ }
53
+
54
+ function throttled(rule, field, ctx) {
55
+ if (typeof rule !== "object" && rule === "AllowAnonymous" && !LocalAnonymousLimit$ReventlessLocal.admit(field, Stdlib_Option.getOr(Primitive_option.fromNullable(ctx.sourceAddress), "unknown"))) {
56
+ return rejectThrottled(field);
57
+ }
58
+ }
59
+
42
60
  let pluginStatusGate = {
43
61
  contents: undefined
44
62
  };
@@ -157,6 +175,9 @@ function register(fields, commandSchema, authorizationOf, server) {
157
175
  if (!Authorization$Reventless.isAllowed(rule, identity)) {
158
176
  return rejectForbidden(field);
159
177
  }
178
+ if (Stdlib_Option.isSome(throttled(rule, field, ctx))) {
179
+ return rejectThrottled(field);
180
+ }
160
181
  let payload_meta = {
161
182
  ip: [],
162
183
  user: identity.userId,
@@ -200,6 +221,9 @@ function registerDcb(fieldName, commandSchema, authorizationOf, server) {
200
221
  if (!Authorization$Reventless.isAllowed(rule, identity)) {
201
222
  return rejectForbidden(fieldName);
202
223
  }
224
+ if (Stdlib_Option.isSome(throttled(rule, fieldName, ctx))) {
225
+ return rejectThrottled(fieldName);
226
+ }
203
227
  let payload_meta = {
204
228
  ip: [],
205
229
  user: identity.userId,
@@ -255,6 +279,8 @@ let commandOutcomeToJson = CommandTopic$ReventlessCore.commandOutcomeToJson;
255
279
  export {
256
280
  extractIdentity,
257
281
  rejectForbidden,
282
+ rejectThrottled,
283
+ throttled,
258
284
  pluginStatusGate,
259
285
  setPluginStatusGate,
260
286
  resetPluginStatusGate,
@@ -15,7 +15,7 @@ let receiveRegistry: dict<receive> = Dict.make()
15
15
 
16
16
  type pendingCall = {
17
17
  inputJson: JSON.t,
18
- caller: option<Reventless.Identity.t>,
18
+ caller?: Reventless.Identity.t,
19
19
  resolve: ReventlessInfra.InboundTranslationSlice.receiveResult => unit,
20
20
  }
21
21
 
@@ -47,7 +47,7 @@ let register = (
47
47
 
48
48
  let queuingReceive: receive = (inputJson, ~caller=?) =>
49
49
  Promise.make((resolve, _reject) => {
50
- pendingQueue.contents->Array.push({inputJson, caller, resolve})
50
+ pendingQueue.contents->Array.push({inputJson, ?caller, resolve})
51
51
  })
52
52
  receiveRegistry->Dict.set(fieldName, queuingReceive)
53
53
 
@@ -61,6 +61,14 @@ let register = (
61
61
  permission->Option.mapOr(false, rule => Reventless.Authorization.isAllowed(rule, caller))
62
62
  if !admitted {
63
63
  CommandGeneratorResolvers_GraphQL.rejectForbidden(~field=fieldName)
64
+ } else if (
65
+ permission
66
+ ->Option.flatMap(rule =>
67
+ CommandGeneratorResolvers_GraphQL.throttled(~rule, ~field=fieldName, ~ctx)
68
+ )
69
+ ->Option.isSome
70
+ ) {
71
+ CommandGeneratorResolvers_GraphQL.rejectThrottled(~field=fieldName)
64
72
  } else {
65
73
  let receive = receiveRegistry->Dict.getUnsafe(fieldName)
66
74
  let result = await receive(args->Obj.magic, ~caller)
@@ -82,7 +90,7 @@ let bindReceive = (~fieldName: string, ~receive: receive) => {
82
90
  | Some(pendingQueue) =>
83
91
  let pending = pendingQueue.contents
84
92
  pendingQueue.contents = []
85
- pending->Array.forEach(({inputJson, caller, resolve}) => {
93
+ pending->Array.forEach(({inputJson, ?caller, resolve}) => {
86
94
  let _ = receive(inputJson, ~caller?)->Promise.thenResolve(result => resolve(result))
87
95
  })
88
96
  | None => ()
@@ -33,6 +33,9 @@ function register(fieldName, externalInputSchema, permission, server) {
33
33
  if (!admitted) {
34
34
  return CommandGeneratorResolvers_GraphQL$ReventlessLocal.rejectForbidden(fieldName);
35
35
  }
36
+ if (Stdlib_Option.isSome(Stdlib_Option.flatMap(permission, rule => CommandGeneratorResolvers_GraphQL$ReventlessLocal.throttled(rule, fieldName, ctx)))) {
37
+ return CommandGeneratorResolvers_GraphQL$ReventlessLocal.rejectThrottled(fieldName);
38
+ }
36
39
  let receive = receiveRegistry[fieldName];
37
40
  let result = await receive(args, caller);
38
41
  return CommandTopic$ReventlessCore.commandOutcomeToJson(InboundTranslationSlice_Callback$ReventlessCore.receiveResultToOutcome(result));
@@ -908,7 +908,7 @@ let diagnostics = (): diagnostics => {
908
908
  resolverMutationCount: mutResolverNames->Array.length,
909
909
  resolverQueryCount: queryResolverNames->Array.length,
910
910
  mismatches,
911
- fullSdl: lastFullSdl.contents,
911
+ fullSdl: ?lastFullSdl.contents,
912
912
  serverRunning: activeServer.contents->Option.isSome,
913
913
  }
914
914
  }
@@ -17,3 +17,7 @@ external make: (string, {"extensions": {"code": string}}) => exn = "GraphQLError
17
17
  `BAD_USER_INPUT` sense. */
18
18
  let badUserInput = (message: string): exn =>
19
19
  make(message, {"extensions": {"code": "BAD_USER_INPUT"}})
20
+
21
+ /** A door the caller's source has knocked on too often. The same code the
22
+ mutation side answers with, so a client reads one word for both. */
23
+ let throttled = (message: string): exn => make(message, {"extensions": {"code": "Throttled"}})
@@ -10,7 +10,16 @@ function badUserInput(message) {
10
10
  });
11
11
  }
12
12
 
13
+ function throttled(message) {
14
+ return new Graphql.GraphQLError(message, {
15
+ extensions: {
16
+ code: "Throttled"
17
+ }
18
+ });
19
+ }
20
+
13
21
  export {
14
22
  badUserInput,
23
+ throttled,
15
24
  }
16
25
  /* graphql Not a pure module */
@@ -0,0 +1,35 @@
1
+ // The local platform's limit on doors open to callers who are not signed in:
2
+ // requests per source address per window, read from
3
+ // `REVENTLESS_ANONYMOUS_REQUEST_LIMIT` (`10/3600` when unset).
4
+ //
5
+ // One limiter for the process, built on first use so a test can set the
6
+ // variable first. A value that does not parse fails the request rather than
7
+ // falling back: a limit someone set and mistyped must not quietly become one they
8
+ // did not choose.
9
+
10
+ let limiter: ref<option<ReventlessCore.AnonymousLimiter.t>> = ref(None)
11
+
12
+ let configured = (): Reventless.AnonymousRequestLimit.t =>
13
+ switch NodeProcess.env->Dict.get(Reventless.AnonymousRequestLimit.envKey) {
14
+ | None | Some("") => Reventless.AnonymousRequestLimit.default
15
+ | Some(raw) =>
16
+ switch Reventless.AnonymousRequestLimit.parse(raw) {
17
+ | Ok(limit) => limit
18
+ | Error(message) => JsError.throwWithMessage(message)
19
+ }
20
+ }
21
+
22
+ let current = (): ReventlessCore.AnonymousLimiter.t =>
23
+ switch limiter.contents {
24
+ | Some(l) => l
25
+ | None =>
26
+ let l = ReventlessCore.AnonymousLimiter.make(~limit=configured())
27
+ limiter := Some(l)
28
+ l
29
+ }
30
+
31
+ /** Forget every count and re-read the configuration on next use. */
32
+ let reset = () => limiter := None
33
+
34
+ /** Whether one more request from `source` reaches `door`. */
35
+ let admit = (~door: string, ~source: string): bool => current().admit(~door, ~source)
@@ -0,0 +1,52 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Stdlib_JsError from "@rescript/runtime/lib/es6/Stdlib_JsError.js";
4
+ import * as AnonymousLimiter$ReventlessCore from "@reventlessdev/reventless-core/src/adapter/Throttle/AnonymousLimiter.res.mjs";
5
+ import * as AnonymousRequestLimit$Reventless from "@reventlessdev/reventless-spec/src/types/AnonymousRequestLimit.res.mjs";
6
+
7
+ let limiter = {
8
+ contents: undefined
9
+ };
10
+
11
+ function configured() {
12
+ let raw = process.env[AnonymousRequestLimit$Reventless.envKey];
13
+ if (raw === undefined) {
14
+ return AnonymousRequestLimit$Reventless.default;
15
+ }
16
+ if (raw === "") {
17
+ return AnonymousRequestLimit$Reventless.default;
18
+ }
19
+ let limit = AnonymousRequestLimit$Reventless.parse(raw);
20
+ if (limit.TAG === "Ok") {
21
+ return limit._0;
22
+ } else {
23
+ return Stdlib_JsError.throwWithMessage(limit._0);
24
+ }
25
+ }
26
+
27
+ function current() {
28
+ let l = limiter.contents;
29
+ if (l !== undefined) {
30
+ return l;
31
+ }
32
+ let l$1 = AnonymousLimiter$ReventlessCore.make(configured(), undefined);
33
+ limiter.contents = l$1;
34
+ return l$1;
35
+ }
36
+
37
+ function reset() {
38
+ limiter.contents = undefined;
39
+ }
40
+
41
+ function admit(door, source) {
42
+ return current().admit(door, source);
43
+ }
44
+
45
+ export {
46
+ limiter,
47
+ configured,
48
+ current,
49
+ reset,
50
+ admit,
51
+ }
52
+ /* No side effect */
@@ -39,12 +39,14 @@ let registerIdentityProvider = (provider: Reventless.IdentityProvider.t) =>
39
39
  read off the same refs `capabilities()` reads, so it cannot disagree with
40
40
  what a slice is handed. Geocoding is never here — no local geocoder exists. */
41
41
  let provisioned = (): array<Reventless.CapabilityNeed.t> =>
42
- Array.concat(
42
+ [
43
43
  messagingSenderRef.contents == "" ? [] : [Reventless.CapabilityNeed.Messaging],
44
44
  identityProviderRef.contents.operations == []
45
45
  ? []
46
46
  : [Reventless.CapabilityNeed.IdentityProvider],
47
- )
47
+ // Always: an in-memory stash needs nothing a root could fail to provide.
48
+ [Reventless.CapabilityNeed.Stash],
49
+ ]->Array.flat
48
50
 
49
51
  /**
50
52
  The warning for a plugin declaring needs this platform does not provision, or
@@ -69,6 +71,8 @@ let unmetWarning = (~declared: array<(string, string)>): option<string> =>
69
71
  `\n Messaging is \`Capability_Messaging_Log.make()\`, passed to the platform as ` ++ `\`~messagingSender\`.`
70
72
  | IdentityProvider =>
71
73
  `\n IdentityProvider is \`Capability_IdentityProvider_UserStore.make()\`, passed to the ` ++ `platform as \`~identityProvider\`.`
74
+ // Never unmet here: the local platform always provisions one.
75
+ | Stash => ``
72
76
  }
73
77
  )
74
78
  ->Belt.Set.String.fromArray
@@ -77,9 +81,17 @@ let unmetWarning = (~declared: array<(string, string)>): option<string> =>
77
81
  )
78
82
  }
79
83
 
84
+ // One stash for the process: the door that puts and the slice that takes both run
85
+ // here, and a stash per call would hand the slice an empty one.
86
+ let stash = ref(ReventlessCore.Stash_Memory_Backend.make())
87
+
88
+ /** A fresh stash, for a test that must not see another's values. */
89
+ let resetStash = () => stash := ReventlessCore.Stash_Memory_Backend.make()
90
+
80
91
  let capabilities = (): Reventless.Capabilities.t => {
81
92
  geocode: Reventless.Capabilities.none.geocode,
82
93
  messaging: ReventlessCore.Messaging_Log_Backend.provider(~sender=messagingSenderRef.contents),
83
94
  identityProvider: identityProviderRef.contents,
84
95
  secrets: ReventlessCore.Secrets_Node_Backend.provider,
96
+ stash: stash.contents,
85
97
  }
@@ -5,6 +5,7 @@ import * as Primitive_object from "@rescript/runtime/lib/es6/Primitive_object.js
5
5
  import * as Capabilities$Reventless from "@reventlessdev/reventless-spec/src/semantic/Capabilities.res.mjs";
6
6
  import * as CapabilityNeed$Reventless from "@reventlessdev/reventless-spec/src/semantic/CapabilityNeed.res.mjs";
7
7
  import * as Secrets_Node_Backend$ReventlessCore from "@reventlessdev/reventless-core/src/adapter/Secrets/Secrets_Node_Backend.res.mjs";
8
+ import * as Stash_Memory_Backend$ReventlessCore from "@reventlessdev/reventless-core/src/adapter/Stash/Stash_Memory_Backend.res.mjs";
8
9
  import * as Messaging_Log_Backend$ReventlessCore from "@reventlessdev/reventless-core/src/adapter/Messaging/Messaging_Log_Backend.res.mjs";
9
10
 
10
11
  let messagingSenderRef = {
@@ -24,9 +25,11 @@ function registerIdentityProvider(provider) {
24
25
  }
25
26
 
26
27
  function provisioned() {
27
- return (
28
- messagingSenderRef.contents === "" ? [] : ["Messaging"]
29
- ).concat(Primitive_object.equal(identityProviderRef.contents.operations, []) ? [] : ["IdentityProvider"]);
28
+ return [
29
+ messagingSenderRef.contents === "" ? [] : ["Messaging"],
30
+ Primitive_object.equal(identityProviderRef.contents.operations, []) ? [] : ["IdentityProvider"],
31
+ ["Stash"]
32
+ ].flat();
30
33
  }
31
34
 
32
35
  function unmetWarning(declared) {
@@ -41,17 +44,28 @@ function unmetWarning(declared) {
41
44
  return `\n Messaging is \`Capability_Messaging_Log.make()\`, passed to the platform as \`~messagingSender\`.`;
42
45
  case "IdentityProvider" :
43
46
  return `\n IdentityProvider is \`Capability_IdentityProvider_UserStore.make()\`, passed to the platform as \`~identityProvider\`.`;
47
+ case "Stash" :
48
+ return ``;
44
49
  }
45
50
  }))).join("");
46
51
  }
47
52
  }
48
53
 
54
+ let stash = {
55
+ contents: Stash_Memory_Backend$ReventlessCore.make(undefined)
56
+ };
57
+
58
+ function resetStash() {
59
+ stash.contents = Stash_Memory_Backend$ReventlessCore.make(undefined);
60
+ }
61
+
49
62
  function capabilities() {
50
63
  return {
51
64
  geocode: Capabilities$Reventless.none.geocode,
52
65
  messaging: Messaging_Log_Backend$ReventlessCore.provider(messagingSenderRef.contents),
53
66
  identityProvider: identityProviderRef.contents,
54
- secrets: Secrets_Node_Backend$ReventlessCore.provider
67
+ secrets: Secrets_Node_Backend$ReventlessCore.provider,
68
+ stash: stash.contents
55
69
  };
56
70
  }
57
71
 
@@ -62,6 +76,8 @@ export {
62
76
  registerIdentityProvider,
63
77
  provisioned,
64
78
  unmetWarning,
79
+ stash,
80
+ resetStash,
65
81
  capabilities,
66
82
  }
67
- /* Capabilities-Reventless Not a pure module */
83
+ /* stash Not a pure module */
@@ -43,6 +43,8 @@ module Make = (Bus: LocalBus.T) => {
43
43
  // `Identity.t` to `ctx.identity`. Fallback to anonymous only when ctx is
44
44
  // malformed.
45
45
 
46
+ @get @return(nullable) external sourceAddressOf: JSON.t => option<string> = "sourceAddress"
47
+
46
48
  let extractIdentity = (ctx: JSON.t): Reventless.Identity.t => {
47
49
  try {
48
50
  switch (ctx->Obj.magic)["identity"]->Nullable.toOption {
@@ -133,6 +135,26 @@ module Make = (Bus: LocalBus.T) => {
133
135
  // the user-supplied interceptor (mirrors mutation enforcement).
134
136
  if !Reventless.Authorization.isAllowed(authorization, identity) {
135
137
  Deny("Forbidden")
138
+ } else if (
139
+ // A view open to callers who are not signed in is counted per source
140
+ // address like any anonymous door, whoever the caller turns out to be.
141
+ switch authorization {
142
+ | AllowAnonymous =>
143
+ !LocalAnonymousLimit.admit(
144
+ ~door=name,
145
+ ~source=ctx->sourceAddressOf->Option.getOr("unknown"),
146
+ )
147
+ | _ => false
148
+ }
149
+ ) {
150
+ // An error, not a denial: a denied read answers an empty page, and an
151
+ // empty page from this door would read as "nothing to say" — a visitor
152
+ // asking which roles are open would be told all of them are.
153
+ throw(
154
+ GraphQL_CallerError.throttled(
155
+ `Query.${name}: too many requests from this source; retry later`,
156
+ ),
157
+ )
136
158
  } else {
137
159
  switch QueryDb_Callback.queryInterceptorHook.contents {
138
160
  | None => Allow
@@ -9,6 +9,7 @@ import * as Effect from "effect/Effect";
9
9
  import * as Stdlib_Nullable from "@rescript/runtime/lib/es6/Stdlib_Nullable.js";
10
10
  import * as Owner$Reventless from "@reventlessdev/reventless-spec/src/components/Owner.res.mjs";
11
11
  import * as Primitive_object from "@rescript/runtime/lib/es6/Primitive_object.js";
12
+ import * as Primitive_option from "@rescript/runtime/lib/es6/Primitive_option.js";
12
13
  import * as Identity$Reventless from "@reventlessdev/reventless-spec/src/types/Identity.res.mjs";
13
14
  import * as RowImage$Reventless from "@reventlessdev/reventless-spec/src/semantic/RowImage.res.mjs";
14
15
  import * as ReadModel$Reventless from "@reventlessdev/reventless-spec/src/components/ReadModel.res.mjs";
@@ -21,6 +22,7 @@ import * as SortKey_Filter$ReventlessLocal from "./SortKey_Filter.res.mjs";
21
22
  import * as QueryDbListQuery$ReventlessCore from "@reventlessdev/reventless-core/src/components/Api/QueryDbListQuery.res.mjs";
22
23
  import * as QueryDb_Callback$ReventlessCore from "@reventlessdev/reventless-core/src/components/QueryDb/QueryDb_Callback.res.mjs";
23
24
  import * as GraphQL_CallerError$ReventlessLocal from "../GraphQL_CallerError.res.mjs";
25
+ import * as LocalAnonymousLimit$ReventlessLocal from "../LocalAnonymousLimit.res.mjs";
24
26
  import * as DomainGraphQL_Server$ReventlessLocal from "../DomainGraphQL_Server.res.mjs";
25
27
  import * as OwnerScopeDiagnostics$ReventlessCore from "@reventlessdev/reventless-core/src/components/Api/OwnerScopeDiagnostics.res.mjs";
26
28
  import * as GraphQL_FragmentGenerator$ReventlessCore from "@reventlessdev/reventless-core/src/components/Api/GraphQL_FragmentGenerator.res.mjs";
@@ -87,6 +89,11 @@ function Make(Bus) {
87
89
  _0: "Forbidden"
88
90
  };
89
91
  }
92
+ let tmp;
93
+ tmp = typeof authorization !== "object" && authorization === "AllowAnonymous" ? !LocalAnonymousLimit$ReventlessLocal.admit(name, Stdlib_Option.getOr(Primitive_option.fromNullable(ctx.sourceAddress), "unknown")) : false;
94
+ if (tmp) {
95
+ throw GraphQL_CallerError$ReventlessLocal.throttled(`Query.` + name + `: too many requests from this source; retry later`);
96
+ }
90
97
  let interceptor = QueryDb_Callback$ReventlessCore.queryInterceptorHook.contents;
91
98
  if (interceptor !== undefined) {
92
99
  return await interceptor(identity, name, args);