@nimee/wallet-generator 1.0.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.
Files changed (54) hide show
  1. package/dist/apple/ApplePassGenerator.d.ts +71 -0
  2. package/dist/apple/ApplePassGenerator.js +262 -0
  3. package/dist/apple/ApplePassGenerator.js.map +1 -0
  4. package/dist/apple/signPassWorker.d.ts +1 -0
  5. package/dist/apple/signPassWorker.js +44 -0
  6. package/dist/apple/signPassWorker.js.map +1 -0
  7. package/dist/apple/template.pass/icon.png +0 -0
  8. package/dist/apple/template.pass/icon@2x.png +0 -0
  9. package/dist/apple/template.pass/logo.png +0 -0
  10. package/dist/apple/template.pass/logo@2x.png +0 -0
  11. package/dist/apple/template.pass/pass.json +16 -0
  12. package/dist/apple/template.pass/template.pass/icon.png +0 -0
  13. package/dist/apple/template.pass/template.pass/icon@2x.png +0 -0
  14. package/dist/apple/template.pass/template.pass/logo.png +0 -0
  15. package/dist/apple/template.pass/template.pass/logo@2x.png +0 -0
  16. package/dist/apple/template.pass/template.pass/pass.json +16 -0
  17. package/dist/google/GooglePassGenerator.d.ts +31 -0
  18. package/dist/google/GooglePassGenerator.js +103 -0
  19. package/dist/google/GooglePassGenerator.js.map +1 -0
  20. package/dist/google/googleAuth.d.ts +11 -0
  21. package/dist/google/googleAuth.js +99 -0
  22. package/dist/google/googleAuth.js.map +1 -0
  23. package/dist/helpers/colorHelpers.d.ts +9 -0
  24. package/dist/helpers/colorHelpers.js +32 -0
  25. package/dist/helpers/colorHelpers.js.map +1 -0
  26. package/dist/helpers/imageHelpers.d.ts +23 -0
  27. package/dist/helpers/imageHelpers.js +94 -0
  28. package/dist/helpers/imageHelpers.js.map +1 -0
  29. package/dist/index.d.ts +5 -0
  30. package/dist/index.js +25 -0
  31. package/dist/index.js.map +1 -0
  32. package/dist/types.d.ts +65 -0
  33. package/dist/types.js +3 -0
  34. package/dist/types.js.map +1 -0
  35. package/jest.config.js +18 -0
  36. package/package.json +38 -0
  37. package/src/apple/ApplePassGenerator.ts +291 -0
  38. package/src/apple/signPassWorker.ts +52 -0
  39. package/src/apple/template.pass/icon.png +0 -0
  40. package/src/apple/template.pass/icon@2x.png +0 -0
  41. package/src/apple/template.pass/logo.png +0 -0
  42. package/src/apple/template.pass/logo@2x.png +0 -0
  43. package/src/apple/template.pass/pass.json +16 -0
  44. package/src/google/GooglePassGenerator.ts +104 -0
  45. package/src/google/googleAuth.ts +134 -0
  46. package/src/helpers/colorHelpers.ts +34 -0
  47. package/src/helpers/imageHelpers.ts +87 -0
  48. package/src/index.ts +5 -0
  49. package/src/types.ts +66 -0
  50. package/tests/apple/ApplePassGenerator.test.ts +47 -0
  51. package/tests/google/GooglePassGenerator.test.ts +47 -0
  52. package/tests/helpers/colorHelpers.test.ts +30 -0
  53. package/tests/helpers/imageHelpers.test.ts +19 -0
  54. package/tsconfig.json +28 -0
@@ -0,0 +1,87 @@
1
+ import axios from 'axios';
2
+ import sharp from 'sharp';
3
+
4
+ // ─── Image validation constants ───────────────────────────────────────────────
5
+
6
+ export const IMAGE_MAX_SIZE = 5 * 1024 * 1024; // 5 MB
7
+ export const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
8
+ export const JPEG_MAGIC = Buffer.from([0xff, 0xd8, 0xff]);
9
+ export const WEBP_RIFF = Buffer.from([0x52, 0x49, 0x46, 0x46]);
10
+ export const WEBP_WEBP = Buffer.from([0x57, 0x45, 0x42, 0x50]);
11
+
12
+ // ─── URL validation ───────────────────────────────────────────────────────────
13
+
14
+ /**
15
+ * Returns true if the URL is safe to fetch for wallet images.
16
+ * Requires https:// and optionally restricts to hosts in the allowedHosts list.
17
+ * An empty allowedHosts list means all https hosts are permitted.
18
+ */
19
+ export function isAllowedImageUrl(url: string, allowedHosts: string[]): boolean {
20
+ let parsed: URL;
21
+ try {
22
+ parsed = new URL(url);
23
+ } catch {
24
+ return false;
25
+ }
26
+ if (parsed.protocol !== 'https:') return false;
27
+
28
+ if (allowedHosts.length === 0) return true;
29
+
30
+ return allowedHosts.some((host) => parsed.hostname === host || parsed.hostname.endsWith(`.${host}`));
31
+ }
32
+
33
+ // ─── Image download ───────────────────────────────────────────────────────────
34
+
35
+ /**
36
+ * Downloads an image from a URL, validates it, and returns its buffer.
37
+ * Returns undefined if the URL is not allowed, the download fails, or the
38
+ * image is invalid/too large.
39
+ */
40
+ export async function downloadImageBuffer(
41
+ url: string,
42
+ allowedHosts: string[],
43
+ timeoutMs: number = 10_000
44
+ ): Promise<Buffer | undefined> {
45
+ if (!isAllowedImageUrl(url, allowedHosts)) return undefined;
46
+
47
+ try {
48
+ const response = await axios.get<Buffer>(url, {
49
+ responseType: 'arraybuffer',
50
+ timeout: timeoutMs,
51
+ maxContentLength: IMAGE_MAX_SIZE,
52
+ });
53
+
54
+ const buffer = Buffer.from(response.data);
55
+ if (buffer.length > IMAGE_MAX_SIZE) return undefined;
56
+
57
+ // Validate image format via magic bytes
58
+ const isPng = buffer.slice(0, 8).equals(PNG_MAGIC);
59
+ const isJpeg = buffer.slice(0, 3).equals(JPEG_MAGIC);
60
+ const isWebp =
61
+ buffer.slice(0, 4).equals(WEBP_RIFF) && buffer.slice(8, 12).equals(WEBP_WEBP);
62
+
63
+ if (!isPng && !isJpeg && !isWebp) return undefined;
64
+
65
+ return buffer;
66
+ } catch {
67
+ return undefined;
68
+ }
69
+ }
70
+
71
+ // ─── Image resize helper ──────────────────────────────────────────────────────
72
+
73
+ /**
74
+ * Resizes an image buffer to the given dimensions using sharp.
75
+ * Returns undefined on error.
76
+ */
77
+ export async function resizeImageBuffer(
78
+ buffer: Buffer,
79
+ width: number,
80
+ height: number
81
+ ): Promise<Buffer | undefined> {
82
+ try {
83
+ return await sharp(buffer).resize(width, height, { fit: 'inside' }).png().toBuffer();
84
+ } catch {
85
+ return undefined;
86
+ }
87
+ }
package/src/index.ts ADDED
@@ -0,0 +1,5 @@
1
+ export * from './types';
2
+ export * from './helpers/colorHelpers';
3
+ export * from './helpers/imageHelpers';
4
+ export { ApplePassGenerator } from './apple/ApplePassGenerator';
5
+ export { GooglePassGenerator } from './google/GooglePassGenerator';
package/src/types.ts ADDED
@@ -0,0 +1,66 @@
1
+ export interface IWalletLayoutField {
2
+ key: string;
3
+ row: 'header' | 'secondary' | 'auxiliary' | 'back' | 'hidden';
4
+ position: number;
5
+ }
6
+
7
+ /**
8
+ * Caller-assembled pass data. All design values (colors, URLs) must be
9
+ * pre-resolved by the service before passing in — generators do not touch the DB.
10
+ */
11
+ export interface IWalletPassData {
12
+ /** MongoDB _id of the source document (userEvent or endUserSeasonTicket). Used as Google pass object ID. */
13
+ objectId: string;
14
+ /** Person's display name shown on the pass. */
15
+ holderName: string;
16
+ /** Primary title (event name or plan name). */
17
+ passTitle: string;
18
+ /** Secondary label (ticket type, plan type). Optional. */
19
+ passSubtitle?: string;
20
+ /** Human-readable date string already formatted for display (e.g. "14/05/2026 18:00"). */
21
+ dateDisplay?: string;
22
+ startDate?: Date;
23
+ endDate?: Date;
24
+ seat?: string;
25
+ orderNumber?: string;
26
+ /** True when the QR code has already been scanned/used. Affects Google pass state. */
27
+ isCheckedIn?: boolean;
28
+ /** Raw string encoded in the QR barcode. For events: "userEventId/ticketInfoId". For subscriptions: "sub/{sellerId}/{endUserSeasonTicketId}". */
29
+ qrContent: string;
30
+ marketplaceName?: string;
31
+ /** Used to build the Google Wallet class ID suffix. E.g. "event-<eventId>" or "subscription". */
32
+ classIdSuffix?: string;
33
+ accountId?: string;
34
+ /** Pre-resolved logo URL (HTTPS). */
35
+ logoUrl?: string;
36
+ /** Pre-resolved strip/banner image URL (HTTPS). */
37
+ stripImageUrl?: string;
38
+ /** Background color in rgb() format. */
39
+ backgroundColor?: string;
40
+ /** Background color in #hex format (for Google). */
41
+ backgroundColorHex?: string;
42
+ /** Text color in rgb() format. Apple only. */
43
+ foregroundColor?: string;
44
+ /** Label color in rgb() format. Apple only. */
45
+ labelColor?: string;
46
+ /** Layout from walletDesign. Generators fall back to their own DEFAULT_LAYOUT if absent. */
47
+ layout?: { fields: IWalletLayoutField[] };
48
+ }
49
+
50
+ export interface IAppleWalletConfig {
51
+ cert: string;
52
+ key: string;
53
+ wwdr: string;
54
+ passTypeIdentifier: string;
55
+ teamIdentifier: string;
56
+ /** Absolute path to the `.pass` template directory. Defaults to the bundled template. */
57
+ templatePath?: string;
58
+ }
59
+
60
+ export interface IGoogleWalletConfig {
61
+ issuerId: string;
62
+ /** If provided, used as-is. Otherwise built from issuerId + data.classIdSuffix. */
63
+ classId?: string;
64
+ serviceAccountEmail: string;
65
+ privateKey: string;
66
+ }
@@ -0,0 +1,47 @@
1
+ import sinon from 'sinon';
2
+ import * as imageHelpers from '../../src/helpers/imageHelpers';
3
+ import { ApplePassGenerator } from '../../src/apple/ApplePassGenerator';
4
+ import { IWalletPassData, IAppleWalletConfig, IWalletLayoutField } from '../../src/types';
5
+
6
+ const MOCK_CONFIG: IAppleWalletConfig = {
7
+ cert: 'cert-pem',
8
+ key: 'key-pem',
9
+ wwdr: 'wwdr-pem',
10
+ passTypeIdentifier: 'pass.il.co.nimi.test',
11
+ teamIdentifier: 'TEAMID123',
12
+ };
13
+
14
+ const MOCK_DATA: IWalletPassData = {
15
+ objectId: 'abc123',
16
+ holderName: 'John Doe',
17
+ passTitle: 'Summer Event',
18
+ qrContent: 'abc123/ticket456',
19
+ isCheckedIn: false,
20
+ };
21
+
22
+ const DEFAULT_LAYOUT: IWalletLayoutField[] = [
23
+ { key: 'event', row: 'secondary', position: 0 },
24
+ ];
25
+
26
+ describe('ApplePassGenerator', () => {
27
+ let generator: ApplePassGenerator;
28
+ let downloadStub: sinon.SinonStub;
29
+
30
+ beforeEach(() => {
31
+ generator = new ApplePassGenerator();
32
+ downloadStub = sinon.stub(imageHelpers, 'downloadImageBuffer').resolves(undefined);
33
+ });
34
+
35
+ afterEach(() => sinon.restore());
36
+
37
+ it('skips logo download when logoUrl is absent', async () => {
38
+ await generator.generate(MOCK_DATA, MOCK_CONFIG, DEFAULT_LAYOUT).catch(() => {});
39
+ expect(downloadStub.callCount).toBe(0);
40
+ });
41
+
42
+ it('attempts logo download when logoUrl is present', async () => {
43
+ const data = { ...MOCK_DATA, logoUrl: 'https://cdn.example.com/logo.png' };
44
+ await generator.generate(data, MOCK_CONFIG, DEFAULT_LAYOUT).catch(() => {});
45
+ expect(downloadStub.calledWith('https://cdn.example.com/logo.png', sinon.match.any, sinon.match.any)).toBe(true);
46
+ });
47
+ });
@@ -0,0 +1,47 @@
1
+ import sinon from 'sinon';
2
+ import { GooglePassGenerator } from '../../src/google/GooglePassGenerator';
3
+ import { IWalletPassData, IGoogleWalletConfig } from '../../src/types';
4
+
5
+ const MOCK_CONFIG: IGoogleWalletConfig = {
6
+ issuerId: 'issuer123',
7
+ classId: 'issuer123.test-class',
8
+ serviceAccountEmail: 'wallet@project.iam.gserviceaccount.com',
9
+ privateKey: '-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEA0000\n-----END RSA PRIVATE KEY-----',
10
+ };
11
+
12
+ const MOCK_DATA: IWalletPassData = {
13
+ objectId: 'sub123',
14
+ holderName: 'Jane Member',
15
+ passTitle: 'Gold Membership',
16
+ qrContent: 'sub/seller456/sub123',
17
+ isCheckedIn: false,
18
+ classIdSuffix: 'account-seller456-subscription',
19
+ };
20
+
21
+ describe('GooglePassGenerator', () => {
22
+ let generator: GooglePassGenerator;
23
+
24
+ beforeEach(() => {
25
+ generator = new GooglePassGenerator();
26
+ });
27
+
28
+ afterEach(() => sinon.restore());
29
+
30
+ it('uses provided classId from config when present', () => {
31
+ const classId = (generator as any).buildClassId(MOCK_DATA, MOCK_CONFIG);
32
+ expect(classId).toBe('issuer123.test-class');
33
+ });
34
+
35
+ it('derives classId from issuerId + classIdSuffix when classId absent', () => {
36
+ const configWithout: IGoogleWalletConfig = { ...MOCK_CONFIG, classId: undefined };
37
+ const classId = (generator as any).buildClassId(MOCK_DATA, configWithout);
38
+ expect(classId).toBe('issuer123.account-seller456-subscription');
39
+ });
40
+
41
+ it('generateSaveUrl returns a Google Wallet save URL', async () => {
42
+ sinon.stub(generator as any, 'ensureClass').resolves();
43
+ sinon.stub(generator as any, 'signJwt').returns('fake-jwt-token');
44
+ const url = await generator.generateSaveUrl(MOCK_DATA, MOCK_CONFIG);
45
+ expect(url).toMatch(/^https:\/\/pay\.google\.com\/gp\/v\/save\//);
46
+ });
47
+ });
@@ -0,0 +1,30 @@
1
+ import { normalizeColor } from '../../src/helpers/colorHelpers';
2
+
3
+ describe('normalizeColor', () => {
4
+ it('returns undefined for empty string', () => {
5
+ expect(normalizeColor('')).toBeUndefined();
6
+ });
7
+
8
+ it('normalizes 6-digit hex to rgb and hex', () => {
9
+ const result = normalizeColor('#1a2b3c');
10
+ expect(result?.rgb).toBe('rgb(26,43,60)');
11
+ expect(result?.hex).toBe('#1a2b3c');
12
+ });
13
+
14
+ it('normalizes hex without # prefix', () => {
15
+ const result = normalizeColor('1a2b3c');
16
+ expect(result?.rgb).toBe('rgb(26,43,60)');
17
+ expect(result?.hex).toBe('#1a2b3c');
18
+ });
19
+
20
+ it('normalizes rgb() input back to both formats', () => {
21
+ const result = normalizeColor('rgb(26,43,60)');
22
+ expect(result?.rgb).toBe('rgb(26,43,60)');
23
+ expect(result?.hex).toBe('#1a2b3c');
24
+ });
25
+
26
+ it('handles rgb() with spaces', () => {
27
+ const result = normalizeColor('rgb(26, 43, 60)');
28
+ expect(result?.rgb).toBe('rgb(26,43,60)');
29
+ });
30
+ });
@@ -0,0 +1,19 @@
1
+ import { isAllowedImageUrl } from '../../src/helpers/imageHelpers';
2
+
3
+ describe('isAllowedImageUrl', () => {
4
+ it('accepts https URLs when no allowlist configured', () => {
5
+ expect(isAllowedImageUrl('https://cdn.example.com/img.png', [])).toBe(true);
6
+ });
7
+
8
+ it('rejects http URLs', () => {
9
+ expect(isAllowedImageUrl('http://cdn.example.com/img.png', [])).toBe(false);
10
+ });
11
+
12
+ it('rejects when host not in non-empty allowlist', () => {
13
+ expect(isAllowedImageUrl('https://evil.com/img.png', ['cdn.example.com'])).toBe(false);
14
+ });
15
+
16
+ it('accepts when host in allowlist', () => {
17
+ expect(isAllowedImageUrl('https://cdn.example.com/img.png', ['cdn.example.com'])).toBe(true);
18
+ });
19
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "compileOnSave": true,
3
+ "compilerOptions": {
4
+ "module": "commonjs",
5
+ "esModuleInterop": true,
6
+ "declaration": true,
7
+ "target": "es6",
8
+ "noImplicitAny": true,
9
+ "skipLibCheck": true,
10
+ "moduleResolution": "node",
11
+ "sourceMap": true,
12
+ "outDir": "./dist",
13
+ "baseUrl": ".",
14
+ "paths": {
15
+ "*": [
16
+ "node_modules/*",
17
+ "src/types/*"
18
+ ]
19
+ }
20
+ },
21
+ "include": [
22
+ "src/**/*"
23
+ ],
24
+ "exclude": [
25
+ "node_modules",
26
+ "dist"
27
+ ]
28
+ }