@learncard/helpers 1.3.13 → 1.4.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.
Files changed (54) hide show
  1. package/dist/Utilities.d.ts +59 -0
  2. package/dist/Utilities.d.ts.map +1 -0
  3. package/dist/app-install/index.d.ts +62 -0
  4. package/dist/app-install/index.d.ts.map +1 -0
  5. package/dist/arrays/index.d.ts +3 -0
  6. package/dist/arrays/index.d.ts.map +1 -0
  7. package/dist/bitstring-status-list/index.d.ts +8 -0
  8. package/dist/bitstring-status-list/index.d.ts.map +1 -0
  9. package/dist/credential-format.d.ts +96 -0
  10. package/dist/credential-format.d.ts.map +1 -0
  11. package/dist/did.d.ts +3 -0
  12. package/dist/did.d.ts.map +1 -0
  13. package/dist/environment.d.ts +32 -0
  14. package/dist/environment.d.ts.map +1 -0
  15. package/dist/helpers.cjs.development.cjs +229 -127
  16. package/dist/helpers.cjs.development.cjs.map +4 -4
  17. package/dist/helpers.cjs.production.min.cjs +15 -11
  18. package/dist/helpers.cjs.production.min.cjs.map +4 -4
  19. package/dist/helpers.esm.js +175 -83
  20. package/dist/helpers.esm.js.map +4 -4
  21. package/dist/images/discord.helpers.d.ts +65 -0
  22. package/dist/images/discord.helpers.d.ts.map +1 -0
  23. package/dist/images/filestack.helpers.d.ts +92 -0
  24. package/dist/images/filestack.helpers.d.ts.map +1 -0
  25. package/dist/images/images.helpers.d.ts +32 -0
  26. package/dist/images/images.helpers.d.ts.map +1 -0
  27. package/dist/images/index.d.ts +3 -0
  28. package/dist/images/index.d.ts.map +1 -0
  29. package/dist/images/sanitize.helpers.d.ts +28 -0
  30. package/dist/images/sanitize.helpers.d.ts.map +1 -0
  31. package/dist/images/unsplash.helpers.d.ts +64 -0
  32. package/dist/images/unsplash.helpers.d.ts.map +1 -0
  33. package/dist/images/url.helpers.d.ts +14 -0
  34. package/dist/images/url.helpers.d.ts.map +1 -0
  35. package/dist/index.d.cts +43 -0
  36. package/dist/index.d.ts +43 -0
  37. package/dist/index.d.ts.map +1 -0
  38. package/dist/numbers/index.d.ts +5 -0
  39. package/dist/numbers/index.d.ts.map +1 -0
  40. package/dist/state.d.ts +130 -0
  41. package/dist/state.d.ts.map +1 -0
  42. package/dist/strings/index.d.ts +2 -0
  43. package/dist/strings/index.d.ts.map +1 -0
  44. package/dist/types/index.d.ts +3 -0
  45. package/dist/types/index.d.ts.map +1 -0
  46. package/package.json +8 -8
  47. package/src/credential-format.ts +5 -4
  48. package/src/environment.ts +75 -0
  49. package/src/images/images.helpers.ts +24 -7
  50. package/src/images/index.ts +1 -0
  51. package/src/images/sanitize.helpers.ts +59 -0
  52. package/src/index.ts +11 -10
  53. package/dist/helpers.d.cts +0 -979
  54. package/dist/helpers.d.ts +0 -979
@@ -5,6 +5,7 @@ import type {
5
5
  StoredCredential,
6
6
  StoredCredentialEnvelope,
7
7
  VC,
8
+ VP,
8
9
  } from '@learncard/types';
9
10
  import { isStoredCredentialEnvelope } from '@learncard/types';
10
11
 
@@ -485,8 +486,8 @@ const projectJwtVcCompactToVc = (compact: string): VC | undefined => {
485
486
  * `read.get` result without branching themselves).
486
487
  */
487
488
  export const resolveStorageReadResult = (
488
- value: VC | StoredCredentialEnvelope | undefined
489
- ): VC | StoredCredentialEnvelope | undefined => {
489
+ value: VC | VP | StoredCredentialEnvelope | undefined
490
+ ): VC | VP | StoredCredentialEnvelope | undefined => {
490
491
  if (!value) return value;
491
492
  if (!isStoredCredentialEnvelope(value)) return value;
492
493
  const projected = projectEnvelopeToDisplayVc(value);
@@ -499,8 +500,8 @@ const inferW3cVersionFromContext = (vc: unknown): 'w3c-vc-2.0' | 'w3c-vc-1.1' =>
499
500
  const contexts = Array.isArray(contextRaw)
500
501
  ? contextRaw
501
502
  : contextRaw !== undefined
502
- ? [contextRaw]
503
- : [];
503
+ ? [contextRaw]
504
+ : [];
504
505
  const isV2 = contexts.some(
505
506
  c => typeof c === 'string' && c.includes('w3.org/ns/credentials/v2')
506
507
  );
@@ -0,0 +1,75 @@
1
+ import { z } from 'zod';
2
+
3
+ export type EnvironmentSource = {
4
+ project: string;
5
+ source: string;
6
+ examplePath: string;
7
+ };
8
+
9
+ export class EnvironmentConfigurationError extends Error {
10
+ constructor(message: string) {
11
+ super(message);
12
+ this.name = 'EnvironmentConfigurationError';
13
+ }
14
+ }
15
+
16
+ export const requiredEnvironmentString = z.string().trim().min(1, 'Required but missing');
17
+
18
+ export const optionalEnvironmentString = z.preprocess(
19
+ value => (value === '' ? undefined : value),
20
+ requiredEnvironmentString.optional()
21
+ );
22
+
23
+ export const optionalEnvironmentUrl = z.preprocess(
24
+ value => (value === '' ? undefined : value),
25
+ z.url('Expected a valid URL').optional()
26
+ );
27
+
28
+ /**
29
+ * Deployment manifests historically use both true/false and 1/0. These are the complete
30
+ * accepted spellings; every other value fails validation.
31
+ */
32
+ export const environmentBoolean = z
33
+ .enum(['true', 'false', '1', '0'])
34
+ .transform(value => value === 'true' || value === '1');
35
+
36
+ export const optionalEnvironmentBoolean = z.preprocess(
37
+ value => (value === '' || value === undefined ? undefined : value),
38
+ environmentBoolean.optional()
39
+ );
40
+
41
+ export const environmentPort = z.coerce.number().int().min(1).max(65_535);
42
+
43
+ export const optionalEnvironmentPort = z.preprocess(
44
+ value => (value === '' || value === undefined ? undefined : value),
45
+ environmentPort.optional()
46
+ );
47
+
48
+ export const parseEnvironment = <Schema extends z.ZodType>(
49
+ schema: Schema,
50
+ raw: Record<string, unknown>,
51
+ context: EnvironmentSource
52
+ ): z.output<Schema> => {
53
+ const result = schema.safeParse(raw);
54
+
55
+ if (result.success) return result.data;
56
+
57
+ const issues = result.error.issues
58
+ .map(issue => {
59
+ const key = issue.path.length ? issue.path.map(String).join('.') : '(environment)';
60
+
61
+ return `${key}\n ${issue.message}`;
62
+ })
63
+ .join('\n\n');
64
+
65
+ throw new EnvironmentConfigurationError(
66
+ [
67
+ `Invalid ${context.project} configuration`,
68
+ '',
69
+ issues,
70
+ '',
71
+ `Configuration source: ${context.source}`,
72
+ `Copy ${context.examplePath} to .env and correct the values above.`,
73
+ ].join('\n')
74
+ );
75
+ };
@@ -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
@@ -9,7 +9,7 @@ import type { DataTransformer } from '@trpc/server';
9
9
  export const isHex = (str: string) => /^[0-9a-f]+$/i.test(str);
10
10
 
11
11
  /** Determines whether or not an object is an encrypted JWE */
12
- export const isEncrypted = (item: Record<string, any>): item is JWE => {
12
+ export const isEncrypted = (item: unknown): item is JWE => {
13
13
  return JWEValidator.safeParse(item).success;
14
14
  };
15
15
 
@@ -17,7 +17,7 @@ export const isEncrypted = (item: Record<string, any>): item is JWE => {
17
17
  * tRPC data transformer that handles RegExp serialization/deserialization
18
18
  */
19
19
  export const RegExpTransformer: DataTransformer = {
20
- serialize(object: any): any {
20
+ serialize(object) {
21
21
  return JSON.stringify(object, (_key, value) => {
22
22
  if (value instanceof RegExp) return value.toString(); // Converts to format /pattern/flags
23
23
 
@@ -25,7 +25,7 @@ export const RegExpTransformer: DataTransformer = {
25
25
  });
26
26
  },
27
27
 
28
- deserialize(object: any): any {
28
+ deserialize(object) {
29
29
  // Old clients will for some reason already be deserialized, so this checks for that to retain
30
30
  // backwards compat
31
31
  if (typeof object !== 'string') return object;
@@ -51,14 +51,14 @@ export const RegExpTransformer: DataTransformer = {
51
51
  /**
52
52
  * Determines if a credential uses VC 2.0 format by checking the context array
53
53
  */
54
- export const isVC2Format = (credential: UnsignedVC | VC): boolean => {
55
- if (!credential['@context'] || !Array.isArray(credential['@context'])) {
56
- return false;
57
- }
54
+ export const isVC2Format = (credential: unknown): boolean => {
55
+ if (!credential || typeof credential !== 'object' || !('@context' in credential)) return false;
56
+
57
+ const context = credential['@context'];
58
58
 
59
- return credential['@context'].some(
60
- context => context === 'https://www.w3.org/ns/credentials/v2'
61
- );
59
+ return Array.isArray(context)
60
+ ? context.some(value => value === 'https://www.w3.org/ns/credentials/v2')
61
+ : context === 'https://www.w3.org/ns/credentials/v2';
62
62
  };
63
63
 
64
64
  /** Unwraps a boost credential from a CertifiedBoostCredential, if it is one */
@@ -86,6 +86,7 @@ export * from './Utilities';
86
86
  export * from './app-install';
87
87
  export * from './credential-format';
88
88
  export * from './did';
89
+ export * from './environment';
89
90
 
90
91
  // ADR-0001 Phase 1: format-tagged credential storage projector
91
92
  export * from './credential-format';