@learncard/helpers 1.6.2 → 1.7.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.
package/dist/index.d.cts CHANGED
@@ -42,4 +42,5 @@ export * from './credential-format';
42
42
  */
43
43
  export declare const isAppDidWeb: (did?: string) => boolean;
44
44
  export * from './share-content-canonical';
45
+ export * from './credential-materialization';
45
46
  //# sourceMappingURL=index.d.ts.map
package/dist/index.d.ts CHANGED
@@ -42,4 +42,5 @@ export * from './credential-format';
42
42
  */
43
43
  export declare const isAppDidWeb: (did?: string) => boolean;
44
44
  export * from './share-content-canonical';
45
+ export * from './credential-materialization';
45
46
  //# sourceMappingURL=index.d.ts.map
@@ -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,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;AAEF,cAAc,2BAA2B,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;AAEF,cAAc,2BAA2B,CAAC;AAE1C,cAAc,8BAA8B,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@learncard/helpers",
3
- "version": "1.6.2",
3
+ "version": "1.7.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.23.0",
57
+ "@learncard/types": "5.23.2",
58
58
  "@trpc/server": "11.8.0",
59
59
  "immer": "^10.0.3",
60
60
  "use-immer": "^0.9.0",
@@ -0,0 +1,91 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ materializeCredentialFields,
4
+ normalizeCredentialAwardDate,
5
+ parseCredentialNumber,
6
+ } from './credential-materialization';
7
+
8
+ describe('standard credential materialization', () => {
9
+ it('converts only declared numeric paths, preserving zero, result text and identifiers', () => {
10
+ const input = {
11
+ type: ['VerifiableCredential', 'OpenBadgeCredential'],
12
+ credentialSubject: {
13
+ id: '0012',
14
+ creditsEarned: '0',
15
+ achievement: { creditsAvailable: '12.5' },
16
+ result: [{ value: '95' }],
17
+ },
18
+ awardedDate: '2024-02-29',
19
+ };
20
+ expect(materializeCredentialFields(input)).toEqual({
21
+ ...input,
22
+ credentialSubject: {
23
+ ...input.credentialSubject,
24
+ creditsEarned: 0,
25
+ achievement: { creditsAvailable: 12.5 },
26
+ },
27
+ awardedDate: '2024-02-29T00:00:00.000Z',
28
+ });
29
+ expect(input.credentialSubject.creditsEarned).toBe('0');
30
+ });
31
+ it.each(['', ' '])('omits blank optional credits %j without changing the input', value => {
32
+ const input = {
33
+ type: ['OpenBadgeCredential'],
34
+ credentialSubject: { creditsEarned: value, achievement: { creditsAvailable: value } },
35
+ };
36
+ expect(materializeCredentialFields(input)).toEqual({
37
+ type: ['OpenBadgeCredential'],
38
+ credentialSubject: { achievement: {} },
39
+ });
40
+ expect(input.credentialSubject.creditsEarned).toBe(value);
41
+ });
42
+ it.each(['', ' '])(
43
+ 'omits a blank optional awardedDate %j without mutating the source',
44
+ awardedDate => {
45
+ const input = { type: ['OpenBadgeCredential'], awardedDate, credentialSubject: {} };
46
+ expect(materializeCredentialFields(input)).not.toHaveProperty('awardedDate');
47
+ expect(input.awardedDate).toBe(awardedDate);
48
+ }
49
+ );
50
+ it('does not modify signed credentials or embedded signed credentials', () => {
51
+ const signed = {
52
+ type: ['OpenBadgeCredential'],
53
+ proof: { proofValue: 'signature' },
54
+ credentialSubject: { creditsEarned: '3' },
55
+ };
56
+ expect(materializeCredentialFields(signed)).toBe(signed);
57
+ const clr = {
58
+ type: ['ClrCredential'],
59
+ credentialSubject: {
60
+ achievement: [{ creditsAvailable: '3' }],
61
+ verifiableCredential: [signed],
62
+ },
63
+ };
64
+ expect(materializeCredentialFields(clr)).toMatchObject({
65
+ credentialSubject: { verifiableCredential: [signed] },
66
+ });
67
+ });
68
+ it.each(['', '12 hours', '0x10', '1,000', 'Infinity', 'NaN', '1e999'])(
69
+ 'rejects incomplete or ambiguous numeric input %s',
70
+ value => expect(() => parseCredentialNumber(value)).toThrow()
71
+ );
72
+ it('normalizes explicit offsets without changing the represented instant', () =>
73
+ expect(normalizeCredentialAwardDate('2024-03-01T01:30:00+02:00')).toBe(
74
+ '2024-02-29T23:30:00.000Z'
75
+ ));
76
+ it('preserves submillisecond precision while normalizing an explicit offset', () => {
77
+ expect(normalizeCredentialAwardDate('2024-03-01T01:30:00.123456+02:00')).toBe(
78
+ '2024-02-29T23:30:00.123456Z'
79
+ );
80
+ });
81
+ it.each([
82
+ '02/03/2024',
83
+ '2023-02-29',
84
+ '2024-04-31',
85
+ '2024-01-01T12:00:00',
86
+ '2024-05-01T10:00Z',
87
+ '2024-05-01 10:00:00Z',
88
+ ])('rejects ambiguous or invalid dates %s', value =>
89
+ expect(() => normalizeCredentialAwardDate(value)).toThrow('awardedDate')
90
+ );
91
+ });
@@ -0,0 +1,101 @@
1
+ export class CredentialFieldError extends Error {
2
+ constructor(readonly field: 'creditsEarned' | 'creditsAvailable' | 'awardedDate') {
3
+ super(`Invalid standard credential field: ${field}`);
4
+ this.name = 'CredentialFieldError';
5
+ }
6
+ }
7
+
8
+ /** Parse a complete finite decimal, without guessing locale or accepting partial input. */
9
+ export const parseCredentialNumber = (value: unknown): number => {
10
+ if (typeof value === 'number' && Number.isFinite(value)) return value;
11
+ if (
12
+ typeof value !== 'string' ||
13
+ !/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?$/i.test(value.trim())
14
+ )
15
+ throw new Error('Expected a finite decimal');
16
+ const number = Number(value.trim());
17
+ if (!Number.isFinite(number)) throw new Error('Expected a finite decimal');
18
+ return number;
19
+ };
20
+
21
+ /** Calendar dates keep their day; timestamps must declare their timezone. */
22
+ export const normalizeCredentialAwardDate = (value: string): string => {
23
+ const text = value.trim();
24
+ const calendar = /^(\d{4})-(\d{2})-(\d{2})/.exec(text);
25
+ if (
26
+ !calendar ||
27
+ !/^\d{4}-\d{2}-\d{2}(?:T(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2}))?$/i.test(
28
+ text
29
+ )
30
+ )
31
+ throw new CredentialFieldError('awardedDate');
32
+ const day = new Date(`${calendar[0]}T00:00:00Z`);
33
+ if (
34
+ !Number.isFinite(day.getTime()) ||
35
+ day.getUTCFullYear() !== Number(calendar[1]) ||
36
+ day.getUTCMonth() + 1 !== Number(calendar[2]) ||
37
+ day.getUTCDate() !== Number(calendar[3])
38
+ )
39
+ throw new CredentialFieldError('awardedDate');
40
+ const instant = text.length === 10 ? day : new Date(text);
41
+ if (!Number.isFinite(instant.getTime())) throw new CredentialFieldError('awardedDate');
42
+ const fraction = /\.(\d+)(?:Z|[+-]\d{2}:\d{2})$/i.exec(text)?.[1];
43
+ const iso = instant.toISOString();
44
+ return fraction && fraction.length > 3 ? iso.replace(/\.\d{3}Z$/, `.${fraction}Z`) : iso;
45
+ };
46
+
47
+ const object = (value: unknown): value is Record<string, unknown> =>
48
+ Boolean(value && typeof value === 'object' && !Array.isArray(value));
49
+
50
+ /**
51
+ * Materialize declared OBv3/CLR numeric fields after template substitution.
52
+ * Result values and identifiers remain strings. Signed credentials are never changed.
53
+ */
54
+ export const materializeCredentialFields = (
55
+ credential: Record<string, unknown>
56
+ ): Record<string, unknown> => {
57
+ if (credential.proof) return credential;
58
+ const types = Array.isArray(credential.type) ? credential.type : [credential.type];
59
+ if (
60
+ !types.some(type =>
61
+ ['OpenBadgeCredential', 'AchievementCredential', 'ClrCredential'].includes(String(type))
62
+ )
63
+ )
64
+ return credential;
65
+ const result = structuredClone(credential);
66
+ const numeric = (
67
+ container: Record<string, unknown>,
68
+ field: 'creditsEarned' | 'creditsAvailable'
69
+ ): void => {
70
+ if (container[field] === undefined) return;
71
+ if (typeof container[field] === 'string' && !(container[field] as string).trim()) {
72
+ delete container[field];
73
+ return;
74
+ }
75
+ try {
76
+ container[field] = parseCredentialNumber(container[field]);
77
+ } catch {
78
+ throw new CredentialFieldError(field);
79
+ }
80
+ };
81
+ const subject = (value: unknown): void => {
82
+ if (!object(value)) return;
83
+ numeric(value, 'creditsEarned');
84
+ for (const achievement of Array.isArray(value.achievement)
85
+ ? value.achievement
86
+ : [value.achievement]) {
87
+ if (object(achievement)) numeric(achievement, 'creditsAvailable');
88
+ }
89
+ // Embedded verifiableCredential entries have independent signatures.
90
+ };
91
+ for (const value of Array.isArray(result.credentialSubject)
92
+ ? result.credentialSubject
93
+ : [result.credentialSubject])
94
+ subject(value);
95
+ if (result.awardedDate !== undefined) {
96
+ if (typeof result.awardedDate !== 'string') throw new CredentialFieldError('awardedDate');
97
+ if (!result.awardedDate.trim()) delete result.awardedDate;
98
+ else result.awardedDate = normalizeCredentialAwardDate(result.awardedDate);
99
+ }
100
+ return result;
101
+ };
package/src/index.ts CHANGED
@@ -108,3 +108,5 @@ export const isAppDidWeb = (did?: string): boolean => {
108
108
  };
109
109
 
110
110
  export * from './share-content-canonical';
111
+
112
+ export * from './credential-materialization';