@learncard/render-method-plugin 3.0.4 → 3.0.6

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,64 +1,66 @@
1
1
  {
2
- "name": "@learncard/render-method-plugin",
3
- "version": "3.0.4",
4
- "description": "LearnCard plugin for attaching W3C renderMethod to Verifiable Credentials",
5
- "type": "module",
6
- "main": "./dist/index.cjs",
7
- "module": "./dist/render-method-plugin.esm.js",
8
- "exports": {
9
- ".": {
10
- "import": {
11
- "types": "./dist/index.d.ts",
12
- "default": "./dist/render-method-plugin.esm.js"
13
- },
14
- "require": {
15
- "types": "./dist/index.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/plugins/render-method/README.md",
26
- "repository": {
27
- "type": "git",
28
- "url": "git+https://github.com/learningeconomy/LearnCard.git"
29
- },
30
- "bugs": {
31
- "url": "https://github.com/learningeconomy/LearnCard/issues"
32
- },
33
- "devDependencies": {
34
- "@types/jest": "^29.2.2",
35
- "@types/node": "^17.0.31",
36
- "aqu": "0.4.3",
37
- "esbuild": "^0.14.38",
38
- "esbuild-jest": "^0.5.0",
39
- "esbuild-plugin-copy": "^1.3.0",
40
- "jest": "^29.3.0",
41
- "rimraf": "^3.0.2",
42
- "shx": "^0.3.4",
43
- "ts-jest": "^29.0.3",
44
- "@learncard/core": "9.4.24",
45
- "@learncard/types": "5.17.4",
46
- "@learncard/helpers": "1.3.6"
47
- },
48
- "peerDependencies": {
49
- "@learncard/core": "9.4.24",
50
- "@learncard/types": "5.17.4",
51
- "@learncard/helpers": "1.3.6"
52
- },
53
- "dependencies": {
54
- "@learncard/helpers": "1.3.6"
55
- },
56
- "types": "./dist/index.d.ts",
57
- "sideEffects": false,
58
- "scripts": {
59
- "build": "node ./scripts/build.mjs && shx cp ./scripts/mixedEntypoint.js ./dist/index.cjs && tsc --p tsconfig.json && shx cp ./dist/index.d.ts ./dist/index.d.cts",
60
- "test": "jest --passWithNoTests",
61
- "test:watch": "jest --watch",
62
- "test:coverage": "jest --silent --ci --coverage --coverageReporters=\"text\" --coverageReporters=\"text-summary\""
63
- }
64
- }
2
+ "name": "@learncard/render-method-plugin",
3
+ "version": "3.0.6",
4
+ "description": "LearnCard plugin for attaching W3C renderMethod to Verifiable Credentials",
5
+ "type": "module",
6
+ "main": "./dist/index.cjs",
7
+ "module": "./dist/render-method-plugin.esm.js",
8
+ "exports": {
9
+ ".": {
10
+ "development": "./src/index.ts",
11
+ "import": {
12
+ "types": "./dist/index.d.ts",
13
+ "default": "./dist/render-method-plugin.esm.js"
14
+ },
15
+ "require": {
16
+ "types": "./dist/index.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 && tsc --p tsconfig.json && shx cp ./dist/index.d.ts ./dist/index.d.cts",
27
+ "test": "jest --passWithNoTests",
28
+ "test:watch": "jest --watch",
29
+ "test:coverage": "jest --silent --ci --coverage --coverageReporters=\"text\" --coverageReporters=\"text-summary\""
30
+ },
31
+ "author": "Learning Economy Foundation (www.learningeconomy.io)",
32
+ "license": "MIT",
33
+ "homepage": "https://github.com/learningeconomy/LearnCard/tree/main/packages/plugins/render-method/README.md",
34
+ "repository": {
35
+ "type": "git",
36
+ "url": "git+https://github.com/learningeconomy/LearnCard.git"
37
+ },
38
+ "bugs": {
39
+ "url": "https://github.com/learningeconomy/LearnCard/issues"
40
+ },
41
+ "devDependencies": {
42
+ "@learncard/core": "9.4.26",
43
+ "@learncard/helpers": "1.3.8",
44
+ "@learncard/types": "5.17.5",
45
+ "@types/jest": "^29.2.2",
46
+ "@types/node": "^17.0.31",
47
+ "aqu": "0.4.3",
48
+ "esbuild": "^0.14.38",
49
+ "esbuild-jest": "^0.5.0",
50
+ "esbuild-plugin-copy": "^1.3.0",
51
+ "jest": "^29.3.0",
52
+ "rimraf": "^3.0.2",
53
+ "shx": "^0.3.4",
54
+ "ts-jest": "^29.0.3"
55
+ },
56
+ "peerDependencies": {
57
+ "@learncard/core": "9.4.26",
58
+ "@learncard/helpers": "1.3.8",
59
+ "@learncard/types": "5.17.5"
60
+ },
61
+ "dependencies": {
62
+ "@learncard/helpers": "1.3.8"
63
+ },
64
+ "types": "./dist/index.d.ts",
65
+ "sideEffects": false
66
+ }
package/src/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ export * from './types';
2
+ export * from './plugin';
3
+ export * from './read';
package/src/plugin.ts ADDED
@@ -0,0 +1,150 @@
1
+ import { UnsignedVC, TemplateRenderMethod } from '@learncard/types';
2
+
3
+ import {
4
+ AttachRenderMethodConfig,
5
+ RenderMethodDependentLearnCard,
6
+ RenderMethodPlugin,
7
+ RENDER_METHOD_CONTEXT,
8
+ } from './types';
9
+ import {
10
+ buildRenderData,
11
+ findRenderMethod,
12
+ findRenderMethods,
13
+ findTemplateRenderMethod,
14
+ findTemplateRenderMethods,
15
+ getRenderMethods,
16
+ getSvgMustacheRenderMethod,
17
+ } from './read';
18
+
19
+ /**
20
+ * The default hosted SVG Mustache template, served from `packages/render-method-templates/`.
21
+ *
22
+ * Exported so callers can opt in to the default explicitly:
23
+ *
24
+ * lc.invoke.attachRenderMethod(vc, { templateId: DEFAULT_TEMPLATE_ID })
25
+ *
26
+ * Per the opt-in contract for `attachRenderMethod`, calling without a config will NOT attach this
27
+ * template — callers must be explicit.
28
+ */
29
+ export const DEFAULT_TEMPLATE_ID = 'https://templates.learncard.com/svg/card/card-1.0.0.svg';
30
+
31
+ /**
32
+ * Validates a `templateId` URL. Only `http://` and `https://` schemes are accepted.
33
+ *
34
+ * Why this exists: the `templateId` is fetched at render time. Permitting `javascript:`,
35
+ * `file:`, or empty strings would expose the renderer to obvious script-injection or
36
+ * local-file-read vectors. The renderer (`renderSvgMustache`) also runs DOMPurify as a
37
+ * second line of defense, but blocking unsafe schemes at the source is cheaper.
38
+ */
39
+ const isValidTemplateId = (templateId: string): boolean => {
40
+ if (!templateId || !templateId.trim()) return false;
41
+ return /^https?:\/\//i.test(templateId);
42
+ };
43
+
44
+ /**
45
+ * Builds a `TemplateRenderMethod` from the given config. Inline `templateValue` takes priority
46
+ * and is URL-encoded into a `data:image/svg+xml,...` URI. Otherwise `templateId` is used as-is.
47
+ *
48
+ * @throws if neither `templateId` nor `templateValue` is provided, if `templateId` is not http(s),
49
+ * or if `templateValue` is empty.
50
+ */
51
+ export const buildTemplateRenderMethod = (
52
+ config: AttachRenderMethodConfig
53
+ ): TemplateRenderMethod => {
54
+ let template: string;
55
+
56
+ if ('templateValue' in config && config.templateValue !== undefined) {
57
+ if (!config.templateValue.trim()) {
58
+ throw new Error(
59
+ 'buildTemplateRenderMethod: `templateValue` must be a non-empty SVG string.'
60
+ );
61
+ }
62
+ template = `data:image/svg+xml,${encodeURIComponent(config.templateValue)}`;
63
+ } else if ('templateId' in config && config.templateId !== undefined) {
64
+ if (!isValidTemplateId(config.templateId)) {
65
+ throw new Error(
66
+ `buildTemplateRenderMethod: \`templateId\` must be an http(s) URL, got: ${config.templateId}`
67
+ );
68
+ }
69
+ template = config.templateId;
70
+ } else {
71
+ throw new Error(
72
+ 'buildTemplateRenderMethod: must provide either `templateId` or `templateValue` in config.'
73
+ );
74
+ }
75
+
76
+ return {
77
+ type: 'TemplateRenderMethod',
78
+ // TODO: Add other render suites (e.g., WebRenderingTemplate2022, HtmlMustacheRenderingTemplate)
79
+ renderSuite: 'svg-mustache',
80
+ template,
81
+ // Only include renderProperty when explicitly provided to avoid overriding VC defaults
82
+ ...(config.renderProperty ? { renderProperty: config.renderProperty } : {}),
83
+ // TODO: Make mediaType configurable per renderSuite once additional suites are supported
84
+ outputPreference: { mediaType: 'image/svg+xml' as const },
85
+ };
86
+ };
87
+
88
+ /**
89
+ * Attaches a `TemplateRenderMethod` to an unsigned VC, merging with any existing `renderMethod`
90
+ * entries. Injects the render-method JSON-LD context if not already present.
91
+ *
92
+ * **Opt-in semantics:** If `config` is omitted, the VC is returned UNCHANGED. The caller must
93
+ * explicitly opt in by providing `templateId` or `templateValue`. Pass `DEFAULT_TEMPLATE_ID` to
94
+ * use the default hosted template.
95
+ *
96
+ * This contract intentionally avoids polluting every issued credential's `@context` with the
97
+ * draft render-method context URL. See `RENDER_METHOD_CONTEXT` for the rationale.
98
+ */
99
+ export const attachRenderMethod = (
100
+ vc: UnsignedVC,
101
+ config?: AttachRenderMethodConfig
102
+ ): UnsignedVC => {
103
+ // Opt-in: no config → no-op. Callers must explicitly choose a template.
104
+ if (!config) return vc;
105
+
106
+ const renderMethod = buildTemplateRenderMethod(config);
107
+
108
+ // Normalize @context to an array so we can safely append to it.
109
+ const context = Array.isArray(vc['@context']) ? vc['@context'] : [vc['@context']];
110
+ const updatedContext = context.includes(RENDER_METHOD_CONTEXT)
111
+ ? context
112
+ : [...context, RENDER_METHOD_CONTEXT];
113
+
114
+ const existingRenderMethod = vc.renderMethod;
115
+ let updatedRenderMethod: TemplateRenderMethod | TemplateRenderMethod[];
116
+
117
+ if (!existingRenderMethod) {
118
+ updatedRenderMethod = renderMethod;
119
+ } else if (Array.isArray(existingRenderMethod)) {
120
+ updatedRenderMethod = [...(existingRenderMethod as TemplateRenderMethod[]), renderMethod];
121
+ } else {
122
+ updatedRenderMethod = [existingRenderMethod as TemplateRenderMethod, renderMethod];
123
+ }
124
+
125
+ return {
126
+ ...vc,
127
+ '@context': updatedContext,
128
+ renderMethod: updatedRenderMethod,
129
+ };
130
+ };
131
+
132
+ export const getRenderMethodPlugin = (
133
+ _learnCard: RenderMethodDependentLearnCard
134
+ ): RenderMethodPlugin => ({
135
+ name: 'Render Method',
136
+ displayName: 'Render Method Plugin',
137
+ description:
138
+ 'Attaches W3C renderMethod to Verifiable Credentials for standards-based rendering',
139
+ methods: {
140
+ attachRenderMethod: (_lc, vc, config) => attachRenderMethod(vc, config),
141
+ buildTemplateRenderMethod: (_lc, config) => buildTemplateRenderMethod(config),
142
+ getRenderMethods: (_lc, vc) => getRenderMethods(vc),
143
+ findRenderMethod: (_lc, vc, predicate) => findRenderMethod(vc, predicate),
144
+ findRenderMethods: (_lc, vc, predicate) => findRenderMethods(vc, predicate),
145
+ findTemplateRenderMethod: (_lc, vc, suite) => findTemplateRenderMethod(vc, suite),
146
+ findTemplateRenderMethods: (_lc, vc, suite) => findTemplateRenderMethods(vc, suite),
147
+ getSvgMustacheRenderMethod: (_lc, vc) => getSvgMustacheRenderMethod(vc),
148
+ buildRenderData: (_lc, vc, renderProperty) => buildRenderData(vc, renderProperty),
149
+ },
150
+ });
package/src/read.ts ADDED
@@ -0,0 +1,225 @@
1
+ import { unwrapBoostCredential } from '@learncard/helpers';
2
+ import {
3
+ UnsignedVC,
4
+ VC,
5
+ RenderMethod,
6
+ TemplateRenderMethod,
7
+ TemplateRenderMethodValidator,
8
+ } from '@learncard/types';
9
+
10
+ type AnyCredential = VC | UnsignedVC;
11
+ type JsonRecord = Record<string, unknown>;
12
+
13
+ /** Mustache context shape produced by `buildRenderData`. */
14
+ export type RenderMethodData = JsonRecord;
15
+
16
+ /**
17
+ * Type guard for any `TemplateRenderMethod` (Zod-validated). Use for shape
18
+ * checks before treating an unknown `renderMethod` entry as a template render.
19
+ */
20
+ export const isTemplateRenderMethod = (rm: unknown): rm is TemplateRenderMethod =>
21
+ TemplateRenderMethodValidator.safeParse(rm).success;
22
+
23
+ /**
24
+ * Type guard for `TemplateRenderMethod` with `renderSuite === 'svg-mustache'`.
25
+ * Exported so consumers can compose their own selection (e.g., capability
26
+ * negotiation across multiple suites the renderer supports).
27
+ */
28
+ export const isSvgMustacheRenderMethod = (rm: unknown): rm is TemplateRenderMethod => {
29
+ const parsed = TemplateRenderMethodValidator.safeParse(rm);
30
+ return parsed.success && parsed.data.renderSuite === 'svg-mustache';
31
+ };
32
+
33
+ /**
34
+ * Returns all `renderMethod` entries on a VC. Normalizes single-object form to
35
+ * an array and unwraps `CertifiedBoostCredential` so callers don't have to
36
+ * think about the wrapping layer.
37
+ *
38
+ * Entries are NOT validated here. Callers apply type guards via
39
+ * `findRenderMethod` / `findRenderMethods`, or one of the typed conveniences.
40
+ */
41
+ export const getRenderMethods = (vc: AnyCredential): RenderMethod[] => {
42
+ const effective = (unwrapBoostCredential(vc) ?? vc) as
43
+ | Record<string, unknown>
44
+ | undefined;
45
+ const raw = effective?.renderMethod;
46
+ if (!raw) return [];
47
+ return (Array.isArray(raw) ? raw : [raw]) as RenderMethod[];
48
+ };
49
+
50
+ /**
51
+ * First `renderMethod` matching a type-guard predicate, or `null` if none.
52
+ *
53
+ * The predicate receives raw entries so it can apply Zod-validated guards
54
+ * (e.g., `isSvgMustacheRenderMethod`). The return type is narrowed by the
55
+ * predicate.
56
+ */
57
+ export function findRenderMethod<T extends RenderMethod = RenderMethod>(
58
+ vc: AnyCredential,
59
+ predicate: (rm: RenderMethod) => rm is T
60
+ ): T | null {
61
+ for (const entry of getRenderMethods(vc)) {
62
+ if (predicate(entry)) return entry;
63
+ }
64
+ return null;
65
+ }
66
+
67
+ /** Like `findRenderMethod`, but returns every matching entry. */
68
+ export function findRenderMethods<T extends RenderMethod = RenderMethod>(
69
+ vc: AnyCredential,
70
+ predicate: (rm: RenderMethod) => rm is T
71
+ ): T[] {
72
+ return getRenderMethods(vc).filter(predicate);
73
+ }
74
+
75
+ /**
76
+ * First `TemplateRenderMethod` on a VC whose `renderSuite` matches the given
77
+ * suite name, or `null`. Accepts a single suite string OR an array of suite
78
+ * strings (returns the first match across the list — useful for capability
79
+ * negotiation when a renderer supports multiple suites).
80
+ *
81
+ * Returns the Zod-parsed shape (not the raw entry).
82
+ *
83
+ * findTemplateRenderMethod(vc, 'svg-mustache')
84
+ * findTemplateRenderMethod(vc, ['svg-mustache', 'html-mustache'])
85
+ */
86
+ export const findTemplateRenderMethod = (
87
+ vc: AnyCredential,
88
+ suite: string | readonly string[]
89
+ ): TemplateRenderMethod | null => {
90
+ const suites = typeof suite === 'string' ? new Set([suite]) : new Set(suite);
91
+ for (const entry of getRenderMethods(vc)) {
92
+ const parsed = TemplateRenderMethodValidator.safeParse(entry);
93
+ if (parsed.success && suites.has(parsed.data.renderSuite)) {
94
+ return parsed.data;
95
+ }
96
+ }
97
+ return null;
98
+ };
99
+
100
+ /** Like `findTemplateRenderMethod`, but returns every matching entry. */
101
+ export const findTemplateRenderMethods = (
102
+ vc: AnyCredential,
103
+ suite: string | readonly string[]
104
+ ): TemplateRenderMethod[] => {
105
+ const suites = typeof suite === 'string' ? new Set([suite]) : new Set(suite);
106
+ const result: TemplateRenderMethod[] = [];
107
+ for (const entry of getRenderMethods(vc)) {
108
+ const parsed = TemplateRenderMethodValidator.safeParse(entry);
109
+ if (parsed.success && suites.has(parsed.data.renderSuite)) {
110
+ result.push(parsed.data);
111
+ }
112
+ }
113
+ return result;
114
+ };
115
+
116
+ /**
117
+ * Convenience: the first svg-mustache `TemplateRenderMethod` on a VC, or `null`.
118
+ * Equivalent to `findTemplateRenderMethod(vc, 'svg-mustache')`.
119
+ */
120
+ export const getSvgMustacheRenderMethod = (
121
+ vc: AnyCredential
122
+ ): TemplateRenderMethod | null => findTemplateRenderMethod(vc, 'svg-mustache');
123
+
124
+ /**
125
+ * Prototype-pollution guard. JSON Pointers come from credential data
126
+ * (untrusted), and these three keys are the canonical pollution vectors when
127
+ * a pointer is walked with bracket-indexing.
128
+ */
129
+ const isUnsafeKey = (key: string): boolean =>
130
+ key === '__proto__' || key === 'constructor' || key === 'prototype';
131
+
132
+ const resolvePointer = (obj: unknown, pointer: string): unknown => {
133
+ if (!pointer || pointer === '/') return obj;
134
+ const parts = pointer
135
+ .replace(/^\//, '')
136
+ .split('/')
137
+ .map(p => p.replace(/~1/g, '/').replace(/~0/g, '~'));
138
+
139
+ if (parts.some(isUnsafeKey)) return undefined;
140
+
141
+ let current: unknown = obj;
142
+ for (const part of parts) {
143
+ if (current === null || current === undefined || typeof current !== 'object')
144
+ return undefined;
145
+ current = (current as Record<string, unknown>)[part];
146
+ }
147
+ return current;
148
+ };
149
+
150
+ const buildRenderDataFromPointers = (
151
+ vc: AnyCredential,
152
+ pointers: string[]
153
+ ): JsonRecord => {
154
+ const result: JsonRecord = Object.create(null) as JsonRecord;
155
+ const isUnsafeKey = (key: string): boolean =>
156
+ key === '__proto__' || key === 'constructor' || key === 'prototype';
157
+
158
+ for (const pointer of pointers) {
159
+ const value = resolvePointer(vc, pointer);
160
+ if (value === undefined) continue;
161
+
162
+ const parts = pointer
163
+ .replace(/^\//, '')
164
+ .split('/')
165
+ .map(p => p.replace(/~1/g, '/').replace(/~0/g, '~'));
166
+
167
+ if (parts.some(isUnsafeKey)) continue;
168
+
169
+ let current = result;
170
+ for (let i = 0; i < parts.length - 1; i++) {
171
+ const part = parts[i];
172
+ if (!current[part] || typeof current[part] !== 'object') {
173
+ current[part] = Object.create(null) as JsonRecord;
174
+ }
175
+ current = current[part] as JsonRecord;
176
+ }
177
+
178
+ const lastPart = parts[parts.length - 1];
179
+ if (isUnsafeKey(lastPart)) continue;
180
+ current[lastPart] = value;
181
+ }
182
+
183
+ return result;
184
+ };
185
+
186
+ /**
187
+ * Builds the portable Mustache context for a VC. Designed for SVG / HTML /
188
+ * text Mustache templates that may have been authored against any of these
189
+ * shapes:
190
+ *
191
+ * {{credentialSubject.name}} // direct VC paths
192
+ * {{credential.credentialSubject.name}} // `credential` alias
193
+ * {{vc.issuer.name}} // `vc` alias
194
+ * {{#credentialSubjects}}{{name}}{{/credentialSubjects}} // section over array
195
+ *
196
+ * The top-level credential is the single source of truth; aliases keep older
197
+ * templates working. When `renderProperty` (RFC 6901 JSON Pointers) is given,
198
+ * those values are overlaid at their pointer paths in the resulting context.
199
+ *
200
+ * This function does NOT unwrap `CertifiedBoostCredential` — pass the layer
201
+ * you want the template to bind to. `getRenderMethods` is the helper that
202
+ * unwraps.
203
+ */
204
+ export const buildRenderData = (
205
+ vc: AnyCredential,
206
+ renderProperty?: string[]
207
+ ): RenderMethodData => {
208
+ const credential = vc as unknown as JsonRecord;
209
+ const base: RenderMethodData = {
210
+ ...credential,
211
+ vc: credential,
212
+ credential,
213
+ credentialSubjects: Array.isArray(credential.credentialSubject)
214
+ ? credential.credentialSubject
215
+ : [credential.credentialSubject].filter(Boolean),
216
+ };
217
+
218
+ if (!renderProperty || renderProperty.length === 0) return base;
219
+
220
+ return {
221
+ ...base,
222
+ ...buildRenderDataFromPointers(vc, renderProperty),
223
+ credential,
224
+ };
225
+ };