@dev-crew-berlin/enter-js-utils 0.98.12 → 0.99.1

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,508 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import jws from 'jws';
3
+ import { encodeJWS, parseEmailHtmlToken, parseEmailViewToken, parseRegistrationToken, parseTrackingToken, parseUnsubscribeToken, readRegistrationToken, storeRegistrationToken, deleteRegistrationToken } from './tokens';
4
+ const options = {
5
+ jwtSecret: 'test-secret',
6
+ instanceName: 'my-instance'
7
+ };
8
+ function sign(payload, expiresInSeconds) {
9
+ return encodeJWS(payload, options.jwtSecret, expiresInSeconds);
10
+ }
11
+ describe('encodeJWS', () => {
12
+ it('produces a signature verifiable with the same secret', () => {
13
+ const token = encodeJWS({
14
+ hello: 'world'
15
+ }, options.jwtSecret);
16
+ expect(jws.verify(token, 'HS256', options.jwtSecret)).toBe(true);
17
+ });
18
+ it('rejects verification with a different secret', () => {
19
+ const token = encodeJWS({
20
+ hello: 'world'
21
+ }, options.jwtSecret);
22
+ expect(jws.verify(token, 'HS256', 'wrong-secret')).toBe(false);
23
+ });
24
+ it('round-trips the payload unchanged', () => {
25
+ const token = encodeJWS({
26
+ foo: 'bar',
27
+ n: 1
28
+ }, options.jwtSecret);
29
+ const decoded = jws.decode(token);
30
+ expect(decoded?.payload).toEqual({
31
+ foo: 'bar',
32
+ n: 1
33
+ });
34
+ });
35
+ it('adds an exp claim expiresInSeconds in the future when provided', () => {
36
+ const before = Math.floor(Date.now() / 1000);
37
+ const token = encodeJWS({
38
+ foo: 'bar'
39
+ }, options.jwtSecret, 3600);
40
+ const decoded = jws.decode(token);
41
+ expect(decoded?.payload.exp).toBeGreaterThanOrEqual(before + 3600);
42
+ expect(decoded?.payload.exp).toBeLessThanOrEqual(before + 3601);
43
+ });
44
+ it('omits exp entirely when no expiry is given', () => {
45
+ const token = encodeJWS({
46
+ foo: 'bar'
47
+ }, options.jwtSecret);
48
+ const decoded = jws.decode(token);
49
+ expect(decoded?.payload.exp).toBeUndefined();
50
+ });
51
+ });
52
+ describe('parseRegistrationToken', () => {
53
+ function makeToken(overrides = {}, expiresInSeconds = 600) {
54
+ return sign({
55
+ instance_name: options.instanceName,
56
+ attendee_id: 'att-1',
57
+ session_type: 'register',
58
+ flags: {},
59
+ ...overrides
60
+ }, expiresInSeconds);
61
+ }
62
+ it('parses a valid token', () => {
63
+ const result = parseRegistrationToken(makeToken(), options);
64
+ expect(result.success).toBe(true);
65
+ });
66
+ it('surfaces the attendee id', () => {
67
+ const result = parseRegistrationToken(makeToken(), options);
68
+ if (result.success) expect(result.data.attendeeId).toBe('att-1');
69
+ });
70
+ it('defaults session_type to "register" when absent', () => {
71
+ const token = sign({
72
+ instance_name: options.instanceName,
73
+ attendee_id: 'att-1',
74
+ exp: Math.floor(Date.now() / 1000) + 600
75
+ });
76
+ const result = parseRegistrationToken(token, options);
77
+ if (result.success) expect(result.data.sessionType).toBe('register');
78
+ });
79
+ it('surfaces a custom session_type', () => {
80
+ const result = parseRegistrationToken(makeToken({
81
+ session_type: 'edit'
82
+ }), options);
83
+ if (result.success) expect(result.data.sessionType).toBe('edit');
84
+ });
85
+ it('surfaces flags', () => {
86
+ const result = parseRegistrationToken(makeToken({
87
+ flags: {
88
+ skipPayment: true
89
+ }
90
+ }), options);
91
+ if (result.success) expect(result.data.flags).toEqual({
92
+ skipPayment: true
93
+ });
94
+ });
95
+ it('defaults flags to an empty object when absent', () => {
96
+ const token = sign({
97
+ instance_name: options.instanceName,
98
+ attendee_id: 'att-1',
99
+ exp: Math.floor(Date.now() / 1000) + 600
100
+ });
101
+ const result = parseRegistrationToken(token, options);
102
+ if (result.success) expect(result.data.flags).toEqual({});
103
+ });
104
+ it('surfaces the email name/mailing id when present', () => {
105
+ const result = parseRegistrationToken(makeToken({
106
+ email: {
107
+ name: 'welcome',
108
+ mailing_id: 'mailing-1'
109
+ }
110
+ }), options);
111
+ if (result.success) {
112
+ expect(result.data.email).toEqual({
113
+ name: 'welcome',
114
+ mailingId: 'mailing-1'
115
+ });
116
+ }
117
+ });
118
+ it('leaves email undefined when absent', () => {
119
+ const result = parseRegistrationToken(makeToken(), options);
120
+ if (result.success) expect(result.data.email).toBeUndefined();
121
+ });
122
+ it('marks a token past its exp claim as expired, not rejected', () => {
123
+ const result = parseRegistrationToken(makeToken({}, -600), options);
124
+ expect(result.success).toBe(true);
125
+ if (result.success) expect(result.data.isExpired).toBe(true);
126
+ });
127
+ it('marks a token before its exp claim as not expired', () => {
128
+ const result = parseRegistrationToken(makeToken({}, 600), options);
129
+ if (result.success) expect(result.data.isExpired).toBe(false);
130
+ });
131
+ it('rejects a missing token', () => {
132
+ const result = parseRegistrationToken(undefined, options);
133
+ expect(result).toEqual({
134
+ success: false,
135
+ error: 'token_missing'
136
+ });
137
+ });
138
+ it('rejects a token signed with the wrong secret', () => {
139
+ const token = encodeJWS({
140
+ instance_name: options.instanceName,
141
+ attendee_id: 'att-1'
142
+ }, 'a-different-secret', 600);
143
+ expect(parseRegistrationToken(token, options)).toEqual({
144
+ success: false,
145
+ error: 'token_invalid'
146
+ });
147
+ });
148
+ it('rejects a token for a different instance', () => {
149
+ const token = makeToken({
150
+ instance_name: 'other-instance'
151
+ });
152
+ expect(parseRegistrationToken(token, options)).toEqual({
153
+ success: false,
154
+ error: 'token_invalid'
155
+ });
156
+ });
157
+ it('rejects a token missing an exp claim', () => {
158
+ const token = sign({
159
+ instance_name: options.instanceName,
160
+ attendee_id: 'att-1'
161
+ });
162
+ expect(parseRegistrationToken(token, options)).toEqual({
163
+ success: false,
164
+ error: 'token_invalid'
165
+ });
166
+ });
167
+ });
168
+ describe('parseTrackingToken', () => {
169
+ function makeToken(overrides = {}) {
170
+ return sign({
171
+ instance_name: options.instanceName,
172
+ attendee_id: 'att-1',
173
+ email_name: 'welcome',
174
+ target: 'https://example.com/home',
175
+ mailing_id: null,
176
+ ...overrides
177
+ });
178
+ }
179
+ it('parses a valid token', () => {
180
+ const result = parseTrackingToken(makeToken(), options);
181
+ expect(result.success).toBe(true);
182
+ });
183
+ it('surfaces the target url', () => {
184
+ const result = parseTrackingToken(makeToken(), options);
185
+ if (result.success) expect(result.data.target).toBe('https://example.com/home');
186
+ });
187
+ it('surfaces a null mailing id as null, not undefined', () => {
188
+ const result = parseTrackingToken(makeToken(), options);
189
+ if (result.success) expect(result.data.mailingId).toBeNull();
190
+ });
191
+ it('surfaces a string mailing id', () => {
192
+ const result = parseTrackingToken(makeToken({
193
+ mailing_id: 'mailing-1'
194
+ }), options);
195
+ if (result.success) expect(result.data.mailingId).toBe('mailing-1');
196
+ });
197
+ it('rejects a missing token', () => {
198
+ expect(parseTrackingToken(undefined, options)).toEqual({
199
+ success: false,
200
+ error: 'token_missing'
201
+ });
202
+ });
203
+ it('rejects a token for a different instance', () => {
204
+ const token = makeToken({
205
+ instance_name: 'other-instance'
206
+ });
207
+ expect(parseTrackingToken(token, options)).toEqual({
208
+ success: false,
209
+ error: 'token_invalid'
210
+ });
211
+ });
212
+ it('rejects a token missing a target', () => {
213
+ const token = sign({
214
+ instance_name: options.instanceName,
215
+ attendee_id: 'att-1',
216
+ email_name: 'welcome',
217
+ mailing_id: null
218
+ });
219
+ expect(parseTrackingToken(token, options)).toEqual({
220
+ success: false,
221
+ error: 'token_invalid'
222
+ });
223
+ });
224
+ it('rejects a mailing id that is neither a string nor null', () => {
225
+ const token = makeToken({
226
+ mailing_id: 42
227
+ });
228
+ expect(parseTrackingToken(token, options)).toEqual({
229
+ success: false,
230
+ error: 'token_invalid'
231
+ });
232
+ });
233
+ });
234
+ describe('parseUnsubscribeToken', () => {
235
+ function makeToken(overrides = {}) {
236
+ return sign({
237
+ instance_name: options.instanceName,
238
+ attendee_id: 'att-1',
239
+ email_name: 'welcome',
240
+ email_address: 'a@b.com',
241
+ ...overrides
242
+ });
243
+ }
244
+ it('parses a valid token', () => {
245
+ const result = parseUnsubscribeToken(makeToken(), options);
246
+ expect(result.success).toBe(true);
247
+ });
248
+ it('surfaces the email address', () => {
249
+ const result = parseUnsubscribeToken(makeToken(), options);
250
+ if (result.success) expect(result.data.emailAddress).toBe('a@b.com');
251
+ });
252
+ it('surfaces the attendee id and email name', () => {
253
+ const result = parseUnsubscribeToken(makeToken(), options);
254
+ if (result.success) {
255
+ expect(result.data.attendeeId).toBe('att-1');
256
+ expect(result.data.emailName).toBe('welcome');
257
+ }
258
+ });
259
+ it('accepts a token with no exp claim (permanent link)', () => {
260
+ const result = parseUnsubscribeToken(makeToken(), options);
261
+ expect(result.success).toBe(true);
262
+ });
263
+ it('rejects a missing token', () => {
264
+ expect(parseUnsubscribeToken(undefined, options)).toEqual({
265
+ success: false,
266
+ error: 'token_missing'
267
+ });
268
+ });
269
+ it('rejects a token for a different instance', () => {
270
+ const token = makeToken({
271
+ instance_name: 'other-instance'
272
+ });
273
+ expect(parseUnsubscribeToken(token, options)).toEqual({
274
+ success: false,
275
+ error: 'token_invalid'
276
+ });
277
+ });
278
+ it('rejects a token missing an email address', () => {
279
+ const token = sign({
280
+ instance_name: options.instanceName,
281
+ attendee_id: 'att-1',
282
+ email_name: 'welcome'
283
+ });
284
+ expect(parseUnsubscribeToken(token, options)).toEqual({
285
+ success: false,
286
+ error: 'token_invalid'
287
+ });
288
+ });
289
+ });
290
+ describe('parseEmailHtmlToken', () => {
291
+ function makeToken(overrides = {}, expiresInSeconds = 600) {
292
+ return sign({
293
+ instance_name: options.instanceName,
294
+ attendee_id: 'att-1',
295
+ email_name: 'welcome',
296
+ mailing_id: null,
297
+ ...overrides
298
+ }, expiresInSeconds);
299
+ }
300
+ it('parses a valid token', () => {
301
+ const result = parseEmailHtmlToken(makeToken(), options);
302
+ expect(result.success).toBe(true);
303
+ });
304
+ it('surfaces the attendee id and email name', () => {
305
+ const result = parseEmailHtmlToken(makeToken(), options);
306
+ if (result.success) {
307
+ expect(result.data.attendeeId).toBe('att-1');
308
+ expect(result.data.emailName).toBe('welcome');
309
+ }
310
+ });
311
+ it('surfaces the mailing id when present', () => {
312
+ const result = parseEmailHtmlToken(makeToken({
313
+ mailing_id: 'mailing-1'
314
+ }), options);
315
+ if (result.success) expect(result.data.mailingId).toBe('mailing-1');
316
+ });
317
+ it('rejects a missing token', () => {
318
+ expect(parseEmailHtmlToken(undefined, options)).toEqual({
319
+ success: false,
320
+ error: 'token_missing'
321
+ });
322
+ });
323
+ it('rejects a token missing an exp claim', () => {
324
+ const token = sign({
325
+ instance_name: options.instanceName,
326
+ attendee_id: 'att-1',
327
+ email_name: 'welcome'
328
+ });
329
+ expect(parseEmailHtmlToken(token, options)).toEqual({
330
+ success: false,
331
+ error: 'token_invalid'
332
+ });
333
+ });
334
+ it('rejects an expired token', () => {
335
+ const token = makeToken({}, -600);
336
+ expect(parseEmailHtmlToken(token, options)).toEqual({
337
+ success: false,
338
+ error: 'token_expired'
339
+ });
340
+ });
341
+ });
342
+ describe('parseEmailViewToken', () => {
343
+ function makeToken(overrides = {}, expiresInSeconds = 600) {
344
+ return sign({
345
+ instance_name: options.instanceName,
346
+ attendee_id: 'att-1',
347
+ email_name: 'welcome',
348
+ mailing_id: null,
349
+ purpose: 'view',
350
+ ...overrides
351
+ }, expiresInSeconds);
352
+ }
353
+ it('parses a valid view-purpose token', () => {
354
+ const result = parseEmailViewToken(makeToken(), options);
355
+ expect(result.success).toBe(true);
356
+ if (result.success) {
357
+ expect(result.data.attendeeId).toBe('att-1');
358
+ expect(result.data.emailName).toBe('welcome');
359
+ expect(result.data.purpose).toBe('view');
360
+ expect(result.data.mailingId).toBeUndefined();
361
+ }
362
+ });
363
+ it('parses a render-purpose token', () => {
364
+ const result = parseEmailViewToken(makeToken({
365
+ purpose: 'render'
366
+ }), options);
367
+ expect(result.success).toBe(true);
368
+ if (result.success) expect(result.data.purpose).toBe('render');
369
+ });
370
+ it('rejects a missing token', () => {
371
+ const result = parseEmailViewToken(undefined, options);
372
+ expect(result).toEqual({
373
+ success: false,
374
+ error: 'token_missing'
375
+ });
376
+ });
377
+ it('rejects a token signed with the wrong secret', () => {
378
+ const token = encodeJWS({
379
+ instance_name: options.instanceName,
380
+ attendee_id: 'att-1',
381
+ email_name: 'welcome',
382
+ purpose: 'view'
383
+ }, 'a-different-secret', 600);
384
+ const result = parseEmailViewToken(token, options);
385
+ expect(result).toEqual({
386
+ success: false,
387
+ error: 'token_invalid'
388
+ });
389
+ });
390
+ it('rejects a token for a different instance', () => {
391
+ const token = makeToken({
392
+ instance_name: 'other-instance'
393
+ });
394
+ const result = parseEmailViewToken(token, options);
395
+ expect(result).toEqual({
396
+ success: false,
397
+ error: 'token_invalid'
398
+ });
399
+ });
400
+ it('rejects an invalid purpose value', () => {
401
+ const token = makeToken({
402
+ purpose: 'delete'
403
+ });
404
+ const result = parseEmailViewToken(token, options);
405
+ expect(result).toEqual({
406
+ success: false,
407
+ error: 'token_invalid'
408
+ });
409
+ });
410
+ it('rejects an expired token', () => {
411
+ const token = makeToken({}, -600);
412
+ const result = parseEmailViewToken(token, options);
413
+ expect(result).toEqual({
414
+ success: false,
415
+ error: 'token_expired'
416
+ });
417
+ });
418
+ it('surfaces the mailing id when present', () => {
419
+ const token = makeToken({
420
+ mailing_id: 'mailing-1'
421
+ });
422
+ const result = parseEmailViewToken(token, options);
423
+ expect(result.success).toBe(true);
424
+ if (result.success) expect(result.data.mailingId).toBe('mailing-1');
425
+ });
426
+ it('a "render"-purpose token is never returned for a token missing purpose entirely', () => {
427
+ const token = sign({
428
+ instance_name: options.instanceName,
429
+ attendee_id: 'att-1',
430
+ email_name: 'welcome',
431
+ exp: Math.floor(Date.now() / 1000) + 600
432
+ });
433
+ const result = parseEmailViewToken(token, options);
434
+ expect(result).toEqual({
435
+ success: false,
436
+ error: 'token_invalid'
437
+ });
438
+ });
439
+ });
440
+ describe('registration token cookie helpers', () => {
441
+ const cookieOptions = {
442
+ instanceName: options.instanceName,
443
+ jwtSecret: options.jwtSecret
444
+ };
445
+ it('readRegistrationToken parses the token from the default cookie name', () => {
446
+ const token = sign({
447
+ instance_name: options.instanceName,
448
+ attendee_id: 'att-1'
449
+ }, 600);
450
+ const getCookie = vi.fn(name => name === 'enter-frontend.registation-token' ? {
451
+ name,
452
+ value: token
453
+ } : undefined);
454
+ const result = readRegistrationToken(getCookie, cookieOptions);
455
+ expect(getCookie).toHaveBeenCalledWith('enter-frontend.registation-token');
456
+ expect(result.success).toBe(true);
457
+ });
458
+ it('readRegistrationToken respects a custom cookie name', () => {
459
+ const token = sign({
460
+ instance_name: options.instanceName,
461
+ attendee_id: 'att-1'
462
+ }, 600);
463
+ const getCookie = vi.fn(name => name === 'custom-cookie' ? {
464
+ name,
465
+ value: token
466
+ } : undefined);
467
+ const result = readRegistrationToken(getCookie, {
468
+ ...cookieOptions,
469
+ registrationTokenCookieName: 'custom-cookie'
470
+ });
471
+ expect(result.success).toBe(true);
472
+ });
473
+ it('readRegistrationToken fails when the cookie is absent', () => {
474
+ const getCookie = vi.fn(() => undefined);
475
+ const result = readRegistrationToken(getCookie, cookieOptions);
476
+ expect(result).toEqual({
477
+ success: false,
478
+ error: 'token_missing'
479
+ });
480
+ });
481
+ it('storeRegistrationToken sets the cookie with lax/root defaults', () => {
482
+ const setCookie = vi.fn();
483
+ storeRegistrationToken('a-token', setCookie);
484
+ expect(setCookie).toHaveBeenCalledWith('enter-frontend.registation-token', 'a-token', {
485
+ sameSite: 'lax',
486
+ path: '/'
487
+ });
488
+ });
489
+ it('storeRegistrationToken respects a custom cookie name', () => {
490
+ const setCookie = vi.fn();
491
+ storeRegistrationToken('a-token', setCookie, {
492
+ registrationTokenCookieName: 'custom-cookie'
493
+ });
494
+ expect(setCookie).toHaveBeenCalledWith('custom-cookie', 'a-token', expect.anything());
495
+ });
496
+ it('deleteRegistrationToken removes the default cookie name', () => {
497
+ const deleteCookie = vi.fn();
498
+ deleteRegistrationToken(deleteCookie);
499
+ expect(deleteCookie).toHaveBeenCalledWith('enter-frontend.registation-token');
500
+ });
501
+ it('deleteRegistrationToken respects a custom cookie name', () => {
502
+ const deleteCookie = vi.fn();
503
+ deleteRegistrationToken(deleteCookie, {
504
+ registrationTokenCookieName: 'custom-cookie'
505
+ });
506
+ expect(deleteCookie).toHaveBeenCalledWith('custom-cookie');
507
+ });
508
+ });
@@ -0,0 +1,23 @@
1
+ export type ContentValue = {
2
+ type: 'handlebars' | 'text';
3
+ content: string;
4
+ };
5
+ export type EmailContentBlock = {
6
+ key: string;
7
+ name: string;
8
+ sort_order: number;
9
+ value: ContentValue;
10
+ };
11
+ export type PublicEmailContent = {
12
+ subject: ContentValue;
13
+ to: ContentValue;
14
+ to_name: ContentValue;
15
+ from: ContentValue;
16
+ cc: ContentValue;
17
+ bcc: ContentValue;
18
+ reply: ContentValue;
19
+ pre: ContentValue;
20
+ pdf: ContentValue;
21
+ pdfname: ContentValue;
22
+ blocks: EmailContentBlock[];
23
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -16,6 +16,7 @@ export type { FieldValue } from './field-value';
16
16
  export type { TextField, EmailField, NumberField, CheckboxField, ToggleField, RadioField, SelectField, Field, } from './field';
17
17
  export { Instance } from './instance';
18
18
  export type { InstanceJSON } from './instance';
19
+ export type { ContentValue, EmailContentBlock, PublicEmailContent, } from './email-content';
19
20
  export type { Permission } from './permission';
20
21
  export type { User } from './user';
21
22
  export type { Variable } from './variable';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dev-crew-berlin/enter-js-utils",
3
- "version": "0.98.12",
3
+ "version": "0.99.1",
4
4
  "description": "utils such as vaildation and other helpers to work with data from the enter app",
5
5
  "files": [
6
6
  "dist",
@@ -79,6 +79,7 @@
79
79
  "dependencies": {
80
80
  "cookie": "^1.0.1",
81
81
  "eventsource-parser": "^3.0.6",
82
+ "handlebars": "^4.7.9",
82
83
  "jws": "^4.0.0",
83
84
  "stable-hash": "^0.0.5",
84
85
  "styled-jsx": "^5.1.6",