@dev-crew-berlin/enter-js-utils 0.98.11 → 0.99.0

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,348 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import jws from 'jws';
3
+ import { makeGoogleWalletLink, makeQrCodeLink, makeRegistrationLink, makeTrackingLink, makeUnsubscribeLink, makeWalletLink } from './email-links';
4
+
5
+ /**
6
+ * Mirrors FrontendConfig's link builders
7
+ * (enter-core/common/models/frontend_config.py) -- each describe block
8
+ * below corresponds to one make_*_link method there. Split into
9
+ * single-assertion cases (enter-core/tests/unittests/common/models/
10
+ * frontend_config_test.py follows the same style) so a failure names
11
+ * exactly which behaviour broke.
12
+ */
13
+
14
+ const options = {
15
+ jwtSecret: 'test-secret',
16
+ instanceName: 'my-instance',
17
+ domain: 'example.com'
18
+ };
19
+ function decode(link) {
20
+ const token = link.split('/').pop();
21
+ return jws.decode(token)?.payload;
22
+ }
23
+ function decodeQueryToken(link) {
24
+ const token = link.split('token=')[1];
25
+ return jws.decode(token)?.payload;
26
+ }
27
+ describe('makeTrackingLink', () => {
28
+ describe('tracking disabled', () => {
29
+ it('returns the raw target url unsigned', () => {
30
+ const link = makeTrackingLink(options, {
31
+ attendeeId: 'att-1',
32
+ emailName: 'welcome',
33
+ urlLink: '/home',
34
+ trackingEnabled: false
35
+ });
36
+ expect(link).toBe('https://example.com/home');
37
+ });
38
+ it('does not prefix an external url', () => {
39
+ const link = makeTrackingLink(options, {
40
+ attendeeId: 'att-1',
41
+ emailName: 'welcome',
42
+ urlLink: 'https://google.com',
43
+ trackingEnabled: false
44
+ });
45
+ expect(link).toBe('https://google.com/');
46
+ });
47
+ });
48
+ describe('tracking enabled', () => {
49
+ it('returns a /link/{token} url', () => {
50
+ const link = makeTrackingLink(options, {
51
+ attendeeId: 'att-1',
52
+ emailName: 'welcome',
53
+ urlLink: '/home',
54
+ trackingEnabled: true
55
+ });
56
+ expect(link.startsWith('https://example.com/link/')).toBe(true);
57
+ });
58
+ it('includes the instance name in the token', () => {
59
+ const link = makeTrackingLink(options, {
60
+ attendeeId: 'att-1',
61
+ emailName: 'welcome',
62
+ urlLink: '/home',
63
+ trackingEnabled: true
64
+ });
65
+ expect(decode(link).instance_name).toBe('my-instance');
66
+ });
67
+ it('includes the attendee id in the token', () => {
68
+ const link = makeTrackingLink(options, {
69
+ attendeeId: 'att-1',
70
+ emailName: 'welcome',
71
+ urlLink: '/home',
72
+ trackingEnabled: true
73
+ });
74
+ expect(decode(link).attendee_id).toBe('att-1');
75
+ });
76
+ it('includes the email name in the token', () => {
77
+ const link = makeTrackingLink(options, {
78
+ attendeeId: 'att-1',
79
+ emailName: 'welcome',
80
+ urlLink: '/home',
81
+ trackingEnabled: true
82
+ });
83
+ expect(decode(link).email_name).toBe('welcome');
84
+ });
85
+ it('resolves a relative url into an absolute target in the token', () => {
86
+ const link = makeTrackingLink(options, {
87
+ attendeeId: 'att-1',
88
+ emailName: 'welcome',
89
+ urlLink: '/home',
90
+ trackingEnabled: true
91
+ });
92
+ expect(decode(link).target).toBe('https://example.com/home');
93
+ });
94
+ it('does not prefix an external url in the token target either', () => {
95
+ const link = makeTrackingLink(options, {
96
+ attendeeId: 'att-1',
97
+ emailName: 'welcome',
98
+ urlLink: 'https://google.com',
99
+ trackingEnabled: true
100
+ });
101
+ expect(decode(link).target).toBe('https://google.com/');
102
+ });
103
+ it('defaults the mailing id to null when not provided', () => {
104
+ const link = makeTrackingLink(options, {
105
+ attendeeId: 'att-1',
106
+ emailName: 'welcome',
107
+ urlLink: '/home',
108
+ trackingEnabled: true
109
+ });
110
+ expect(decode(link).mailing_id).toBeNull();
111
+ });
112
+ it('includes the mailing id in the token when provided', () => {
113
+ const link = makeTrackingLink(options, {
114
+ attendeeId: 'att-1',
115
+ emailName: 'welcome',
116
+ urlLink: '/home',
117
+ mailingId: 'mailing-1',
118
+ trackingEnabled: true
119
+ });
120
+ expect(decode(link).mailing_id).toBe('mailing-1');
121
+ });
122
+ it('has no exp claim (permanent link)', () => {
123
+ const link = makeTrackingLink(options, {
124
+ attendeeId: 'att-1',
125
+ emailName: 'welcome',
126
+ urlLink: '/home',
127
+ trackingEnabled: true
128
+ });
129
+ expect(decode(link).exp).toBeUndefined();
130
+ });
131
+ });
132
+ });
133
+ describe('makeUnsubscribeLink', () => {
134
+ it('builds a link under /unsubscribe with a query-string token', () => {
135
+ const link = makeUnsubscribeLink(options, {
136
+ attendeeId: 'att-1',
137
+ emailName: 'welcome',
138
+ emailAddress: 'a@b.com'
139
+ });
140
+ expect(link.startsWith('https://example.com/unsubscribe?token=')).toBe(true);
141
+ });
142
+ it('includes the instance name in the token', () => {
143
+ const link = makeUnsubscribeLink(options, {
144
+ attendeeId: 'att-1',
145
+ emailName: 'welcome',
146
+ emailAddress: 'a@b.com'
147
+ });
148
+ expect(decodeQueryToken(link).instance_name).toBe('my-instance');
149
+ });
150
+ it('includes the attendee id in the token', () => {
151
+ const link = makeUnsubscribeLink(options, {
152
+ attendeeId: 'att-1',
153
+ emailName: 'welcome',
154
+ emailAddress: 'a@b.com'
155
+ });
156
+ expect(decodeQueryToken(link).attendee_id).toBe('att-1');
157
+ });
158
+ it('includes the email address in the token', () => {
159
+ const link = makeUnsubscribeLink(options, {
160
+ attendeeId: 'att-1',
161
+ emailName: 'welcome',
162
+ emailAddress: 'a@b.com'
163
+ });
164
+ expect(decodeQueryToken(link).email_address).toBe('a@b.com');
165
+ });
166
+ it('includes the email name in the token', () => {
167
+ const link = makeUnsubscribeLink(options, {
168
+ attendeeId: 'att-1',
169
+ emailName: 'welcome',
170
+ emailAddress: 'a@b.com'
171
+ });
172
+ expect(decodeQueryToken(link).email_name).toBe('welcome');
173
+ });
174
+ it('has no exp claim -- an unsubscribe link never expires', () => {
175
+ const link = makeUnsubscribeLink(options, {
176
+ attendeeId: 'att-1',
177
+ emailName: 'welcome',
178
+ emailAddress: 'a@b.com'
179
+ });
180
+ expect(decodeQueryToken(link).exp).toBeUndefined();
181
+ });
182
+ });
183
+ describe('makeRegistrationLink', () => {
184
+ it('builds a link under /register/{token}', () => {
185
+ const link = makeRegistrationLink(options, {
186
+ attendeeId: 'att-1'
187
+ });
188
+ expect(link.startsWith('https://example.com/register/')).toBe(true);
189
+ });
190
+ it('includes the attendee id in the token', () => {
191
+ const link = makeRegistrationLink(options, {
192
+ attendeeId: 'att-1'
193
+ });
194
+ expect(decode(link).attendee_id).toBe('att-1');
195
+ });
196
+ it('includes the instance name in the token', () => {
197
+ const link = makeRegistrationLink(options, {
198
+ attendeeId: 'att-1'
199
+ });
200
+ expect(decode(link).instance_name).toBe('my-instance');
201
+ });
202
+ it('defaults session_type to "register"', () => {
203
+ const link = makeRegistrationLink(options, {
204
+ attendeeId: 'att-1'
205
+ });
206
+ expect(decode(link).session_type).toBe('register');
207
+ });
208
+ it('uses a custom session_type when provided', () => {
209
+ const link = makeRegistrationLink(options, {
210
+ attendeeId: 'att-1',
211
+ sessionType: 'edit'
212
+ });
213
+ expect(decode(link).session_type).toBe('edit');
214
+ });
215
+ it('defaults flags to an empty object', () => {
216
+ const link = makeRegistrationLink(options, {
217
+ attendeeId: 'att-1'
218
+ });
219
+ expect(decode(link).flags).toEqual({});
220
+ });
221
+ it('passes custom flags through', () => {
222
+ const link = makeRegistrationLink(options, {
223
+ attendeeId: 'att-1',
224
+ flags: {
225
+ skipPayment: true
226
+ }
227
+ });
228
+ expect(decode(link).flags).toEqual({
229
+ skipPayment: true
230
+ });
231
+ });
232
+ it('omits the email claim entirely when not provided', () => {
233
+ const link = makeRegistrationLink(options, {
234
+ attendeeId: 'att-1'
235
+ });
236
+ expect(decode(link).email).toBeUndefined();
237
+ });
238
+ it('includes the email name/mailing id when provided', () => {
239
+ const link = makeRegistrationLink(options, {
240
+ attendeeId: 'att-1',
241
+ email: {
242
+ name: 'welcome',
243
+ mailingId: 'mailing-1'
244
+ }
245
+ });
246
+ expect(decode(link).email).toEqual({
247
+ name: 'welcome',
248
+ mailing_id: 'mailing-1'
249
+ });
250
+ });
251
+ it('defaults to a 15 day expiry', () => {
252
+ const before = Math.floor(Date.now() / 1000);
253
+ const link = makeRegistrationLink(options, {
254
+ attendeeId: 'att-1'
255
+ });
256
+ const fifteenDays = 15 * 24 * 60 * 60;
257
+ expect(decode(link).exp).toBeGreaterThanOrEqual(before + fifteenDays);
258
+ expect(decode(link).exp).toBeLessThanOrEqual(before + fifteenDays + 1);
259
+ });
260
+ it('uses a custom expiry when provided', () => {
261
+ const before = Math.floor(Date.now() / 1000);
262
+ const link = makeRegistrationLink(options, {
263
+ attendeeId: 'att-1',
264
+ expiresInSeconds: 3600
265
+ });
266
+ expect(decode(link).exp).toBeGreaterThanOrEqual(before + 3600);
267
+ expect(decode(link).exp).toBeLessThanOrEqual(before + 3601);
268
+ });
269
+ });
270
+ describe('makeWalletLink', () => {
271
+ it('builds an unsigned /wallet/{attendeeId} link', () => {
272
+ expect(makeWalletLink(options, 'att-1')).toBe('https://example.com/wallet/att-1');
273
+ });
274
+ });
275
+ describe('makeGoogleWalletLink', () => {
276
+ it('builds an unsigned /google-wallet/{attendeeId} link', () => {
277
+ expect(makeGoogleWalletLink(options, 'att-1')).toBe('https://example.com/google-wallet/att-1');
278
+ });
279
+ });
280
+ describe('makeQrCodeLink', () => {
281
+ it('builds a link under /qr/', () => {
282
+ const link = makeQrCodeLink(options, {
283
+ content: 'WAR-140'
284
+ });
285
+ expect(link.startsWith('https://example.com/qr/?')).toBe(true);
286
+ });
287
+ it('encodes the given content', () => {
288
+ const url = new URL(makeQrCodeLink(options, {
289
+ content: 'WAR-140'
290
+ }));
291
+ expect(url.searchParams.get('content')).toBe('WAR-140');
292
+ });
293
+ it('defaults size to 600', () => {
294
+ const url = new URL(makeQrCodeLink(options, {
295
+ content: 'WAR-140'
296
+ }));
297
+ expect(url.searchParams.get('size')).toBe('600');
298
+ });
299
+ it('defaults background to white', () => {
300
+ const url = new URL(makeQrCodeLink(options, {
301
+ content: 'WAR-140'
302
+ }));
303
+ expect(url.searchParams.get('background')).toBe('white');
304
+ });
305
+ it('defaults body to black', () => {
306
+ const url = new URL(makeQrCodeLink(options, {
307
+ content: 'WAR-140'
308
+ }));
309
+ expect(url.searchParams.get('body')).toBe('black');
310
+ });
311
+ it('defaults corners to black', () => {
312
+ const url = new URL(makeQrCodeLink(options, {
313
+ content: 'WAR-140'
314
+ }));
315
+ expect(url.searchParams.get('corners')).toBe('black');
316
+ });
317
+ it('defaults fancy to 0', () => {
318
+ const url = new URL(makeQrCodeLink(options, {
319
+ content: 'WAR-140'
320
+ }));
321
+ expect(url.searchParams.get('fancy')).toBe('0');
322
+ });
323
+ it('applies a custom size', () => {
324
+ const url = new URL(makeQrCodeLink(options, {
325
+ content: 'WAR-140',
326
+ size: 300
327
+ }));
328
+ expect(url.searchParams.get('size')).toBe('300');
329
+ });
330
+ it('applies custom background/body/corners colors', () => {
331
+ const url = new URL(makeQrCodeLink(options, {
332
+ content: 'WAR-140',
333
+ background: 'red',
334
+ body: 'blue',
335
+ corners: 'green'
336
+ }));
337
+ expect(url.searchParams.get('background')).toBe('red');
338
+ expect(url.searchParams.get('body')).toBe('blue');
339
+ expect(url.searchParams.get('corners')).toBe('green');
340
+ });
341
+ it('applies a custom fancy flag', () => {
342
+ const url = new URL(makeQrCodeLink(options, {
343
+ content: 'WAR-140',
344
+ fancy: 1
345
+ }));
346
+ expect(url.searchParams.get('fancy')).toBe('1');
347
+ });
348
+ });
@@ -7,3 +7,5 @@ export * from './translate';
7
7
  export * from './headers';
8
8
  export * from './csrf';
9
9
  export * from './tokens';
10
+ export * from './email-links';
11
+ export * from './email-handlebars';
package/dist/lib/index.js CHANGED
@@ -6,4 +6,6 @@ export * as theme from './theme';
6
6
  export * from './translate';
7
7
  export * from './headers';
8
8
  export * from './csrf';
9
- export * from './tokens';
9
+ export * from './tokens';
10
+ export * from './email-links';
11
+ export * from './email-handlebars';
@@ -27,6 +27,12 @@ export type TrackingToken = {
27
27
  target: string;
28
28
  mailingId: string | null;
29
29
  };
30
+ /**
31
+ * Sign a payload as an HS256 JWS, mirroring
32
+ * FrontendConfig.encode_jws on the backend -- same shared secret, same
33
+ * compact-JWS format, so tokens minted here verify there and vice versa.
34
+ */
35
+ export declare function encodeJWS(payload: Record<string, unknown>, secret: string, expiresInSeconds?: number): string;
30
36
  export declare function parseRegistrationToken(tokenString: string | undefined, options: TokenOptions): Result<TokenValidationError, RegistrationToken>;
31
37
  export declare function parseTrackingToken(tokenString: string | undefined, options: TokenOptions): Result<TokenValidationError, TrackingToken>;
32
38
  export declare function parseUnsubscribeToken(tokenString: string | undefined, options: TokenOptions): Result<TokenValidationError, {
@@ -39,6 +45,20 @@ export declare function parseEmailHtmlToken(tokenString: string | undefined, opt
39
45
  emailName: string;
40
46
  mailingId?: string;
41
47
  }>;
48
+ export type EmailViewToken = {
49
+ attendeeId: string;
50
+ emailName: string;
51
+ mailingId?: string;
52
+ purpose: 'view' | 'render';
53
+ };
54
+ /**
55
+ * Parses a /email/{mail_name}/{token} token. `purpose` -- not the
56
+ * request's Accept header -- is what a route should trust to decide
57
+ * whether to return a human-facing HTML page ("view") or the full
58
+ * structured render payload ("render", minted short-lived and used
59
+ * server-to-server only). See FrontendConfig.make_email_view_link.
60
+ */
61
+ export declare function parseEmailViewToken(tokenString: string | undefined, options: TokenOptions): Result<TokenValidationError, EmailViewToken>;
42
62
  export declare function readRegistrationToken(getCookie: CookieGetter, options: TokenOptions & RegistrationTokenOptions): Result<TokenValidationError, RegistrationToken>;
43
63
  export declare function storeRegistrationToken(token: string, setCookie: CookieSetter, options?: RegistrationTokenOptions): void;
44
64
  export declare function deleteRegistrationToken(deleteCookie: CookieRemover, options?: RegistrationTokenOptions): void;
@@ -10,6 +10,29 @@ function parseJWS(tokenString, options) {
10
10
  if (!signatureIsValid) return failure('token_invalid');
11
11
  return success(jws.decode(tokenString));
12
12
  }
13
+
14
+ /**
15
+ * Sign a payload as an HS256 JWS, mirroring
16
+ * FrontendConfig.encode_jws on the backend -- same shared secret, same
17
+ * compact-JWS format, so tokens minted here verify there and vice versa.
18
+ */
19
+ export function encodeJWS(payload, secret, expiresInSeconds) {
20
+ const finalPayload = expiresInSeconds ? {
21
+ ...payload,
22
+ exp: Math.floor(Date.now() / 1000) + expiresInSeconds
23
+ } : payload;
24
+ // typ: 'JWT' matters beyond convention here -- jws.decode() only parses
25
+ // the payload as JSON (rather than leaving it a raw string) when this is
26
+ // set, and the backend's authlib-signed tokens always set it too.
27
+ return jws.sign({
28
+ header: {
29
+ alg: 'HS256',
30
+ typ: 'JWT'
31
+ },
32
+ payload: finalPayload,
33
+ secret
34
+ });
35
+ }
13
36
  export function parseRegistrationToken(tokenString, options) {
14
37
  const tokenResult = parseJWS(tokenString, options);
15
38
  if (!tokenResult.success) return tokenResult;
@@ -75,6 +98,31 @@ export function parseEmailHtmlToken(tokenString, options) {
75
98
  mailingId: payload.mailing_id ?? undefined
76
99
  });
77
100
  }
101
+ /**
102
+ * Parses a /email/{mail_name}/{token} token. `purpose` -- not the
103
+ * request's Accept header -- is what a route should trust to decide
104
+ * whether to return a human-facing HTML page ("view") or the full
105
+ * structured render payload ("render", minted short-lived and used
106
+ * server-to-server only). See FrontendConfig.make_email_view_link.
107
+ */
108
+ export function parseEmailViewToken(tokenString, options) {
109
+ const tokenResult = parseJWS(tokenString, options);
110
+ if (!tokenResult.success) return tokenResult;
111
+ const payload = tokenResult.data.payload;
112
+ if (payload.instance_name !== options.instanceName || typeof payload.attendee_id !== 'string' || typeof payload.email_name !== 'string' || payload.mailing_id != undefined && typeof payload.mailing_id !== 'string' || payload.purpose !== 'view' && payload.purpose !== 'render' || typeof payload.exp !== 'number') {
113
+ return failure('token_invalid');
114
+ }
115
+ const now = new Date();
116
+ const expires = new Date(payload.exp * 1000);
117
+ const isExpired = now >= expires;
118
+ if (isExpired) return failure('token_expired');
119
+ return success({
120
+ attendeeId: payload.attendee_id,
121
+ emailName: payload.email_name,
122
+ mailingId: payload.mailing_id ?? undefined,
123
+ purpose: payload.purpose
124
+ });
125
+ }
78
126
  export function readRegistrationToken(getCookie, options) {
79
127
  const {
80
128
  registrationTokenCookieName = REGISTRATION_TOKEN_COOKIE_NAME