@reventlessdev/reventless-core 3.0.0-alpha.297 → 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 +12 -0
- package/package.json +7 -7
- package/src/components/Dcb/Dcb_Builder.res +32 -12
- package/src/components/Dcb/Dcb_Builder.res.mjs +12 -8
- package/src/components/InboundTranslationSlice/InboundTranslationSlice_Builder.res +7 -2
- package/src/components/InboundTranslationSlice/InboundTranslationSlice_Builder.res.mjs +7 -2
- 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/tests/plugin/PluginStructureAccessTest.res +1 -0
- package/tests/plugin/PluginStructureAccessTest.res.mjs +19 -3
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,18 @@
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
|
5
5
|
|
|
6
|
+
# 3.0.0-alpha.298 (2026-10-16)
|
|
7
|
+
|
|
8
|
+
* feat!(inbound): a translation slice can declare a signed webhook URL ([0ad6994](https://github.com/ReventlessDev/reventless-core/commit/0ad69949fcc91c2756c60ca8cb120dd468841d8b))
|
|
9
|
+
|
|
10
|
+
### BREAKING CHANGES
|
|
11
|
+
|
|
12
|
+
* InboundTranslationSlice.Spec requires `endpoint`. Specs under
|
|
13
|
+
@@reventless.spec get it from the PPX (republish reventless-ppx with this
|
|
14
|
+
release); hand-written specs add `let endpoint = None`.
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
|
|
6
18
|
# 3.0.0-alpha.297 (2026-10-16)
|
|
7
19
|
|
|
8
20
|
* fix(auth)!: only operators read the framework's own stores ([a2fd202](https://github.com/ReventlessDev/reventless-core/commit/a2fd2022b6876dae855b185df7f8263353fc971d))
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reventlessdev/reventless-core",
|
|
3
|
-
"version": "3.0.0-alpha.
|
|
3
|
+
"version": "3.0.0-alpha.298",
|
|
4
4
|
"description": "Core package for Reventless framework",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"jest": {
|
|
@@ -28,17 +28,17 @@
|
|
|
28
28
|
"dependencies": {
|
|
29
29
|
"sury": "11.0.0",
|
|
30
30
|
"uuid": "^13.0.0",
|
|
31
|
-
"@reventlessdev/rescript-jest": "1.0.0-alpha.12",
|
|
32
31
|
"@reventlessdev/rescript-effect": "1.0.0-alpha.0",
|
|
33
32
|
"@reventlessdev/rescript-hash-object": "1.2.0-alpha.15",
|
|
34
|
-
"@reventlessdev/rescript-fast-csv": "2.0.0-alpha.22",
|
|
35
33
|
"@reventlessdev/rescript-node": "2.0.0-alpha.22",
|
|
36
|
-
"@reventlessdev/rescript-
|
|
34
|
+
"@reventlessdev/rescript-jest": "1.0.0-alpha.12",
|
|
35
|
+
"@reventlessdev/rescript-fast-csv": "2.0.0-alpha.22",
|
|
36
|
+
"@reventlessdev/rescript-pulumi-pulumi": "3.0.0-alpha.0",
|
|
37
37
|
"@reventlessdev/rescript-ssh2": "2.0.0-alpha.23",
|
|
38
38
|
"@reventlessdev/rescript-uuid": "2.0.0-alpha.1",
|
|
39
|
-
"@reventlessdev/reventless-interop": "3.0.0-alpha.
|
|
40
|
-
"@reventlessdev/reventless-infra": "3.0.0-alpha.
|
|
41
|
-
"@reventlessdev/reventless-spec": "3.0.0-alpha.
|
|
39
|
+
"@reventlessdev/reventless-interop": "3.0.0-alpha.41",
|
|
40
|
+
"@reventlessdev/reventless-infra": "3.0.0-alpha.195",
|
|
41
|
+
"@reventlessdev/reventless-spec": "3.0.0-alpha.167"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
44
|
"rescript": "12.3.1",
|
|
@@ -759,38 +759,51 @@ module Make = (
|
|
|
759
759
|
~opts,
|
|
760
760
|
)
|
|
761
761
|
let fieldName = Api_Naming.sliceMutationField(~plugin=name, ~slice=ITS.Spec.name)
|
|
762
|
+
// `@noApi` on `externalInput` withholds the mutation; the webhook is
|
|
763
|
+
// then the only door, and a slice with neither could never be reached.
|
|
764
|
+
let exposed = InboundTranslationSlice_Callback.exposesMutation(
|
|
765
|
+
ITS.Spec.externalInputSchema->S.castToUnknown,
|
|
766
|
+
)
|
|
767
|
+
InboundTranslationSlice_Callback.checkReachable(
|
|
768
|
+
~plugin=name,
|
|
769
|
+
~slice=ITS.Spec.name,
|
|
770
|
+
~mutation=exposed,
|
|
771
|
+
~webhook=ITS.Spec.endpoint->Option.isSome,
|
|
772
|
+
)
|
|
762
773
|
|
|
763
774
|
switch HooksConfig.hooks.inboundMutationResolverHook {
|
|
764
|
-
| Some(registerResolver) =>
|
|
775
|
+
| Some(registerResolver) if exposed =>
|
|
765
776
|
registerResolver(
|
|
766
777
|
~fieldName,
|
|
767
778
|
~externalInputSchema=ITS.Spec.externalInputSchema->S.castToUnknown,
|
|
768
779
|
~permission=?inboundDoorPermission(ITS.Spec.commandSchema, ITS.Spec.authorizationOf),
|
|
769
780
|
)
|
|
770
|
-
|
|
|
781
|
+
| _ => ()
|
|
771
782
|
}
|
|
772
783
|
|
|
773
784
|
switch HooksConfig.hooks.inboundMutationBindReceiveHook {
|
|
774
|
-
| Some(bindReceive) =>
|
|
785
|
+
| Some(bindReceive) if exposed =>
|
|
775
786
|
let _ =
|
|
776
787
|
its
|
|
777
788
|
->Component.operations
|
|
778
|
-
->Pulumi.Output.apply(ops =>
|
|
779
|
-
|
|
789
|
+
->Pulumi.Output.apply(ops =>
|
|
790
|
+
bindReceive(~fieldName, ~receive=(input, ~caller=?) => ops.receive(input, ~caller?))
|
|
791
|
+
)
|
|
792
|
+
| _ => ()
|
|
780
793
|
}
|
|
781
794
|
|
|
782
|
-
(ITS.Spec.name, fieldName, its, ITS.Spec.externalInputSchema->S.castToUnknown)
|
|
795
|
+
(ITS.Spec.name, fieldName, its, ITS.Spec.externalInputSchema->S.castToUnknown, exposed)
|
|
783
796
|
})
|
|
784
797
|
|
|
785
798
|
let inboundTranslationSlicesOutputs =
|
|
786
799
|
inboundTranslationSliceData
|
|
787
|
-
->Array.map(((specName, _, its, _)) => (specName, its->Component.outputs))
|
|
800
|
+
->Array.map(((specName, _, its, _, _)) => (specName, its->Component.outputs))
|
|
788
801
|
->Dict.fromArray
|
|
789
802
|
|
|
790
803
|
// Collect InboundTranslationSlice receive functions for composite handler routing
|
|
791
804
|
let inboundReceiversOutput =
|
|
792
805
|
inboundTranslationSliceData
|
|
793
|
-
->Array.map(((_, fieldName, its, _)) =>
|
|
806
|
+
->Array.map(((_, fieldName, its, _, _)) =>
|
|
794
807
|
its
|
|
795
808
|
->Component.operations
|
|
796
809
|
->Pulumi.Output.apply(ops => (fieldName, ops.receive))
|
|
@@ -942,9 +955,10 @@ module Make = (
|
|
|
942
955
|
->Dict.valuesToArray
|
|
943
956
|
->Array.flatMap(outputs => outputs.resources)
|
|
944
957
|
|
|
945
|
-
let
|
|
946
|
-
inboundTranslationSliceData->Array.
|
|
947
|
-
let
|
|
958
|
+
let exposedInbound =
|
|
959
|
+
inboundTranslationSliceData->Array.filter(((_, _, _, _, exposed)) => exposed)
|
|
960
|
+
let inboundFieldNames = exposedInbound->Array.map(((_, fieldName, _, _, _)) => fieldName)
|
|
961
|
+
let inboundSchemas = exposedInbound->Array.map(((_, _, _, schema, _)) => schema)
|
|
948
962
|
|
|
949
963
|
// Collect DCB mutation field names + TAGs for AppSync resolver creation
|
|
950
964
|
// (excludes @noApi slices — includes both sync and async)
|
|
@@ -1092,7 +1106,13 @@ module Make = (
|
|
|
1092
1106
|
})
|
|
1093
1107
|
|
|
1094
1108
|
let mutationEntriesFromInboundSlices =
|
|
1095
|
-
inboundTranslationSlices
|
|
1109
|
+
inboundTranslationSlices
|
|
1110
|
+
->Array.filter((module(ITS: InboundTranslationSlice.T)) =>
|
|
1111
|
+
InboundTranslationSlice_Callback.exposesMutation(
|
|
1112
|
+
ITS.Spec.externalInputSchema->S.castToUnknown,
|
|
1113
|
+
)
|
|
1114
|
+
)
|
|
1115
|
+
->Array.map((module(ITS: InboundTranslationSlice.T)) => {
|
|
1096
1116
|
let fieldName = Api_Naming.sliceMutationField(~plugin=name, ~slice=ITS.Spec.name)
|
|
1097
1117
|
let fieldPermissions = Dict.make()
|
|
1098
1118
|
inboundDoorPermission(
|
|
@@ -407,19 +407,22 @@ function Make(DcbEventLogStorage) {
|
|
|
407
407
|
let inboundTranslationSliceData = inboundTranslationSlices.map(ITS => {
|
|
408
408
|
let its = ITS.make(publishJsons, componentRuntime[ITS.Spec.name], opts);
|
|
409
409
|
let fieldName = Api_Naming$ReventlessCore.sliceMutationField(name, ITS.Spec.name);
|
|
410
|
+
let exposed = InboundTranslationSlice_Callback$ReventlessCore.exposesMutation(ITS.Spec.externalInputSchema);
|
|
411
|
+
InboundTranslationSlice_Callback$ReventlessCore.checkReachable(name, ITS.Spec.name, exposed, Stdlib_Option.isSome(ITS.Spec.endpoint));
|
|
410
412
|
let registerResolver = HooksConfig.hooks.inboundMutationResolverHook;
|
|
411
|
-
if (registerResolver !== undefined) {
|
|
413
|
+
if (registerResolver !== undefined && exposed) {
|
|
412
414
|
registerResolver(fieldName, ITS.Spec.externalInputSchema, inboundDoorPermission(ITS.Spec.commandSchema, ITS.Spec.authorizationOf));
|
|
413
415
|
}
|
|
414
416
|
let bindReceive = HooksConfig.hooks.inboundMutationBindReceiveHook;
|
|
415
|
-
if (bindReceive !== undefined) {
|
|
416
|
-
Component$ReventlessCore.operations(its).apply(ops => bindReceive(fieldName, ops.receive));
|
|
417
|
+
if (bindReceive !== undefined && exposed) {
|
|
418
|
+
Component$ReventlessCore.operations(its).apply(ops => bindReceive(fieldName, (input, caller) => ops.receive(input, caller, undefined)));
|
|
417
419
|
}
|
|
418
420
|
return [
|
|
419
421
|
ITS.Spec.name,
|
|
420
422
|
fieldName,
|
|
421
423
|
its,
|
|
422
|
-
ITS.Spec.externalInputSchema
|
|
424
|
+
ITS.Spec.externalInputSchema,
|
|
425
|
+
exposed
|
|
423
426
|
];
|
|
424
427
|
});
|
|
425
428
|
let inboundTranslationSlicesOutputs = Object.fromEntries(inboundTranslationSliceData.map(param => [
|
|
@@ -482,7 +485,7 @@ function Make(DcbEventLogStorage) {
|
|
|
482
485
|
let receiveFn = receivers[event.fieldName];
|
|
483
486
|
if (receiveFn !== undefined) {
|
|
484
487
|
return Effect.promise(async () => {
|
|
485
|
-
let result = await receiveFn(event.arguments, InboundTranslationSlice_Callback$ReventlessCore.doorCaller(event.identity));
|
|
488
|
+
let result = await receiveFn(event.arguments, InboundTranslationSlice_Callback$ReventlessCore.doorCaller(event.identity), undefined);
|
|
486
489
|
return CommandTopic$ReventlessCore.commandOutcomeToJson(InboundTranslationSlice_Callback$ReventlessCore.receiveResultToOutcome(result));
|
|
487
490
|
});
|
|
488
491
|
} else {
|
|
@@ -507,8 +510,9 @@ function Make(DcbEventLogStorage) {
|
|
|
507
510
|
});
|
|
508
511
|
let dcbResources = Object.values(stateChangeSlicesOutputs).flatMap(outputs => outputs.resources).concat(Object.values(inboundTranslationSlicesOutputs).flatMap(outputs => outputs.resources.concat(outputs.queryDb.resources)));
|
|
509
512
|
let asyncDcbResources = Object.values(asyncStateChangeSlicesOutputs).flatMap(outputs => outputs.resources);
|
|
510
|
-
let
|
|
511
|
-
let
|
|
513
|
+
let exposedInbound = inboundTranslationSliceData.filter(param => param[4]);
|
|
514
|
+
let inboundFieldNames = exposedInbound.map(param => param[1]);
|
|
515
|
+
let inboundSchemas = exposedInbound.map(param => param[3]);
|
|
512
516
|
let dcbMutationData = stateChangeSlices.flatMap(S => {
|
|
513
517
|
let commandSchema = S.Spec.commandSchema;
|
|
514
518
|
if (ApiNoApiHelpers$ReventlessCore.isNoApi(commandSchema)) {
|
|
@@ -578,7 +582,7 @@ function Make(DcbEventLogStorage) {
|
|
|
578
582
|
consistencyRead: Stdlib_Option.flatMap(sliceDef, d => d.consistencyRead)
|
|
579
583
|
};
|
|
580
584
|
});
|
|
581
|
-
let mutationEntriesFromInboundSlices = inboundTranslationSlices.map(ITS => {
|
|
585
|
+
let mutationEntriesFromInboundSlices = inboundTranslationSlices.filter(ITS => InboundTranslationSlice_Callback$ReventlessCore.exposesMutation(ITS.Spec.externalInputSchema)).map(ITS => {
|
|
582
586
|
let fieldName = Api_Naming$ReventlessCore.sliceMutationField(name, ITS.Spec.name);
|
|
583
587
|
let fieldPermissions = {};
|
|
584
588
|
Stdlib_Option.forEach(inboundDoorPermission(ITS.Spec.commandSchema, ITS.Spec.authorizationOf), rule => {
|
|
@@ -81,8 +81,13 @@ module Make = (
|
|
|
81
81
|
->Pulumi.Output.all2
|
|
82
82
|
->Pulumi.Output.apply(((queryDbOps, publishJsonsFn)) => {
|
|
83
83
|
let ops: InboundTranslationSlice.operations = {
|
|
84
|
-
receive: async (inputJson, ~caller=?) => {
|
|
85
|
-
let result = await Callback.receive(publishJsonsFn, inputJson, ~caller?)
|
|
84
|
+
receive: async (inputJson, ~caller=?, ~door=?) => {
|
|
85
|
+
let result = await Callback.receive(publishJsonsFn, inputJson, ~caller?, ~door?)
|
|
86
|
+
await syncToQueryDb(queryDbOps, result->InboundTranslationSlice_Callback.requestIdOf)
|
|
87
|
+
result
|
|
88
|
+
},
|
|
89
|
+
refuse: async (~body, ~errorCode, error) => {
|
|
90
|
+
let result = Callback.refuse(~body, ~errorCode, error)
|
|
86
91
|
await syncToQueryDb(queryDbOps, result->InboundTranslationSlice_Callback.requestIdOf)
|
|
87
92
|
result
|
|
88
93
|
},
|
|
@@ -57,8 +57,13 @@ function Make(QueryDbStorage) {
|
|
|
57
57
|
let publishJsonsFn = param[1];
|
|
58
58
|
let queryDbOps = param[0];
|
|
59
59
|
return {
|
|
60
|
-
receive: async (inputJson, caller) => {
|
|
61
|
-
let result = await Callback.receive(publishJsonsFn, inputJson, caller);
|
|
60
|
+
receive: async (inputJson, caller, door) => {
|
|
61
|
+
let result = await Callback.receive(publishJsonsFn, inputJson, caller, door);
|
|
62
|
+
await syncToQueryDb(queryDbOps, InboundTranslationSlice_Callback$ReventlessCore.requestIdOf(result));
|
|
63
|
+
return result;
|
|
64
|
+
},
|
|
65
|
+
refuse: async (body, errorCode, error) => {
|
|
66
|
+
let result = Callback.refuse(body, errorCode, error);
|
|
62
67
|
await syncToQueryDb(queryDbOps, InboundTranslationSlice_Callback$ReventlessCore.requestIdOf(result));
|
|
63
68
|
return result;
|
|
64
69
|
}
|
|
@@ -15,6 +15,8 @@ type auditRow = {
|
|
|
15
15
|
commandCount?: int,
|
|
16
16
|
error?: string,
|
|
17
17
|
receivedAt: string,
|
|
18
|
+
/** Absent on rows written before the slice had a second door. */
|
|
19
|
+
door?: ReventlessInfra.InboundTranslationSlice.door,
|
|
18
20
|
}
|
|
19
21
|
|
|
20
22
|
/** The `CommandResult` a `receive` outcome answers with: the first target as
|
|
@@ -76,6 +78,19 @@ type doorInvocation = {
|
|
|
76
78
|
let doorCaller = (identity: Nullable.t<Reventless.Identity.t>): Reventless.Identity.t =>
|
|
77
79
|
identity->Nullable.toOption->Option.getOr(Reventless.Identity.anonymous)
|
|
78
80
|
|
|
81
|
+
/** Whether a slice's input is offered as a mutation: not when `externalInput`
|
|
82
|
+
carries `@noApi`, which leaves the webhook as its only door. */
|
|
83
|
+
let exposesMutation = (externalInputSchema: S.t<unknown>): bool =>
|
|
84
|
+
!ApiNoApiHelpers.isNoApi(externalInputSchema)
|
|
85
|
+
|
|
86
|
+
/** Refuse a slice with neither door, which nothing could ever reach. */
|
|
87
|
+
let checkReachable = (~plugin: string, ~slice: string, ~mutation: bool, ~webhook: bool) =>
|
|
88
|
+
if !mutation && !webhook {
|
|
89
|
+
JsError.throwWithMessage(
|
|
90
|
+
`${plugin}: inbound translation slice ${slice} has @noApi on externalInput and declares no endpoint, so nothing can reach it`,
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
|
|
79
94
|
module type T = {
|
|
80
95
|
module Spec: Reventless.InboundTranslationSlice.Spec
|
|
81
96
|
module Translation: Reventless.InboundTranslationSlice.Translation with module Spec := Spec
|
|
@@ -88,7 +103,18 @@ module type T = {
|
|
|
88
103
|
ReventlessInfra.CommandTopic.publishJsons,
|
|
89
104
|
JSON.t,
|
|
90
105
|
~caller: Reventless.Identity.t=?,
|
|
106
|
+
~door: ReventlessInfra.InboundTranslationSlice.door=?,
|
|
91
107
|
) => promise<ReventlessInfra.InboundTranslationSlice.receiveResult>
|
|
108
|
+
|
|
109
|
+
/** Record a webhook request refused before it could be read, and reject it. */
|
|
110
|
+
let refuse: (
|
|
111
|
+
~body: string,
|
|
112
|
+
~errorCode: string,
|
|
113
|
+
string,
|
|
114
|
+
) => ReventlessInfra.InboundTranslationSlice.receiveResult
|
|
115
|
+
|
|
116
|
+
/** The slice's webhook route, if its spec declares an endpoint. */
|
|
117
|
+
let webhook: option<InboundTranslationSlice_Webhook.route>
|
|
92
118
|
}
|
|
93
119
|
|
|
94
120
|
module Make = (
|
|
@@ -110,15 +136,12 @@ module Make = (
|
|
|
110
136
|
exn->JsExn.fromException->Option.flatMap(JsExn.message)->Option.getOr(fallback)
|
|
111
137
|
|
|
112
138
|
// One audit row per request, whatever the outcome.
|
|
113
|
-
let failWith = (~requestId, ~
|
|
114
|
-
auditLog->Dict.set(
|
|
115
|
-
requestId,
|
|
116
|
-
{input: inputJson->JSON.stringify, status: Failure, error, receivedAt: now()},
|
|
117
|
-
)
|
|
139
|
+
let failWith = (~requestId, ~input, ~door, ~errorCode=?, error) => {
|
|
140
|
+
auditLog->Dict.set(requestId, {input, status: Failure, error, receivedAt: now(), door})
|
|
118
141
|
Error({ReventlessInfra.InboundTranslationSlice.requestId, error, ?errorCode})
|
|
119
142
|
}
|
|
120
143
|
|
|
121
|
-
let succeed = (~requestId, ~inputJson, targetIds) => {
|
|
144
|
+
let succeed = (~requestId, ~inputJson, ~door, targetIds) => {
|
|
122
145
|
let commandCount = targetIds->Array.length
|
|
123
146
|
auditLog->Dict.set(
|
|
124
147
|
requestId,
|
|
@@ -128,6 +151,7 @@ module Make = (
|
|
|
128
151
|
targetIds,
|
|
129
152
|
commandCount,
|
|
130
153
|
receivedAt: now(),
|
|
154
|
+
door,
|
|
131
155
|
},
|
|
132
156
|
)
|
|
133
157
|
Ok({ReventlessInfra.InboundTranslationSlice.requestId, targetIds, commandCount})
|
|
@@ -155,9 +179,12 @@ module Make = (
|
|
|
155
179
|
publishJsons: ReventlessInfra.CommandTopic.publishJsons,
|
|
156
180
|
inputJson: JSON.t,
|
|
157
181
|
~caller: option<Reventless.Identity.t>=?,
|
|
182
|
+
~door: ReventlessInfra.InboundTranslationSlice.door=Mutation,
|
|
158
183
|
): ReventlessInfra.InboundTranslationSlice.receiveResult => {
|
|
159
184
|
let requestId = Uuid.v4()
|
|
160
|
-
let fail = (~errorCode=?, error) =>
|
|
185
|
+
let fail = (~errorCode=?, error) =>
|
|
186
|
+
failWith(~requestId, ~input=inputJson->JSON.stringify, ~door, ~errorCode?, error)
|
|
187
|
+
let succeed = succeed(~requestId, ~inputJson, ~door, ...)
|
|
161
188
|
let parsed = try Ok(inputJson->S.parseOrThrow(~to=Spec.externalInputSchema)) catch {
|
|
162
189
|
| exn => Error(messageOf(exn, "invalid input"))
|
|
163
190
|
}
|
|
@@ -192,16 +219,24 @@ module Make = (
|
|
|
192
219
|
~errorCode="Forbidden",
|
|
193
220
|
`${Spec.name}: the caller is not authorized for every command this input translates into`,
|
|
194
221
|
)
|
|
195
|
-
| Ok([]) => succeed(
|
|
222
|
+
| Ok([]) => succeed([])
|
|
196
223
|
| Ok(msgs) =>
|
|
197
224
|
try {
|
|
198
225
|
await publishJsons(msgs)
|
|
199
|
-
succeed(
|
|
226
|
+
succeed(pairs->Array.map(((targetId, _)) => targetId))
|
|
200
227
|
} catch {
|
|
201
|
-
| exn => fail(messageOf(exn, "publish failed"))
|
|
228
|
+
| exn => fail(~errorCode="PublishFailed", messageOf(exn, "publish failed"))
|
|
202
229
|
}
|
|
203
230
|
}
|
|
204
231
|
}
|
|
205
232
|
}
|
|
206
233
|
}
|
|
234
|
+
|
|
235
|
+
let refuse = (~body, ~errorCode, error) =>
|
|
236
|
+
failWith(~requestId=Uuid.v4(), ~input=body, ~door=Webhook, ~errorCode, error)
|
|
237
|
+
|
|
238
|
+
let webhook =
|
|
239
|
+
Spec.endpoint->Option.map(endpoint =>
|
|
240
|
+
InboundTranslationSlice_Webhook.routeOf(~slice=Spec.name, endpoint)
|
|
241
|
+
)
|
|
207
242
|
}
|
|
@@ -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 */
|
|
@@ -146,6 +146,7 @@ module PsTwoRuleFeed = {
|
|
|
146
146
|
@schema type command = AddProduct({sku: string}) | AddCategory({sku: string})
|
|
147
147
|
let targetName = "AddProduct"
|
|
148
148
|
let externalSystem = None
|
|
149
|
+
let endpoint = None
|
|
149
150
|
type role = Reventless.Role.name
|
|
150
151
|
let authorizationOf = (name: string): Reventless.Authorization.rule<role> =>
|
|
151
152
|
switch name {
|
|
@@ -178,6 +178,8 @@ globalThis.describe("requiredAccess derived from the authorization rule", () =>
|
|
|
178
178
|
});
|
|
179
179
|
});
|
|
180
180
|
|
|
181
|
+
let name = "PsTwoRuleFeed";
|
|
182
|
+
|
|
181
183
|
let moduleUrl = "ps-two-rule-feed://spec";
|
|
182
184
|
|
|
183
185
|
let externalInputSchema = Sury.$schema(s => ({
|
|
@@ -195,6 +197,8 @@ let commandSchema = Sury.union([
|
|
|
195
197
|
}))
|
|
196
198
|
]);
|
|
197
199
|
|
|
200
|
+
let targetName = "AddProduct";
|
|
201
|
+
|
|
198
202
|
function authorizationOf(name) {
|
|
199
203
|
switch (name) {
|
|
200
204
|
case "AddCategory" :
|
|
@@ -215,12 +219,13 @@ function authorizationOf(name) {
|
|
|
215
219
|
let traits = [];
|
|
216
220
|
|
|
217
221
|
let PsTwoRuleFeed = {
|
|
218
|
-
name:
|
|
222
|
+
name: name,
|
|
219
223
|
moduleUrl: moduleUrl,
|
|
220
224
|
externalInputSchema: externalInputSchema,
|
|
221
225
|
commandSchema: commandSchema,
|
|
222
|
-
targetName:
|
|
226
|
+
targetName: targetName,
|
|
223
227
|
externalSystem: undefined,
|
|
228
|
+
endpoint: undefined,
|
|
224
229
|
authorizationOf: authorizationOf,
|
|
225
230
|
traits: traits
|
|
226
231
|
};
|
|
@@ -241,8 +246,19 @@ function make$3(param, param$1, param$2) {
|
|
|
241
246
|
return 0;
|
|
242
247
|
}
|
|
243
248
|
|
|
249
|
+
let PsTwoRuleFeedSlice_Spec = {
|
|
250
|
+
name: name,
|
|
251
|
+
moduleUrl: moduleUrl,
|
|
252
|
+
externalInputSchema: externalInputSchema,
|
|
253
|
+
commandSchema: commandSchema,
|
|
254
|
+
targetName: targetName,
|
|
255
|
+
externalSystem: undefined,
|
|
256
|
+
authorizationOf: authorizationOf,
|
|
257
|
+
endpoint: undefined
|
|
258
|
+
};
|
|
259
|
+
|
|
244
260
|
let PsTwoRuleFeedSlice = {
|
|
245
|
-
Spec:
|
|
261
|
+
Spec: PsTwoRuleFeedSlice_Spec,
|
|
246
262
|
Translation: Translation,
|
|
247
263
|
queryDbName: "PsTwoRuleFeedAudit",
|
|
248
264
|
make: make$3
|