@learncard/helpers 1.3.5 → 1.3.7

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.
package/package.json CHANGED
@@ -1,67 +1,69 @@
1
1
  {
2
- "name": "@learncard/helpers",
3
- "version": "1.3.5",
4
- "description": "Shared helpers for LearnCard packages",
5
- "type": "module",
6
- "main": "./dist/index.cjs",
7
- "module": "./dist/helpers.esm.js",
8
- "exports": {
9
- ".": {
10
- "import": {
11
- "types": "./dist/helpers.d.ts",
12
- "default": "./dist/helpers.esm.js"
13
- },
14
- "require": {
15
- "types": "./dist/helpers.d.cts",
16
- "default": "./dist/index.cjs"
17
- }
18
- }
19
- },
20
- "files": [
21
- "dist"
22
- ],
23
- "author": "Learning Economy Foundation (www.learningeconomy.io)",
24
- "license": "MIT",
25
- "homepage": "https://github.com/learningeconomy/LearnCard/tree/main/packages/learn-card-helpers/README.md",
26
- "repository": {
27
- "type": "git",
28
- "url": "https://github.com/learningeconomy/LearnCard"
29
- },
30
- "bugs": {
31
- "url": "https://github.com/learningeconomy/LearnCard/issues"
32
- },
33
- "devDependencies": {
34
- "@esbuild-plugins/node-resolve": "^0.1.4",
35
- "@types/jest": "^27.5.0",
36
- "@types/node": "^18.7.19",
37
- "@types/react": "^18.0.0",
38
- "aqu": "0.4.3",
39
- "dts-bundle-generator": "^6.10.0",
40
- "esbuild": "^0.27.1",
41
- "esbuild-jest": "^0.5.0",
42
- "jest": "^28.1.3",
43
- "jest-environment-jsdom": "^28.1.3",
44
- "shx": "^0.3.4",
45
- "ts-jest": "^28.0.5"
46
- },
47
- "types": "./dist/helpers.d.ts",
48
- "dependencies": {
49
- "@noble/hashes": "^1.8.0",
50
- "@sd-jwt/decode": "^0.19.0",
51
- "@trpc/server": "11.7.1",
52
- "immer": "^10.0.3",
53
- "use-immer": "^0.9.0",
54
- "zod": "^3.23.8",
55
- "@learncard/types": "5.17.3"
56
- },
57
- "sideEffects": false,
58
- "scripts": {
59
- "build": "node ./scripts/build.mjs && shx cp ./scripts/mixedEntypoint.js ./dist/index.cjs && dts-bundle-generator src/index.ts -o dist/helpers.d.ts --no-check && shx cp ./dist/helpers.d.ts ./dist/helpers.d.cts",
60
- "start": "aqu watch",
61
- "lint": "aqu lint",
62
- "lint:fix": "aqu lint --fix",
63
- "test": "jest",
64
- "test:watch": "jest --watch",
65
- "test:coverage": "jest --silent --ci --coverage --coverageReporters=\"text\" --coverageReporters=\"text-summary\""
66
- }
67
- }
2
+ "name": "@learncard/helpers",
3
+ "version": "1.3.7",
4
+ "description": "Shared helpers for LearnCard packages",
5
+ "type": "module",
6
+ "main": "./dist/index.cjs",
7
+ "module": "./dist/helpers.esm.js",
8
+ "exports": {
9
+ ".": {
10
+ "development": "./src/index.ts",
11
+ "import": {
12
+ "types": "./dist/helpers.d.ts",
13
+ "default": "./dist/helpers.esm.js"
14
+ },
15
+ "require": {
16
+ "types": "./dist/helpers.d.cts",
17
+ "default": "./dist/index.cjs"
18
+ }
19
+ }
20
+ },
21
+ "files": [
22
+ "dist",
23
+ "src"
24
+ ],
25
+ "scripts": {
26
+ "build": "node ./scripts/build.mjs && shx cp ./scripts/mixedEntypoint.js ./dist/index.cjs && dts-bundle-generator src/index.ts -o dist/helpers.d.ts --no-check && shx cp ./dist/helpers.d.ts ./dist/helpers.d.cts",
27
+ "start": "aqu watch",
28
+ "lint": "aqu lint",
29
+ "lint:fix": "aqu lint --fix",
30
+ "test": "jest",
31
+ "test:watch": "jest --watch",
32
+ "test:coverage": "jest --silent --ci --coverage --coverageReporters=\"text\" --coverageReporters=\"text-summary\""
33
+ },
34
+ "author": "Learning Economy Foundation (www.learningeconomy.io)",
35
+ "license": "MIT",
36
+ "homepage": "https://github.com/learningeconomy/LearnCard/tree/main/packages/learn-card-helpers/README.md",
37
+ "repository": {
38
+ "type": "git",
39
+ "url": "https://github.com/learningeconomy/LearnCard"
40
+ },
41
+ "bugs": {
42
+ "url": "https://github.com/learningeconomy/LearnCard/issues"
43
+ },
44
+ "devDependencies": {
45
+ "@esbuild-plugins/node-resolve": "^0.1.4",
46
+ "@types/jest": "^27.5.0",
47
+ "@types/node": "^18.7.19",
48
+ "@types/react": "^18.0.0",
49
+ "aqu": "0.4.3",
50
+ "dts-bundle-generator": "^6.10.0",
51
+ "esbuild": "^0.27.1",
52
+ "esbuild-jest": "^0.5.0",
53
+ "jest": "^28.1.3",
54
+ "jest-environment-jsdom": "^28.1.3",
55
+ "shx": "^0.3.4",
56
+ "ts-jest": "^28.0.5"
57
+ },
58
+ "types": "./dist/helpers.d.ts",
59
+ "dependencies": {
60
+ "@noble/hashes": "^1.8.0",
61
+ "@sd-jwt/decode": "^0.19.0",
62
+ "@learncard/types": "workspace:*",
63
+ "@trpc/server": "11.7.1",
64
+ "immer": "^10.0.3",
65
+ "use-immer": "^0.9.0",
66
+ "zod": "4.1.13"
67
+ },
68
+ "sideEffects": false
69
+ }
@@ -0,0 +1,71 @@
1
+ import { z } from 'zod';
2
+
3
+ // Note: SetState is exported from ./state.ts to avoid duplicate exports
4
+
5
+ export const ImageResizingValidator = z.object({ type: z.literal('resizing') });
6
+ export type ImageResizing = z.infer<typeof ImageResizingValidator>;
7
+
8
+ export const ImageUploadingValidator = z.object({
9
+ type: z.literal('uploading'),
10
+ progress: z.number().optional(),
11
+ });
12
+ export type ImageUploading = z.infer<typeof ImageUploadingValidator>;
13
+
14
+ export const ImageWithLoadingStateValdator = z.object({
15
+ image: z.string(),
16
+ loading: z.union([ImageResizingValidator, ImageUploadingValidator]).optional(),
17
+ });
18
+ export type ImageWithLoadingState = z.infer<typeof ImageWithLoadingStateValdator>;
19
+
20
+ export type FilestackImage = {
21
+ filename: string;
22
+ handle: string;
23
+ mimetype: string;
24
+ originalFile: {
25
+ name: string;
26
+ size: number;
27
+ type: string;
28
+ };
29
+ originalPath: string;
30
+ size: number;
31
+ source: string;
32
+ status: string;
33
+ uploadId: string;
34
+ url: string;
35
+ };
36
+
37
+ export type Simplify<T> = {
38
+ [KeyType in keyof T]: T[KeyType];
39
+ };
40
+
41
+ export type Maybify<T> = Simplify<{
42
+ [P in keyof T]?: Maybify<T[P]> | null;
43
+ }>;
44
+
45
+ /** If Maybify gives you an exceeds maximum length error, try this one! */
46
+ export type PerformantMaybify<T> = {
47
+ [P in keyof T]?: PerformantMaybify<T[P]> | null;
48
+ };
49
+
50
+ export type DeepValues<ObjectType extends object> = {
51
+ [Key in keyof ObjectType & (string | number)]: ObjectType[Key] extends object
52
+ ? DeepValues<ObjectType[Key]>
53
+ : ObjectType[Key];
54
+ }[keyof ObjectType & (string | number)];
55
+
56
+ export interface Nothing {}
57
+ export type Other<T> = T & Nothing;
58
+
59
+ export type SuggestString<Literals extends string> = Literals | Other<string>;
60
+
61
+ export type DeepPartial<T> = T extends Date
62
+ ? T
63
+ : T extends object
64
+ ? {
65
+ [P in keyof T]?: DeepPartial<T[P]>;
66
+ }
67
+ : T;
68
+
69
+ export const isNotUndefined = <T>(value: T | null | undefined): value is T => Boolean(value);
70
+
71
+ export const filterUndefined = <T>(arr: (T | null | undefined)[]) => arr.filter(isNotUndefined);
@@ -0,0 +1,168 @@
1
+ /**
2
+ * App installation age restriction logic shared between frontend and backend.
3
+ *
4
+ * This helper evaluates whether a user can install an app based on:
5
+ * - Hard age blocks (min_age) - always blocks, even with guardian approval
6
+ * - Soft age restrictions (age_rating) - can be approved by guardian for child profiles
7
+ * - Contract requirements - require guardian approval for child profiles
8
+ */
9
+
10
+ export type AppInstallCheckResult =
11
+ | { action: 'proceed' }
12
+ | { action: 'hard_blocked'; reason: string }
13
+ | { action: 'require_dob'; reason: string }
14
+ | { action: 'require_guardian_approval'; reason: string };
15
+
16
+ export type AppInstallCheckInput = {
17
+ /** Whether the user is a child profile (managed account) */
18
+ isChildProfile: boolean;
19
+ /** User's age calculated from DOB, null if DOB is unknown */
20
+ userAge: number | null;
21
+ /** Hard minimum age requirement (blocks completely, no guardian override) */
22
+ minAge?: number;
23
+ /** Soft age rating string (e.g., '4+', '9+', '12+', '17+') */
24
+ ageRating?: string;
25
+ /** Whether the app has an associated consent contract */
26
+ hasContract: boolean;
27
+ /** Whether guardian approval has already been obtained (for backend validation) */
28
+ hasGuardianApproval?: boolean;
29
+ };
30
+
31
+ /**
32
+ * Evaluates whether a user can install an app based on age restrictions and profile type.
33
+ *
34
+ * Cases:
35
+ * 1. Hard block: min_age violation (blocks completely, cannot be overridden)
36
+ * 2. Child profile scenarios:
37
+ * 2a. Child age unknown → require DOB entry (with guardian approval)
38
+ * 2b. No age rating specified → require guardian approval
39
+ * 2c. Child too young for age rating → require guardian approval
40
+ * 2d. App has a contract → require guardian approval
41
+ * 2e. Child old enough, no contract → proceed without guardian
42
+ * 3. Adult user old enough → proceed
43
+ *
44
+ * @param input - The check parameters
45
+ * @returns The action to take and reason if blocked/requires approval
46
+ */
47
+ export const checkAppInstallEligibility = (input: AppInstallCheckInput): AppInstallCheckResult => {
48
+ const {
49
+ isChildProfile,
50
+ userAge,
51
+ minAge,
52
+ ageRating,
53
+ hasContract,
54
+ hasGuardianApproval = false,
55
+ } = input;
56
+
57
+ const ageRatingMinAge = getAgeRatingMinAge(ageRating);
58
+
59
+ // Case 1: Hard block - min_age violation (block completely)
60
+ // Only applies when userAge is known
61
+ const isHardBlocked =
62
+ userAge !== null && minAge !== undefined && minAge > 0 && userAge < minAge;
63
+
64
+ if (isHardBlocked) {
65
+ return {
66
+ action: 'hard_blocked',
67
+ reason: `User does not meet the minimum age requirement of ${minAge}`,
68
+ };
69
+ }
70
+
71
+ // Case 2: Child profile - check age restrictions
72
+ if (isChildProfile) {
73
+ const noAgeRating = ageRatingMinAge === 0;
74
+ const childAgeUnknown = userAge === null;
75
+ const childTooYoung = userAge !== null && userAge < ageRatingMinAge;
76
+
77
+ // Case 2a: Child age unknown - require guardian to verify age via DOB entry
78
+ // If guardian has already approved, allow proceed (DOB may not be saved yet)
79
+ if (childAgeUnknown) {
80
+ if (hasGuardianApproval) {
81
+ return { action: 'proceed' };
82
+ }
83
+ return {
84
+ action: 'require_dob',
85
+ reason: 'Child profile age is unknown and must be verified by guardian',
86
+ };
87
+ }
88
+
89
+ // Case 2b: No age rating specified - require guardian approval
90
+ if (noAgeRating) {
91
+ if (hasGuardianApproval) {
92
+ return { action: 'proceed' };
93
+ }
94
+ return {
95
+ action: 'require_guardian_approval',
96
+ reason: 'App has no age rating; guardian approval required for child profiles',
97
+ };
98
+ }
99
+
100
+ // Case 2c: Child too young for age rating - require guardian approval
101
+ if (childTooYoung) {
102
+ if (hasGuardianApproval) {
103
+ return { action: 'proceed' };
104
+ }
105
+ return {
106
+ action: 'require_guardian_approval',
107
+ reason: `Child is under the age rating of ${ageRatingMinAge}+; guardian approval required`,
108
+ };
109
+ }
110
+
111
+ // Case 2d: App with a contract - require guardian approval
112
+ if (hasContract) {
113
+ if (hasGuardianApproval) {
114
+ return { action: 'proceed' };
115
+ }
116
+ return {
117
+ action: 'require_guardian_approval',
118
+ reason: 'App requires consent to a contract; guardian approval required for child profiles',
119
+ };
120
+ }
121
+
122
+ // Case 2e: Child old enough, no contract - proceed directly without guardian approval
123
+ }
124
+
125
+ // Case 3: User is old enough (or adult) - proceed directly
126
+ return { action: 'proceed' };
127
+ };
128
+
129
+ /**
130
+ * Map age_rating strings to numeric minimum ages.
131
+ */
132
+ export const AGE_RATING_TO_MIN_AGE: Record<string, number> = {
133
+ '4+': 4,
134
+ '9+': 9,
135
+ '12+': 12,
136
+ '17+': 17,
137
+ };
138
+
139
+ /**
140
+ * Convert an age_rating string to a numeric minimum age.
141
+ * Returns 0 if no rating is specified or the rating is unknown.
142
+ */
143
+ const getAgeRatingMinAge = (ageRating?: string): number => {
144
+ if (!ageRating) return 0;
145
+ return AGE_RATING_TO_MIN_AGE[ageRating] ?? 0;
146
+ };
147
+
148
+ /**
149
+ * Calculate age in years from a date of birth string.
150
+ * @param dob - Date of birth as ISO string or parseable date string
151
+ * @returns Age in years, or null if the date is invalid
152
+ */
153
+ export const calculateAgeFromDob = (dob?: string | null): number | null => {
154
+ if (!dob) return null;
155
+
156
+ const birthDate = new Date(dob);
157
+ if (isNaN(birthDate.getTime())) return null;
158
+
159
+ const today = new Date();
160
+ let age = today.getFullYear() - birthDate.getFullYear();
161
+ const monthDiff = today.getMonth() - birthDate.getMonth();
162
+
163
+ if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birthDate.getDate())) {
164
+ age--;
165
+ }
166
+
167
+ return age;
168
+ };
@@ -0,0 +1,20 @@
1
+ /** Quantizes a number to the closest number in an array of numbers */
2
+ export const quantizeValue = (value: number, array: number[]) => {
3
+ const foundIndex = array.findIndex((number) => number === value);
4
+
5
+ if (foundIndex > -1) return array[foundIndex];
6
+
7
+ const closestIndex = array.reduce(
8
+ (closest, current, index) => {
9
+ const currentDistance = Math.abs(current - value);
10
+
11
+ if (currentDistance <= closest.distance)
12
+ return { index, distance: currentDistance };
13
+
14
+ return closest;
15
+ },
16
+ { index: 0, distance: Infinity },
17
+ ).index;
18
+
19
+ return array[closestIndex];
20
+ };
@@ -0,0 +1,69 @@
1
+ import type { BitstringStatusListEntry, BitstringStatusPurpose } from '@learncard/types';
2
+
3
+ export const getCredentialStatusArray = (credential: unknown): Record<string, unknown>[] => {
4
+ if (!credential || typeof credential !== 'object' || Array.isArray(credential)) return [];
5
+
6
+ const status = (credential as { credentialStatus?: unknown }).credentialStatus;
7
+ if (!status) return [];
8
+
9
+ return Array.isArray(status)
10
+ ? (status as Record<string, unknown>[])
11
+ : [status as Record<string, unknown>];
12
+ };
13
+
14
+ export const isBitstringStatusListEntry = (status: unknown): status is BitstringStatusListEntry => {
15
+ if (!status || typeof status !== 'object') return false;
16
+
17
+ const record = status as Record<string, unknown>;
18
+
19
+ return (
20
+ record.type === 'BitstringStatusListEntry' &&
21
+ (record.statusPurpose === 'revocation' || record.statusPurpose === 'suspension') &&
22
+ typeof record.statusListIndex === 'string' &&
23
+ typeof record.statusListCredential === 'string'
24
+ );
25
+ };
26
+
27
+ export const getBitstringStatusListEntries = (credential: unknown): BitstringStatusListEntry[] => {
28
+ if (!credential || typeof credential !== 'object' || Array.isArray(credential)) return [];
29
+
30
+ const record = credential as Record<string, unknown>;
31
+ const statuses = getCredentialStatusArray(record);
32
+
33
+ return [
34
+ ...statuses.filter(isBitstringStatusListEntry),
35
+ ...getBitstringStatusListEntries(record.boostCredential),
36
+ ];
37
+ };
38
+
39
+ export const getBitstringStatusListEntryForPurpose = (
40
+ credential: unknown,
41
+ statusPurpose: BitstringStatusPurpose
42
+ ): BitstringStatusListEntry | undefined =>
43
+ getBitstringStatusListEntries(credential).find(entry => entry.statusPurpose === statusPurpose);
44
+
45
+ export const getBitstringStatusListBit = (bitstring: Uint8Array, index: number): boolean => {
46
+ if (!Number.isInteger(index) || index < 0) {
47
+ throw new Error('Bitstring status list index is out of range');
48
+ }
49
+
50
+ const byte = bitstring[Math.floor(index / 8)] ?? 0;
51
+
52
+ return (byte & (1 << index % 8)) !== 0;
53
+ };
54
+
55
+ export const setBitstringStatusListBit = (
56
+ bitstring: Uint8Array,
57
+ index: number,
58
+ value: boolean
59
+ ): void => {
60
+ if (!Number.isInteger(index) || index < 0 || index >= bitstring.length * 8) {
61
+ throw new Error('Bitstring status list index is out of range');
62
+ }
63
+
64
+ const byteIndex = Math.floor(index / 8);
65
+ const mask = 1 << index % 8;
66
+ const current = bitstring[byteIndex] ?? 0;
67
+
68
+ bitstring[byteIndex] = value ? current | mask : current & ~mask;
69
+ };