@learncard/helpers 1.5.1 → 1.6.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.
- package/dist/credential-refresh.d.ts +58 -1
- package/dist/credential-refresh.d.ts.map +1 -1
- package/dist/helpers.cjs.development.cjs +6190 -5065
- package/dist/helpers.cjs.development.cjs.map +4 -4
- package/dist/helpers.cjs.production.min.cjs +56 -56
- package/dist/helpers.cjs.production.min.cjs.map +4 -4
- package/dist/helpers.esm.js +1121 -179
- package/dist/helpers.esm.js.map +4 -4
- package/dist/index.d.cts +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/share-content-canonical.d.ts +13 -0
- package/dist/share-content-canonical.d.ts.map +1 -0
- package/package.json +2 -2
- package/src/credential-refresh.ts +258 -0
- package/src/index.ts +2 -0
- package/src/share-content-canonical.ts +128 -0
package/dist/index.d.cts
CHANGED
package/dist/index.d.ts
CHANGED
package/dist/index.d.ts.map
CHANGED
|
@@ -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"}
|
|
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"}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** C1 request serialization shared by the signing client and verifying service. */
|
|
2
|
+
export declare const DEFAULT_MAX_CANONICAL_BYTES: number;
|
|
3
|
+
export declare const DEFAULT_MAX_CANONICAL_DEPTH = 32;
|
|
4
|
+
export declare class ShareContentCanonicalizationError extends Error {
|
|
5
|
+
readonly code = "UNSUPPORTED_REQUEST_VALUE";
|
|
6
|
+
constructor(message: string);
|
|
7
|
+
}
|
|
8
|
+
/** Deterministic UTF-8 canonical serialization of a request body. */
|
|
9
|
+
export declare const canonicalizeShareContentRequestBody: (body: unknown, options?: {
|
|
10
|
+
maxBytes?: number;
|
|
11
|
+
maxDepth?: number;
|
|
12
|
+
}) => string;
|
|
13
|
+
//# sourceMappingURL=share-content-canonical.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"share-content-canonical.d.ts","sourceRoot":"","sources":["../src/share-content-canonical.ts"],"names":[],"mappings":"AAAA,mFAAmF;AACnF,eAAO,MAAM,2BAA2B,QAAkB,CAAC;AAC3D,eAAO,MAAM,2BAA2B,KAAK,CAAC;AAI9C,qBAAa,iCAAkC,SAAQ,KAAK;IACxD,QAAQ,CAAC,IAAI,+BAA+B;gBAEhC,OAAO,EAAE,MAAM;CAI9B;AAoGD,qEAAqE;AACrE,eAAO,MAAM,mCAAmC,GAC5C,MAAM,OAAO,EACb,UAAS;IAAE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;CAAO,KACvD,MAUF,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@learncard/helpers",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.1",
|
|
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.
|
|
57
|
+
"@learncard/types": "5.22.0",
|
|
58
58
|
"@trpc/server": "11.8.0",
|
|
59
59
|
"immer": "^10.0.3",
|
|
60
60
|
"use-immer": "^0.9.0",
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
|
+
ManagedCredentialRefreshServiceValidator,
|
|
2
3
|
SupportedCredentialRefreshServiceValidator,
|
|
4
|
+
type ManagedCredentialRefreshService,
|
|
3
5
|
type SupportedCredentialRefreshService,
|
|
4
6
|
} from '@learncard/types';
|
|
5
7
|
|
|
@@ -105,6 +107,262 @@ export const canonicalizeCredentialContent = <T>(value: T): T => {
|
|
|
105
107
|
export const canonicalizeCredentialJson = (value: unknown): string =>
|
|
106
108
|
JSON.stringify(canonicalizeCredentialContent(value));
|
|
107
109
|
|
|
110
|
+
// --- Managed refresh JSON-LD context preparation (LC-2198) --------------------
|
|
111
|
+
|
|
112
|
+
/** Term used by the LearnCard-managed refresh service type. */
|
|
113
|
+
const MANAGED_REFRESH_TYPE_TERM = 'LearnCardCredentialRefresh2026';
|
|
114
|
+
|
|
115
|
+
/** Term for the LearnCard DID-auth extension inside a managed refresh service. */
|
|
116
|
+
const MANAGED_REFRESH_DID_AUTH_TERM = 'LearnCardDIDAuth';
|
|
117
|
+
|
|
118
|
+
/** IRI the managed refresh service type term must resolve to. */
|
|
119
|
+
export const MANAGED_REFRESH_TYPE_IRI =
|
|
120
|
+
'https://learncard.com/refresh#LearnCardCredentialRefresh2026';
|
|
121
|
+
|
|
122
|
+
/** OBv3-namespaced IRI for the authorization term of a managed refresh service. */
|
|
123
|
+
export const MANAGED_REFRESH_AUTHORIZATION_IRI =
|
|
124
|
+
'https://purl.imsglobal.org/spec/ob/v3p0#authorization';
|
|
125
|
+
|
|
126
|
+
/** IRI for the LearnCard DID-auth scheme of a managed refresh service. */
|
|
127
|
+
export const MANAGED_REFRESH_DID_AUTH_IRI =
|
|
128
|
+
'https://docs.learncard.com/definitions#LearnCardDIDAuth';
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Inline JSON-LD context fragment required to sign credentials carrying a
|
|
132
|
+
* LearnCard-managed `LearnCardCredentialRefresh2026` refresh service.
|
|
133
|
+
*
|
|
134
|
+
* Neither VCDM 1.1 (which defines only `ManualRefreshService2018`), VCDM 2.0, nor the
|
|
135
|
+
* live OBv3/CLR contexts define the term `LearnCardCredentialRefresh2026` (nor the
|
|
136
|
+
* LearnCard `authorization` / `LearnCardDIDAuth` extension terms), so DIDKit's
|
|
137
|
+
* data-loss detection refuses to sign unless issuers define these terms inline.
|
|
138
|
+
*/
|
|
139
|
+
export const MANAGED_REFRESH_SERVICE_CONTEXT = {
|
|
140
|
+
[MANAGED_REFRESH_TYPE_TERM]: MANAGED_REFRESH_TYPE_IRI,
|
|
141
|
+
authorization: {
|
|
142
|
+
'@id': MANAGED_REFRESH_AUTHORIZATION_IRI,
|
|
143
|
+
'@context': { [MANAGED_REFRESH_DID_AUTH_TERM]: MANAGED_REFRESH_DID_AUTH_IRI },
|
|
144
|
+
},
|
|
145
|
+
} as const;
|
|
146
|
+
|
|
147
|
+
type ContextObject = Record<string, unknown>;
|
|
148
|
+
|
|
149
|
+
type CredentialWithOptionalRefreshService = {
|
|
150
|
+
refreshService?: unknown;
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const isPlainObject = (value: unknown): value is ContextObject =>
|
|
154
|
+
!!value && typeof value === 'object' && !Array.isArray(value);
|
|
155
|
+
|
|
156
|
+
/** `"iri"` and `{ "@id": "iri" }` are the same JSON-LD term definition. */
|
|
157
|
+
const toIdForm = (definition: unknown): unknown =>
|
|
158
|
+
typeof definition === 'string' ? { '@id': definition } : definition;
|
|
159
|
+
|
|
160
|
+
/** Extracts the `@id` of a term definition, accounting for the string shorthand. */
|
|
161
|
+
const definitionId = (definition: unknown): unknown => {
|
|
162
|
+
const idForm = toIdForm(definition);
|
|
163
|
+
|
|
164
|
+
return isPlainObject(idForm) ? idForm['@id'] : undefined;
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Effective (last-wins) definition of a term across the credential's inline context
|
|
169
|
+
* objects. Only top-level context objects participate: nested `@context` blocks scope
|
|
170
|
+
* to their term's value and cannot redefine unrelated terms.
|
|
171
|
+
*/
|
|
172
|
+
const findTopLevelDefinition = (contextObjects: ContextObject[], term: string): unknown => {
|
|
173
|
+
let found: unknown;
|
|
174
|
+
|
|
175
|
+
for (const contextObject of contextObjects) {
|
|
176
|
+
if (term in contextObject) found = contextObject[term];
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return found;
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
/** Ensures an existing term definition resolves to the expected IRI, or throws. */
|
|
183
|
+
const assertDefinitionMatchesIri = (term: string, definition: unknown, expectedIri: string) => {
|
|
184
|
+
const actualIri = definitionId(definition);
|
|
185
|
+
|
|
186
|
+
if (actualIri !== expectedIri) {
|
|
187
|
+
throw new Error(
|
|
188
|
+
`Conflicting JSON-LD definition for term "${term}": expected "${expectedIri}" but the credential already defines it as ${JSON.stringify(
|
|
189
|
+
definition
|
|
190
|
+
)}. Remove or correct the existing definition before signing.`
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Returns the LearnCard-managed refresh services carried by a credential.
|
|
197
|
+
*
|
|
198
|
+
* Accepts a single service object or an array; standard 1EdTech services and unknown
|
|
199
|
+
* entries are ignored.
|
|
200
|
+
*/
|
|
201
|
+
export const getManagedRefreshServices = (
|
|
202
|
+
vc: CredentialWithOptionalRefreshService
|
|
203
|
+
): ManagedCredentialRefreshService[] => {
|
|
204
|
+
const refreshService = vc?.refreshService;
|
|
205
|
+
|
|
206
|
+
if (!refreshService) return [];
|
|
207
|
+
|
|
208
|
+
const services = Array.isArray(refreshService) ? refreshService : [refreshService];
|
|
209
|
+
|
|
210
|
+
return services.flatMap(service => {
|
|
211
|
+
const parsed = ManagedCredentialRefreshServiceValidator.safeParse(service);
|
|
212
|
+
|
|
213
|
+
return parsed.success ? [parsed.data] : [];
|
|
214
|
+
});
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Ensures a credential carrying a LearnCard-managed refresh service also carries the
|
|
219
|
+
* inline JSON-LD context needed to sign it.
|
|
220
|
+
*
|
|
221
|
+
* - Credentials without a managed service are returned **by reference**, untouched.
|
|
222
|
+
* - The input is never mutated; a shallow-extended copy is returned when a fragment
|
|
223
|
+
* must be appended.
|
|
224
|
+
* - Equivalent existing definitions are idempotent (no duplicate fragment).
|
|
225
|
+
* - Incomplete mappings are completed with only the missing terms.
|
|
226
|
+
* - Conflicting definitions (same term, different IRI) throw a clear error instead of
|
|
227
|
+
* silently signing an unexpected expansion.
|
|
228
|
+
*/
|
|
229
|
+
export const prepareManagedRefreshContext = <T>(credential: T): T => {
|
|
230
|
+
const source = credential as unknown as CredentialWithOptionalRefreshService;
|
|
231
|
+
|
|
232
|
+
if (getManagedRefreshServices(source).length === 0) return credential;
|
|
233
|
+
|
|
234
|
+
const existingContext = (credential as unknown as ContextObject)['@context'];
|
|
235
|
+
const contextList: unknown[] =
|
|
236
|
+
existingContext === undefined || existingContext === null
|
|
237
|
+
? []
|
|
238
|
+
: Array.isArray(existingContext)
|
|
239
|
+
? [...existingContext]
|
|
240
|
+
: [existingContext];
|
|
241
|
+
const inlineObjects = contextList.filter(isPlainObject);
|
|
242
|
+
|
|
243
|
+
const fragment: Record<string, unknown> = {};
|
|
244
|
+
|
|
245
|
+
// Managed refresh service type term
|
|
246
|
+
const typeDefinition = findTopLevelDefinition(inlineObjects, MANAGED_REFRESH_TYPE_TERM);
|
|
247
|
+
|
|
248
|
+
if (typeDefinition === undefined) {
|
|
249
|
+
fragment[MANAGED_REFRESH_TYPE_TERM] = MANAGED_REFRESH_TYPE_IRI;
|
|
250
|
+
} else {
|
|
251
|
+
assertDefinitionMatchesIri(
|
|
252
|
+
MANAGED_REFRESH_TYPE_TERM,
|
|
253
|
+
typeDefinition,
|
|
254
|
+
MANAGED_REFRESH_TYPE_IRI
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Authorization term (scopes the DID-auth term inside managed refresh services)
|
|
259
|
+
const authorizationDefinition = findTopLevelDefinition(inlineObjects, 'authorization');
|
|
260
|
+
let didAuthCovered = false;
|
|
261
|
+
|
|
262
|
+
if (authorizationDefinition === undefined) {
|
|
263
|
+
// Full definition, including the nested LearnCardDIDAuth scope.
|
|
264
|
+
fragment['authorization'] = MANAGED_REFRESH_SERVICE_CONTEXT.authorization;
|
|
265
|
+
didAuthCovered = true;
|
|
266
|
+
} else {
|
|
267
|
+
assertDefinitionMatchesIri(
|
|
268
|
+
'authorization',
|
|
269
|
+
authorizationDefinition,
|
|
270
|
+
MANAGED_REFRESH_AUTHORIZATION_IRI
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
if (
|
|
274
|
+
isPlainObject(authorizationDefinition) &&
|
|
275
|
+
isPlainObject(authorizationDefinition['@context'])
|
|
276
|
+
) {
|
|
277
|
+
const scopedContext = authorizationDefinition['@context'];
|
|
278
|
+
|
|
279
|
+
if (MANAGED_REFRESH_DID_AUTH_TERM in scopedContext) {
|
|
280
|
+
assertDefinitionMatchesIri(
|
|
281
|
+
MANAGED_REFRESH_DID_AUTH_TERM,
|
|
282
|
+
scopedContext[MANAGED_REFRESH_DID_AUTH_TERM],
|
|
283
|
+
MANAGED_REFRESH_DID_AUTH_IRI
|
|
284
|
+
);
|
|
285
|
+
didAuthCovered = true;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// LearnCardDIDAuth term (top-level fallback when not covered by authorization's scope)
|
|
291
|
+
if (!didAuthCovered) {
|
|
292
|
+
const didAuthDefinition = findTopLevelDefinition(
|
|
293
|
+
inlineObjects,
|
|
294
|
+
MANAGED_REFRESH_DID_AUTH_TERM
|
|
295
|
+
);
|
|
296
|
+
|
|
297
|
+
if (didAuthDefinition === undefined) {
|
|
298
|
+
fragment[MANAGED_REFRESH_DID_AUTH_TERM] = MANAGED_REFRESH_DID_AUTH_IRI;
|
|
299
|
+
} else {
|
|
300
|
+
assertDefinitionMatchesIri(
|
|
301
|
+
MANAGED_REFRESH_DID_AUTH_TERM,
|
|
302
|
+
didAuthDefinition,
|
|
303
|
+
MANAGED_REFRESH_DID_AUTH_IRI
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (Object.keys(fragment).length === 0) return credential;
|
|
309
|
+
|
|
310
|
+
return {
|
|
311
|
+
...credential,
|
|
312
|
+
'@context': [...contextList, fragment],
|
|
313
|
+
} as T;
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Injects an allocated managed refresh service into an unsigned credential so the
|
|
318
|
+
* service becomes part of the signed payload.
|
|
319
|
+
*
|
|
320
|
+
* The managed service is made primary: any existing refresh services (for example a
|
|
321
|
+
* standard `1EdTechCredentialRefresh` service) are kept unchanged after it. Injecting
|
|
322
|
+
* a second, different managed service is rejected. The required inline context is
|
|
323
|
+
* prepared via {@link prepareManagedRefreshContext}.
|
|
324
|
+
*/
|
|
325
|
+
export const injectManagedRefreshService = <T>(
|
|
326
|
+
credential: T,
|
|
327
|
+
refreshService: ManagedCredentialRefreshService
|
|
328
|
+
): T => {
|
|
329
|
+
const service = ManagedCredentialRefreshServiceValidator.parse(refreshService);
|
|
330
|
+
const source = credential as unknown as ContextObject;
|
|
331
|
+
|
|
332
|
+
const existing = source['refreshService'];
|
|
333
|
+
const existingServices: unknown[] =
|
|
334
|
+
existing === undefined || existing === null
|
|
335
|
+
? []
|
|
336
|
+
: Array.isArray(existing)
|
|
337
|
+
? [...existing]
|
|
338
|
+
: [existing];
|
|
339
|
+
|
|
340
|
+
const existingManaged = getManagedRefreshServices({ refreshService: existingServices });
|
|
341
|
+
|
|
342
|
+
if (existingManaged.some(entry => entry.id !== service.id)) {
|
|
343
|
+
throw new Error(
|
|
344
|
+
'Credential already carries a different managed refresh service; a credential can only reference one managed refresh service.'
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const nonManagedEntries = existingServices.filter(
|
|
349
|
+
entry => !ManagedCredentialRefreshServiceValidator.safeParse(entry).success
|
|
350
|
+
);
|
|
351
|
+
|
|
352
|
+
const mergedRefreshService: unknown =
|
|
353
|
+
existingServices.length === 0
|
|
354
|
+
? service
|
|
355
|
+
: !Array.isArray(existing) && existingManaged.length > 0
|
|
356
|
+
? // Re-injecting the same managed service: keep object form (idempotent)
|
|
357
|
+
service
|
|
358
|
+
: [service, ...nonManagedEntries];
|
|
359
|
+
|
|
360
|
+
return prepareManagedRefreshContext({
|
|
361
|
+
...credential,
|
|
362
|
+
refreshService: mergedRefreshService,
|
|
363
|
+
} as T);
|
|
364
|
+
};
|
|
365
|
+
|
|
108
366
|
/**
|
|
109
367
|
* Proof-insensitive content comparison for refresh changed-content detection.
|
|
110
368
|
*
|
package/src/index.ts
CHANGED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/** C1 request serialization shared by the signing client and verifying service. */
|
|
2
|
+
export const DEFAULT_MAX_CANONICAL_BYTES = 2 * 1024 * 1024;
|
|
3
|
+
export const DEFAULT_MAX_CANONICAL_DEPTH = 32;
|
|
4
|
+
|
|
5
|
+
const LONE_SURROGATE_RE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
|
|
6
|
+
|
|
7
|
+
export class ShareContentCanonicalizationError extends Error {
|
|
8
|
+
readonly code = 'UNSUPPORTED_REQUEST_VALUE';
|
|
9
|
+
|
|
10
|
+
constructor(message: string) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.name = 'ShareContentCanonicalizationError';
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const serializeCanonical = (
|
|
17
|
+
value: unknown,
|
|
18
|
+
seen: Set<object>,
|
|
19
|
+
depth: number,
|
|
20
|
+
maxDepth: number
|
|
21
|
+
): string => {
|
|
22
|
+
if (value === null) return 'null';
|
|
23
|
+
|
|
24
|
+
switch (typeof value) {
|
|
25
|
+
case 'boolean':
|
|
26
|
+
return value ? 'true' : 'false';
|
|
27
|
+
|
|
28
|
+
case 'string': {
|
|
29
|
+
if (LONE_SURROGATE_RE.test(value)) {
|
|
30
|
+
throw new ShareContentCanonicalizationError(
|
|
31
|
+
'string contains an unpaired surrogate'
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return JSON.stringify(value);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
case 'number': {
|
|
39
|
+
if (!Number.isSafeInteger(value) || Object.is(value, -0)) {
|
|
40
|
+
throw new ShareContentCanonicalizationError(
|
|
41
|
+
'numbers must be safe integers without a negative-zero sign'
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return String(value);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
case 'object': {
|
|
49
|
+
if (depth >= maxDepth) {
|
|
50
|
+
throw new ShareContentCanonicalizationError(
|
|
51
|
+
'value exceeds the maximum nesting depth'
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (seen.has(value)) {
|
|
56
|
+
throw new ShareContentCanonicalizationError('cyclic value is not supported');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
seen.add(value);
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
if (Array.isArray(value)) {
|
|
63
|
+
const items: string[] = [];
|
|
64
|
+
|
|
65
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
66
|
+
if (!(index in value)) {
|
|
67
|
+
throw new ShareContentCanonicalizationError(
|
|
68
|
+
'sparse arrays are not supported'
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
items.push(serializeCanonical(value[index], seen, depth + 1, maxDepth));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return `[${items.join(',')}]`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const prototype = Object.getPrototypeOf(value);
|
|
79
|
+
|
|
80
|
+
if (prototype !== Object.prototype && prototype !== null) {
|
|
81
|
+
throw new ShareContentCanonicalizationError('only plain objects are supported');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const record = value as Record<string, unknown>;
|
|
85
|
+
|
|
86
|
+
const entries = Object.keys(record)
|
|
87
|
+
.sort()
|
|
88
|
+
.map(key => {
|
|
89
|
+
if (LONE_SURROGATE_RE.test(key)) {
|
|
90
|
+
throw new ShareContentCanonicalizationError(
|
|
91
|
+
'object key contains an unpaired surrogate'
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return `${JSON.stringify(key)}:${serializeCanonical(
|
|
96
|
+
record[key],
|
|
97
|
+
seen,
|
|
98
|
+
depth + 1,
|
|
99
|
+
maxDepth
|
|
100
|
+
)}`;
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
return `{${entries.join(',')}}`;
|
|
104
|
+
} finally {
|
|
105
|
+
seen.delete(value);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
default:
|
|
110
|
+
throw new ShareContentCanonicalizationError(`unsupported value type: ${typeof value}`);
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
/** Deterministic UTF-8 canonical serialization of a request body. */
|
|
115
|
+
export const canonicalizeShareContentRequestBody = (
|
|
116
|
+
body: unknown,
|
|
117
|
+
options: { maxBytes?: number; maxDepth?: number } = {}
|
|
118
|
+
): string => {
|
|
119
|
+
const maxBytes = options.maxBytes ?? DEFAULT_MAX_CANONICAL_BYTES;
|
|
120
|
+
const maxDepth = options.maxDepth ?? DEFAULT_MAX_CANONICAL_DEPTH;
|
|
121
|
+
const serialized = serializeCanonical(body, new Set(), 0, maxDepth);
|
|
122
|
+
|
|
123
|
+
if (new TextEncoder().encode(serialized).byteLength > maxBytes) {
|
|
124
|
+
throw new ShareContentCanonicalizationError('canonical request exceeds the maximum size');
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return serialized;
|
|
128
|
+
};
|