@learncard/helpers 1.4.0 → 1.5.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.
@@ -1 +1 @@
1
- {"version":3,"file":"images.helpers.d.ts","sourceRoot":"","sources":["../../src/images/images.helpers.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,SAAS,MAAM,qBAAqB,CAAC;AACjD,OAAO,KAAK,QAAQ,MAAM,oBAAoB,CAAC;AAC/C,OAAO,KAAK,OAAO,MAAM,mBAAmB,CAAC;AAE7C,QAAA,MAAM,SAAS;;;;CAAmC,CAAC;AAEnD,eAAO,MAAM,WAAW,GAAI,MAAM,MAAM,KAAG,MAAM,OAAO,SAAS,GAAG,IAQnE,CAAC;AAEF,eAAO,MAAM,aAAa,GAAI,KAAK,MAAM,EAAE,SAAS,MAAM,KAAG,MAM5D,CAAC;AAEF,eAAO,MAAM,MAAM,GAAI,KAAK,MAAM,EAAE,WAAW,MAAM,EAAE,cAAY,KAAG,MAMrE,CAAC;AAEF,eAAO,MAAM,cAAc,GACvB,KAAK,MAAM,EACX,aAAa,MAAM,EAAE,EACrB,UAAS;IAAE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAAC,GAAG,CAAC,EAAE,OAAO,CAAC;IAAC,IAAI,CAAC,EAAE,OAAO,CAAA;CAAO,KACnE,MAMF,CAAC;AAEF,eAAO,MAAM,sBAAsB,GAC/B,KAAK,MAAM,EACX,MAAM,MAAM,EACZ,SAAS,MAAM,EACf,UAAS;IAAE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAAC,GAAG,CAAC,EAAE,OAAO,CAAC;IAAC,IAAI,CAAC,EAAE,OAAO,CAAA;CAAO,KACnE,MAMF,CAAC;AAEF,eAAO,MAAM,mBAAmB,UAAkB,CAAC;AAEnD;;;;;;GAMG;AACH,eAAO,MAAM,iBAAiB,GAAI,QAAQ,MAAM,KAAG,CAAC,MAAM,EAAE,MAAM,CAAC,EAQlE,CAAC"}
1
+ {"version":3,"file":"images.helpers.d.ts","sourceRoot":"","sources":["../../src/images/images.helpers.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,SAAS,MAAM,qBAAqB,CAAC;AACjD,OAAO,KAAK,QAAQ,MAAM,oBAAoB,CAAC;AAC/C,OAAO,KAAK,OAAO,MAAM,mBAAmB,CAAC;AAE7C,QAAA,MAAM,SAAS;;;;CAAmC,CAAC;AASnD,eAAO,MAAM,WAAW,GAAI,MAAM,MAAM,KAAG,MAAM,OAAO,SAAS,GAAG,IAkBnE,CAAC;AAEF,eAAO,MAAM,aAAa,GAAI,KAAK,MAAM,EAAE,SAAS,MAAM,KAAG,MAM5D,CAAC;AAEF,eAAO,MAAM,MAAM,GAAI,KAAK,MAAM,EAAE,WAAW,MAAM,EAAE,cAAY,KAAG,MAMrE,CAAC;AAEF,eAAO,MAAM,cAAc,GACvB,KAAK,MAAM,EACX,aAAa,MAAM,EAAE,EACrB,UAAS;IAAE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAAC,GAAG,CAAC,EAAE,OAAO,CAAC;IAAC,IAAI,CAAC,EAAE,OAAO,CAAA;CAAO,KACnE,MAMF,CAAC;AAEF,eAAO,MAAM,sBAAsB,GAC/B,KAAK,MAAM,EACX,MAAM,MAAM,EACZ,SAAS,MAAM,EACf,UAAS;IAAE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAAC,GAAG,CAAC,EAAE,OAAO,CAAC;IAAC,IAAI,CAAC,EAAE,OAAO,CAAA;CAAO,KACnE,MAMF,CAAC;AAEF,eAAO,MAAM,mBAAmB,UAAkB,CAAC;AAEnD;;;;;;GAMG;AACH,eAAO,MAAM,iBAAiB,GAAI,QAAQ,MAAM,KAAG,CAAC,MAAM,EAAE,MAAM,CAAC,EAQlE,CAAC"}
@@ -1,2 +1,3 @@
1
1
  export * from './images.helpers';
2
+ export * from './sanitize.helpers';
2
3
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/images/index.ts"],"names":[],"mappings":"AAAA,cAAc,kBAAkB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/images/index.ts"],"names":[],"mappings":"AAAA,cAAc,kBAAkB,CAAC;AACjC,cAAc,oBAAoB,CAAC"}
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Options for sanitizeImageUrl
3
+ */
4
+ export type SanitizeImageUrlOptions = {
5
+ /**
6
+ * Allow blob: URLs (e.g., from URL.createObjectURL for local file previews).
7
+ * Default: false
8
+ */
9
+ allowBlobUrls?: boolean;
10
+ /**
11
+ * Allow data: URLs (e.g., data:image/png;base64,... for inline images).
12
+ * Only data:image/* URLs are allowed when enabled.
13
+ * Default: false
14
+ */
15
+ allowDataUrls?: boolean;
16
+ };
17
+ /**
18
+ * Sanitizes an image URL to prevent XSS via javascript: or other malicious URL schemes.
19
+ * Only allows http: and https: protocols by default.
20
+ * Optionally allows blob: URLs for local file preview scenarios.
21
+ * Optionally allows data:image/* URLs for inline base64 images.
22
+ *
23
+ * @param url - The URL to sanitize
24
+ * @param options - Optional configuration
25
+ * @returns The sanitized URL, or undefined if the URL is invalid or uses a disallowed protocol
26
+ */
27
+ export declare const sanitizeImageUrl: (url: string | undefined, options?: SanitizeImageUrlOptions) => string | undefined;
28
+ //# sourceMappingURL=sanitize.helpers.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sanitize.helpers.d.ts","sourceRoot":"","sources":["../../src/images/sanitize.helpers.ts"],"names":[],"mappings":"AAAA;;GAEG;AACH,MAAM,MAAM,uBAAuB,GAAG;IAClC;;;OAGG;IACH,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB;;;;OAIG;IACH,aAAa,CAAC,EAAE,OAAO,CAAC;CAC3B,CAAC;AAEF;;;;;;;;;GASG;AACH,eAAO,MAAM,gBAAgB,GACzB,KAAK,MAAM,GAAG,SAAS,EACvB,UAAS,uBAA4B,KACtC,MAAM,GAAG,SA4BX,CAAC"}
package/dist/index.d.cts CHANGED
@@ -28,6 +28,7 @@ export * from './bitstring-status-list';
28
28
  export * from './Utilities';
29
29
  export * from './app-install';
30
30
  export * from './credential-format';
31
+ export * from './credential-refresh';
31
32
  export * from './did';
32
33
  export * from './environment';
33
34
  export * from './credential-format';
package/dist/index.d.ts CHANGED
@@ -28,6 +28,7 @@ export * from './bitstring-status-list';
28
28
  export * from './Utilities';
29
29
  export * from './app-install';
30
30
  export * from './credential-format';
31
+ export * from './credential-refresh';
31
32
  export * from './did';
32
33
  export * from './environment';
33
34
  export * from './credential-format';
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,GAAG,EAAgB,UAAU,EAAE,EAAE,EAAE,MAAM,kBAAkB,CAAC;AACrE,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,cAAc,CAAC;AAEpD;;;;GAIG;AACH,eAAO,MAAM,KAAK,GAAI,KAAK,MAAM,YAA6B,CAAC;AAE/D,8DAA8D;AAC9D,eAAO,MAAM,WAAW,GAAI,MAAM,OAAO,KAAG,IAAI,IAAI,GAEnD,CAAC;AAEF;;GAEG;AACH,eAAO,MAAM,iBAAiB,EAAE,eA8B/B,CAAC;AAEF;;GAEG;AACH,eAAO,MAAM,WAAW,GAAI,YAAY,OAAO,KAAG,OAQjD,CAAC;AAEF,+EAA+E;AAC/E,eAAO,MAAM,qBAAqB,GAAI,KAAK,EAAE,GAAG,UAAU,QAMzD,CAAC;AAGF,cAAc,UAAU,CAAC;AACzB,cAAc,UAAU,CAAC;AACzB,cAAc,SAAS,CAAC;AACxB,cAAc,WAAW,CAAC;AAC1B,cAAc,WAAW,CAAC;AAC1B,cAAc,SAAS,CAAC;AACxB,cAAc,yBAAyB,CAAC;AAGxC,cAAc,aAAa,CAAC;AAG5B,cAAc,eAAe,CAAC;AAC9B,cAAc,qBAAqB,CAAC;AACpC,cAAc,OAAO,CAAC;AACtB,cAAc,eAAe,CAAC;AAG9B,cAAc,qBAAqB,CAAC;AAEpC;;;;;;;GAOG;AACH,eAAO,MAAM,WAAW,GAAI,MAAM,MAAM,KAAG,OAK1C,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,GAAG,EAAgB,UAAU,EAAE,EAAE,EAAE,MAAM,kBAAkB,CAAC;AACrE,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,cAAc,CAAC;AAEpD;;;;GAIG;AACH,eAAO,MAAM,KAAK,GAAI,KAAK,MAAM,YAA6B,CAAC;AAE/D,8DAA8D;AAC9D,eAAO,MAAM,WAAW,GAAI,MAAM,OAAO,KAAG,IAAI,IAAI,GAEnD,CAAC;AAEF;;GAEG;AACH,eAAO,MAAM,iBAAiB,EAAE,eA8B/B,CAAC;AAEF;;GAEG;AACH,eAAO,MAAM,WAAW,GAAI,YAAY,OAAO,KAAG,OAQjD,CAAC;AAEF,+EAA+E;AAC/E,eAAO,MAAM,qBAAqB,GAAI,KAAK,EAAE,GAAG,UAAU,QAMzD,CAAC;AAGF,cAAc,UAAU,CAAC;AACzB,cAAc,UAAU,CAAC;AACzB,cAAc,SAAS,CAAC;AACxB,cAAc,WAAW,CAAC;AAC1B,cAAc,WAAW,CAAC;AAC1B,cAAc,SAAS,CAAC;AACxB,cAAc,yBAAyB,CAAC;AAGxC,cAAc,aAAa,CAAC;AAG5B,cAAc,eAAe,CAAC;AAC9B,cAAc,qBAAqB,CAAC;AACpC,cAAc,sBAAsB,CAAC;AACrC,cAAc,OAAO,CAAC;AACtB,cAAc,eAAe,CAAC;AAG9B,cAAc,qBAAqB,CAAC;AAEpC;;;;;;;GAOG;AACH,eAAO,MAAM,WAAW,GAAI,MAAM,MAAM,KAAG,OAK1C,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@learncard/helpers",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
4
4
  "description": "Shared helpers for LearnCard packages",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -54,7 +54,7 @@
54
54
  "dependencies": {
55
55
  "@noble/hashes": "^1.8.0",
56
56
  "@sd-jwt/decode": "^0.19.0",
57
- "@learncard/types": "5.18.3",
57
+ "@learncard/types": "5.19.0",
58
58
  "@trpc/server": "11.8.0",
59
59
  "immer": "^10.0.3",
60
60
  "use-immer": "^0.9.0",
@@ -0,0 +1,128 @@
1
+ import {
2
+ SupportedCredentialRefreshServiceValidator,
3
+ type SupportedCredentialRefreshService,
4
+ } from '@learncard/types';
5
+
6
+ /**
7
+ * Canonical helpers for credential refresh (LC-2117, LC-2135, LC-2136).
8
+ *
9
+ * These helpers are storage-independent: they select a supported refresh service,
10
+ * normalize issuer/effective-time identity, and provide deterministic canonicalization
11
+ * plus a proof-insensitive content comparison used by both the holder SDK and the
12
+ * brain-service publication pipeline.
13
+ */
14
+
15
+ type RefreshableCredential = Record<string, unknown> & {
16
+ refreshService?: unknown;
17
+ issuer?: string | { id?: unknown } | null;
18
+ validFrom?: unknown;
19
+ issuanceDate?: unknown;
20
+ proof?: unknown;
21
+ };
22
+
23
+ /**
24
+ * Selects the first supported refresh service from a credential's `refreshService`.
25
+ *
26
+ * Accepts a single service object or an array. An array is treated as ordered: the
27
+ * first entry whose type is supported is selected. Supported types are
28
+ * `1EdTechCredentialRefresh` and `LearnCardCredentialRefresh2026`.
29
+ *
30
+ * @returns the supported service, or `undefined` when none is present/supported
31
+ */
32
+ export const getSupportedRefreshService = (
33
+ vc: RefreshableCredential
34
+ ): SupportedCredentialRefreshService | undefined => {
35
+ const refreshService = vc?.refreshService;
36
+
37
+ if (!refreshService) return undefined;
38
+
39
+ const services = Array.isArray(refreshService) ? refreshService : [refreshService];
40
+
41
+ for (const service of services) {
42
+ const parsed = SupportedCredentialRefreshServiceValidator.safeParse(service);
43
+
44
+ if (parsed.success) return parsed.data;
45
+ }
46
+
47
+ return undefined;
48
+ };
49
+
50
+ /**
51
+ * Normalizes a credential's issuer to its identifier.
52
+ *
53
+ * Handles both the string form (`issuer: 'did:example:x'`) and the object form
54
+ * (`issuer: { id: 'did:example:x', ... }`).
55
+ */
56
+ export const getCredentialIssuerId = (vc: RefreshableCredential): string | undefined => {
57
+ const issuer = vc?.issuer;
58
+
59
+ if (!issuer) return undefined;
60
+ if (typeof issuer === 'string') return issuer;
61
+
62
+ return typeof issuer.id === 'string' ? issuer.id : undefined;
63
+ };
64
+
65
+ /**
66
+ * Returns the credential's effective timestamp in milliseconds since the epoch.
67
+ *
68
+ * Prefers VCDM 2.0 `validFrom` and falls back to VCDM 1.1 `issuanceDate`. Returns
69
+ * `undefined` when no parseable timestamp exists.
70
+ */
71
+ export const getCredentialEffectiveTime = (vc: RefreshableCredential): number | undefined => {
72
+ const raw = vc?.validFrom ?? vc?.issuanceDate;
73
+
74
+ if (typeof raw !== 'string' || raw.length === 0) return undefined;
75
+
76
+ const parsed = Date.parse(raw);
77
+
78
+ return Number.isNaN(parsed) ? undefined : parsed;
79
+ };
80
+
81
+ /**
82
+ * Deterministically canonicalizes a JSON-like value: object keys are recursively
83
+ * sorted, array order is preserved, and primitives pass through unchanged.
84
+ */
85
+ export const canonicalizeCredentialContent = <T>(value: T): T => {
86
+ if (Array.isArray(value)) {
87
+ return value.map(entry => canonicalizeCredentialContent(entry)) as T;
88
+ }
89
+
90
+ if (value !== null && typeof value === 'object') {
91
+ const source = value as Record<string, unknown>;
92
+ const sorted: Record<string, unknown> = {};
93
+
94
+ for (const key of Object.keys(source).sort()) {
95
+ sorted[key] = canonicalizeCredentialContent(source[key]);
96
+ }
97
+
98
+ return sorted as T;
99
+ }
100
+
101
+ return value;
102
+ };
103
+
104
+ /** Serializes a value to a deterministic canonical JSON string */
105
+ export const canonicalizeCredentialJson = (value: unknown): string =>
106
+ JSON.stringify(canonicalizeCredentialContent(value));
107
+
108
+ /**
109
+ * Proof-insensitive content comparison for refresh changed-content detection.
110
+ *
111
+ * Only the top-level `proof` property is excluded; everything else (subject claims,
112
+ * identifiers, timestamps, services) participates in the comparison.
113
+ */
114
+ export const credentialContentsEqual = (
115
+ first: RefreshableCredential,
116
+ second: RefreshableCredential
117
+ ): boolean => {
118
+ const stripProof = (vc: RefreshableCredential) => {
119
+ const { proof: _proof, ...rest } = vc ?? {};
120
+
121
+ return rest;
122
+ };
123
+
124
+ return (
125
+ canonicalizeCredentialJson(stripProof(first)) ===
126
+ canonicalizeCredentialJson(stripProof(second))
127
+ );
128
+ };
@@ -4,14 +4,31 @@ import * as discord from './discord.helpers';
4
4
 
5
5
  const Providers = { filestack, unsplash, discord };
6
6
 
7
- export const getProvider = (url?: string): keyof typeof Providers | null => {
8
- if (url?.includes('cdn.filestackcontent.com')) return 'filestack';
9
-
10
- if (url?.includes('images.unsplash.com')) return 'unsplash';
11
-
12
- if (url?.includes('cdn.discordapp.com')) return 'discord';
7
+ /**
8
+ * Checks if the hostname matches exactly or is a subdomain of the target domain.
9
+ */
10
+ const isHostnameMatch = (hostname: string, target: string): boolean => {
11
+ return hostname === target || hostname.endsWith(`.${target}`);
12
+ };
13
13
 
14
- return null;
14
+ export const getProvider = (url?: string): keyof typeof Providers | null => {
15
+ if (!url) return null;
16
+
17
+ try {
18
+ // Handle protocol-relative URLs (e.g., //cdn.filestackcontent.com/...)
19
+ // by prepending https: so URL parsing succeeds
20
+ const normalizedUrl = url.startsWith('//') ? `https:${url}` : url;
21
+ const { hostname } = new URL(normalizedUrl);
22
+
23
+ // Check exact hostname or proper subdomain to prevent bypass via attacker-controlled domains
24
+ if (isHostnameMatch(hostname, 'cdn.filestackcontent.com')) return 'filestack';
25
+ if (isHostnameMatch(hostname, 'images.unsplash.com')) return 'unsplash';
26
+ if (isHostnameMatch(hostname, 'cdn.discordapp.com')) return 'discord';
27
+
28
+ return null;
29
+ } catch {
30
+ return null;
31
+ }
15
32
  };
16
33
 
17
34
  export const changeQuality = (url: string, quality: number): string => {
@@ -1 +1,2 @@
1
1
  export * from './images.helpers';
2
+ export * from './sanitize.helpers';
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Options for sanitizeImageUrl
3
+ */
4
+ export type SanitizeImageUrlOptions = {
5
+ /**
6
+ * Allow blob: URLs (e.g., from URL.createObjectURL for local file previews).
7
+ * Default: false
8
+ */
9
+ allowBlobUrls?: boolean;
10
+ /**
11
+ * Allow data: URLs (e.g., data:image/png;base64,... for inline images).
12
+ * Only data:image/* URLs are allowed when enabled.
13
+ * Default: false
14
+ */
15
+ allowDataUrls?: boolean;
16
+ };
17
+
18
+ /**
19
+ * Sanitizes an image URL to prevent XSS via javascript: or other malicious URL schemes.
20
+ * Only allows http: and https: protocols by default.
21
+ * Optionally allows blob: URLs for local file preview scenarios.
22
+ * Optionally allows data:image/* URLs for inline base64 images.
23
+ *
24
+ * @param url - The URL to sanitize
25
+ * @param options - Optional configuration
26
+ * @returns The sanitized URL, or undefined if the URL is invalid or uses a disallowed protocol
27
+ */
28
+ export const sanitizeImageUrl = (
29
+ url: string | undefined,
30
+ options: SanitizeImageUrlOptions = {}
31
+ ): string | undefined => {
32
+ if (!url) return undefined;
33
+
34
+ const { allowBlobUrls = false, allowDataUrls = false } = options;
35
+
36
+ try {
37
+ const parsed = new URL(url);
38
+
39
+ if (parsed.protocol === 'https:' || parsed.protocol === 'http:') {
40
+ return parsed.href;
41
+ }
42
+
43
+ if (allowBlobUrls && parsed.protocol === 'blob:') {
44
+ return parsed.href;
45
+ }
46
+
47
+ if (allowDataUrls && parsed.protocol === 'data:') {
48
+ // Only allow image/* MIME types for security
49
+ if (url.startsWith('data:image/')) {
50
+ return url;
51
+ }
52
+ return undefined;
53
+ }
54
+
55
+ return undefined;
56
+ } catch {
57
+ return undefined;
58
+ }
59
+ };
package/src/index.ts CHANGED
@@ -85,6 +85,7 @@ export * from './Utilities';
85
85
  // Export app install helpers
86
86
  export * from './app-install';
87
87
  export * from './credential-format';
88
+ export * from './credential-refresh';
88
89
  export * from './did';
89
90
  export * from './environment';
90
91