@learncard/learn-card-plugin 1.2.24 → 1.2.25

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.
@@ -0,0 +1,160 @@
1
+ import { VerificationItem, VerificationStatusEnum } from '@learncard/types';
2
+
3
+ type Label = { check: string; message: string };
4
+
5
+ const SUCCESS_LABELS: Record<string, Label> = {
6
+ parse: { check: 'Format', message: 'Valid' },
7
+ disclosure_hash_integrity: { check: 'Selective Disclosure', message: 'Claims verified' },
8
+ issuer_resolved: { check: 'Issuer', message: 'Identified' },
9
+ issuer_signature: { check: 'Signature', message: 'Valid' },
10
+ expiration: { check: 'Expiration', message: 'Does Not Expire' },
11
+ vct: { check: 'Credential Type', message: 'Verified' },
12
+ proof: { check: 'Proof', message: 'Valid' },
13
+ credentialStatus: { check: 'Status', message: 'Active' },
14
+ // The W3C verifier (learn-card/verify.ts) normalizes both `status` and
15
+ // `credentialStatus` success rows to check: 'status' (lowercase), so the
16
+ // bare `status` alias is required or those rows render unprettified.
17
+ status: { check: 'Status', message: 'Active' },
18
+ credentialSchema: { check: 'Schema', message: 'Valid' },
19
+ };
20
+
21
+ const ERROR_LABELS: Record<string, Label> = {
22
+ invalid_compact_form: { check: 'Format', message: 'Invalid' },
23
+ invalid_jwt: { check: 'Format', message: 'Invalid' },
24
+ invalid_typ: { check: 'Format', message: 'Invalid type' },
25
+ missing_iss: { check: 'Issuer', message: 'Missing' },
26
+ missing_vct: { check: 'Credential Type', message: 'Missing' },
27
+ missing_alg: { check: 'Signature', message: 'Algorithm missing' },
28
+ unsupported_alg: { check: 'Signature', message: 'Unsupported algorithm' },
29
+ unsupported_sd_alg: { check: 'Selective Disclosure', message: 'Unsupported algorithm' },
30
+ invalid_disclosure: { check: 'Selective Disclosure', message: 'Invalid' },
31
+ disclosure_hash_mismatch: { check: 'Selective Disclosure', message: 'Tampered' },
32
+ issuer_resolution_failed: { check: 'Issuer', message: 'Could not be found' },
33
+ verification_method_not_found: { check: 'Issuer', message: 'Key not found' },
34
+ verification_method_not_authorized: { check: 'Issuer', message: 'Key not authorized' },
35
+ signature_invalid: { check: 'Signature', message: 'Invalid' },
36
+ expired: { check: 'Expiration', message: 'Expired' },
37
+ not_yet_valid: { check: 'Expiration', message: 'Not yet valid' },
38
+ vct_mismatch: { check: 'Credential Type', message: 'Mismatch' },
39
+ status_check_failed: { check: 'Status', message: 'Revoked or suspended' },
40
+ kb_jwt_invalid: { check: 'Key Binding', message: 'Invalid' },
41
+ presentation_verification_failed: { check: 'Presentation', message: 'Invalid' },
42
+ key_binding_mismatch: { check: 'Holder Key', message: 'Mismatch' },
43
+ unsupported_cnf_confirmation_type: { check: 'Holder Key', message: 'Unsupported type' },
44
+ internal_error: { check: 'Verification', message: 'Something went wrong' },
45
+ };
46
+
47
+ const isAlreadyPrettified = (check: string | undefined): boolean => {
48
+ if (!check) return true;
49
+ return /^[A-Z]/.test(check) || /\s/.test(check);
50
+ };
51
+
52
+ const extractErrorCode = (message: string | undefined): string | undefined => {
53
+ if (!message) return undefined;
54
+ const match = message.match(/^([a-z][a-z0-9_]*):/);
55
+ return match?.[1];
56
+ };
57
+
58
+ /**
59
+ * Derive a snake_case error code from a `check` that may already be a raw
60
+ * diagnostic phrase. The W3C verifier's `transformErrorCheck` returns
61
+ * `error.split(' error')[0]`, which can be e.g. `"signature_invalid: bad
62
+ * signature"` — the leading token before `:`/whitespace is the real code.
63
+ */
64
+ const codeFromCheck = (check: string | undefined): string | undefined => {
65
+ if (!check) return undefined;
66
+ const token = check.split(/[:\s]/)[0];
67
+ return /^[a-z][a-z0-9_]*$/.test(token) ? token : undefined;
68
+ };
69
+
70
+ /**
71
+ * Resolve a known ERROR_LABELS code from a failed item. Layer 1
72
+ * (verify.ts) puts the code in `check` (possibly containing spaces) and
73
+ * the human diagnostic in `details`, NOT `message` — so scan all three
74
+ * and only accept a candidate that maps to a known label.
75
+ */
76
+ const resolveErrorCode = (item: VerificationItem): string | undefined => {
77
+ const candidates = [
78
+ extractErrorCode(item.message),
79
+ extractErrorCode(item.details),
80
+ codeFromCheck(item.check),
81
+ ];
82
+ return candidates.find((code): code is string => code !== undefined && code in ERROR_LABELS);
83
+ };
84
+
85
+ /**
86
+ * A `message` is "raw" when it carries no human-meaningful detail
87
+ * beyond the check code itself. Upstream verifiers vary:
88
+ *
89
+ * - SD-JWT-VC plugin emits `{ check: 'expiration', message: 'expiration' }`
90
+ * (or message === undefined) — the message is a placeholder and we
91
+ * should replace it with the prettified label.
92
+ * - The W3C VC verifier (`packages/plugins/learn-card/src/verify.ts`)
93
+ * emits `{ check: 'expiration', message: 'Expires 28 FEB 2023' }` —
94
+ * the message is already humanized with credential-specific detail
95
+ * (the actual expiration date), and we MUST preserve it.
96
+ * - SD-JWT-VC failures arrive as `{ check: 'signature_invalid',
97
+ * message: 'signature_invalid: details' }` — the message is raw
98
+ * diagnostic prefixed with the code; replace with the label.
99
+ *
100
+ * Treating "message looks raw" as the trigger keeps the prettifier
101
+ * idempotent and non-destructive for any verifier that has already
102
+ * done its own humanization.
103
+ */
104
+ const isMessageRaw = (message: string | undefined, check: string): boolean => {
105
+ if (!message) return true;
106
+ if (message === check) return true;
107
+ if (/^[a-z][a-z0-9_]*$/.test(message)) return true;
108
+ if (message.startsWith(`${check}:`)) return true;
109
+ return false;
110
+ };
111
+
112
+ export const prettifyVerificationItem = (item: VerificationItem): VerificationItem => {
113
+ const isFailed = item.status === VerificationStatusEnum.Failed;
114
+
115
+ if (isFailed) {
116
+ // Failures are resolved before the `isAlreadyPrettified` guard
117
+ // because Layer 1 emits a `check` that often contains whitespace
118
+ // (e.g. "signature_invalid: bad signature"), which the guard would
119
+ // otherwise treat as already-prettified and skip.
120
+ const code = resolveErrorCode(item);
121
+ if (!code) return item;
122
+ const label = ERROR_LABELS[code];
123
+
124
+ // The UI renders failed rows as `message ?? details`. If Layer 1
125
+ // humanized the diagnostic into `details` (e.g. "Expired 28 FEB
126
+ // 2023", "Status: Revoked"), preserve it: keep `message` empty so
127
+ // the UI falls through to `details`. If `message` itself is already
128
+ // humanized, keep it. Only when neither carries human detail do we
129
+ // synthesize the generic label, so a real diagnostic is never shadowed.
130
+ const detailsHumanized = Boolean(item.details) && !isMessageRaw(item.details, item.check);
131
+ const messageHumanized = Boolean(item.message) && !isMessageRaw(item.message, item.check);
132
+
133
+ let message: string | undefined;
134
+ if (messageHumanized) message = item.message;
135
+ else if (detailsHumanized) message = undefined;
136
+ else message = label.message;
137
+
138
+ return {
139
+ ...item,
140
+ check: label.check,
141
+ message,
142
+ };
143
+ }
144
+
145
+ if (isAlreadyPrettified(item.check)) return item;
146
+
147
+ const label = SUCCESS_LABELS[item.check];
148
+ if (label) {
149
+ return {
150
+ ...item,
151
+ check: label.check,
152
+ message: isMessageRaw(item.message, item.check) ? label.message : item.message,
153
+ };
154
+ }
155
+
156
+ return item;
157
+ };
158
+
159
+ export const prettifyVerificationItems = (items: VerificationItem[]): VerificationItem[] =>
160
+ items.map(prettifyVerificationItem);
package/src/verify.ts ADDED
@@ -0,0 +1,194 @@
1
+ import {
2
+ StatusCheckEntry,
3
+ VC,
4
+ VerificationCheck,
5
+ VerificationItem,
6
+ VerificationStatusEnum,
7
+ } from '@learncard/types';
8
+ import { format } from 'date-fns';
9
+
10
+ import { LearnCard } from '@learncard/core';
11
+ import { ProofOptions } from '@learncard/didkit-plugin';
12
+
13
+ const STATUS_CHECKS = new Set(['status', 'credentialStatus']);
14
+
15
+ const isStatusCheck = (check: string): boolean => STATUS_CHECKS.has(check);
16
+
17
+ const getStatusPurposeLabel = (statusPurpose: string): string => {
18
+ switch (statusPurpose) {
19
+ case 'revocation':
20
+ return 'Revocation';
21
+ case 'suspension':
22
+ return 'Suspension';
23
+ default:
24
+ return statusPurpose
25
+ .replace(/([a-z])([A-Z])/g, '$1 $2')
26
+ .replace(/[-_]+/g, ' ')
27
+ .replace(/\s+/g, ' ')
28
+ .trim()
29
+ .replace(/\b\w/g, letter => letter.toUpperCase());
30
+ }
31
+ };
32
+
33
+ const getClearStatusMessage = (statusPurpose: string): string => {
34
+ switch (statusPurpose) {
35
+ case 'revocation':
36
+ return 'Not Revoked';
37
+ case 'suspension':
38
+ return 'Not Suspended';
39
+ default:
40
+ return `${getStatusPurposeLabel(statusPurpose)} Clear`;
41
+ }
42
+ };
43
+
44
+ const getSetStatusMessage = (statusPurpose: string): string => {
45
+ switch (statusPurpose) {
46
+ case 'revocation':
47
+ return 'Revoked';
48
+ case 'suspension':
49
+ return 'Suspended';
50
+ default:
51
+ return `${getStatusPurposeLabel(statusPurpose)} Set`;
52
+ }
53
+ };
54
+
55
+ const getUniqueStatusPurposes = (statuses: StatusCheckEntry[]): string[] => [
56
+ ...new Set(statuses.map(status => status.statusPurpose)),
57
+ ];
58
+
59
+ const transformStatusCheckMessage = (statusEntries: StatusCheckEntry[] = []): string => {
60
+ const setStatuses = statusEntries.filter(status => status.isSet);
61
+ if (setStatuses.length > 0) {
62
+ return getUniqueStatusPurposes(setStatuses).map(getSetStatusMessage).join(', ');
63
+ }
64
+
65
+ const clearPurposes = getUniqueStatusPurposes(statusEntries);
66
+ if (clearPurposes.length === 1) return getClearStatusMessage(clearPurposes[0]!);
67
+
68
+ return 'Active';
69
+ };
70
+
71
+ const getStatusErrorMessage = (
72
+ error: string,
73
+ verificationCheck?: VerificationCheck
74
+ ): string | undefined => {
75
+ if (/credential is revoked/i.test(error)) return 'Status: Revoked';
76
+ if (/credential is suspended/i.test(error)) return 'Status: Suspended';
77
+
78
+ const statusPurpose = error.match(/credential status is set for purpose:\s*([^.]+)/i)?.[1];
79
+ if (statusPurpose) return `Status: ${getSetStatusMessage(statusPurpose.trim())}`;
80
+
81
+ if (!/status/i.test(error)) return;
82
+
83
+ const setStatuses = verificationCheck?.status?.filter(status => status.isSet) ?? [];
84
+ if (setStatuses.length > 0) return `Status: ${transformStatusCheckMessage(setStatuses)}`;
85
+ };
86
+
87
+ const transformErrorCheck = (
88
+ error: string,
89
+ _credential: VC,
90
+ verificationCheck?: VerificationCheck
91
+ ): string => {
92
+ if (getStatusErrorMessage(error, verificationCheck)) return 'status';
93
+
94
+ const prefix = error.split(' error')[0];
95
+
96
+ return prefix || error;
97
+ };
98
+
99
+ const transformErrorMessage = (
100
+ error: string,
101
+ credential: VC,
102
+ verificationCheck?: VerificationCheck
103
+ ): string => {
104
+ const statusErrorMessage = getStatusErrorMessage(error, verificationCheck);
105
+ if (statusErrorMessage) return statusErrorMessage;
106
+
107
+ if (error.startsWith('expiration')) {
108
+ return credential.expirationDate
109
+ ? `Expired ${format(new Date(credential.expirationDate), 'dd MMM yyyy').toUpperCase()}`
110
+ : 'Expired';
111
+ }
112
+
113
+ return error;
114
+ };
115
+
116
+ const transformWarningCheck = (warning: string, _credential: VC): string => {
117
+ if (warning.includes('Boost Authenticity')) return 'Boost Authenticity';
118
+
119
+ const prefix = warning.split(' warning')[0];
120
+
121
+ return prefix || warning;
122
+ };
123
+
124
+ const transformCheckMessage = (
125
+ check: string,
126
+ credential: VC,
127
+ verificationCheck: VerificationCheck
128
+ ): string => {
129
+ if (isStatusCheck(check)) return transformStatusCheckMessage(verificationCheck.status);
130
+
131
+ return (
132
+ {
133
+ proof: 'Valid',
134
+ expiration: credential.expirationDate
135
+ ? `Expires ${format(
136
+ new Date(credential.expirationDate),
137
+ 'dd MMM yyyy'
138
+ ).toUpperCase()}`
139
+ : 'Does Not Expire',
140
+ }[check] || check
141
+ );
142
+ };
143
+
144
+ export const verifyCredential = (
145
+ learnCard: LearnCard<
146
+ any,
147
+ any,
148
+ {
149
+ verifyCredential: (
150
+ credential: VC,
151
+ options?: Partial<ProofOptions>
152
+ ) => Promise<VerificationCheck>;
153
+ }
154
+ >
155
+ ): ((
156
+ _learnCard: LearnCard<any, any, any>,
157
+ credential: VC,
158
+ options?: Partial<ProofOptions>,
159
+ prettify?: boolean
160
+ ) => Promise<VerificationItem[] | VerificationCheck>) => {
161
+ return async (_learnCard, credential, options, prettify = false) => {
162
+ const rawVerificationCheck = await learnCard.invoke.verifyCredential(credential, options);
163
+
164
+ if (!prettify) return rawVerificationCheck;
165
+
166
+ const verificationItems: VerificationItem[] = [];
167
+
168
+ rawVerificationCheck.errors.forEach(error => {
169
+ verificationItems.push({
170
+ status: VerificationStatusEnum.Failed,
171
+ check: transformErrorCheck(error, credential, rawVerificationCheck),
172
+ details: transformErrorMessage(error, credential, rawVerificationCheck),
173
+ });
174
+ });
175
+
176
+ rawVerificationCheck.warnings.forEach(warning => {
177
+ verificationItems.push({
178
+ status: VerificationStatusEnum.Error,
179
+ check: transformWarningCheck(warning, credential),
180
+ message: warning,
181
+ });
182
+ });
183
+
184
+ rawVerificationCheck.checks.forEach(check => {
185
+ verificationItems.push({
186
+ status: VerificationStatusEnum.Success,
187
+ check: isStatusCheck(check) ? 'status' : check,
188
+ message: transformCheckMessage(check, credential, rawVerificationCheck),
189
+ });
190
+ });
191
+
192
+ return verificationItems;
193
+ };
194
+ };