@reventlessdev/reventless-core 3.0.0-alpha.296 → 3.0.0-alpha.298
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 +25 -0
- package/package.json +9 -9
- package/src/components/Api/GraphQL_FragmentGenerator.res +15 -0
- package/src/components/Api/GraphQL_FragmentGenerator.res.mjs +13 -0
- package/src/components/AutomationSlice/AutomationSlice_Builder.res +2 -2
- package/src/components/AutomationSlice/AutomationSlice_Builder.res.mjs +5 -2
- package/src/components/Dcb/Dcb_Builder.res +39 -12
- package/src/components/Dcb/Dcb_Builder.res.mjs +18 -8
- package/src/components/InboundTranslationSlice/InboundTranslationSlice_Builder.res +8 -3
- package/src/components/InboundTranslationSlice/InboundTranslationSlice_Builder.res.mjs +10 -3
- package/src/components/InboundTranslationSlice/InboundTranslationSlice_Callback.res +45 -10
- package/src/components/InboundTranslationSlice/InboundTranslationSlice_Callback.res.mjs +48 -23
- package/src/components/InboundTranslationSlice/InboundTranslationSlice_Webhook.res +231 -0
- package/src/components/InboundTranslationSlice/InboundTranslationSlice_Webhook.res.mjs +294 -0
- package/src/components/OutboundTranslationSlice/OutboundTranslationSlice_Builder.res +1 -1
- package/src/components/OutboundTranslationSlice/OutboundTranslationSlice_Builder.res.mjs +3 -1
- package/tests/plugin/PluginStructureAccessTest.res +1 -0
- package/tests/plugin/PluginStructureAccessTest.res.mjs +19 -3
|
@@ -7,6 +7,7 @@ import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
|
|
|
7
7
|
import * as Stdlib_JsExn from "@rescript/runtime/lib/es6/Stdlib_JsExn.js";
|
|
8
8
|
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
9
9
|
import * as Effect from "effect/Effect";
|
|
10
|
+
import * as Stdlib_JsError from "@rescript/runtime/lib/es6/Stdlib_JsError.js";
|
|
10
11
|
import * as Primitive_option from "@rescript/runtime/lib/es6/Primitive_option.js";
|
|
11
12
|
import * as Message$Reventless from "@reventlessdev/reventless-spec/src/types/Message.res.mjs";
|
|
12
13
|
import * as Identity$Reventless from "@reventlessdev/reventless-spec/src/types/Identity.res.mjs";
|
|
@@ -16,6 +17,9 @@ import * as OwnerScope$Reventless from "@reventlessdev/reventless-spec/src/types
|
|
|
16
17
|
import * as Message$ReventlessCore from "../../Message.res.mjs";
|
|
17
18
|
import * as Authorization$Reventless from "@reventlessdev/reventless-spec/src/types/Authorization.res.mjs";
|
|
18
19
|
import * as EffectLogger$ReventlessCore from "../../util/EffectLogger.res.mjs";
|
|
20
|
+
import * as ApiNoApiHelpers$ReventlessCore from "../Api/ApiNoApiHelpers.res.mjs";
|
|
21
|
+
import * as InboundTranslationSlice$ReventlessInfra from "@reventlessdev/reventless-infra/src/components/InboundTranslationSlice.res.mjs";
|
|
22
|
+
import * as InboundTranslationSlice_Webhook$ReventlessCore from "./InboundTranslationSlice_Webhook.res.mjs";
|
|
19
23
|
|
|
20
24
|
let auditStatusSchema = Sury.union([
|
|
21
25
|
Sury.literal("Success"),
|
|
@@ -28,7 +32,8 @@ let auditRowSchema = Sury.$schema(s => ({
|
|
|
28
32
|
targetIds: s.m(Sury.$option(Sury.array(Sury.string))),
|
|
29
33
|
commandCount: s.m(Sury.$option(Sury.int32)),
|
|
30
34
|
error: s.m(Sury.$option(Sury.string)),
|
|
31
|
-
receivedAt: s.m(Sury.string)
|
|
35
|
+
receivedAt: s.m(Sury.string),
|
|
36
|
+
door: s.m(Sury.$option(InboundTranslationSlice$ReventlessInfra.doorSchema))
|
|
32
37
|
}));
|
|
33
38
|
|
|
34
39
|
function receiveResultToOutcome(result) {
|
|
@@ -87,26 +92,35 @@ function doorCaller(identity) {
|
|
|
87
92
|
return Stdlib_Option.getOr((identity == null) ? undefined : Primitive_option.some(identity), Identity$Reventless.anonymous);
|
|
88
93
|
}
|
|
89
94
|
|
|
95
|
+
function exposesMutation(externalInputSchema) {
|
|
96
|
+
return !ApiNoApiHelpers$ReventlessCore.isNoApi(externalInputSchema);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function checkReachable(plugin, slice, mutation, webhook) {
|
|
100
|
+
if (!mutation && !webhook) {
|
|
101
|
+
return Stdlib_JsError.throwWithMessage(plugin + `: inbound translation slice ` + slice + ` has @noApi on externalInput and declares no endpoint, so nothing can reach it`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
90
105
|
function Make(Spec) {
|
|
91
106
|
return Translation => {
|
|
92
107
|
let auditLog = {};
|
|
93
108
|
let makeMeta = () => Message$ReventlessCore.generateMeta(Spec.targetName, undefined, undefined, undefined, undefined, undefined, undefined, undefined);
|
|
94
109
|
let messageOf = (exn, fallback) => Stdlib_Option.getOr(Stdlib_Option.flatMap(Stdlib_JsExn.fromException(exn), Stdlib_JsExn.message), fallback);
|
|
95
|
-
let
|
|
96
|
-
let commandCount = targetIds.length;
|
|
110
|
+
let failWith = (requestId, input, door, errorCode, error) => {
|
|
97
111
|
auditLog[requestId] = {
|
|
98
|
-
input:
|
|
99
|
-
status: "
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
112
|
+
input: input,
|
|
113
|
+
status: "Failure",
|
|
114
|
+
error: error,
|
|
115
|
+
receivedAt: new Date().toISOString(),
|
|
116
|
+
door: door
|
|
103
117
|
};
|
|
104
118
|
return {
|
|
105
|
-
TAG: "
|
|
119
|
+
TAG: "Error",
|
|
106
120
|
_0: {
|
|
107
121
|
requestId: requestId,
|
|
108
|
-
|
|
109
|
-
|
|
122
|
+
error: error,
|
|
123
|
+
errorCode: errorCode
|
|
110
124
|
}
|
|
111
125
|
};
|
|
112
126
|
};
|
|
@@ -129,21 +143,26 @@ function Make(Spec) {
|
|
|
129
143
|
};
|
|
130
144
|
}
|
|
131
145
|
};
|
|
132
|
-
let receive = async (publishJsons, inputJson, caller) => {
|
|
146
|
+
let receive = async (publishJsons, inputJson, caller, doorOpt) => {
|
|
147
|
+
let door = doorOpt !== undefined ? doorOpt : "Mutation";
|
|
133
148
|
let requestId = Uuid.v4();
|
|
134
|
-
let fail = (errorCode, error) =>
|
|
149
|
+
let fail = (errorCode, error) => failWith(requestId, JSON.stringify(inputJson), door, errorCode, error);
|
|
150
|
+
let succeed = extra => {
|
|
151
|
+
let commandCount = extra.length;
|
|
135
152
|
auditLog[requestId] = {
|
|
136
153
|
input: JSON.stringify(inputJson),
|
|
137
|
-
status: "
|
|
138
|
-
|
|
139
|
-
|
|
154
|
+
status: "Success",
|
|
155
|
+
targetIds: extra,
|
|
156
|
+
commandCount: commandCount,
|
|
157
|
+
receivedAt: new Date().toISOString(),
|
|
158
|
+
door: door
|
|
140
159
|
};
|
|
141
160
|
return {
|
|
142
|
-
TAG: "
|
|
161
|
+
TAG: "Ok",
|
|
143
162
|
_0: {
|
|
144
163
|
requestId: requestId,
|
|
145
|
-
|
|
146
|
-
|
|
164
|
+
targetIds: extra,
|
|
165
|
+
commandCount: commandCount
|
|
147
166
|
}
|
|
148
167
|
};
|
|
149
168
|
};
|
|
@@ -197,20 +216,24 @@ function Make(Spec) {
|
|
|
197
216
|
return fail("Forbidden", Spec.name + `: the caller is not authorized for every command this input translates into`);
|
|
198
217
|
}
|
|
199
218
|
if (msgs.length === 0) {
|
|
200
|
-
return succeed(
|
|
219
|
+
return succeed([]);
|
|
201
220
|
}
|
|
202
221
|
try {
|
|
203
222
|
await publishJsons(msgs);
|
|
204
|
-
return succeed(
|
|
223
|
+
return succeed(pairs.map(param => param[0]));
|
|
205
224
|
} catch (raw_exn$1) {
|
|
206
225
|
let exn$1 = Primitive_exceptions.internalToException(raw_exn$1);
|
|
207
|
-
return fail(
|
|
226
|
+
return fail("PublishFailed", messageOf(exn$1, "publish failed"));
|
|
208
227
|
}
|
|
209
228
|
};
|
|
229
|
+
let refuse = (body, errorCode, error) => failWith(Uuid.v4(), body, "Webhook", errorCode, error);
|
|
230
|
+
let webhook = Stdlib_Option.map(Spec.endpoint, endpoint => InboundTranslationSlice_Webhook$ReventlessCore.routeOf(Spec.name, endpoint));
|
|
210
231
|
return {
|
|
211
232
|
Spec: Spec,
|
|
212
233
|
auditLog: auditLog,
|
|
213
|
-
receive: receive
|
|
234
|
+
receive: receive,
|
|
235
|
+
refuse: refuse,
|
|
236
|
+
webhook: webhook
|
|
214
237
|
};
|
|
215
238
|
};
|
|
216
239
|
}
|
|
@@ -223,6 +246,8 @@ export {
|
|
|
223
246
|
takeAuditRow,
|
|
224
247
|
callerAdmits,
|
|
225
248
|
doorCaller,
|
|
249
|
+
exposesMutation,
|
|
250
|
+
checkReachable,
|
|
226
251
|
Make,
|
|
227
252
|
}
|
|
228
253
|
/* auditStatusSchema Not a pure module */
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
// The HTTP door of an inbound translation slice: find the slice a request's path
|
|
2
|
+
// names, check its signature over the raw body, and hand the parsed body to
|
|
3
|
+
// `receive` as the caller the endpoint stands for. Runtime-pure — the AWS command
|
|
4
|
+
// Lambda and the local registry both serve through it.
|
|
5
|
+
|
|
6
|
+
type request = {
|
|
7
|
+
method: string,
|
|
8
|
+
path: string,
|
|
9
|
+
/** Header names lower-cased. */
|
|
10
|
+
headers: dict<string>,
|
|
11
|
+
/** The body exactly as sent: a signature is over these bytes, not over a re-encoding. */
|
|
12
|
+
body: string,
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
type response = {status: int, body: string}
|
|
16
|
+
|
|
17
|
+
/** An endpoint with its slice's role erased to a role name. */
|
|
18
|
+
type route = {
|
|
19
|
+
slice: string,
|
|
20
|
+
path: string,
|
|
21
|
+
method: string,
|
|
22
|
+
auth: Reventless.InboundTranslationSlice.webhookAuth,
|
|
23
|
+
roles: array<Reventless.Role.name>,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** A route and the slice operations that serve it. `receive` is the webhook door's. */
|
|
27
|
+
type slice = {
|
|
28
|
+
route: route,
|
|
29
|
+
receive: (
|
|
30
|
+
JSON.t,
|
|
31
|
+
~caller: Reventless.Identity.t,
|
|
32
|
+
) => promise<ReventlessInfra.InboundTranslationSlice.receiveResult>,
|
|
33
|
+
refuse: (
|
|
34
|
+
~body: string,
|
|
35
|
+
~errorCode: string,
|
|
36
|
+
string,
|
|
37
|
+
) => promise<ReventlessInfra.InboundTranslationSlice.receiveResult>,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
let normalizePath = (path: string): string =>
|
|
41
|
+
path->String.split("/")->Array.filter(segment => segment != "")->Array.join("/")
|
|
42
|
+
|
|
43
|
+
let routeOf = (
|
|
44
|
+
~slice: string,
|
|
45
|
+
endpoint: Reventless.InboundTranslationSlice.endpointConfig<'role>,
|
|
46
|
+
): route => {
|
|
47
|
+
slice,
|
|
48
|
+
path: normalizePath(endpoint.path),
|
|
49
|
+
method: (endpoint.method->Option.getOr(Post) :> string),
|
|
50
|
+
auth: endpoint.auth,
|
|
51
|
+
roles: switch endpoint.role {
|
|
52
|
+
| Some(role) =>
|
|
53
|
+
Reventless.Authorization.AllowRoles([role])
|
|
54
|
+
->Reventless.Authorization.named
|
|
55
|
+
->Reventless.Authorization.rolesOf
|
|
56
|
+
| None => []
|
|
57
|
+
},
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The environment variable a named secret is read from. */
|
|
61
|
+
let secretEnvKey = (name: string): string =>
|
|
62
|
+
"REVENTLESS_WEBHOOK_SECRET_" ++ name->String.toUpperCase->String.replaceRegExp(/[^A-Z0-9]/g, "_")
|
|
63
|
+
|
|
64
|
+
/** The secrets a variable holds: several, comma-separated, while one is rotated. */
|
|
65
|
+
let secretsOf = (raw: string): array<string> =>
|
|
66
|
+
raw->String.split(",")->Array.map(String.trim)->Array.filter(secret => secret != "")
|
|
67
|
+
|
|
68
|
+
@val external env: dict<string> = "process.env"
|
|
69
|
+
|
|
70
|
+
let secretFromEnv = (name: string): option<string> => env->Dict.get(secretEnvKey(name))
|
|
71
|
+
|
|
72
|
+
// Compares every character whatever the first difference, so the time taken does
|
|
73
|
+
// not tell a forger how much of a guess was right.
|
|
74
|
+
let sameText = (a: string, b: string): bool => {
|
|
75
|
+
let code = (text, i) => text->String.charCodeAt(i)->Option.getOr(0)
|
|
76
|
+
a->String.length == b->String.length && {
|
|
77
|
+
let diff = ref(0)
|
|
78
|
+
for i in 0 to a->String.length - 1 {
|
|
79
|
+
diff := diff.contents->Int.bitwiseOr(code(a, i)->Int.bitwiseXor(code(b, i)))
|
|
80
|
+
}
|
|
81
|
+
diff.contents == 0
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
let hmacHex = (~secret: string, text: string): string =>
|
|
86
|
+
NodeCrypto.createHmac("sha256", secret)->NodeCrypto.hmacUpdate(text)->NodeCrypto.hmacDigest("hex")
|
|
87
|
+
|
|
88
|
+
/** Whether `body` carries a valid signature by one of `secrets`. */
|
|
89
|
+
let verify = (
|
|
90
|
+
signature: Reventless.InboundTranslationSlice.signature,
|
|
91
|
+
~secrets: array<string>,
|
|
92
|
+
~headers: dict<string>,
|
|
93
|
+
~body: string,
|
|
94
|
+
~nowSeconds: float,
|
|
95
|
+
): result<unit, string> => {
|
|
96
|
+
let header = name => headers->Dict.get(name->String.toLowerCase)
|
|
97
|
+
let given = switch (header(signature.header), signature.prefix) {
|
|
98
|
+
| (None, _) => Error(`missing ${signature.header} header`)
|
|
99
|
+
| (Some(raw), None) => Ok(raw)
|
|
100
|
+
| (Some(raw), Some(prefix)) if raw->String.startsWith(prefix) =>
|
|
101
|
+
Ok(raw->String.slice(~start=prefix->String.length))
|
|
102
|
+
| (Some(_), Some(prefix)) => Error(`${signature.header} does not start with "${prefix}"`)
|
|
103
|
+
}
|
|
104
|
+
let signed = switch signature.timestampHeader {
|
|
105
|
+
| None => Ok(body)
|
|
106
|
+
| Some(name) =>
|
|
107
|
+
switch header(name) {
|
|
108
|
+
| None => Error(`missing ${name} header`)
|
|
109
|
+
| Some(raw) =>
|
|
110
|
+
switch raw->Float.fromString {
|
|
111
|
+
| None => Error(`${name} is not a time in seconds`)
|
|
112
|
+
| Some(at)
|
|
113
|
+
if Math.abs(nowSeconds -. at) >
|
|
114
|
+
signature.toleranceSeconds->Option.getOr(300)->Int.toFloat =>
|
|
115
|
+
Error(`${name} is outside the accepted window`)
|
|
116
|
+
| Some(_) => Ok(`${raw}.${body}`)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
switch (given, signed) {
|
|
121
|
+
| (Error(msg), _) | (_, Error(msg)) => Error(msg)
|
|
122
|
+
| (Ok(given), Ok(text)) =>
|
|
123
|
+
let given = given->String.trim->String.toLowerCase
|
|
124
|
+
secrets->Array.some(secret => sameText(hmacHex(~secret, text), given))
|
|
125
|
+
? Ok()
|
|
126
|
+
: Error(`${signature.header} does not match the body`)
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Who a request that passed `route.auth` acts as. An open URL identifies nobody. */
|
|
131
|
+
let callerOf = (route: route, ~signed: bool): Reventless.Identity.t =>
|
|
132
|
+
signed
|
|
133
|
+
? {
|
|
134
|
+
userId: `webhook:${route.slice}`,
|
|
135
|
+
username: route.slice,
|
|
136
|
+
groups: route.roles->Array.map(Reventless.Role.groupOf),
|
|
137
|
+
provider: Custom("webhook"),
|
|
138
|
+
}
|
|
139
|
+
: Reventless.Identity.anonymous
|
|
140
|
+
|
|
141
|
+
let json = (fields: array<(string, JSON.t)>): string =>
|
|
142
|
+
fields->Dict.fromArray->JSON.Encode.object->JSON.stringify
|
|
143
|
+
|
|
144
|
+
let plain = (status, error) => {status, body: json([("error", JSON.Encode.string(error))])}
|
|
145
|
+
|
|
146
|
+
/** The status a sender acts on: 2xx done, 5xx try again, any other 4xx do not. */
|
|
147
|
+
let statusOf = (result: ReventlessInfra.InboundTranslationSlice.receiveResult): int =>
|
|
148
|
+
switch result {
|
|
149
|
+
| Ok(_) => 200
|
|
150
|
+
| Error({errorCode: "Unsigned"}) => 401
|
|
151
|
+
| Error({errorCode: "InvalidBody"}) => 400
|
|
152
|
+
| Error({errorCode: "Forbidden"}) => 403
|
|
153
|
+
| Error({errorCode: "PublishFailed"}) => 503
|
|
154
|
+
| Error(_) => 422
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
let respond = (result: ReventlessInfra.InboundTranslationSlice.receiveResult): response => {
|
|
158
|
+
status: statusOf(result),
|
|
159
|
+
body: switch result {
|
|
160
|
+
| Ok({requestId, commandCount}) =>
|
|
161
|
+
json([
|
|
162
|
+
("requestId", JSON.Encode.string(requestId)),
|
|
163
|
+
("commandCount", JSON.Encode.int(commandCount)),
|
|
164
|
+
])
|
|
165
|
+
| Error({requestId, error}) =>
|
|
166
|
+
json([("requestId", JSON.Encode.string(requestId)), ("error", JSON.Encode.string(error))])
|
|
167
|
+
},
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Serve one request on its slice. A secret the platform does not have is a
|
|
171
|
+
misconfiguration, answered 500 and logged, never treated as open. */
|
|
172
|
+
let serve = async (
|
|
173
|
+
slice: slice,
|
|
174
|
+
~secretOf: string => option<string>=secretFromEnv,
|
|
175
|
+
~nowSeconds: float=Date.now() /. 1000.,
|
|
176
|
+
request: request,
|
|
177
|
+
): response =>
|
|
178
|
+
if request.method->String.toUpperCase != slice.route.method {
|
|
179
|
+
plain(405, `${slice.route.path} accepts ${slice.route.method}`)
|
|
180
|
+
} else {
|
|
181
|
+
let signed = switch slice.route.auth {
|
|
182
|
+
| Open => Ok(Ok(false))
|
|
183
|
+
| SignedBody(signature) =>
|
|
184
|
+
switch secretOf(signature.secret)->Option.mapOr([], secretsOf) {
|
|
185
|
+
| [] => Error(secretEnvKey(signature.secret))
|
|
186
|
+
| secrets =>
|
|
187
|
+
Ok(
|
|
188
|
+
verify(
|
|
189
|
+
signature,
|
|
190
|
+
~secrets,
|
|
191
|
+
~headers=request.headers,
|
|
192
|
+
~body=request.body,
|
|
193
|
+
~nowSeconds,
|
|
194
|
+
)->Result.map(() => true),
|
|
195
|
+
)
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
switch signed {
|
|
199
|
+
| Error(key) =>
|
|
200
|
+
EffectLogger.logError(
|
|
201
|
+
~comp=`InboundTranslationSlice(${slice.route.slice})`,
|
|
202
|
+
`webhook secret ${key} is not set; refusing every request`,
|
|
203
|
+
)->Effect.runSync
|
|
204
|
+
plain(500, "webhook is not configured")
|
|
205
|
+
| Ok(Error(msg)) => respond(await slice.refuse(~body=request.body, ~errorCode="Unsigned", msg))
|
|
206
|
+
| Ok(Ok(signed)) =>
|
|
207
|
+
switch try Some(JSON.parseOrThrow(request.body)) catch {
|
|
208
|
+
| _ => None
|
|
209
|
+
} {
|
|
210
|
+
| None =>
|
|
211
|
+
respond(
|
|
212
|
+
await slice.refuse(~body=request.body, ~errorCode="InvalidBody", "body is not JSON"),
|
|
213
|
+
)
|
|
214
|
+
| Some(input) => respond(await slice.receive(input, ~caller=callerOf(slice.route, ~signed)))
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Serve a request on whichever slice its path names. */
|
|
220
|
+
let dispatch = async (
|
|
221
|
+
slices: array<slice>,
|
|
222
|
+
~secretOf=?,
|
|
223
|
+
~nowSeconds=?,
|
|
224
|
+
request: request,
|
|
225
|
+
): response => {
|
|
226
|
+
let path = normalizePath(request.path)
|
|
227
|
+
switch slices->Array.find(slice => slice.route.path == path) {
|
|
228
|
+
| Some(slice) => await serve(slice, ~secretOf?, ~nowSeconds?, request)
|
|
229
|
+
| None => plain(404, `no webhook at /${path}`)
|
|
230
|
+
}
|
|
231
|
+
}
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
import * as Nodecrypto from "node:crypto";
|
|
4
|
+
import * as Stdlib_Float from "@rescript/runtime/lib/es6/Stdlib_Float.js";
|
|
5
|
+
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
6
|
+
import * as Stdlib_Result from "@rescript/runtime/lib/es6/Stdlib_Result.js";
|
|
7
|
+
import * as Stdlib_String from "@rescript/runtime/lib/es6/Stdlib_String.js";
|
|
8
|
+
import * as Effect from "effect/Effect";
|
|
9
|
+
import * as Role$Reventless from "@reventlessdev/reventless-spec/src/types/Role.res.mjs";
|
|
10
|
+
import * as Primitive_option from "@rescript/runtime/lib/es6/Primitive_option.js";
|
|
11
|
+
import * as Identity$Reventless from "@reventlessdev/reventless-spec/src/types/Identity.res.mjs";
|
|
12
|
+
import * as Authorization$Reventless from "@reventlessdev/reventless-spec/src/types/Authorization.res.mjs";
|
|
13
|
+
import * as EffectLogger$ReventlessCore from "../../util/EffectLogger.res.mjs";
|
|
14
|
+
|
|
15
|
+
function normalizePath(path) {
|
|
16
|
+
return path.split("/").filter(segment => segment !== "").join("/");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function routeOf(slice, endpoint) {
|
|
20
|
+
let role = endpoint.role;
|
|
21
|
+
return {
|
|
22
|
+
slice: slice,
|
|
23
|
+
path: normalizePath(endpoint.path),
|
|
24
|
+
method: Stdlib_Option.getOr(endpoint.method, "POST"),
|
|
25
|
+
auth: endpoint.auth,
|
|
26
|
+
roles: role !== undefined ? Authorization$Reventless.rolesOf(Authorization$Reventless.named({
|
|
27
|
+
TAG: "AllowRoles",
|
|
28
|
+
_0: [Primitive_option.valFromOption(role)]
|
|
29
|
+
})) : []
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function secretEnvKey(name) {
|
|
34
|
+
return "REVENTLESS_WEBHOOK_SECRET_" + name.toUpperCase().replace(/[^A-Z0-9]/g, "_");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function secretsOf(raw) {
|
|
38
|
+
return raw.split(",").map(prim => prim.trim()).filter(secret => secret !== "");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function secretFromEnv(name) {
|
|
42
|
+
return process.env[secretEnvKey(name)];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function sameText(a, b) {
|
|
46
|
+
let code = (text, i) => Stdlib_Option.getOr(Stdlib_String.charCodeAt(text, i), 0);
|
|
47
|
+
if (a.length !== b.length) {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
let diff = 0;
|
|
51
|
+
for (let i = 0, i_finish = a.length; i < i_finish; ++i) {
|
|
52
|
+
diff = diff | code(a, i) ^ code(b, i);
|
|
53
|
+
}
|
|
54
|
+
return diff === 0;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function hmacHex(secret, text) {
|
|
58
|
+
return Nodecrypto.createHmac("sha256", secret).update(text).digest("hex");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function verify(signature, secrets, headers, body, nowSeconds) {
|
|
62
|
+
let match = headers[signature.header.toLowerCase()];
|
|
63
|
+
let match$1 = signature.prefix;
|
|
64
|
+
let given = match !== undefined ? (
|
|
65
|
+
match$1 !== undefined ? (
|
|
66
|
+
match.startsWith(match$1) ? ({
|
|
67
|
+
TAG: "Ok",
|
|
68
|
+
_0: match.slice(match$1.length)
|
|
69
|
+
}) : ({
|
|
70
|
+
TAG: "Error",
|
|
71
|
+
_0: signature.header + ` does not start with "` + match$1 + `"`
|
|
72
|
+
})
|
|
73
|
+
) : ({
|
|
74
|
+
TAG: "Ok",
|
|
75
|
+
_0: match
|
|
76
|
+
})
|
|
77
|
+
) : ({
|
|
78
|
+
TAG: "Error",
|
|
79
|
+
_0: `missing ` + signature.header + ` header`
|
|
80
|
+
});
|
|
81
|
+
let name = signature.timestampHeader;
|
|
82
|
+
let signed;
|
|
83
|
+
if (name !== undefined) {
|
|
84
|
+
let raw = headers[name.toLowerCase()];
|
|
85
|
+
if (raw !== undefined) {
|
|
86
|
+
let at = Stdlib_Float.fromString(raw);
|
|
87
|
+
signed = at !== undefined ? (
|
|
88
|
+
Math.abs(nowSeconds - at) > Stdlib_Option.getOr(signature.toleranceSeconds, 300) ? ({
|
|
89
|
+
TAG: "Error",
|
|
90
|
+
_0: name + ` is outside the accepted window`
|
|
91
|
+
}) : ({
|
|
92
|
+
TAG: "Ok",
|
|
93
|
+
_0: raw + `.` + body
|
|
94
|
+
})
|
|
95
|
+
) : ({
|
|
96
|
+
TAG: "Error",
|
|
97
|
+
_0: name + ` is not a time in seconds`
|
|
98
|
+
});
|
|
99
|
+
} else {
|
|
100
|
+
signed = {
|
|
101
|
+
TAG: "Error",
|
|
102
|
+
_0: `missing ` + name + ` header`
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
} else {
|
|
106
|
+
signed = {
|
|
107
|
+
TAG: "Ok",
|
|
108
|
+
_0: body
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
if (given.TAG !== "Ok") {
|
|
112
|
+
return {
|
|
113
|
+
TAG: "Error",
|
|
114
|
+
_0: given._0
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
if (signed.TAG !== "Ok") {
|
|
118
|
+
return {
|
|
119
|
+
TAG: "Error",
|
|
120
|
+
_0: signed._0
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
let text = signed._0;
|
|
124
|
+
let given$1 = given._0.trim().toLowerCase();
|
|
125
|
+
if (secrets.some(secret => sameText(hmacHex(secret, text), given$1))) {
|
|
126
|
+
return {
|
|
127
|
+
TAG: "Ok",
|
|
128
|
+
_0: undefined
|
|
129
|
+
};
|
|
130
|
+
} else {
|
|
131
|
+
return {
|
|
132
|
+
TAG: "Error",
|
|
133
|
+
_0: signature.header + ` does not match the body`
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function callerOf(route, signed) {
|
|
139
|
+
if (signed) {
|
|
140
|
+
return {
|
|
141
|
+
userId: `webhook:` + route.slice,
|
|
142
|
+
username: route.slice,
|
|
143
|
+
groups: route.roles.map(Role$Reventless.groupOf),
|
|
144
|
+
provider: {
|
|
145
|
+
TAG: "Custom",
|
|
146
|
+
_0: "webhook"
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
} else {
|
|
150
|
+
return Identity$Reventless.anonymous;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function json(fields) {
|
|
155
|
+
return JSON.stringify(Object.fromEntries(fields));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function plain(status, error) {
|
|
159
|
+
return {
|
|
160
|
+
status: status,
|
|
161
|
+
body: json([[
|
|
162
|
+
"error",
|
|
163
|
+
error
|
|
164
|
+
]])
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function statusOf(result) {
|
|
169
|
+
if (result.TAG === "Ok") {
|
|
170
|
+
return 200;
|
|
171
|
+
}
|
|
172
|
+
switch (result._0.errorCode) {
|
|
173
|
+
case "Forbidden" :
|
|
174
|
+
return 403;
|
|
175
|
+
case "InvalidBody" :
|
|
176
|
+
return 400;
|
|
177
|
+
case "PublishFailed" :
|
|
178
|
+
return 503;
|
|
179
|
+
case "Unsigned" :
|
|
180
|
+
return 401;
|
|
181
|
+
default:
|
|
182
|
+
return 422;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function respond(result) {
|
|
187
|
+
let tmp;
|
|
188
|
+
if (result.TAG === "Ok") {
|
|
189
|
+
let match = result._0;
|
|
190
|
+
tmp = json([
|
|
191
|
+
[
|
|
192
|
+
"requestId",
|
|
193
|
+
match.requestId
|
|
194
|
+
],
|
|
195
|
+
[
|
|
196
|
+
"commandCount",
|
|
197
|
+
match.commandCount
|
|
198
|
+
]
|
|
199
|
+
]);
|
|
200
|
+
} else {
|
|
201
|
+
let match$1 = result._0;
|
|
202
|
+
tmp = json([
|
|
203
|
+
[
|
|
204
|
+
"requestId",
|
|
205
|
+
match$1.requestId
|
|
206
|
+
],
|
|
207
|
+
[
|
|
208
|
+
"error",
|
|
209
|
+
match$1.error
|
|
210
|
+
]
|
|
211
|
+
]);
|
|
212
|
+
}
|
|
213
|
+
return {
|
|
214
|
+
status: statusOf(result),
|
|
215
|
+
body: tmp
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function serve(slice, secretOfOpt, nowSecondsOpt, request) {
|
|
220
|
+
let secretOf = secretOfOpt !== undefined ? secretOfOpt : secretFromEnv;
|
|
221
|
+
let nowSeconds = nowSecondsOpt !== undefined ? nowSecondsOpt : Date.now() / 1000;
|
|
222
|
+
if (request.method.toUpperCase() !== slice.route.method) {
|
|
223
|
+
return plain(405, slice.route.path + ` accepts ` + slice.route.method);
|
|
224
|
+
}
|
|
225
|
+
let signature = slice.route.auth;
|
|
226
|
+
let signed;
|
|
227
|
+
if (typeof signature !== "object") {
|
|
228
|
+
signed = {
|
|
229
|
+
TAG: "Ok",
|
|
230
|
+
_0: {
|
|
231
|
+
TAG: "Ok",
|
|
232
|
+
_0: false
|
|
233
|
+
}
|
|
234
|
+
};
|
|
235
|
+
} else {
|
|
236
|
+
let signature$1 = signature._0;
|
|
237
|
+
let secrets = Stdlib_Option.mapOr(secretOf(signature$1.secret), [], secretsOf);
|
|
238
|
+
signed = secrets.length !== 0 ? ({
|
|
239
|
+
TAG: "Ok",
|
|
240
|
+
_0: Stdlib_Result.map(verify(signature$1, secrets, request.headers, request.body, nowSeconds), () => true)
|
|
241
|
+
}) : ({
|
|
242
|
+
TAG: "Error",
|
|
243
|
+
_0: secretEnvKey(signature$1.secret)
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
if (signed.TAG === "Ok") {
|
|
247
|
+
let msg = signed._0;
|
|
248
|
+
if (msg.TAG !== "Ok") {
|
|
249
|
+
return respond(await slice.refuse(request.body, "Unsigned", msg._0));
|
|
250
|
+
}
|
|
251
|
+
let input;
|
|
252
|
+
try {
|
|
253
|
+
input = JSON.parse(request.body);
|
|
254
|
+
} catch (exn) {
|
|
255
|
+
input = undefined;
|
|
256
|
+
}
|
|
257
|
+
if (input !== undefined) {
|
|
258
|
+
return respond(await slice.receive(input, callerOf(slice.route, msg._0)));
|
|
259
|
+
} else {
|
|
260
|
+
return respond(await slice.refuse(request.body, "InvalidBody", "body is not JSON"));
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
Effect.runSync(EffectLogger$ReventlessCore.logError(`InboundTranslationSlice(` + slice.route.slice + `)`, undefined, `webhook secret ` + signed._0 + ` is not set; refusing every request`));
|
|
264
|
+
return plain(500, "webhook is not configured");
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async function dispatch(slices, secretOf, nowSeconds, request) {
|
|
268
|
+
let path = normalizePath(request.path);
|
|
269
|
+
let slice = slices.find(slice => slice.route.path === path);
|
|
270
|
+
if (slice !== undefined) {
|
|
271
|
+
return await serve(slice, secretOf, nowSeconds, request);
|
|
272
|
+
} else {
|
|
273
|
+
return plain(404, `no webhook at /` + path);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export {
|
|
278
|
+
normalizePath,
|
|
279
|
+
routeOf,
|
|
280
|
+
secretEnvKey,
|
|
281
|
+
secretsOf,
|
|
282
|
+
secretFromEnv,
|
|
283
|
+
sameText,
|
|
284
|
+
hmacHex,
|
|
285
|
+
verify,
|
|
286
|
+
callerOf,
|
|
287
|
+
json,
|
|
288
|
+
plain,
|
|
289
|
+
statusOf,
|
|
290
|
+
respond,
|
|
291
|
+
serve,
|
|
292
|
+
dispatch,
|
|
293
|
+
}
|
|
294
|
+
/* node:crypto Not a pure module */
|
|
@@ -52,7 +52,7 @@ module Make = (
|
|
|
52
52
|
let config = Reventless.ReadModel.config()
|
|
53
53
|
let subIdConfig: option<Reventless.ReadModel.subIdConfig<state>> = None
|
|
54
54
|
type role = Reventless.Role.name
|
|
55
|
-
let authorization: Reventless.Authorization.rule<role> =
|
|
55
|
+
let authorization: Reventless.Authorization.rule<role> = Reventless.OwnerScope.operators()
|
|
56
56
|
let visibility: Reventless.Visibility.t = Public
|
|
57
57
|
}
|
|
58
58
|
|