@reventlessdev/reventless-local 3.0.0-alpha.298 → 3.0.0-alpha.300
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 +37 -0
- package/package.json +12 -12
- package/src/EmitCapabilities.res +3 -3
- package/src/LocalPlatformRegistry.res +1 -1
- package/src/LocalSeedTarget.res +1 -1
- package/src/Platform.res +57 -3
- package/src/Platform.res.mjs +61 -6
- package/src/ShellConfig.res +11 -1
- package/src/ShellConfig.res.mjs +10 -4
- package/src/adapter/Auth/Auth_GraphqlContext.res +18 -2
- package/src/adapter/Auth/Auth_GraphqlContext.res.mjs +8 -1
- package/src/adapter/Auth/UserStore.res +102 -3
- package/src/adapter/Auth/UserStore.res.mjs +128 -2
- package/src/adapter/CommandGenerator/CommandGeneratorResolvers_GraphQL.res +33 -0
- package/src/adapter/CommandGenerator/CommandGeneratorResolvers_GraphQL.res.mjs +26 -0
- package/src/adapter/CommandGenerator/InboundTranslationResolvers_GraphQL.res +11 -3
- package/src/adapter/CommandGenerator/InboundTranslationResolvers_GraphQL.res.mjs +3 -0
- package/src/adapter/DomainGraphQL_Server.res +1 -1
- package/src/adapter/GraphQL_CallerError.res +4 -0
- package/src/adapter/GraphQL_CallerError.res.mjs +9 -0
- package/src/adapter/LocalAnonymousLimit.res +35 -0
- package/src/adapter/LocalAnonymousLimit.res.mjs +52 -0
- package/src/adapter/LocalCapabilities.res +14 -2
- package/src/adapter/LocalCapabilities.res.mjs +21 -5
- package/src/adapter/QueryDb/QueryDbResolvers_GraphQL.res +22 -0
- package/src/adapter/QueryDb/QueryDbResolvers_GraphQL.res.mjs +7 -0
- package/src/components/InboundTranslationSlice_Builder.res +1 -0
- package/src/components/InboundTranslationSlice_Builder.res.mjs +4 -1
- package/tests/EmitCapabilitiesTest.res +4 -6
- package/tests/EmitCapabilitiesTest.res.mjs +1 -2
- package/tests/HostShellDistTest.res.mjs +1 -1
- package/tests/LocalPlatformRegistryTest.res +3 -5
- package/tests/LocalPlatformRegistryTest.res.mjs +2 -4
- package/tests/LocalPlatformStartTest.res +2 -2
- package/tests/LocalPlatformStartTest.res.mjs +1 -2
- package/tests/ShellConfigTest.res.mjs +27 -27
- package/tests/adapter/InboundTranslationMutationTest.res +13 -2
- package/tests/adapter/InboundTranslationMutationTest.res.mjs +7 -4
- package/tests/adapter/LocalAnonymousLimitTest.res +70 -0
- package/tests/adapter/LocalAnonymousLimitTest.res.mjs +85 -0
- package/tests/adapter/LocalAuthLoginTest.res +2 -2
- package/tests/adapter/LocalAuthUserStoreTest.res +88 -0
- package/tests/adapter/LocalAuthUserStoreTest.res.mjs +83 -0
- package/tests/capability/LocalCapabilitiesTest.res +2 -1
- package/tests/capability/LocalCapabilitiesTest.res.mjs +3 -2
- package/tests/components/inboundtranslationslice/InboundTranslationCredentialTest.res +136 -0
- package/tests/components/inboundtranslationslice/InboundTranslationCredentialTest.res.mjs +183 -0
- package/tests/components/inboundtranslationslice/InboundTranslationSliceCallbackTest.res +143 -11
- package/tests/components/inboundtranslationslice/InboundTranslationSliceCallbackTest.res.mjs +129 -22
- package/tests/components/inboundtranslationslice/InboundTranslationSliceFixtures.res +18 -5
- package/tests/components/inboundtranslationslice/InboundTranslationSliceFixtures.res.mjs +12 -3
- package/tests/components/inboundtranslationslice/InboundTranslationWebhookTest.res +26 -6
- package/tests/components/inboundtranslationslice/InboundTranslationWebhookTest.res.mjs +48 -13
- package/tests/plugin/Plugin_Fixtures.res +0 -4
- 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 (
|
|
83
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/*
|
|
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);
|