@reventlessdev/reventless-local 3.0.0-alpha.311 → 3.0.0-alpha.313

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.
@@ -0,0 +1,160 @@
1
+ // An outbound work item that carries personal data is stored as an event is: its
2
+ // marked fields sealed, its row keyed by a fingerprint of the key `collect`
3
+ // chose, opened before it is translated, and completed without a call once its
4
+ // subject is erased.
5
+
6
+ open JestGlobals
7
+
8
+ let sent: ref<array<string>> = ref([])
9
+
10
+ module VerifySpec = {
11
+ let name = "VerifyAddress"
12
+ let moduleUrl: string = %raw(`import.meta.url`)
13
+
14
+ @schema
15
+ type consumedEvent = AddressGiven({customerId: string, email: string})
16
+
17
+ @schema
18
+ type outboundItem = {
19
+ customerId: string,
20
+ email: @s.matches(Reventless.Pii.mark(~subject="customerId", S.string)) string,
21
+ }
22
+
23
+ @schema
24
+ type inboundCommand = unit
25
+
26
+ // Keyed by the address, as the shop's translations are: a change is new work.
27
+ let collect = (event, ~sourceId as _) =>
28
+ switch event {
29
+ | AddressGiven({customerId, email}) => [(`${customerId}:${email}`, {customerId, email})]
30
+ }
31
+
32
+ let translate = async (_id, item: outboundItem, ~capabilities as _) => {
33
+ sent := sent.contents->Array.concat([item.email])
34
+ Ok(None)
35
+ }
36
+ let onExhausted = (_id, _item, ~lastError as _) => None
37
+
38
+ let maxRetries = 3
39
+ let targetName = None
40
+ let sourceNames: array<string> = []
41
+ let externalSystem = None
42
+ let capabilityNeeds: array<Reventless.CapabilityNeed.t> = []
43
+ let traits: array<Reventless.Trait.t> = []
44
+ }
45
+ module VerifyTranslation = {
46
+ let collect = VerifySpec.collect
47
+ let translate = VerifySpec.translate
48
+ let onExhausted = VerifySpec.onExhausted
49
+ let moduleUrl = VerifySpec.moduleUrl
50
+ }
51
+ module Callback = ReventlessCore.OutboundTranslationSlice_Callback.Make(
52
+ VerifySpec,
53
+ VerifyTranslation,
54
+ )
55
+
56
+ let toBase64 = (s: string) =>
57
+ NodeCrypto.bufferFromString(s, "utf8")->NodeCrypto.bufferToString("base64")
58
+ let fromBase64 = (s: string) =>
59
+ NodeCrypto.bufferFromString(s, "base64")->NodeCrypto.bufferToString("utf8")
60
+
61
+ /** "Encrypts" by base64; `erased` makes every key gone, as an erasure does. */
62
+ let erased = ref(false)
63
+ let provider: Reventless.Pii.provider = {
64
+ name: "fake",
65
+ encrypts: true,
66
+ seal: async (~subject, ~field as _, ~forgettable as _, json) => Ok(
67
+ Reventless.Pii.wrap(`enc:v1:${subject}:fake:${json->JSON.stringify->toBase64}`),
68
+ ),
69
+ prefetch: async _ => Ok(),
70
+ openNow: (~field, json) =>
71
+ switch json->Reventless.Pii.unwrap->Option.flatMap(Reventless.Pii.parseStored) {
72
+ | Some(Cipher(_)) if erased.contents => Ok(Reventless.Pii.wrap(Reventless.Pii.erasedMarker))
73
+ | Some(Cipher({bytes})) => Ok(bytes->fromBase64->JSON.parseOrThrow)
74
+ | _ => Error(Reventless.Pii.UnknownForm({field: field}))
75
+ },
76
+ release: async (~subject as _, ~ref as _) => Ok(),
77
+ }
78
+
79
+ let keyed: Reventless.Capabilities.t = {
80
+ ...Reventless.Capabilities.none,
81
+ secrets: Reventless.Secrets.fixed(
82
+ ~token="t",
83
+ ~hash=s => s,
84
+ ~fingerprint=(~purpose, input) =>
85
+ `fp(${purpose})#${input->toBase64->String.length->Int.toString}`,
86
+ ),
87
+ }
88
+
89
+ let given = VerifySpec.AddressGiven({customerId: "c-1", email: "ada@example.org"})
90
+ let rows = () => Callback.todoItems->Dict.toArray
91
+ let publish: ReventlessInfra.CommandTopic.publishJsons = async _ => ()
92
+
93
+ @val external env: dict<string> = "process.env"
94
+
95
+ describe("an outbound work item with personal data", () => {
96
+ beforeEach(() => {
97
+ Callback.todoItems->Dict.keysToArray->Array.forEach(k => Callback.todoItems->Dict.delete(k))
98
+ sent := []
99
+ erased := false
100
+ Reventless.Pii.Provider.install(provider)
101
+ })
102
+ afterEach(() => {
103
+ Reventless.Pii.Provider.install(Reventless.Pii.plain)
104
+ env->Dict.delete("REVENTLESS_PII_REQUIRE_ENCRYPTION")
105
+ })
106
+
107
+ testPromise("is stored sealed, under a key that holds no address", async () => {
108
+ await Callback.phase1([("c-1", given)], ~capabilities=keyed)
109
+ let stored = rows()->JSON.stringifyAny->Option.getOr("")
110
+ expect(stored->String.includes("ada@example.org"))->toBe(false)
111
+ switch rows() {
112
+ | [(id, row)] =>
113
+ expect(id->String.startsWith("fp(outboundWorkItem:VerifyAddress)"))->toBe(true)
114
+ // A sealed string stays a string, so the work list still serves it as one.
115
+ let email = row.item->JSON.Decode.object->Option.flatMap(d => d->Dict.get("email"))
116
+ expect(
117
+ email
118
+ ->Option.flatMap(JSON.Decode.string)
119
+ ->Option.mapOr(false, s => s->String.startsWith("enc:v1:c-1:")),
120
+ )->toBe(true)
121
+ | _ => fail("expected one row")
122
+ }
123
+ })
124
+
125
+ testPromise("is collected once, however often its event arrives", async () => {
126
+ await Callback.phase1([("c-1", given)], ~capabilities=keyed)
127
+ await Callback.phase1([("c-1", given)], ~capabilities=keyed)
128
+ expect(rows()->Array.length)->toBe(1)
129
+ })
130
+
131
+ testPromise("is opened before it is translated", async () => {
132
+ await Callback.phase1([("c-1", given)], ~capabilities=keyed)
133
+ await Callback.phase2(publish, ~capabilities=keyed)
134
+ expect(sent.contents)->toEqual(["ada@example.org"])
135
+ })
136
+
137
+ testPromise("is completed without a call once its subject is erased", async () => {
138
+ await Callback.phase1([("c-1", given)], ~capabilities=keyed)
139
+ erased := true
140
+ await Callback.phase2(publish, ~capabilities=keyed)
141
+ expect(sent.contents)->toEqual([])
142
+ expect(rows()->Array.map(((_, row)) => row.status))->toEqual([
143
+ ReventlessCore.OutboundTranslationSlice_Callback.Completed,
144
+ ])
145
+ })
146
+
147
+ testPromise("keeps the key it was given where nothing can fingerprint it", async () => {
148
+ await Callback.phase1([("c-1", given)], ~capabilities=Reventless.Capabilities.none)
149
+ expect(rows()->Array.map(Pair.first))->toEqual(["c-1:ada@example.org"])
150
+ })
151
+
152
+ testPromise(
153
+ "is not stored at all where encryption is required and nothing can fingerprint it",
154
+ async () => {
155
+ env->Dict.set("REVENTLESS_PII_REQUIRE_ENCRYPTION", "true")
156
+ await Callback.phase1([("c-1", given)], ~capabilities=Reventless.Capabilities.none)
157
+ expect(rows())->toEqual([])
158
+ },
159
+ )
160
+ })
@@ -0,0 +1,300 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Sury from "sury";
4
+ import * as JestGlobals from "@reventlessdev/rescript-jest/src/JestGlobals.res.mjs";
5
+ import * as Stdlib_Dict from "@rescript/runtime/lib/es6/Stdlib_Dict.js";
6
+ import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
7
+ import * as Nodebuffer from "node:buffer";
8
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
9
+ import * as Pii$Reventless from "@reventlessdev/reventless-spec/src/components/Pii.res.mjs";
10
+ import * as Secrets$Reventless from "@reventlessdev/reventless-spec/src/semantic/Secrets.res.mjs";
11
+ import * as Capabilities$Reventless from "@reventlessdev/reventless-spec/src/semantic/Capabilities.res.mjs";
12
+ import * as OutboundTranslationSlice_Callback$ReventlessCore from "@reventlessdev/reventless-core/src/components/OutboundTranslationSlice/OutboundTranslationSlice_Callback.res.mjs";
13
+
14
+ let sent = {
15
+ contents: []
16
+ };
17
+
18
+ let name = "VerifyAddress";
19
+
20
+ let moduleUrl = import.meta.url;
21
+
22
+ let consumedEventSchema = Sury.$schema(s => ({
23
+ TAG: "AddressGiven",
24
+ customerId: s.m(Sury.string),
25
+ email: s.m(Sury.string)
26
+ }));
27
+
28
+ let outboundItemSchema = Sury.$schema(s => ({
29
+ customerId: s.m(Sury.string),
30
+ email: s.m(Pii$Reventless.mark("customerId", undefined, Sury.string))
31
+ }));
32
+
33
+ let inboundCommandSchema = Sury.$unit;
34
+
35
+ function collect(event, param) {
36
+ let email = event.email;
37
+ let customerId = event.customerId;
38
+ return [[
39
+ customerId + `:` + email,
40
+ {
41
+ customerId: customerId,
42
+ email: email
43
+ }
44
+ ]];
45
+ }
46
+
47
+ async function translate(_id, item, param) {
48
+ sent.contents = sent.contents.concat([item.email]);
49
+ return {
50
+ TAG: "Ok",
51
+ _0: undefined
52
+ };
53
+ }
54
+
55
+ function onExhausted(_id, _item, param) {
56
+
57
+ }
58
+
59
+ let sourceNames = [];
60
+
61
+ let capabilityNeeds = [];
62
+
63
+ let traits = [];
64
+
65
+ let VerifySpec = {
66
+ name: name,
67
+ moduleUrl: moduleUrl,
68
+ consumedEventSchema: consumedEventSchema,
69
+ outboundItemSchema: outboundItemSchema,
70
+ inboundCommandSchema: inboundCommandSchema,
71
+ collect: collect,
72
+ translate: translate,
73
+ onExhausted: onExhausted,
74
+ maxRetries: 3,
75
+ targetName: undefined,
76
+ sourceNames: sourceNames,
77
+ externalSystem: undefined,
78
+ capabilityNeeds: capabilityNeeds,
79
+ traits: traits
80
+ };
81
+
82
+ let VerifyTranslation = {
83
+ collect: collect,
84
+ translate: translate,
85
+ onExhausted: onExhausted,
86
+ moduleUrl: moduleUrl
87
+ };
88
+
89
+ let Callback = OutboundTranslationSlice_Callback$ReventlessCore.Make({
90
+ name: name,
91
+ moduleUrl: moduleUrl,
92
+ consumedEventSchema: consumedEventSchema,
93
+ outboundItemSchema: outboundItemSchema,
94
+ inboundCommandSchema: inboundCommandSchema,
95
+ maxRetries: 3,
96
+ targetName: undefined,
97
+ sourceNames: sourceNames,
98
+ externalSystem: undefined,
99
+ capabilityNeeds: capabilityNeeds,
100
+ traits: traits
101
+ })(VerifyTranslation);
102
+
103
+ function toBase64(s) {
104
+ return Nodebuffer.Buffer.from(s, "utf8").toString("base64");
105
+ }
106
+
107
+ function fromBase64(s) {
108
+ return Nodebuffer.Buffer.from(s, "base64").toString("utf8");
109
+ }
110
+
111
+ let erased = {
112
+ contents: false
113
+ };
114
+
115
+ async function provider_seal(subject, param, param$1, json) {
116
+ return {
117
+ TAG: "Ok",
118
+ _0: Pii$Reventless.wrap(`enc:v1:` + subject + `:fake:` + toBase64(JSON.stringify(json)))
119
+ };
120
+ }
121
+
122
+ async function provider_prefetch(param) {
123
+ return {
124
+ TAG: "Ok",
125
+ _0: undefined
126
+ };
127
+ }
128
+
129
+ function provider_openNow(field, json) {
130
+ let match = Stdlib_Option.flatMap(Pii$Reventless.unwrap(json), Pii$Reventless.parseStored);
131
+ if (match === undefined) {
132
+ return {
133
+ TAG: "Error",
134
+ _0: {
135
+ TAG: "UnknownForm",
136
+ field: field
137
+ }
138
+ };
139
+ }
140
+ switch (match.TAG) {
141
+ case "Cipher" :
142
+ if (erased.contents) {
143
+ return {
144
+ TAG: "Ok",
145
+ _0: Pii$Reventless.wrap(Pii$Reventless.erasedMarker)
146
+ };
147
+ } else {
148
+ return {
149
+ TAG: "Ok",
150
+ _0: JSON.parse(fromBase64(match.bytes))
151
+ };
152
+ }
153
+ case "ErasedForm" :
154
+ case "ForgottenForm" :
155
+ return {
156
+ TAG: "Error",
157
+ _0: {
158
+ TAG: "UnknownForm",
159
+ field: field
160
+ }
161
+ };
162
+ }
163
+ }
164
+
165
+ async function provider_release(param, param$1) {
166
+ return {
167
+ TAG: "Ok",
168
+ _0: undefined
169
+ };
170
+ }
171
+
172
+ let provider = {
173
+ name: "fake",
174
+ encrypts: true,
175
+ seal: provider_seal,
176
+ prefetch: provider_prefetch,
177
+ openNow: provider_openNow,
178
+ release: provider_release
179
+ };
180
+
181
+ let keyed_geocode = Capabilities$Reventless.none.geocode;
182
+
183
+ let keyed_messaging = Capabilities$Reventless.none.messaging;
184
+
185
+ let keyed_identityProvider = Capabilities$Reventless.none.identityProvider;
186
+
187
+ let keyed_secrets = Secrets$Reventless.fixed("t", s => s, (purpose, input) => `fp(` + purpose + `)#` + toBase64(input).length.toString());
188
+
189
+ let keyed_stash = Capabilities$Reventless.none.stash;
190
+
191
+ let keyed = {
192
+ geocode: keyed_geocode,
193
+ messaging: keyed_messaging,
194
+ identityProvider: keyed_identityProvider,
195
+ secrets: keyed_secrets,
196
+ stash: keyed_stash
197
+ };
198
+
199
+ let given = {
200
+ TAG: "AddressGiven",
201
+ customerId: "c-1",
202
+ email: "ada@example.org"
203
+ };
204
+
205
+ function rows() {
206
+ return Object.entries(Callback.todoItems);
207
+ }
208
+
209
+ async function publish(param) {
210
+
211
+ }
212
+
213
+ globalThis.describe("an outbound work item with personal data", () => {
214
+ globalThis.beforeEach(() => {
215
+ Object.keys(Callback.todoItems).forEach(k => Stdlib_Dict.$$delete(Callback.todoItems, k));
216
+ sent.contents = [];
217
+ erased.contents = false;
218
+ Pii$Reventless.Provider.install(provider);
219
+ });
220
+ globalThis.afterEach(() => {
221
+ Pii$Reventless.Provider.install(Pii$Reventless.plain);
222
+ Stdlib_Dict.$$delete(process.env, "REVENTLESS_PII_REQUIRE_ENCRYPTION");
223
+ });
224
+ globalThis.test("is stored sealed, under a key that holds no address", async () => {
225
+ await Callback.phase1([[
226
+ "c-1",
227
+ given
228
+ ]], keyed);
229
+ let stored = Stdlib_Option.getOr(JSON.stringify(Object.entries(Callback.todoItems)), "");
230
+ globalThis.expect(stored.includes("ada@example.org")).toBe(false);
231
+ let match = Object.entries(Callback.todoItems);
232
+ if (match.length !== 1) {
233
+ return JestGlobals.fail("expected one row");
234
+ }
235
+ let match$1 = match[0];
236
+ globalThis.expect(match$1[0].startsWith("fp(outboundWorkItem:VerifyAddress)")).toBe(true);
237
+ let email = Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(match$1[1].item), d => d["email"]);
238
+ globalThis.expect(Stdlib_Option.mapOr(Stdlib_Option.flatMap(email, Stdlib_JSON.Decode.string), false, s => s.startsWith("enc:v1:c-1:"))).toBe(true);
239
+ });
240
+ globalThis.test("is collected once, however often its event arrives", async () => {
241
+ await Callback.phase1([[
242
+ "c-1",
243
+ given
244
+ ]], keyed);
245
+ await Callback.phase1([[
246
+ "c-1",
247
+ given
248
+ ]], keyed);
249
+ globalThis.expect(Object.entries(Callback.todoItems).length).toBe(1);
250
+ });
251
+ globalThis.test("is opened before it is translated", async () => {
252
+ await Callback.phase1([[
253
+ "c-1",
254
+ given
255
+ ]], keyed);
256
+ await Callback.phase2(publish, keyed);
257
+ globalThis.expect(sent.contents).toEqual(["ada@example.org"]);
258
+ });
259
+ globalThis.test("is completed without a call once its subject is erased", async () => {
260
+ await Callback.phase1([[
261
+ "c-1",
262
+ given
263
+ ]], keyed);
264
+ erased.contents = true;
265
+ await Callback.phase2(publish, keyed);
266
+ globalThis.expect(sent.contents).toEqual([]);
267
+ globalThis.expect(Object.entries(Callback.todoItems).map(param => param[1].status)).toEqual(["Completed"]);
268
+ });
269
+ globalThis.test("keeps the key it was given where nothing can fingerprint it", async () => {
270
+ await Callback.phase1([[
271
+ "c-1",
272
+ given
273
+ ]], Capabilities$Reventless.none);
274
+ globalThis.expect(Object.entries(Callback.todoItems).map(prim => prim[0])).toEqual(["c-1:ada@example.org"]);
275
+ });
276
+ globalThis.test("is not stored at all where encryption is required and nothing can fingerprint it", async () => {
277
+ process.env["REVENTLESS_PII_REQUIRE_ENCRYPTION"] = "true";
278
+ await Callback.phase1([[
279
+ "c-1",
280
+ given
281
+ ]], Capabilities$Reventless.none);
282
+ globalThis.expect(Object.entries(Callback.todoItems)).toEqual([]);
283
+ });
284
+ });
285
+
286
+ export {
287
+ sent,
288
+ VerifySpec,
289
+ VerifyTranslation,
290
+ Callback,
291
+ toBase64,
292
+ fromBase64,
293
+ erased,
294
+ provider,
295
+ keyed,
296
+ given,
297
+ rows,
298
+ publish,
299
+ }
300
+ /* moduleUrl Not a pure module */